Browse Source

fix(tools): classify body snapshot failures

Tianyi Cui 2 months ago
parent
commit
fb5292cee5
2 changed files with 36 additions and 5 deletions
  1. 14 5
      packages/core/tools/src/index.ts
  2. 22 0
      packages/core/tools/tests/tools.spec.ts

+ 14 - 5
packages/core/tools/src/index.ts

@@ -405,6 +405,18 @@ function snapshotProjection<T>(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)

+ 22 - 0
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({