fix(tools): preserve canonical output boundaries
This commit is contained in:
@@ -349,6 +349,25 @@ export class ToolOutputError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one projector exception into the canonical invalid-output failure. */
|
||||
function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {
|
||||
return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])
|
||||
}
|
||||
|
||||
/** Snapshot one projector result before later durable-result materialization. */
|
||||
function snapshotProjection<T>(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T {
|
||||
try {
|
||||
const detached = snapshotJsonValue(candidate)
|
||||
if (detached === undefined) {
|
||||
throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`])
|
||||
}
|
||||
return detached
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ToolOutputError) throw error
|
||||
throw projectionError(toolName, projector, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Successful canonical tool execution, including its Native/model projection. */
|
||||
export interface ToolExecutionSuccess {
|
||||
readonly isError: false
|
||||
@@ -1156,10 +1175,23 @@ export class ToolRegistry extends Service {
|
||||
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 content = tool.output.render(exec.arguments, value)
|
||||
const meta = exec.parent === undefined && tool.output.presentationMeta !== undefined
|
||||
? tool.output.presentationMeta(exec.arguments, value)
|
||||
: undefined
|
||||
let rendered: ContentBlock[]
|
||||
try {
|
||||
rendered = tool.output.render(exec.arguments, value)
|
||||
} catch (error: unknown) {
|
||||
throw projectionError(tool.name, 'render', error)
|
||||
}
|
||||
const content = snapshotProjection(tool.name, 'render', rendered)
|
||||
let meta: JsonValue | undefined
|
||||
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
|
||||
let projected: JsonValue
|
||||
try {
|
||||
projected = tool.output.presentationMeta(exec.arguments, value)
|
||||
} catch (error: unknown) {
|
||||
throw projectionError(tool.name, 'presentationMeta', error)
|
||||
}
|
||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||
}
|
||||
return this.markCanonical(this.materializeFinalResult({
|
||||
isError: false,
|
||||
value,
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
|
||||
expect(result.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
@@ -209,10 +210,10 @@ describe('ToolRegistry', () => {
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId(projector), name: `throwing-${projector}`, arguments: {} })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { message: projector === 'render' ? 'renderer exploded' : 'metadata exploded' },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.message)
|
||||
.toContain(projector === 'render' ? 'renderer exploded' : 'metadata exploded')
|
||||
expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
||||
expect('value' in result).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user