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.

Regenerated docs, catalogs, graphs, scoped events, re-recorded
translation pairs, and updated test assertions.

Fixes #1815
This commit is contained in:
Chinesezjc
2026-08-11 03:01:14 +08:00
parent 2aef2d83fa
commit 24dd48b133
10 changed files with 120 additions and 57 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: f84fdb17f29f9352cda77fc1901beba79a416c2f
config-catalog.zh.md: e7cfcff8d69c3a1efbe8426e25415783bc41f864
config-catalog.md: 6362022dd1b80e17997684574d9dc7f14372e23a
config-catalog.zh.md: 3fd335ee2b1ec6d1526f75ed9667f1d9286faafe
+8 -10
View File
@@ -2481,16 +2481,14 @@ Requires: `systemPrompt`
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* Model presentation for agents that declare none of their own. `native`
* (default) sends every visible schema; `code` sends only `run_code` plus a
* generated SDK prompt; `both` sends both forms. Code modes require a
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
* (TypeScript or Python) and fail prompt assembly when it is absent or has
* no renderer. Under `code`, native names in `toolOrder` are invalid.
*
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
* which is how an agent preset composes a Code Mode agent beside native
* ones in the same process.
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt and collapses the
* executor to the same surface (a model-direct call may only name
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
* sends both forms. Code modes require a `ctx.codeRuntime` whose `language`
* has a registered SDK renderer (TypeScript or Python) and fail prompt
* assembly when it is absent or has no renderer. Under `code`, native names
* in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
/**
+8 -10
View File
@@ -2482,16 +2482,14 @@ export interface Config {
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* Model presentation for agents that declare none of their own. `native`
* (default) sends every visible schema; `code` sends only `run_code` plus a
* generated SDK prompt; `both` sends both forms. Code modes require a
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
* (TypeScript or Python) and fail prompt assembly when it is absent or has
* no renderer. Under `code`, native names in `toolOrder` are invalid.
*
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
* which is how an agent preset composes a Code Mode agent beside native
* ones in the same process.
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt and collapses the
* executor to the same surface (a model-direct call may only name
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
* sends both forms. Code modes require a `ctx.codeRuntime` whose `language`
* has a registered SDK renderer (TypeScript or Python) and fail prompt
* assembly when it is absent or has no renderer. Under `code`, native names
* in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
/**
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/tools.md
tools.md: 4f0360f45e7ed4f3537bedeb0f370f8eb3472ab9
tools.zh.md: aa6c67c6379e4a9b9ab7bdc473f6294d063e3649
tools.md: fff43d587428ca4747cfd19ac5fccca08b4e048e
tools.zh.md: 4705edf81e56c81b237a69caa1c012ba12d67716
+1 -1
View File
@@ -571,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](scope.md)
Source: [`packages/core/tools/src/index.ts:764`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:762`](../../packages/core/tools/src/index.ts)
<a id="tools-events"></a>
+1 -1
View File
@@ -571,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](scope.md)
Source: [`packages/core/tools/src/index.ts:764`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:762`](../../packages/core/tools/src/index.ts)
<a id="tools-events"></a>
+92 -25
View File
@@ -628,16 +628,14 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* Model presentation for agents that declare none of their own. `native`
* (default) sends every visible schema; `code` sends only `run_code` plus a
* generated SDK prompt; `both` sends both forms. Code modes require a
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
* (TypeScript or Python) and fail prompt assembly when it is absent or has
* no renderer. Under `code`, native names in `toolOrder` are invalid.
*
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
* which is how an agent preset composes a Code Mode agent beside native
* ones in the same process.
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt and collapses the
* executor to the same surface (a model-direct call may only name
* `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
* sends both forms. Code modes require a `ctx.codeRuntime` whose `language`
* has a registered SDK renderer (TypeScript or Python) and fail prompt
* assembly when it is absent or has no renderer. Under `code`, native names
* in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
/**
@@ -916,6 +914,7 @@ export class ToolRegistry extends Service {
// keeps one rule instead of a case analysis.
if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection())
}.bind(this), 'tools.presentAs()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity
return dispose
}
@@ -1075,30 +1074,54 @@ export class ToolRegistry extends Service {
/**
* Resolve every registry fact one scope needs in one layer traversal. The
* visible map applies global restrictions, scoped shadowing, and the reserved
* presentation transport; the other sets retain the pre-restriction facts
* needed by restriction and prompt-order validation.
* visible map applies restrictions to the INHERITED surface, then the
* scope's own registrations and the reserved presentation transport; the
* other sets retain the pre-restriction facts needed by restriction and
* prompt-order validation.
*
* A restriction filters what a scope inherits — the global layer and every
* ancestor layer on its chain — and never what its OWN layer registers.
* That exemption is what a per-child capability filter has to keep intact:
* the delegation runtime registers a child's reporting and structured-output
* tools into the child's own layer, and a filter naming the capabilities the
* child may use must not strip the machinery it answers through.
*
* Reading the exempt set as "the global layer" instead of "not mine" held
* only while every model-facing tool sat in the host composition. Once
* presets moved them onto the agent plane they became an ANCESTOR
* contribution, so a child's filter silently stopped constraining anything
* it was given.
* @param scope - the viewing scope (the agent), or undefined for the global view.
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
// Scope-chain layers, farthest ancestor first, the exact scope last.
const layers = this.layers.chainLayers(scope)
// Chain-blind on purpose: this is the ONE layer whose registrations the
// scope owns rather than inherits, and it is absent until the scope
// contributes something.
const own = this.layers.peek(scope)
// Inherited surface, nearest ancestor last: a nearer scope's same-name
// entry shadows a farther one, and the global layer is the farthest.
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
for (const layer of layers) {
if (layer === own) continue
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
}
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.layers.global.tools.entries()) {
for (const [name, definition] of inherited) {
knownNames.add(name)
restrictableNames.add(name)
// Restrictions intersect across the whole chain: any scope on it may
// mask a global-surface name for everything nested inside it.
// mask an inherited name for everything nested inside it.
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
}
// Chain layers second, nearest last: same-name entries REPLACE (shadow)
// the global and farther-scope ones, and scope-local registrations are
// never part of the global filter above.
for (const layer of layers) {
for (const [name, definition] of layer.tools.entries()) {
// The scope's own registrations last, shadowing an inherited name and
// outside the filter above.
if (own !== undefined) {
for (const [name, definition] of own.tools.entries()) {
knownNames.add(name)
visible.set(name, definition)
}
@@ -1127,6 +1150,26 @@ export class ToolRegistry extends Service {
return this.view(scope).visible.get(name)
}
/**
* Resolve the definition that MAY EXECUTE for a call, applying the mode
* collapse at the operation boundary that owns it. The registry view
* (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `code`
* may only name the reserved `run_code` transport, while a nested
* sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool
* it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL`
* through the executor, matching an absent definition.
* @param name - the tool name as registered.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
* @returns the definition that may run, or undefined when the call must be rejected.
*/
private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined {
const tool = this.get(name, scope)
if (tool === undefined) return undefined
if (this.collapses(name, nested)) return undefined
return tool
}
/**
* Project visible definitions onto the allowlisted model-facing schema fields,
* excluding execution and presentation callbacks.
@@ -1176,7 +1219,7 @@ export class ToolRegistry extends Service {
* @returns the fail-closed scheduling mode.
*/
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
const tool = this.get(exec.name, exec.agent)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
try {
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
@@ -1270,6 +1313,19 @@ export class ToolRegistry extends Service {
concludingExecutions.add(this as unknown as ToolExecution)
},
}
// Capture the finalizer BEFORE argument materialization: the
// `finalizeContent` contract snapshots the callback when the call starts,
// and an arguments getter can replace or clear the registered callback
// during `snapshotJsonValue`. The collapse only decides whether the
// CAPTURED callback is retained: the pre-dispatch abort path keeps it
// (the cancellation contract routes aborted results through it — a getter
// that aborts mid-materialization before an invalid-args failure lands in
// the same retained path), while the `UNKNOWN_TOOL` denial and the
// invalid-args failure of a NON-ABORTED collapsed call drop it (the call
// could never execute).
const capturedFinalizer = visible?.finalizeContent?.bind(visible)
const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined =>
collapsed && !signal.aborted ? undefined : capturedFinalizer
try {
const detached = snapshotJsonValue(exec.arguments)
if (detached === undefined) {
@@ -1282,10 +1338,21 @@ export class ToolRegistry extends Service {
callerSignal: signal,
bodyInvoked: false,
})
if (collapsed) {
// The collapse denies the call before the policy pipeline, but a
// pre-dispatch abort still keeps the established cancellation
// contract: `prepare`'s caller-cancellation check is skipped for
// final-results, so honor the abort here instead of surfacing
// `UNKNOWN_TOOL` on an already-cancelled call.
if (signal.aborted) {
return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() }
}
return { kind: 'final-result', exec: execution, result: toolErrorResult(new ToolNotFoundError(name)) }
}
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: MutableToolRunContext = { ...base, arguments: undefined }
this.contentFinalizers.set(execution, finalizeContent)
this.contentFinalizers.set(execution, finalizerFor())
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -1383,7 +1450,7 @@ export class ToolRegistry extends Service {
}
exec.signal = signal
try {
const tool = this.get(exec.name, exec.agent)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool) throw new ToolNotFoundError(exec.name)
state.bodyInvoked = true
const returned = await tool.execute(exec.arguments, exec)
@@ -1605,7 +1672,7 @@ export class ToolRegistry extends Service {
if (result.isError) {
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
}
const tool = this.get(exec.name, exec.agent)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const replaced = this.createSuccessResult(exec, tool, decision.value)
return this.markCanonical(exec, {
@@ -1674,7 +1741,7 @@ export class ToolRegistry extends Service {
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
})
}
const tool = this.get(exec.name, exec.agent)
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const normalized = this.createSuccessResult(exec, tool, result.value)
return this.markCanonical(exec, {
+4 -4
View File
@@ -190,14 +190,14 @@ describe('restrict()', () => {
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
// A scope's own registration is exempt from its own filter, so naming it
// is a caller error rather than a silent no-op.
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/)
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall".*known global tools: real/s)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
const emptyCtx = await mount()
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
.toThrow(/Restrictable tools: \(none\)/)
.toThrow(/known global tools: \(none\)/)
})
})
@@ -343,7 +343,7 @@ describe('startInProcessRun', () => {
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown inherited tool')
}, {})).rejects.toThrow('unknown global tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
@@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})).rejects.toThrow(/unknown inherited tool "no_such_tool"/)
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
expect(ctx.agents.list().length).toBe(before)
})
})