fix(tools): state the code-mode collapse in the prompt and the denial

The executor collapse landed without telling the model it exists. Every
tool contributes its own guidance section naming its tool, none of them
qualify how that tool is reached, and they all render before the SDK
(orders 100-199 against SDK_SECTION_ORDER 150), so the prompt said "Use
the read tool" eleven times and never said only run_code is callable.

A real session shows the consequence: the model emitted a native call,
read `unknown tool "read"` for a tool the same prompt declares, and
concluded the deployment was inconsistent rather than routing through
run_code.

The registry now contributes `tools:code-only` at order 99 -- ahead of
the guidance band -- stating the rule, registered wherever `tools:sdk`
is and rendering empty outside an effective `code`. `both` renders it
empty because its native calls do execute, which is also why
both-mode-turn no longer shares code-mode-turn's expected prompt. The
denial itself now names the route back, since a bare UNKNOWN_TOOL for a
declared tool is what misled the model.
This commit is contained in:
Yichen Jiang
2026-08-11 22:44:34 +08:00
parent 428aec44e6
commit 5d2c943d38
13 changed files with 571 additions and 25 deletions
@@ -12,7 +12,7 @@ The package contract names this exact anti-pattern: schema omission is not enfor
## Decision
`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. A model-direct call (`nested = false`) under `code` may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared.
`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. When `modeFor(scope)` resolves to `code`, a model-direct call (`nested = false`) may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error, whose message names the route back through `run_code` because the name IS declared to this model (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). The effective scope mode includes declarations inherited from an agent preset, so its wire schema and execution permissions remain aligned. A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared.
Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not.
@@ -41,5 +41,6 @@ No provider guarantees interception of unadvertised names; the reported session
- `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract).
- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator).
- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference.
- Under `code` mode, the imperative native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) are filtered from the system prompt by a `system-prompt/assemble` listener so the model is never told to call a tool it cannot reach directly. The `tools:sdk` section (TypeScript bindings) remains, so programs can still use every tool through `run_code`.
- Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls.
- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of the 100-199 guidance band. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt.
- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents.