localnpcrust harness · v0.1.0

Sections

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.

  1. Infer the task profile.
  2. Build a compact context packet.
  3. Ask the selected model for one strict JSON action.
  4. Validate the action against the loaded capability schemas.
  5. Execute the tool, worker, or verifier.
  6. Record every event to SQLite and JSONL.
  7. 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.

The LocalNPC harness loop and its terminal exits A vertical sequence of six repeating stages, from building the context packet through rendering history as a prompt, calling the model, parsing and schema-validating the action, dispatching the matched arm, and recording events. To the right of each stage the events it emits are listed: ModelRequest, ModelPrompt, ModelResponse, ActionParseFailed, ToolStarted, ToolInput, ToolFinished, ToolOutput, and VerificationFinished. Four terminal exits leave the loop on the far right: complete, not_completable, parse_error and missing_evidence. Two feedback arrows return to the model call — one from a parse failure carrying the repair instruction, one from a rejected finish carrying the list of missing acceptance checks. RuntimeKernel::start(goal, "orchestrator") infer_acceptance() · seed_plan() → RunStarted router.load_defaults(["fs", "shell"]) profile capabilities preloaded · loop_runner.rs:213 FOR step IN 0..max_steps flush observation → history pending (call_id, transcript_len) → role:"tool" ContextPacket::build_with_loaded… system + goal + profile + memory + signatures compact_history(TokenBudget) → ContextCompacted model.next_reply(&history) tokio::time::timeout(model_request_timeout) → ModelRequest · ModelPrompt → ModelResponse { chars } parse_tool_action_with_schemas 8 allowed names · jsonschema::validate on Err → ActionParseFailed { error, response_preview } match action.name { … } search · load · call · goal · verify · finish → ToolStarted · ToolInput → ToolFinished · ToolOutput append_transcript + event_sink → TranscriptAppended · LiveReplayRecorder NEXT STEP repair × 8 RunFinished · complete every required check passed kernel.finish_if_ready() RunFinished · not_completable mark_impossible(reason) GoalStatus::Blocked RunFinished · parse_error 9th consecutive parse failure MAX_PARSE_REPAIRS = 8 RunFinished · missing_evid. repeated verify, no auto verifier loop_runner.rs:522 step cap reached returns MissingEvidence(ids) steps = max_steps finish_rejected missing_evidence=… → re-prompt, same step budget STATUS STRINGS ARE VERBATIM EventKind::RunFinished { status } is the only terminal event kind
The harness loop. Six repeating stages inside 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.

src/runtime/loop_runner.rs143–158impl Default for AgentLoop
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.

src/model/strict_json.rs12–22enum ActionParseError
// 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
}
The repair budget — loop_runner.rs:259

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.”

Envelope acceptance — what the parser does with each input shape
Model outputResult
{"name":"finish","arguments":{}}Accepted
Same, wrapped in a ```json fenceAccepted — 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 JSONNotJson → 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.

Lifecycle of a single capability_call, as swimlanes Eight horizontal lanes stacked vertically: model, strict_json parser, normalize, capability router gate, tools dispatch, shell policy and OS sandbox, event stream, and conversation history. A call travels left to right through boxes in each lane. Branches leave the router lane when a tool is not loaded, and leave the dispatch lane to an MCP fallthrough when the tool name is unknown. The shell lane can hold a command for approval instead of running it. The event lane shows ToolStarted, ToolInput, ToolFinished and ToolOutput being emitted, and the history lane shows the assistant tool-call turn and the paired tool-result turn. MODEL AssistantReply {"name":"capability_call", "arguments":{"name":"fs.read",…}} STRICT_JSON parse + validate 8 allowed names jsonschema::JSONSchema::compile reject → repair ActionParseFailed NORMALIZE agent/normalize.rs normalize_tool_name normalize_tool_args ROUTER GATE capabilities/router.rs is_tool_loaded(name)? schema_for_loaded_tool → validate not loaded → capability_load first DISPATCH tools/dispatch.rs dispatch_capability_call… native first · project-root aware MCP fallthrough on "unknown loaded tool" <server>/<tool> → stdio or HTTP JSON-RPC · 30s SHELL PATH tools/shell.rs ShellPolicy::evaluate Deny · Allow · Approval held, not executed ToolApprovalRequested sandbox-exec Seatbelt SBPL jail Allow only EVENTS events.rs ToolStarted { name } ToolInput { arguments } ToolFinished { status, duration_ms } ToolOutput { summary, structured } → role:"tool" paired to call_N INVARIANT Every hop that can reject emits an event before it does. A rejection is not a silent no-op; it is a recorded step the model observes on its next turn. This is what makes the replayed trace a complete account of the run.
Tool-call lifecycle. Lanes are real modules. The 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.

Summary vs. narration

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:

src/runtime/loop_runner.rs627–633capability_load arm
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).”

src/runtime/loop_runner.rs:616 — inline comment
Terminal statuses written to EventKind::RunFinished { status }
statusEmitted byMeaning
completekernel.finish_if_ready()Every required acceptance check passed or was marked impossible.
not_completablekernel.mark_impossible()Model argued the remaining checks cannot be satisfied; goal set to Blocked.
parse_errorloop, after 8 repairsNine consecutive unparseable responses.
missing_evidenceverify loop guardRepeated 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.

The duplicate-action win

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 through TranscriptAppended after normalisation, or through ActionParseFailed.response_preview (first 500 characters) when it fails to parse.
ContextCompacted
Emitted with before_chars and after_chars when compact_history shrinks 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.