Skip to content

Commit 1309572

Browse files
authored
fix(llm): preserve structured tool errors (#33405)
1 parent c5a4a82 commit 1309572

3 files changed

Lines changed: 88 additions & 2 deletions

File tree

packages/llm/src/protocols/shared.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { isRecord }
1919
export const Json = Schema.fromJsonString(Schema.Unknown)
2020
export const decodeJson = Schema.decodeUnknownSync(Json)
2121
export const encodeJson = Schema.encodeSync(Json)
22+
const isJson = Schema.is(Schema.Json)
2223
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
2324
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
2425
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
243244
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
244245

245246
export const toolResultText = (part: ToolResultPart) => {
246-
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
247-
if (part.result.type === "content") return encodeJson(part.result.value)
247+
if (part.result.type === "text") return String(part.result.value)
248+
if (part.result.type === "error") {
249+
const value = part.result.value
250+
const prototype =
251+
typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value)
252+
const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null
253+
return structured && isJson(value) ? encodeJson(value) : String(value)
254+
}
248255
return encodeJson(part.result.value)
249256
}
250257

packages/llm/test/provider/openai-chat.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,27 @@ describe("OpenAI Chat route", () => {
224224
}),
225225
)
226226

227+
it.effect("preserves structured tool errors for the model", () =>
228+
Effect.gen(function* () {
229+
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
230+
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
231+
LLM.request({
232+
model,
233+
messages: [
234+
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
235+
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }),
236+
],
237+
}),
238+
)
239+
240+
expect(prepared.body.messages.at(-1)).toEqual({
241+
role: "tool",
242+
tool_call_id: "call_1",
243+
content: ProviderShared.encodeJson(error),
244+
})
245+
}),
246+
)
247+
227248
it.effect("continues image tool results as vision input without base64 text", () =>
228249
Effect.gen(function* () {
229250
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(

packages/llm/test/provider/openai-responses.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,64 @@ describe("OpenAI Responses route", () => {
360360
}),
361361
)
362362

363+
it.effect("preserves structured tool errors for the model", () =>
364+
Effect.gen(function* () {
365+
const error = {
366+
error: { type: "unknown", message: "Tool execution interrupted" },
367+
content: [],
368+
structured: {},
369+
}
370+
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
371+
LLM.request({
372+
model,
373+
messages: [
374+
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]),
375+
Message.tool({
376+
id: "call_1",
377+
name: "bash",
378+
resultType: "error",
379+
result: error,
380+
}),
381+
],
382+
}),
383+
)
384+
385+
expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error))
386+
}),
387+
)
388+
389+
it.effect("keeps primitive tool errors as plain text", () =>
390+
Effect.gen(function* () {
391+
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
392+
LLM.request({
393+
model,
394+
messages: [
395+
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
396+
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }),
397+
],
398+
}),
399+
)
400+
401+
expect(expectToolOutput(prepared.body).output).toBe("503")
402+
}),
403+
)
404+
405+
it.effect("keeps non-JSON tool errors as plain text", () =>
406+
Effect.gen(function* () {
407+
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
408+
LLM.request({
409+
model,
410+
messages: [
411+
Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
412+
Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }),
413+
],
414+
}),
415+
)
416+
417+
expect(expectToolOutput(prepared.body).output).toBe("Error: boom")
418+
}),
419+
)
420+
363421
// Regression: screenshot/read tool results must stay structured so base64
364422
// image data is not JSON-stringified into `function_call_output.output`.
365423
it.effect("lowers image tool-result content as structured input_image items", () =>

0 commit comments

Comments
 (0)