fix(llm): preserve structured tool errors (#33405)
This commit is contained in:
parent
c5a4a8288c
commit
130957288e
@ -19,6 +19,7 @@ export { isRecord }
|
||||
export const Json = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownSync(Json)
|
||||
export const encodeJson = Schema.encodeSync(Json)
|
||||
const isJson = Schema.is(Schema.Json)
|
||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
@ -243,8 +244,14 @@ export const validateToolFile = (route: string, part: ToolFileContent, supported
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
export const toolResultText = (part: ToolResultPart) => {
|
||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
||||
if (part.result.type === "content") return encodeJson(part.result.value)
|
||||
if (part.result.type === "text") return String(part.result.value)
|
||||
if (part.result.type === "error") {
|
||||
const value = part.result.value
|
||||
const prototype =
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value)
|
||||
const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null
|
||||
return structured && isJson(value) ? encodeJson(value) : String(value)
|
||||
}
|
||||
return encodeJson(part.result.value)
|
||||
}
|
||||
|
||||
|
||||
@ -224,6 +224,27 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves structured tool errors for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.at(-1)).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call_1",
|
||||
content: ProviderShared.encodeJson(error),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues image tool results as vision input without base64 text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
|
||||
@ -360,6 +360,64 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves structured tool errors for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = {
|
||||
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "bash",
|
||||
resultType: "error",
|
||||
result: error,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps primitive tool errors as plain text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toBe("503")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps non-JSON tool errors as plain text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toBe("Error: boom")
|
||||
}),
|
||||
)
|
||||
|
||||
// Regression: screenshot/read tool results must stay structured so base64
|
||||
// image data is not JSON-stringified into `function_call_output.output`.
|
||||
it.effect("lowers image tool-result content as structured input_image items", () =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user