fix(tools): classify body snapshot failures
This commit is contained in:
@@ -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. */
|
/** Successful canonical tool execution, including its Native/model projection. */
|
||||||
export interface ToolExecutionSuccess {
|
export interface ToolExecutionSuccess {
|
||||||
readonly isError: false
|
readonly isError: false
|
||||||
@@ -1316,13 +1328,10 @@ export class ToolRegistry extends Service {
|
|||||||
|
|
||||||
/** Snapshot, validate, render, and optionally project one successful body value. */
|
/** Snapshot, validate, render, and optionally project one successful body value. */
|
||||||
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
|
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
|
||||||
const detached = snapshotJsonValue(candidate)
|
const detached = snapshotToolValue(tool.name, candidate)
|
||||||
if (detached === undefined) {
|
|
||||||
throw new ToolOutputError(tool.name, ['value is not lossless JSON'])
|
|
||||||
}
|
|
||||||
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
|
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
|
||||||
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
|
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
|
||||||
const value = deepFreeze(detached as JsonValue)
|
const value = deepFreeze(detached)
|
||||||
let rendered: ContentBlock[]
|
let rendered: ContentBlock[]
|
||||||
try {
|
try {
|
||||||
rendered = tool.output.render(exec.arguments, value)
|
rendered = tool.output.render(exec.arguments, value)
|
||||||
|
|||||||
@@ -193,6 +193,28 @@ describe('ToolRegistry', () => {
|
|||||||
expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string')
|
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) => {
|
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
|
|||||||
Reference in New Issue
Block a user