fix(llm): preserve tool error defects (#27403)

This commit is contained in:
Kit Langton
2026-05-13 20:43:32 -04:00
committed by GitHub
parent 10c90eb445
commit ba5c8d3822
4 changed files with 48 additions and 23 deletions
+1
View File
@@ -198,5 +198,6 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
*/ */
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", { export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
message: Schema.String, message: Schema.String,
error: Schema.optional(Schema.Defect),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {} }) {}
+1
View File
@@ -171,6 +171,7 @@ export const ToolError = Schema.Struct({
id: ToolCallID, id: ToolCallID,
name: Schema.String, name: Schema.String,
message: Schema.String, message: Schema.String,
error: Schema.optional(Schema.Defect),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolError" }) }).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type<typeof ToolError> export type ToolError = Schema.Schema.Type<typeof ToolError>
+26 -10
View File
@@ -112,17 +112,29 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
const dispatched = yield* Effect.forEach( const dispatched = yield* Effect.forEach(
state.toolCalls, state.toolCalls,
(call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)), (call) =>
dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)),
{ concurrency }, { concurrency },
) )
const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) const resultStream = Stream.fromIterable(
dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)),
)
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream)) if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream)) if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
return resultStream.pipe( return resultStream.pipe(
Stream.concat( Stream.concat(
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata), loop(
followUpRequest(
request,
state,
dispatched.map(([call, result]) => [call, result] as const),
),
step + 1,
totalUsage,
totalProviderMetadata,
),
), ),
) )
}), }),
@@ -215,7 +227,7 @@ const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
| "reasoningTokens" | "reasoningTokens"
| "totalTokens" | "totalTokens"
const sum = (key: UsageKey) => const sum = (key: UsageKey) =>
left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0) left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0)
return new Usage({ return new Usage({
inputTokens: sum("inputTokens"), inputTokens: sum("inputTokens"),
@@ -264,16 +276,20 @@ const appendStreamingText = (
state.assistantContent.push({ type, text, providerMetadata }) state.assistantContent.push({ type, text, providerMetadata })
} }
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => { const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
const tool = tools[call.name] const tool = tools[call.name]
if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` }) if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
if (!tool.execute) if (!tool.execute)
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` }) return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } })
return decodeAndExecute(tool, call).pipe( return decodeAndExecute(tool, call).pipe(
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), Effect.succeed({
result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
error: failure.error,
}),
), ),
Effect.map((result) => ("result" in result ? result : { result })),
) )
} }
@@ -294,10 +310,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })), Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
) )
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> => const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
result.type === "error" result.type === "error"
? [ ? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
LLMEvent.toolResult({ id: call.id, name: call.name, result }), LLMEvent.toolResult({ id: call.id, name: call.name, result }),
] ]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })] : [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
+20 -13
View File
@@ -25,6 +25,7 @@ const baseRequest = LLM.request({
model, model,
prompt: "Use the tool.", prompt: "Use the tool.",
}) })
const weatherFailureCause = new Error("weather lookup denied")
const get_weather = tool({ const get_weather = tool({
description: "Get current weather for a city.", description: "Get current weather for a city.",
@@ -32,7 +33,8 @@ const get_weather = tool({
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
execute: ({ city }) => execute: ({ city }) =>
Effect.gen(function* () { Effect.gen(function* () {
if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` }) if (city === "FAIL")
return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
return { temperature: 22, condition: "sunny" } return { temperature: 22, condition: "sunny" }
}), }),
}) })
@@ -85,23 +87,27 @@ describe("LLMClient tools", () => {
tools: { get_weather }, tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer)) }).pipe(Stream.runCollect, Effect.provide(layer))
const second = bodies[1] as { const second = bodies[1]
readonly messages?: ReadonlyArray<Record<string, unknown>> if (!second || typeof second !== "object") throw new Error("Expected second request body")
readonly tools?: ReadonlyArray<unknown> const messages = Reflect.get(second, "messages")
readonly tool_choice?: unknown const tools = Reflect.get(second, "tools")
readonly max_tokens?: unknown
}
expect(second.max_tokens).toBe(50) expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(second.tool_choice).toBe("auto") expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(second.tools).toHaveLength(1) expect(tools).toHaveLength(1)
expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(
expect(second.messages?.[1]).toMatchObject({ Array.isArray(messages)
? messages.map((message) =>
message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
)
: undefined,
).toEqual(["user", "assistant", "tool"])
expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
role: "assistant", role: "assistant",
content: null, content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }], tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
}) })
expect(second.messages?.[2]).toMatchObject({ expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
role: "tool", role: "tool",
tool_call_id: "call_1", tool_call_id: "call_1",
content: '{"temperature":22,"condition":"sunny"}', content: '{"temperature":22,"condition":"sunny"}',
@@ -327,6 +333,7 @@ describe("LLMClient tools", () => {
const toolError = events.find(LLMEvent.is.toolError) const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
expect(toolError?.message).toBe("Weather lookup failed for FAIL") expect(toolError?.message).toBe("Weather lookup failed for FAIL")
expect(toolError?.error).toBe(weatherFailureCause)
}), }),
) )