fix(tools): preserve bounded fallback guidance

This commit is contained in:
Tianyi Cui
2026-07-23 03:15:15 +08:00
parent 2821826e2c
commit 94adb60e1a
15 changed files with 144 additions and 21 deletions

View File

@@ -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)).

View File

@@ -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))
}
}

View File

@@ -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({

View File

@@ -10,7 +10,7 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools,
All three use generic ACP cards: `read` for output and list, `execute` for kill.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, and detail. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
## Completion notices

View File

@@ -81,8 +81,17 @@ function fitCompletionNotice(snapshot: TaskSnapshot): string {
const omitted = '\n[notice truncated]'
const fixed = `${prefix}${omitted}${action}`
const fixedBytes = encoder.encode(fixed).byteLength
if (fixedBytes >= maxBytes) return retainHead(fixed, maxBytes)
return `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`
if (fixedBytes <= maxBytes) {
return fixedBytes === maxBytes
? fixed
: `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`
}
const compact = `${prefix}${action}`
const compactBytes = encoder.encode(compact).byteLength
if (compactBytes <= maxBytes) return compact
const actionBytes = encoder.encode(action).byteLength
if (actionBytes >= maxBytes) return retainTail(action, maxBytes)
return `${retainHead(prefix, maxBytes - actionBytes)}${action}`
}
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {

View File

@@ -424,6 +424,53 @@ describe('completion notices', () => {
expect(notice).toContain('[notice truncated]\nDone; task_output.')
})
it('keeps the complete PTY task id and collection action at the minimum PTY limit', async () => {
const { ctx } = await setup()
for (let index = 0; index < 99; index += 1) {
const prior = producer({ kind: 'pty-send' })
ctx.tasks.start(prior.spec)
prior.settle({ status: 'completed' })
}
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const target = producer({
owner,
kind: 'pty-send',
label: 'x'.repeat(1_000),
outputLimitBytes: 64,
})
ctx.tasks.start(target.spec)
target.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
await tick()
const content = inject.mock.calls[0]?.[0] as Array<{ type: string; text?: string }> | undefined
const notice = content?.[0]?.text ?? ''
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64)
expect(notice).toBe('background task pty-send-100\nDone; task_output.')
})
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
ctx.tasks.start(tiny.spec)
ctx.tasks.start(short.spec)
tiny.settle({ status: 'completed' })
short.settle({ status: 'completed' })
await tick()
const tinyNotice = (inject.mock.calls[0]?.[0] as Array<{ text?: string }> | undefined)?.[0]?.text ?? ''
const shortNotice = (inject.mock.calls[1]?.[0] as Array<{ text?: string }> | undefined)?.[0]?.text ?? ''
expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8)
expect(tinyNotice).toBe('_output.')
expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32)
expect(shortNotice).toBe('background ta\nDone; task_output.')
})
it('suppresses the notice for a task the model already killed', async () => {
const { ctx } = await setup()
const inject = vi.fn()