diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 66b353c82..c5b6003fd 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -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 = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (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) } diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3..5dbc89f1a 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -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( + 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( diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 717a7e802..b854537fe 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -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( + 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( + 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( + 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", () =>