From fb5292cee5bc462694fe2ffb37895e38785fcbc3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:02:46 +0800 Subject: [PATCH] fix(tools): classify body snapshot failures --- packages/core/tools/src/index.ts | 19 ++++++++++++++----- packages/core/tools/tests/tools.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 37c444bdf5..565e06eb70 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -405,6 +405,18 @@ function snapshotProjection(toolName: string, projector: 'render' | 'presenta } } +/** Snapshot one body or policy value into the canonical invalid-output failure class. */ +function snapshotToolValue(toolName: string, candidate: unknown): JsonValue { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON']) + return detached as JsonValue + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`]) + } +} + /** Successful canonical tool execution, including its Native/model projection. */ export interface ToolExecutionSuccess { readonly isError: false @@ -1316,13 +1328,10 @@ export class ToolRegistry extends Service { /** Snapshot, validate, render, and optionally project one successful body value. */ private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) { - throw new ToolOutputError(tool.name, ['value is not lossless JSON']) - } + const detached = snapshotToolValue(tool.name, candidate) const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') if (violations.length > 0) throw new ToolOutputError(tool.name, violations) - const value = deepFreeze(detached as JsonValue) + const value = deepFreeze(detached) let rendered: ContentBlock[] try { rendered = tool.output.render(exec.arguments, value) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 49ec56d14d..b23d99f405 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -193,6 +193,28 @@ describe('ToolRegistry', () => { expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string') }) + it('classifies a throwing body snapshot as invalid tool output', async () => { + const ctx = await setup() + const hostile = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { throw new Error('body snapshot getter exploded') }, + }) + ctx.tools.register(defineTool({ + name: 'hostile-body', + description: 'hostile body', + parameters: {}, + output: { schema: { type: 'json' }, render: () => [] }, + execute: async () => hostile as JsonValue, + })) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('hostile-body'), name: 'hostile-body', arguments: {}, + }) + expect(result.error?.message).toContain('value snapshot failed: body snapshot getter exploded') + expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) + }) + it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => { const ctx = await setup() ctx.tools.register(defineTool({