Explorar o código

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

Kit Langton hai 2 meses
pai
achega
130957288e

+ 9 - 2
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 = <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)
 }
 

+ 21 - 0
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<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>(

+ 58 - 0
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<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", () =>