localnpcrust harness · v0.1.0

Sections

module src/capabilities/ registry 10 namespaces tools 47 concrete · 8 top-level gate router.rs

Eight tools visible. Forty-seven behind a gate.

A small model handed fifty tool schemas at once will pick badly and burn its context doing it. LocalNPC inverts the arrangement: the model sees a search-and-load interface, and concrete tools only become callable once their namespace has been explicitly requested.

01 · SurfaceWhat the model can name

top_level_tool_names() in src/capabilities/schema.rs returns exactly eight strings. Those eight are the only values the strict-JSON parser will accept in the name field, because parse_tool_action_with_schemas derives its allowed set from the keys of top_level_tool_schemas().

Three of them are the gate itself — capability_search, capability_load, capability_call. Two are bookkeeping: goal_update and memory_search. Three are terminal or near-terminal: verify, finish, mark_impossible.

Top-level tools — signatures as signature_from_schema renders them
SignatureEffect in the loop
capability_search(query:string)Substring + term search across manifest names, descriptions, profile fits and tool descriptions. Returns matching manifests; loads nothing.
capability_load(capability?:string, name?:string, names?:array)Moves one or more manifests into the router’s loaded set. Unknown names are reported back with the loadable list, not fatal.
capability_call(arguments?:object, name?:string)Invokes a concrete tool. Rejected unless the tool’s namespace is loaded.
goal_update(acceptance?:any, evidence?:string, goal?:string, plan?:any, status?:string)Appends a model-kind evidence item to the ledger; acknowledges which keys were supplied.
memory_search(query:string)Queries run memory; hits are folded into the next context packet as memory_hit lines.
verify(claim?:string, evidence?:string, status?:string)Runs the missing verifications, then tests the acceptance ledger. Completes the run if every required check now passes.
finish(summary?:string)Runs verification anyway, records the summary as evidence, then gates. Does not bypass the acceptance checks.
mark_impossible(evidence?:string, reason?:string)Marks every open required check impossible, blocks the goal, terminates as not_completable.
Why the argument order looks like that

signature_from_schema lists required parameters first in their declared required order, then optional ones suffixed with ?. Because serde_json’s Map is a BTreeMap by default, the optional tail comes out alphabetically — which is why goal_update reads acceptance, evidence, goal, plan, status. The signatures are generated, not hand-written, so they cannot drift from the schemas the dispatcher validates against.

The doc comment on signature_from_schema states the payoff directly: showing the model “the exact argument names + types the dispatcher validates against (instead of guessing q vs query), which is the dominant source of wasted steps and rejected calls.” Composite schemas collapse rather than expand — enum renders as enum, oneOf/anyOf as any — so the signature stays one line even for goal_update.

02 · The gateSearch, load, then call

The registry holds ten capability manifests. Each is a name, a description, a list of ToolManifest entries with full JSON input schemas, and a profile_fit list. The router holds a BTreeMap of the manifests currently loaded, and answers two questions the dispatcher needs: is_tool_loaded(name) and schema_for_loaded_tool(name).

Capability gating: from eight visible tools to forty-seven executable ones On the left, a panel listing the eight top-level tools the model may name. Arrows from capability_search and capability_load reach the CapabilityRegistry in the centre, which holds ten manifests: fs, shell, web, mcp, profile, worker, browser, browser_panel, cua and ui, with their tool counts. Loading moves a manifest into the CapabilityRouter's loaded map on the right. A capability_call passes through an is_tool_loaded gate; if the namespace is not loaded the call is rejected, otherwise it reaches the concrete tools listed by namespace. Along the bottom, a profile.switch calls clear_loaded and reloads only the new profile's namespaces, with a note that unresolvable names are reported as unavailable rather than failing. THE MODEL CAN NAME capability_search capability_load capability_call goal_update memory_search verify finish mark_impossible Anything else → UnknownTool(name) CapabilityRegistry :: DEFAULT fs3 tools shell1 tool web2 tools mcp1 tool profile1 tool worker1 tool browser10 tools browser_panel11 tools cua6 tools ui11 tools load() CapabilityRouter · loaded: BTreeMap "fs" → manifest "shell" → manifest preloaded at run start: router.load_defaults(["fs", "shell"]) is_tool_loaded("fs.read") → true schema_for_loaded_tool("fs.read") → Some(schema) loaded_tool_signatures() → shown in the prompt Ordered by capability name, then manifest tool order. is_tool_loaded(name)? then re-validate args vs. the tool schema yes unknown loaded tool → MCP fallthrough, else surfaced as error EXECUTABLE ONLY WHEN LOADED · 47 TOOLS fs read · patch · write shell run web search · extract mcp call profile switch worker spawn browser inspect · open · click · type · close snapshot · evaluate · network screenshot · console browser_panel open · navigate · back · forward · reload click · type · evaluate · close · status snapshot cua snapshot · click · type · hotkey · scroll ui snapshot · screenshot · click · type navigate · select · scroll · drag · resize press · wait PROFILE SWITCH · THE RESET profile.switch { profile: "coder" } via capability_call router.clear_loaded() every manifest dropped no stale tools survive load(policy.loaded_capabilities) only the new profile's namespaces → ProfileChanged { from, to } transcript line profile_capabilities loaded=… unavailable=…
Capability gating. The registry is a static Default impl — ten manifests, forty-seven ToolManifest entries with full input schemas, built at construction. Only fs and shell are preloaded at run start (loop_runner.rs:213); everything else must be searched for and loaded before capability_call will reach it.

A detail worth pulling out: capability_search matches against a haystack that concatenates the manifest name, description, profile_fit entries, and every tool’s name and description. It checks both the whole lowercased query and each whitespace-separated term. That is why a query like "read files" finds fs through its tool descriptions rather than through its one-word name.

03 · SchemasClosed objects, all the way down

Nearly every tool schema in the registry sets "additionalProperties": false. That is a small decision with a large effect: a model that adds a plausible-looking extra argument gets a schema-validation failure with the offending property named, rather than having it silently discarded and then wondering why the tool ignored it.

The one deliberate exception is capability_call, which sets additionalProperties: true — it has to, because the inner arguments object belongs to whichever concrete tool is being called and is validated separately against that tool’s own manifest schema.

src/capabilities/registry.rs27–40ToolManifest for fs.patch
ToolManifest {
    name: "fs.patch".to_string(),
    description: "Apply a precise patch.".to_string(),
    input_schema_json: serde_json::json!({
        "type": "object",
        "additionalProperties": false,
        "properties": {
            "path": {"type": "string"},
            "old":  {"type": "string"},
            "new":  {"type": "string"}
        },
        "required": ["path", "old", "new"]
    }),
}

The tool descriptions carry real operational knowledge, not just labels. browser_panel.open’s description runs to several sentences and is explicit about a specific failure it is preventing: “Never use shell.run with macOS open, /usr/bin/open, Chrome, Safari, or xdg-open to open pages.” That instruction is backed by an actual policy check — gui_open_reason in src/tools/shell.rs:191 hard-denies those commands, so the description describes an enforced rule rather than a hope.

Descriptions that disambiguate near-duplicates

browser_panel and browser both expose click, type, evaluate, snapshot and close. The manifests separate them in prose: browser_panel.click acts on “the paired browser automation session for the visible NPC browser panel” and explicitly “does not directly click the visible native webview”. Ambiguity between two similar namespaces is exactly what a small model resolves badly, so the registry resolves it in advance.

Namespace inventory — CapabilityRegistry::default()
NamespaceToolsprofile_fit
fs3coder, verifier
shell1coder, setup_operator
web2researcher, orchestrator, verifier
mcp1orchestrator, setup_operator, verifier
profile1orchestrator, verifier, coder
worker1orchestrator
browser10browser_operator and related profiles
browser_panel11visible NPC panel control
cua6computer_operator
ui11semantic app control

04 · ProfilesRuntime policies, not personalities

The README is blunt about this: “Profiles are runtime policies, not separate chat personalities.” A ProfilePolicy is four fields — a prompt delta, a list of capabilities to preload, a character budget, and whether workers may be spawned. There is no persona, no tone, no roleplay.

Switching profile calls clear_loaded() before loading the new set. That is the entire security story for mode changes: a run that loaded shell as a Coder and then switches to Researcher cannot still call shell.run, because the manifest is gone from the router.

src/context/profile.rs — policy_for(ProfileId)
ProfilePreloaded capabilitiesContext budgetWorkers
Orchestratornone80,000yes
Coderfs, shell120,000no
Researcherweb90,000no
BrowserOperatorbrowser90,000no
ComputerOperatorcua80,000no
SetupOperatorshell80,000no
FrontendDesignerfs, shell, browser120,000no
MemoryCuratormemory70,000no
Verifiershell, browser, web100,000no

Only the Orchestrator can spawn workers, and it is the one profile that preloads nothing — it has to search and load like everyone else. The asymmetry is intentional: the profile that delegates does not also get a preloaded blast radius.

A policy name the registry does not answer to

MemoryCurator preloads "memory", but there is no memory capability manifest in the registry — memory is reached through the top-level memory_search tool instead. This does not fail the run. load_profile_capabilities collects unresolvable names into a missing list and reports them in the transcript as profile_capabilities profile=MemoryCurator loaded= unavailable=memory. The mismatch is surfaced in the trace rather than swallowed or thrown.

The prompt deltas are one sentence each and read like operating instructions rather than characterisation. Coder: “Inspect code, patch narrowly, run verification, and avoid broad rewrites.” ComputerOperator: “Use CUA only when files, shell, browser, or APIs cannot solve the task directly.” Verifier: “Challenge completion claims and require authoritative acceptance evidence.” Each delta is concatenated into the context packet’s system string alongside a fixed suffix that ends: “… and do not finish without verification evidence.”

Which profile a run starts in

infer_profile_from_goal reads the goal text when the caller has not passed one. A goal mentioning verification or testing routes to Verifier; other keyword families route elsewhere; the fallback is Orchestrator. If the inferred profile is not Orchestrator, the loop emits a ProfileChanged { from: "Orchestrator", to: … } event before the first model call, so the trace records the inference as an explicit transition rather than an initial condition.

05 · Beyond the registryMCP as a fallthrough, not a front door

Configured MCP servers extend the tool surface without entering the registry. The mechanism is a fallthrough in execute_capability_step: native dispatch runs first, and only if it fails with an “unknown loaded tool” error — detected by is_unknown_tool_error — does the harness try an MCP server.

Routing is by configuration and mirrors the /tools/call gateway exactly. A name of the form <server>/<tool> selects a named server; a bare name falls back to the first configured one. A server with a url is reached over the HTTP JSON-RPC client; a server with a command is spawned over stdio with its configured env injected. Both use a 30-second timeout, and stdio calls are pushed onto spawn_blocking so a slow server does not stall the async runtime.

src/runtime/agent/step.rs103–112the fallthrough branch
// Native miss (unknown name) + a configured MCP server -> MCP fallthrough.
let result = match native {
    Err(err) if is_unknown_tool_error(&err) => {
        match dispatch_step_mcp(mcp, tool_name, tool_args).await {
            Some(Ok((summary, success))) => Ok(McpOrNative::Mcp { summary, success }),
            Some(Err(mcp_err)) => Err(mcp_err),
            None => Err(err),
        }
    }
    Ok(result) => Ok(McpOrNative::Native(result)),
    Err(err) => Err(err),
};

Note the None arm. When no MCP server is configured, dispatch_step_mcp returns None rather than an error, and the harness surfaces the original native error. A model that misspelled fs.read gets told about fs.read, not about a missing MCP server it never asked for.

The goal loop passes &BTreeMap::new(), disabling the fallthrough entirely — the doc comment records this as deliberate: “the goal loop does this to stay native-only until it opts in.” The conversational chat loop passes the configured map. Same executor, different policy, expressed as an argument rather than a branch.

“Before Phase D each front-end carried its own copy of ‘try native, fall through to MCP, format the summary’. This module is now the single owner of all three.”

src/runtime/agent/step.rs — module doc

There is also a mcp capability in the registry, exposing mcp.call(command, tool, args?, arguments?). That is the explicit path: the model names a server command and a tool directly. The fallthrough is the implicit path, for tools the operator has already wired into config. Both end up in the same JSON-RPC client.