module src/runtime/│ core loop_runner.rs · 2056 ln│ kernel kernel.rs│ parser model/strict_json.rs
One action per step, or the step doesn’t count.
The AgentLoop is the whole product. Everything else in the crate exists because this loop needs it: a context to send, a schema to validate against, a router to gate execution, a ledger to decide when to stop, and an event stream so the whole thing can be replayed afterwards.
01 · ShapeSeven stages, repeated up to max_steps times
The architecture note in docs/architecture.md lists the loop in seven lines. The implementation in src/runtime/loop_runner.rs is that list plus the guards that keep a small model inside it.
- Infer the task profile.
- Build a compact context packet.
- Ask the selected model for one strict JSON action.
- Validate the action against the loaded capability schemas.
- Execute the tool, worker, or verifier.
- Record every event to SQLite and JSONL.
- Stop only after acceptance evidence passes.
Stage one runs once at entry: infer_profile_from_goal reads the goal string and picks a ProfileId, unless the caller passed an initial profile explicitly. Stages two through six repeat. Stage seven is not a stage at all so much as a gate that several arms of the match statement can trip.
for step in 0..self.max_steps, with five ways out. The two coloured feedback edges are the interesting ones: a parse failure re-prompts up to eight consecutive times without consuming the run, and a rejected finish re-prompts with the exact list of unmet acceptance-check ids rather than terminating.Loop defaults
AgentLoop’s own Default impl (src/runtime/loop_runner.rs:143) is more conservative than the config file’s: twelve steps rather than eighty, no worker slots, and command-mode verification rooted at the current directory. Callers that construct the loop for a real run — app.rs, the daemon, the eval runner — set these explicitly from the scheduler decision and the config.
impl Default for AgentLoop {
fn default() -> Self {
Self {
max_steps: 12,
model_request_timeout: Duration::from_secs(120),
worker_slots: 0,
context_window: 262_000,
verification: VerificationMode::Commands {
project_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
},
}
}
}
The context_window default carries a comment explaining itself: 262 000 is “large enough that ordinary runs never compact, keeping existing behavior byte-identical; a small value forces compaction (and EventKind::ContextCompacted)”. That is a testability decision written into a default — the compaction path is exercised by shrinking the window in a test rather than by generating a quarter-million tokens of history.
02 · The contractExactly one JSON object, nothing else
The action contract is enforced in src/model/strict_json.rs, and it is unusually unforgiving on purpose. parse_tool_action strips one ```json fence if present, parses, requires a JSON object, and then rejects any top-level key that is not name or arguments. A model that helpfully adds "reasoning" or "thought" alongside its action gets ExtraTopLevelKeys — not a silent drop.
Only after the envelope passes does parse_tool_action_with_schemas compile the tool’s JSON Schema and validate arguments against it. Schema compilation failure and validation failure both surface as SchemaInvalid, carrying the joined validator messages, so the repair prompt tells the model what was actually wrong.
// Eight ways a model's step can fail before anything is executed.
pub enum ActionParseError {
Empty, // "empty action"
NotJson(String), // serde_json error text
NotObject, // top level was an array/scalar
MissingName, // no string field 'name'
MissingArguments, // no object field 'arguments'
ArgumentsNotObject, // 'arguments' was not an object
UnknownTool(String), // name not in the allowed set
ExtraTopLevelKeys(Vec<String>), // anything besides name/arguments
SchemaInvalid(String), // jsonschema validation messages
}
MAX_PARSE_REPAIRS = 8 counts consecutive failures and resets to zero on every successfully parsed action. The comment records why: “a productive run is never killed by occasional prose/markup the model emits between good actions (the old single-shot bool terminated the run on the 2nd parse failure ever)”.
The repair instruction itself is a single constant, and it is specific rather than scolding. It restates the exact envelope, forbids prose and markdown outside the JSON, names concrete recovery actions (capability_call to fs.read, fs.patch, shell.run, or finish), and adds a hint aimed at the most common real cause of a truncated action: “if a large file write failed, split it into a smaller action.”
| Model output | Result |
|---|---|
{"name":"finish","arguments":{}} | Accepted |
Same, wrapped in a ```json fence | Accepted — fence stripped by strip_fenced_json |
{"name":…,"arguments":…,"reason":"…"} | ExtraTopLevelKeys(["reason"]) |
[{"name":…}] | NotObject |
{"name":"fs.read","arguments":{…}} | UnknownTool — concrete tools are not top-level |
{"name":"memory_search","arguments":{"q":"x"}} | SchemaInvalid — the property is query |
| Prose, then the JSON | NotJson → repair prompt, budget decremented |
That fifth row is the point of the whole design. fs.read is a real tool that the harness can execute — but it is not in the top-level schema map, so a model that jumps straight to it gets a structured rejection naming the eight tools it may call. Reaching fs.read requires going through capability_load then capability_call.
03 · ExecutionThe lifecycle of one tool call
Between a validated capability_call and a process actually running, the argument object passes through name normalisation, argument normalisation, the loaded-capability gate, a second schema check against the concrete tool’s manifest, the native dispatcher, and — for shell.run — a policy gate and an OS sandbox. Each hop can reject, and each rejection is an event.
Allow only edge is the load-bearing one: a shell.run classified Approval emits ToolApprovalRequested and does not reach the spawn site — the event carries the command and the policy’s human-readable reason so a UI can offer an inline approve/deny.The dispatcher and the shared step executor are deliberately owned in one place. src/runtime/agent/step.rs documents the reason: before a refactor, the goal loop and the conversational chat loop each carried their own copy of “try native, fall through to MCP, format the summary”. That module is now the single owner of all three, and the two front-ends differ only in their event plumbing — expressed as a StepObserver whose narration hook the chat loop attaches and the goal loop leaves None.
CapabilityStepResult.summary is the full, untruncated tool output, fed back into the conversation so the model reasons over the real result. narration is a one-line human-facing string the chat loop streams as an SSE delta. The distinction is stated in the doc comment: “narration is for the human, summary is for the model.”
04 · GuardsThe specific ways a small model derails
Three guards sit in the loop, and each one exists because of an observed failure mode rather than a hypothetical one.
Repeated verify with no automatic verifier
A model that cannot satisfy an acceptance check sometimes calls verify over and over, hoping the answer changes. If the same action repeats and has_no_automatic_verifier_evidence(&kernel) holds — there is no command-backed verifier that could produce evidence — the loop stops with RunFinished { status: "missing_evidence" } and a transcript line reading loop_guard=repeated verify without automatic verifier; stopping for user direction. Better to hand the run back than to burn the step budget.
Repeated capability_search
The mirror-image failure: a model that keeps searching instead of loading. On a repeat, the loop escalates — it runs the missing verifications itself and checks whether the run is in fact already complete. If it is, the run finishes; if not, the search result stands and the loop continues.
Unknown capability names are non-fatal
This one is a deliberate reversal. capability_load used to call router.load(name)?, so a single guessed capability name aborted the whole run. Now the arm loads what is valid, collects the failures, and appends both to the transcript:
let mut ack = format!("capability_load_ack={}", loaded.join(","));
if !failed.is_empty() {
ack.push_str(&format!(
"\ncapability_load_failed={} (unknown capability). Loadable capabilities: {}",
failed.join(","),
router.available_names().join(", ")
));
}
The error the model receives is not “unknown capability”; it is “unknown capability, and here are the ten you may load.” The same instinct shows up in the finish arm, which rejects with the exact list of unmet check ids rather than a generic refusal.
“Non-fatal: an unknown capability name must NOT abort the whole run (it did before, via router.load(name)?, killing runs where the model guessed a bad capability).”
| status | Emitted by | Meaning |
|---|---|---|
| complete | kernel.finish_if_ready() | Every required acceptance check passed or was marked impossible. |
| not_completable | kernel.mark_impossible() | Model argued the remaining checks cannot be satisfied; goal set to Blocked. |
| parse_error | loop, after 8 repairs | Nine consecutive unparseable responses. |
| missing_evidence | verify loop guard | Repeated verify with no automatic verifier available. |
Running out of steps produces no RunFinished event at all — the loop falls out of the for and returns a LoopOutcome whose finish is MissingEvidence with steps: self.max_steps. The absence of a terminal event in a trace is itself the signal that the run hit its cap.
05 · Memory of the runStructured history, flattened on demand
The loop drives a ConversationHistory, not a string. The framing prompt — context packet, goal summary, profile, memory hits, recent observations, tool signatures — is rebuilt each step and installed as the history’s single system turn. Structured turns accumulate alongside it: the seed user turn, an assistant tool-call turn per step, and a role:"tool" result paired to it by a monotonic call_N id.
Text-only models never see any of that. render_history_as_prompt flattens the history back into the one-string prompt the loop has always sent, emitting the leading system turn verbatim so the flattened output stays substring-compatible with the historical shape. Models that override next_reply get the structured form and can dispatch real tool calls.
Pairing each tool result to its originating call is what lets a model observe its own prior tool output on the next turn. Without it, a model that has already read a file has no record that it did, and reads it again. The loop calls this “the duplicate-action win” in the comment at loop_runner.rs:266.
Observations are flushed lazily. A step marks pending_observation = Some((call_id, transcript.len())) before entering the match, and everything the arm appends to the transcript from that point is collected into one role:"tool" turn at the top of the next iteration. That ordering is what makes a continue inside any arm still record its result — including the parse-repair path, which registers its pending observation immediately so even a malformed structured tool call gets a paired result.
- ModelPrompt
- Carries the fully rendered prompt string for the step — the exact bytes sent, recorded before the request is made, so a replay shows what the model actually saw.
- ModelResponse
- Carries only
chars: usize. The response text itself reaches the trace throughTranscriptAppendedafter normalisation, or throughActionParseFailed.response_preview(first 500 characters) when it fails to parse. - ContextCompacted
- Emitted with
before_charsandafter_charswhencompact_historyshrinks the rolling summary turn. The system framing is always preserved.
The result is that a trace is not a log of what the harness decided to print. It is the conversation, the gate decisions, and the tool I/O — enough to reconstruct the step sequence without the model, which is precisely what eval run --replay does.