Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Agent Runtime

A minimal but structurally complete single-agent runtime: a task goes in, the model reasons and calls tools, the runtime validates and executes those tools, verifies the result against the real filesystem, persists every event, and streams the whole process over SSE.

POST /api/tasks -> model call -> tool call -> schema validation -> policy check
-> tool execution -> tool result back to the model -> verification -> evidence
-> events persisted + streamed -> final answer

By default there is no Kubernetes and no Redis, but PostgreSQL is required — see docker-compose.yml for a local instance. Setting REDIS_URL additionally switches the same code to a cross-replica event bus.

Agent Runtime architecture (learning roadmap)

This is the shape of an Agent Runtime as an architectural pattern — the thing that turns a stateless model call into a system with a lifecycle, memory, safety and recovery. It generalises past this specific codebase: swap the model provider, the tool set or the storage backend and the boxes below stay the same. Where a box is grounded in this repo, the file is noted; for a line-by-line walkthrough of this repo's own module layout, see ARCHITECTURE.md.

 Boundary / contract
(what may the outside world assume?
create -> stream -> resume -> cancel)
|
task/goal in, events + answer out
v
┌───────────────────────┐
│ AGENT LOOP │ the runtime's core control flow:
│ reason -> act -> │ reason -> act -> observe -> repeat
│ observe -> repeat │ until a stop condition is reached
└──┬─────┬─────┬─────┬───┘
| | | |
┌──────────▼┐ ┌──▼───┐ ┌───▼─────┐ ┌───▼─────────────┐
│ Reasoning │ │Action │ │ Safety │ │ State & memory │
│ pluggable │ │tools = │ │policy │ │ - lifecycle │
│ model │ │capability│ risk │ │ state machine │
│ behind a │ │surface │ │gate + │ │ - durable event │
│ protocol │ │ │ │sandbox │ │ log (replay) │
└────────────┘ └───────┘ └─────────┘ │ - context budget │
└────────┬─────────┘
|
┌────────────────────────────────────────▼──────────┐
│ Termination gate: verification, not silence │
│ ("model stopped calling tools" != "task is done") │
└────────────────────────────────────────┬──────────┘
|
┌───────────────────────┬─────────────────────────────┬─▼───────────────────┐
│ Human-in-the-loop │ Execution ownership │ Extended cognition │
│ suspend/resume is a │ who may execute this run │ sub-agents, agent-to-│
│ first-class state, │ when there is more than one │ agent protocols, │
│ not an exception │ replica (lease + fencing) │ retrieval, sandboxed │
│ │ │ code execution │
└───────────────────────┴─────────────────────────────┴──────────┬───────────┘
|
┌───────────────────▼──────────┐
│ Continuous improvement │
│ (execution -> lesson -> gated │
│ promotion -> next run's prompt)│
└────────────────────────────────┘
cross-cutting, present at every layer, shaping none of the above logic:
observability (metrics + tracing) · security (secrets, workspace isolation)

Where each concept is grounded in this repo (secondary — use this to jump into code once the concept above makes sense, not the other way round):

ConceptThis repo
Boundary / contractapp/api/{routes,sse}.py
Agent loopapp/runtime/loop.py
Reasoning (model provider)app/providers/{base,dashscope,mock}.py
Action (tools)app/tools/{registry,base}.py + individual tools
Safety (policy + sandbox)app/policy/engine.py, app/tools/workspace.py
Lifecycle state machineapp/domain/enums.py
Durable event logapp/runtime/events.py, app/persistence/
Context budgetapp/runtime/context.py
Verificationapp/runtime/verification.py
Human-in-the-loopapp/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
Execution ownershipapp/runtime/manager.py (lease + fencing token)
Extended cognitionapp/tools/{subagent,remote_agent,knowledge,program}.py
Continuous improvementapp/evolution/
Observabilityapp/observability/, app/web/

Suggested reading order — each stage answers one question before moving on; this is also roughly the order features were built in this repo (see the git history and ARCHITECTURE.md sections of the same names):

StageQuestion to be able to answerRead
0. VocabularyWhat states can a run be in, and who enforces legal transitions?app/domain/enums.py, app/domain/models.py
1. Single-process loopHow does one goal become "model calls a tool, tool result goes back to the model"?app/runtime/loop.py, app/providers/base.py+mock.py, app/tools/registry.py+base.py, app/policy/engine.py
2. Events & persistenceWhy must the event be published before a terminal status is written?app/runtime/events.py, app/persistence/{db,repository,tables}.py
3. The API contractHow does a client create, stream and resume a run without re-executing side effects?app/api/{routes,sse}.py, and Subscribe to the event stream below
4. Human in the loopWhy does a suspended run hold no lease?app/tools/ask_user.py, checkpoint/resume in app/runtime/manager.py
5. Distributed executionWhat stops two replicas from both executing the same run?app/runtime/manager.py (lease + fencing token), docker-compose.yml, scripts/check_infra.py
6. Advanced toolsWhat changes when the "sub-agent" is a different process entirely?app/tools/{subagent,remote_agent,knowledge,program}.py, Talking to other agents in ARCHITECTURE.md
7. Self-evolutionWhy is PROPOSED a security boundary and not just a review step?app/evolution/*, Self-evolution in ARCHITECTURE.md
8. Observability & consoleHow do metrics/tracing/UI attach without becoming part of the critical path?app/observability/*, app/web/*, Metrics / Tracing below

Requirements

  • Python 3.11+
  • PostgreSQL (a local instance via docker compose up -d postgres is enough)
  • git (optional; only used for get_git_diff and diff-based evidence)

Install

uv sync --extra dev

Configure

Exactly four environment variables are required:

cp .env.example .env
docker compose up -d postgres # or point DATABASE_URL at any PostgreSQL you already run
VariableMeaning
DASHSCOPE_API_KEYAPI key for the OpenAI-compatible endpoint
DASHSCOPE_BASE_URLFull OpenAI-compatible base URL, e.g. https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_IDModel identifier used for every request
DATABASE_URLPostgreSQL connection string, e.g. postgresql://user:pass@localhost:5432/db (a plain postgresql:// URL is upgraded to the async driver automatically)

Everything else (iteration budget, timeouts, output caps, workspace) has a default and is changed in code — see RuntimeLimits in app/config.py.

The key is loaded into the runtime process only. It is never logged, never persisted, never sent to a tool's environment and never placed in the model context.

Before starting the app for the first time, apply the schema:

alembic upgrade head

See Schema migrations below for what that command does and why nothing is auto-created at startup.

Trusted-local and secure mode

Authentication is deliberately dual-mode. With RUNTIME_API_KEYS_JSON unset, the runtime accepts local requests as tenant local, keeps the configured workspace root, and does not enforce tenant quotas. This compatibility mode is for a trusted developer machine only.

Setting a tenant-to-secret map enables secure mode:

RUNTIME_API_KEYS_JSON='{"tenant-a":"replace-with-a-long-random-secret"}'
SANDBOX_IMAGE='your-agent-sandbox:latest'

Every /api operation except health then requires Authorization: Bearer <secret>. Repository queries are tenant-scoped, and tenant workspaces live at WORKSPACE_DIR/<tenant-id>. Request and active-run quotas are PostgreSQL-backed and therefore apply across replicas; configure them with TENANT_REQUESTS_PER_MINUTE and TENANT_MAX_ACTIVE_RUNS.

Optional: run as more than one replica

docker compose up -d # postgres, redis, qdrant, meilisearch, prometheus, grafana
python scripts/check_infra.py # connectivity + two distributed-behaviour experiments

Then set the remaining variable in .env:

VariableEffect when set
REDIS_URLEvents fan out to every replica instead of one process
REPLICA_IDLabels this process in metrics and /api/health

Schema migrations

The schema is managed exclusively by Alembic — nothing is auto-created at startup, for any environment:

alembic upgrade head # fresh or existing database
alembic stamp 0001 && alembic upgrade head # a database created by create_all before migrations existed
alembic revision --autogenerate -m "..."# after changing app/persistence/tables.py

The URL comes from the same .env the app reads (ALEMBIC_DATABASE_URL overrides it), and migrations run on the async driver already in the dependencies. create_all is deliberately never used: it creates missing tables and never adds a column to a table that exists, which is the classic "works on a fresh database, fails on the deployed one" bug.

Both are needed together. PostgreSQL alone gives shared state but a stream opened on replica B will not see live events from a run executing on replica A; Redis alone gives fan-out over storage that is not shared.

What Redis is not for: it does not make delivery reliable. The database holds the authoritative, sequenced event log and a client recovers by replaying with Last-Event-ID. Redis only removes the latency of polling.

Run two replicas locally against the same infrastructure:

REPLICA_ID=a uvicorn app.main:app --port 8000 &
REPLICA_ID=b uvicorn app.main:app --port 8001 &# start a run on A, stream it from B:
curl -N http://127.0.0.1:8001/api/runs/<run_id>/stream

Durable dispatch and execution ownership

Creating an auto-start task, starting a parked run, answering ask_user, or approving reconciliation records dispatch_requested_at in the same database transaction as the state change. Each replica polls and claims these durable requests up to MAX_CONCURRENT_RUNS_PER_REPLICA; a crash after the API commit therefore leaves work claimable instead of losing an in-process task.

A run is owned by whichever replica holds a live lease on it, renewed every lease_ttl_s / 3. A lease alone is not mutual exclusion: a replica that stalls past the TTL loses the run without noticing, and keeps writing until its next renewal fails.

So every acquisition also increments lease_epoch, and the owner writes run state with the epoch it was granted. A stale owner's writes no longer match and are rejected with LEASE_LOST — safety stops depending on the old owner realising anything. Sub-agent runs are fenced together with their parent.

The fence is applied per transaction, not per statement: database.repository( fence=(run_id, epoch)) checks the epoch before the first write that touches that run, so events, checkpoints, tool records and status changes all roll back together if the run has been taken over.

lease_ttl_s then trades two measurable things against each other: it must exceed the worst normal pause (GC, slow IO) or healthy replicas get declared dead, and stay under the recovery delay you can tolerate, since a crashed replica's runs are unclaimable until the lease expires.

Metrics

EndpointFor
GET /api/metricsPrometheus text format (machine)
GET /api/metrics/viewThe same numbers with an explanation of each (human)

Metrics are derived from the event stream (agent_runs_total, agent_tool_calls_total, agent_model_tokens_total, agent_run_duration_seconds, agent_dropped_events_total, ...) plus HTTP-level counters (agent_http_requests_total, agent_http_request_duration_seconds).

Prometheus at http://localhost:9090 scrapes /api/metrics; Grafana at http://localhost:3000 (admin/admin) is provisioned with the datasource and an Agent Runtime dashboard from ops/grafana. The dashboard only shows data while the app is running — Prometheus cannot scrape a stopped process, and a target that was never up produces empty panels.

Tracing (Langfuse, optional)

Set LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY — all three, or tracing stays off. Each run becomes one trace: a generation per model call (output, usage, latency) and a span per tool execution, plus the verification result. It hangs off the same hook metrics use, so nothing is sprinkled through the loop.

Metrics and traces are not alternatives: Prometheus answers "how many, how fast, how much" across all runs; a trace answers "what happened in this run". Observations are buffered and shipped when the run reaches a terminal event, so a crashed run leaves no trace rather than half of one. The prompt itself is not traced — it is not in the event stream, and putting a whole context window there would make the event table a data channel.

API conventions

  • Every response carries X-Request-ID; supply one to correlate your own logs.
  • In secure mode every non-health API request requires a configured Bearer token.
  • Supply Idempotency-Key when creating a task so a client retry returns the same tenant-scoped task/run instead of creating another one.
  • Every failure has the same body: {"error_code", "message", "request_id"}.
  • Collections are wrapped in an object ({"run_id", "events"}), never a bare JSON array, so pagination can be added without breaking clients.
  • GET /openapi.json and /docs describe the whole JSON API. The Prometheus endpoints are excluded: they are not part of that contract.

Check model capabilities

python scripts/check_model.py
Connection: PASS
Text completion: PASS
Streaming: PASS
Function calling: PASS
Echo tool round trip: PASS
Model: <MODEL_ID>

If the model cannot do function calling the script says so explicitly and exits non-zero. There is no fallback that simulates tool calls with regexes or prompt parsing.

Run the service

python -m app # fails fast if the 4 variables are missing# or
uvicorn app.main:app --reload # starts even when unconfigured

uvicorn deliberately starts without configuration so that history endpoints still work; the first attempt to start a run then returns 503 CONFIGURATION_ERROR.

Create a task and run it

curl -s -X POST http://127.0.0.1:8000/api/tasks \
-H 'content-type: application/json' \
-H 'Idempotency-Key: example-task-1' \
-d '{"goal": "List the Python files in the workspace and summarise them."}'# -> {"task_id":"task_...","run_id":"run_...","status":"PENDING"}
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/start
curl -s http://127.0.0.1:8000/api/runs/<run_id>

Pass "auto_start": true to POST /api/tasks to skip the explicit start call.

Subscribe to the event stream

curl -N http://127.0.0.1:8000/api/runs/<run_id>/stream
# resume after a disconnect (either header or query parameter):
curl -N -H 'Last-Event-ID: 12' http://127.0.0.1:8000/api/runs/<run_id>/stream
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?after_sequence=12'# skip the history entirely and follow from now on:
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?replay=false'# keep the connection open across an ask_user suspension (default: it closes):
curl -N 'http://127.0.0.1:8000/api/runs/<run_id>/stream?follow_suspended=true'

The stream also ends on input.requested, not only on a terminal event. A run waiting for a person is not over, but nobody is executing it either and the answer may take hours — holding an HTTP connection for that mismatches two time scales. Reconnect with the sequence you already have once you have replied.

Disconnecting a stream never changes run state. On reconnect the runtime replays persisted events first and then continues with live ones; sequence numbers are per-run, gapless and strictly increasing.

The resume point is the client's to state, not the server's: several clients may watch the same run, so no single "last delivered" position exists on the server. Last-Event-ID and after_sequence are the same mechanism through two doors — the header exists because a browser's native EventSource reconnects on its own and only sends that header.

String fields in an event payload are clipped at max_event_payload_chars. When that happens the payload carries truncated_fields naming them, so a client can tell a clipped value from a complete one without parsing it — never re-parse a field listed there.

model.delta is transient: it is delivered but never persisted, so it has "transient": true, sequence: 0 and no SSE id: field. Its content is repeated in full by the model.completed event that follows, so logging every increment would multiply the event table to store what the next row already says. Clients must ignore transient events when tracking their resume position — reconnecting replays the durable log, not the typing.

Endpoints

MethodPathPurpose
POST/api/tasksCreate a task + its run
POST/api/runs/{run_id}/startStart execution
GET/api/runs/{run_id}Run state + completion evidence
GET/api/runs/{run_id}/eventsPersisted events (after_sequence, limit)
GET/api/runs/{run_id}/streamSSE stream (replay + live)
POST/api/runs/{run_id}/cancelCancel a run
POST/api/runs/{run_id}/replyAnswer a pending ask_user question
POST/api/runs/{run_id}/reconcileResolve an uncertain non-idempotent tool call
GET/api/runs/{run_id}/questionsQuestions asked during the run
GET/api/healthConfiguration/tool/policy/backend summary
GET/api/metricsPrometheus metrics
GET/api/metrics/viewThe same metrics, annotated for humans

Human in the loop

The agent can call ask_user when it genuinely cannot proceed. The run then suspends: no task is running, and GET /api/runs/{id} reports WAITING_FOR_INPUT together with the pending question.

curl -s http://127.0.0.1:8000/api/runs/<run_id>| jq .pending_question
curl -s -X POST http://127.0.0.1:8000/api/runs/<run_id>/reply \
-H 'content-type: application/json' -d '{"answer": "notes.txt"}'

Because the suspended run lives only in the database, the answer may arrive minutes later and on a different replica than the one that asked. Resuming replays the checkpoint rather than the tools, so no side effect happens twice. The iteration budget is not reset by a question.

A checkpoint also stores a workspace_digest: the sha256 of every file the run read or wrote. On resume the digests are re-checked, and any file that changed in the meantime is named to the model in a SYSTEM NOTE before it continues. Nothing is rolled back — external side effects cannot be undone. The point is narrower and achievable: the agent must not keep treating a stale read as current.

Tools

list_files, read_file, write_file, search_text, get_git_diff, echo, run_tests (fixed command only), ask_user, write_todos, delegate. There is no arbitrary shell, no network tool and no git push. All paths pass through WorkspaceGuard.

write_todos holds no state: the model re-sends the whole plan and gets it rendered back, so the current plan is always among the newest messages the context manager keeps. At most one item may be in_progress.

read_file returns a window of lines and always states the file's total line count, so a large file can be paged (offset, max_lines) instead of silently truncated. Knowing the total is the part that matters: it is what lets the model choose between paging on and using search_text to jump straight to a section.

delegate starts a sub-agent as a real child run — its own run_id, event stream, checkpoints, verification and iteration budget — and returns only its final answer to the parent's conversation. That is the point: the sub-task's reading never enters the parent's context. The child inherits the parent's lease rather than claiming one (it executes inside the parent's task, so its liveness is the parent's liveness) and shares its cancellation token. It never receives delegate or ask_user, which keeps the tree one level deep. Find the child from the parent's tool.completed event (payload.metadata.child_run_id) or from parent_run_id on the run itself.

Tool recovery and reconciliation

Every built-in tool declares READ_ONLY, IDEMPOTENT, or NON_IDEMPOTENT. The loop persists the assistant's tool-call intent and a deterministic run_id:tool_call_id idempotency key before execution. After a replica crash, unfinished read-only/idempotent calls can be replayed with that same key. Supplying a key does not make an external system idempotent; the tool implementation must honor it.

An unfinished non-idempotent call moves the run to RECONCILIATION_REQUIRED instead of guessing whether its side effect happened. An operator uses POST /api/runs/{run_id}/reconcile with accept plus an observation, explicit retry, or fail. The run holds no lease while waiting.

Optional tools (off unless configured)

ToolSwitched on byAdds
search_knowledgeQDRANT_URL + MEILISEARCH_URL + MEILISEARCH_API_KEYTwo network dependencies
run_programENABLE_PROGRAM_TOOL=trueModel-written Python through the configured execution backend

search_knowledge fuses Meilisearch (lexical) and Qdrant (semantic) with Reciprocal Rank Fusion, because BM25 scores and cosine similarity are not on a comparable scale. The query is embedded with HYBRID_EMBED_MODEL, which must be the model the corpus was indexed with — a mismatch returns plausible-looking noise rather than an error. Seed the corpus with python scripts/seed_hybrid_search.py.

In trusted-local mode, commands use a subprocess with a minimal environment allowlist and a timeout; that is not containment. In secure mode execution tools fail closed unless SANDBOX_IMAGE is configured. The Docker backend uses argv without a shell, no network, a read-only root, dropped capabilities, no-new-privileges, CPU/memory/PID limits, tmpfs, and only the tenant workspace mounted at /workspace.

Context management

The history is budgeted in estimated tokens, not messages — one read_file result can outweigh fifty short turns. context_strategy selects the policy:

StrategyBehaviourCost
sliding-window (default)Keep the system prompt, the goal and the newest turnsFree, but older turns are lost
summarisingReplace older turns with one model-written summaryOne extra model call, and the summary is lossy

Both guarantee a tool message never appears without the assistant message that requested it. Summarising degrades to the window if the summary call fails: context management must never be the reason a run fails.

Current limitations

  • Single agent, single run per process-managed task; no multi-agent orchestration.
  • Recovery is only automatic for tools whose declared semantics permit replay. Generic external tools do not become idempotent merely because the runtime supplies a key; uncertain non-idempotent work requires an operator decision.
  • Verification is intentionally narrow: applicable successful checks produce PASSED, contradicted evidence produces FAILED, and answer-only work or no applicable check produces terminal UNVERIFIED. It does not judge arbitrary semantic correctness.
  • No automatic schema creation. The schema is managed exclusively by Alembic; nothing is created at startup, so alembic upgrade head must be run against the database before starting the app for the first time and after every migration (see Schema migrations above).
  • Trusted-local mode intentionally has no HTTP authentication, tenant quotas, or OS containment. Do not expose that compatibility mode to untrusted users.
  • The Docker backend is a stronger boundary, not a complete hostile-code platform: production deployments should consider image provenance, daemon isolation, seccomp/AppArmor, rootless execution, and a microVM/gVisor boundary.
  • Data retention/partitioning, provider backoff/fallback, cumulative token/cost budgets, and durable telemetry projection remain out of scope.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages