Files
deepseek-harness/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md
Chinesezjc 4806fdabab fix(tools): collapse code-mode executor to run_code for model-direct calls
wireSchemas() already advertised only run_code under mode: 'code', but the
executor resolved every call through get(), which returns the full visible
map plus the reserved transport. A model could name a native tool directly
and bypass run_code entirely. Route the execution-path lookups through a
new private resolveExecution() that applies the mode collapse at the
operation boundary: model-direct calls under 'code' may only name run_code
(UNKNOWN_TOOL otherwise), while SDK sub-dispatches (parent token set) keep
every visible tool. get()/schemas() public semantics are unchanged.

The denial happens at createExecution, before the extensible policy
pipeline — pre-execute listeners, approval ask, and guards never observe
a call that is deterministically denied. A collapsed call honors the
pre-dispatch cancellation contract, routes aborted results through the
visible tool's finalizeContent, and captures the finalizer before
argument materialization.

Under code mode, a system-prompt/assemble listener filters out tool:*
guidance sections that told the model to call native tools directly.
The tools:sdk section and SDK types remain so programs can still use
all tools through run_code.

Fixes #1815
2026-08-11 22:40:19 +08:00

5.1 KiB

Agent Note: Code Mode collapses the executor, not just the wire

Status: implemented

English | 中文

Problem

mode: 'code' collapsed only the announcement surface, not the execution surface. wireSchemas() sent the model exactly one tool — run_code — but the executor resolved every call through get(), which returns the full visible map plus the reserved transport. A model that emitted a native tool name (write, read, bash, subagent, …) bypassed run_code entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing.

The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor.

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.

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.

The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under code returns UNKNOWN_TOOL, the same tool via an SDK sub-dispatch succeeds, and native/both direct calls plus run_code itself are unchanged. The base Code Mode foundation owns the transport design this note layers the execution boundary onto.

Alternatives considered

Filter get() / the registry view by mode

The view is consumed by presenters, tool-cordis inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor.

Filter at the agent-loop entry

The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-encode mode semantics the registry already owns.

Reject via a shipped guard

Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself.

Keep schema omission only (status quo)

No provider guarantees interception of unadvertised names; the reported session proves it does not happen.

Consequences

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