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:
@@ -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
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user