Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
6.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
ToolRuntime 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.
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 becomesUNKNOWN_TOOL, which the model can correct by routing throughrun_code(a pre-aborted call still resolvesABORTED_BEFORE_DISPATCH, per the cancellation contract).bothandnativebehavior is unchanged; SDK sub-dispatches are unchanged (theparenttoken is the discriminator).- A collapsed call is rejected at
prepare, BEFORE the extensible policy pipeline: pre-execute listeners, approvalask, and guards never observe it.executionModealso fails closed (exclusive), so scheduling has no observable difference. - 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 (readoverbash cat,readbeforewritefor the default fs-observation-policy,subagentoverworkflow) 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-onlysection 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, receivedUNKNOWN_TOOLfor 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.bothrenders the rule empty: its native calls do execute, so stating it there would be false — which is whyboth-mode-turnno longer sharescode-mode-turn's expected prompt. - Any future composite transport that sets a
parenttoken opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents.