module src/goal/ · src/tools/verify.rs│ strategies 6│ gate kernel.rs:104│ status Running · Blocked · Complete
finish is a request. The ledger decides.
The failure mode this page is about is the one that makes local-model agents useless: the model says it is done, and it isn’t. LocalNPC answers it structurally. A run carries a ledger of acceptance checks derived from the goal, each check is bound to a verification strategy that resolves to a real command against the real project, and completion is a computed property of that ledger.
01 · StateThe goal ledger
A GoalLedger is created once per run and outlives every step. It holds the run id, the goal text, the active profile, a status, a seeded plan, the acceptance checks, decisions, open questions, and an evidence log. It derives Serialize and Deserialize, and the whole thing is written to the goal_ledger table in SQLite as JSON columns.
pub struct GoalLedger {
pub run_id: Uuid,
pub goal: String,
pub profile: String,
pub status: GoalStatus, // Running | Blocked | Complete
pub plan: Vec<PlanItem>,
pub acceptance: Vec<AcceptanceCheck>,
pub decisions: Vec<String>,
pub open_questions: Vec<String>,
pub evidence: Vec<EvidenceItem>,
}
An AcceptanceCheck has seven fields, and the two boolean ones do different jobs. passed means evidence arrived and satisfied it. impossible means the check was argued away — either individually via mark_acceptance_impossible, or wholesale when the model calls mark_impossible. Both remove a check from the “missing” set, but only one of them is a success.
| Field | Type | Role |
|---|---|---|
| id | String | Stable identifier, e.g. rust-tests. This is what a rejected finish reports back. |
| description | String | Human sentence, folded into the context packet’s goal summary. |
| strategy | VerificationStrategy | Which of six verifier families produces evidence for this check. |
| required | bool | Only required checks gate completion. Every check infer_acceptance creates is required. |
| passed | bool | Set by pass(evidence) when a verification with a matching strategy succeeds. |
| impossible | bool | Set by mark_impossible(evidence). Removes the check from the gate without claiming success. |
| evidence | Option<String> | The evidence string that closed the check, either way. |
Note how evidence propagates. RuntimeKernel::record_verification pushes a VerificationFinished event, appends an EvidenceItem to the ledger, and — only if the verification passed — walks the acceptance list marking every check whose strategy matches. One passing cargo test run closes every RustTests check at once, which matters because plan_for_strategy can retarget several checks onto the same underlying command.
02 · DerivationFrom goal string to executed command
infer_acceptance is a keyword scan, and it is honest about being one. It lowercases the goal and applies five independent rules; every rule that matches adds a required check. If none match, a single generic-evidence check is created so that no run is ever ungated.
The matching is not naïve substring matching throughout. contains_word splits on anything that is not alphanumeric or underscore and compares whole tokens, so “code” matches code but not decoder. Where substring matching is used it is for multi-word phrases — "web app", "look up" — where token splitting would not help.
plan_for_project is checked against the actual project directory at verification time, not at goal-parse time — so a goal that mentions Rust in a Node repository still resolves to the repository’s real test command. The generic-evidence box is reachable only when no keyword rule fires; it exists so that a run can never be created with an empty acceptance list.03 · PlansSix strategies, and the fallbacks that keep them satisfiable
Each strategy resolves to a VerificationPlan — a strategy tag, an optional command, and an evidence label. The evidence label is what ends up in the passing message, which is why an eval scenario can score by looking for the substring rust-checks or node-fixture-test.
fn rust_verification_command() -> String {
[
"CARGO_TARGET_DIR=target/localnpc-verify cargo fmt --check",
"CARGO_TARGET_DIR=target/localnpc-verify cargo clippy --all-targets -- -D warnings",
"CARGO_TARGET_DIR=target/localnpc-verify cargo test",
]
.join(" && ")
}
The redirected CARGO_TARGET_DIR is a small but telling choice. Verification builds into target/localnpc-verify rather than target/, so the harness never invalidates the developer’s own incremental build state while checking the model’s work. The gate is fmt, then clippy with warnings denied, then test — chained with &&, so a formatting failure short-circuits before the expensive steps.
Fallbacks that prevent unwinnable runs
Two strategies deliberately degrade rather than fail. RustTests in a non-Rust repository falls back to plan_for_project and adopts the requested strategy id, so the resulting evidence still satisfies the check that asked for it. FileArtifact with no index.html does the same. The comment on the second case is precise about the failure it prevents:
“… a code exercise whose goal merely mentioned the word ‘file’ (‘do not edit the test file’). Fall back to the project’s real test command … so this check is satisfiable instead of rejecting finish forever and thrashing to the step cap while the tests already pass.”
| Strategy | Resolves to | Evidence label |
|---|---|---|
| RustTests | fmt && clippy -D warnings && test, when Cargo.toml is present; otherwise the project’s real test command under the same strategy id. | rust-checks |
| BrowserEvidence | Playwright screenshot of index.html via scripts/browser_verify_project.py, with console-error failure enabled; falls back to test -s index.html if Playwright is unavailable or headless launch fails. | browser-screenshot … |
| ResearchSources | sources.json: a Python assertion that at least one entry carries both a URL and a date or year. sources.md: a grep for a URL and a year on the same line. | research-sources |
| SetupHealth | bash .localnpc/health.sh, else bash health.sh, else no plan. | setup-health |
| FileArtifact | test -s index.html when present; otherwise the project’s real test command under the FileArtifact id. | html-artifact |
| GenericCommand | Whatever plan_for_project finds, but only if that result is itself GenericCommand — a Rust project does not silently satisfy a generic check. | project-dependent |
The browser plan reads index.html and checks whether it contains <canvas. If it does, the verification command gains --require-canvas-nonblank and the evidence label becomes browser-screenshot canvas <path>. A canvas demo that renders a blank canvas fails verification — which is exactly the eval scenario fixtures/eval/browser_canvas_from_scratch.toml is built to catch, with required_evidence = ["browser-screenshot", "canvas"].
The Playwright command is defensive in a way that reads like it was written after a bad afternoon: it prefers a pinned Python, falls back to python3 if that is not executable, checks that playwright actually imports before trying to use it, and wraps the whole invocation so that a failed headless launch degrades to artifact-existence rather than a hard error — while printing to stderr exactly which fallback it took.
04 · The gateWhat happens when the model says it’s done
finish_decision is four lines of filter. It is not a state machine, not a flag, and not something the model can write to.
pub fn finish_decision(&self) -> FinishDecision {
let missing = self.ledger.acceptance
.iter()
.filter(|check| check.required && !check.passed && !check.impossible)
.map(|check| check.id.clone())
.collect::<Vec<_>>();
if missing.is_empty() {
FinishDecision::Complete
} else {
FinishDecision::MissingEvidence(missing)
}
}
The finish action does not shortcut it. The arm in loop_runner.rs:701 runs verify_missing first — the same authoritative verification the verify action triggers — and only then calls finish_with_summary. That function records the model’s summary as a model-kind evidence item and calls finish_if_ready. Recording the summary explicitly does not mark any check passed; the doc comment says so directly.
A finish with open checks appends: finish_rejected missing_evidence=browser-evidence; gather authoritative verification before finishing. The model gets the exact check ids it has not satisfied, keeps its remaining step budget, and continues. The gate is a redirect, not a wall.
The same authoritative-verification-first pattern shows up in the verify arm and in the repeated-capability_search escalation. All three converge on verify_missing then finish_if_ready, and only finish_if_ready sets GoalStatus::Complete and emits RunFinished { status: "complete" }. There is exactly one place in the codebase where a run becomes successful.
Manual evidence is checked against what was observed
A model can supply evidence directly through verify or goal_update. That evidence is not taken at face value for research tasks: apply_manual_verify_evidence runs alongside observed_source_urls — the URLs actually returned by web.search during this run — and evidence_cites_observed_source checks that a cited URL was one the harness really saw. A fabricated citation does not close a ResearchSources check.
05 · The other exitDeclaring a task not completable
A gate with no escape hatch produces a different pathology: a model that cannot satisfy a check thrashes until the step cap. mark_impossible is the sanctioned exit, and it is careful about what it claims.
It marks every still-open required check impossible with the model’s reasoning as evidence, appends an ImpossibleAcceptance evidence item, sets GoalStatus::Blocked, and emits RunFinished { status: "not_completable" }. It reuses the per-check mark_impossible path rather than inventing a new event kind — the comment notes this explicitly.
- Complete
- Every required check
passedorimpossible.GoalStatus::Complete. This is the only outcome a run can reach by doing the work. - Blocked
- Set only by
mark_impossible. The ledger still shows which checks were closed by argument rather than by evidence, and each carries the reason string. - Running
- The initial state, and the state a run that hits its step cap is left in — the loop returns
MissingEvidencewithout touching the status.
The distinction survives replay. Because impossible and passed are separate fields, a completed run whose ledger contains impossible checks is visibly different from one where everything was verified — you cannot read status: Complete alone and conclude the work was proven.
The gate is only as strong as the command behind the strategy. A project whose verification resolves to manual-evidence-required has command: None, so run_command_verification returns passed: false with the message “no automatic command is available for this project” and the check stays open. That is the correct failure — a run in such a project cannot complete on automatic evidence, and the loop guard at loop_runner.rs:522 exists precisely to stop the model looping on it forever and hand control back to a human.
Five files carry this whole mechanism: src/goal/acceptance.rs derives the checks, src/goal/ledger.rs stores them, src/tools/verify.rs resolves and runs the commands, src/runtime/kernel.rs records results and computes the decision, and src/runtime/loop_runner.rs wires it into the step loop. None of them is long. The strength is in where the boundary sits, not in how much code is behind it.