localnpcrust harness · v0.1.0

Sections

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.

src/goal/ledger.rs5–16struct GoalLedger
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.

src/goal/acceptance.rs — AcceptanceCheck
FieldTypeRole
idStringStable identifier, e.g. rust-tests. This is what a rejected finish reports back.
descriptionStringHuman sentence, folded into the context packet’s goal summary.
strategyVerificationStrategyWhich of six verifier families produces evidence for this check.
requiredboolOnly required checks gate completion. Every check infer_acceptance creates is required.
passedboolSet by pass(evidence) when a verification with a matching strategy succeeds.
impossibleboolSet by mark_impossible(evidence). Removes the check from the gate without claiming success.
evidenceOption<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.

Acceptance pipeline: goal text to acceptance checks to executed verification commands A five-band vertical pipeline. The top band holds the goal string. The second band shows six keyword rules from infer_acceptance, each producing an acceptance check id: rust-tests, browser-evidence, research-sources, setup-health, file-artifact, and a generic-evidence fallback used only when no other rule matches. The third band shows the acceptance ledger holding those checks with required, passed and impossible flags. The fourth band shows plan_for_strategy resolving a strategy against the real project directory, testing for Cargo.toml, package.json, test.js, pytest configuration, plain Python test scripts and index.html in order, and falling back to manual evidence required. The fifth band shows run_command_verification executing the resolved command under a networked command policy and recording the result, which marks every matching-strategy check as passed. GOAL STRING · lowercased once "Create a self-contained browser canvas demo from scratch, serve or verify it through browser evidence…" infer_acceptance() · FIVE RULES + ONE FALLBACK word: rust word: cargo word: code word: fix rust-tests RustTests sub: browser sub: web app sub: html browser-evidence BrowserEvidence word: research sub: look up word: latest research-sources ResearchSources word: setup word: configure word: install setup-health SetupHealth word: file word: write word: create file-artifact FileArtifact no rule matched the only path to this check generic-evidence GenericCommand GoalLedger.acceptance : Vec<AcceptanceCheck> browser-evidence required=true passed=false impossible=false file-artifact required=true passed=false impossible=false completion_ready() — all checks passed OR impossible finish_decision() — required && !passed && !impossible Both derived; neither is a stored flag the model can set. plan_for_project() · FIRST MATCH WINS, AGAINST THE REAL DIRECTORY Cargo.toml exists → RustTests · rust-checks package.json exists → npm test -- --runInBand || npm test test.js exists → node test.js · node-fixture-test pytest.ini | pyproject.toml → python3 -m pytest test_*.py | *_test.py → loop each; exit 1 on any failure index.html exists → Playwright shot; canvas flag if <canvas none of the above → command: None · manual-evidence-required run_command_verification() cd {cwd:?} && {command} CommandPolicy::networked(cwd) 300 s timeout · 256 KB output cap workspace-write + network egress still OS-jailed to the workspace passed = matches!(status, Success(_)) on pass → "{label} passed in {ms} ms" on fail → label, status, stdout, stderr → VerificationFinished { strategy, passed, evidence } record_verification() push VerificationFinished · append EvidenceItem if passed: mark EVERY check with a matching strategy IF NO COMMAND IS AVAILABLE passed = false, evidence = "no automatic command is available for this project" — the check stays open.
Acceptance pipeline. The ladder in 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.

src/tools/verify.rs147–154fn rust_verification_command
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.”

src/tools/verify.rs — VerificationStrategy::FileArtifact arm
Six verification strategies and how each resolves
StrategyResolves toEvidence label
RustTestsfmt && clippy -D warnings && test, when Cargo.toml is present; otherwise the project’s real test command under the same strategy id.rust-checks
BrowserEvidencePlaywright 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 …
ResearchSourcessources.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
SetupHealthbash .localnpc/health.sh, else bash health.sh, else no plan.setup-health
FileArtifacttest -s index.html when present; otherwise the project’s real test command under the FileArtifact id.html-artifact
GenericCommandWhatever 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 canvas flag

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.

src/runtime/kernel.rs104–117fn finish_decision
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.

Rejection is informative, not punitive

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 passed or impossible. 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 MissingEvidence without 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.

What this does not defend against

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.