fix(llm): preserve structured tool errors (#33405)

This commit is contained in:
Kit Langton 2026-06-22 19:43:07 +02:00 committed by GitHub
parent c5a4a8288c
commit 130957288e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 88 additions and 2 deletions

View File

@ -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)
}

View File

@ -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>(

View File

@ -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", () =>