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 Json = Schema.fromJsonString(Schema.Unknown)
|
||||||
export const decodeJson = Schema.decodeUnknownSync(Json)
|
export const decodeJson = Schema.decodeUnknownSync(Json)
|
||||||
export const encodeJson = Schema.encodeSync(Json)
|
export const encodeJson = Schema.encodeSync(Json)
|
||||||
|
const isJson = Schema.is(Schema.Json)
|
||||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
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 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))
|
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 trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||||
|
|
||||||
export const toolResultText = (part: ToolResultPart) => {
|
export const toolResultText = (part: ToolResultPart) => {
|
||||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
if (part.result.type === "text") return String(part.result.value)
|
||||||
if (part.result.type === "content") return encodeJson(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)
|
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", () =>
|
it.effect("continues image tool results as vision input without base64 text", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
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
|
// Regression: screenshot/read tool results must stay structured so base64
|
||||||
// image data is not JSON-stringified into `function_call_output.output`.
|
// image data is not JSON-stringified into `function_call_output.output`.
|
||||||
it.effect("lowers image tool-result content as structured input_image items", () =>
|
it.effect("lowers image tool-result content as structured input_image items", () =>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user