fix(tools): preserve bounded fallback guidance
This commit is contained in:
@@ -15,7 +15,7 @@ tools:
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized. Disposed with the calling fiber.
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while losslessly snapshotting another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
|
||||
@@ -1057,9 +1057,15 @@ export class ToolRegistry extends Service {
|
||||
* @internal
|
||||
*/
|
||||
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
|
||||
let snapshottedResult: ToolExecutionResult
|
||||
try {
|
||||
snapshottedResult = this.snapshotFinalResult(result)
|
||||
} catch (error: unknown) {
|
||||
snapshottedResult = toolErrorResult(error)
|
||||
}
|
||||
let finalResult: ToolExecutionResult
|
||||
try {
|
||||
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, result))
|
||||
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, snapshottedResult))
|
||||
} catch (error: unknown) {
|
||||
finalResult = this.materializeFinalResult(toolErrorResult(error))
|
||||
}
|
||||
@@ -1187,13 +1193,18 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
|
||||
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
/** Validate and detach one candidate outcome before tool-owned final content. */
|
||||
private snapshotFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
const detached = snapshotJsonValue(result)
|
||||
if (detached === undefined) {
|
||||
throw new TypeError('tool result must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(detached)
|
||||
return detached
|
||||
}
|
||||
|
||||
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
|
||||
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
return deepFreeze(this.snapshotFinalResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,62 @@ describe('ToolRegistry', () => {
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
it('finalizes errors discovered while snapshotting non-content result fields', async () => {
|
||||
const ctx = await setup()
|
||||
let finalizeCalls = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'throwing-meta',
|
||||
finalizeContent(_exec, result) {
|
||||
finalizeCalls += 1
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') return undefined
|
||||
return [{ type: 'text', text: block.text.slice(0, 32) }]
|
||||
},
|
||||
async execute() {
|
||||
const meta = {}
|
||||
Object.defineProperty(meta, 'value', {
|
||||
enumerable: true,
|
||||
get() { throw new Error('snapshot failed: '.repeat(100)) },
|
||||
})
|
||||
return { content: [{ type: 'text', text: 'body' }], meta }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('throwing-meta'), name: 'throwing-meta', arguments: {},
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: snapshot failed: snapshot' }])
|
||||
expect(finalizeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('normalizes a throwing final content callback without invoking it again', async () => {
|
||||
const ctx = await setup()
|
||||
let finalizeCalls = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'throwing-finalizer',
|
||||
finalizeContent() {
|
||||
finalizeCalls += 1
|
||||
throw new Error('finalizer violated its total contract')
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('throwing-finalizer'), name: 'throwing-finalizer', arguments: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'Error: finalizer violated its total contract' }],
|
||||
isError: true,
|
||||
})
|
||||
expect(finalizeCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
|
||||
Reference in New Issue
Block a user