fix(ai): preserve provider tool input identity

This commit is contained in:
starptech 2026-07-19 00:54:08 +02:00
commit 0f26246bfd
21 changed files with 833 additions and 74 deletions

View file

@ -316,7 +316,21 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
const errorType = `${wireType}_error`
const syntheticErrorCode =
ProviderShared.isRecord(part.result.value) &&
ProviderShared.isRecord(part.result.value.error) &&
part.result.value.error.type === "provider.invalid-output"
? "invalid_tool_input"
: "unavailable"
const content =
part.result.type !== "error" ||
(ProviderShared.isRecord(part.result.value) &&
part.result.value.type === errorType &&
typeof part.result.value.error_code === "string")
? part.result.value
: { type: errorType, error_code: syntheticErrorCode }
return { type: wireType, tool_use_id: part.id, content } satisfies AnthropicServerToolResultBlock
})
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
@ -703,7 +717,14 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
providerExecuted: block.type === "server_tool_use",
}),
},
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
[
...events,
LLMEvent.toolInputStart({
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
],
]
}

View file

@ -441,6 +441,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
const providerMetadata = part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
@ -448,14 +451,27 @@ const step = (state: ParserState, event: GeminiEvent) => {
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
if (typeof input === "string") {
events.push(
LLMEvent.toolInputStart({ id, name: part.functionCall.name, providerMetadata }),
LLMEvent.toolInputEnd({ id, name: part.functionCall.name, input, providerMetadata }),
LLMEvent.toolInputError({
id,
name: part.functionCall.name,
raw: input,
message: `Invalid JSON input for ${ADAPTER} tool call ${part.functionCall.name}`,
providerMetadata,
}),
)
hasToolCalls = true
continue
}
events.push(
LLMEvent.toolCall({
id,
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
providerMetadata,
}),
)
hasToolCalls = true

View file

@ -820,22 +820,33 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
const existing = state.tools[item.id]
const providerMetadata = openaiMetadata({ itemId: item.id })
const tools = existing
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(ADAPTER, tools, item.id)
: yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const resultEvents = [
...(existing ? [] : [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata })]),
...(result.events ?? []),
]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
{
...state,
lifecycle,
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
hasFunctionCall:
state.hasFunctionCall ||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
tools: result.tools,
},
events,

View file

@ -9,6 +9,7 @@ import {
type ContentPart,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ToolFileContent,
type TextPart,
type ToolResultPart,
@ -152,7 +153,16 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
* input deltas (e.g. zero-arg tools). The error message is uniform across
* routes: `Invalid JSON input for <route> tool call <name>`.
*/
export const parseToolInput = (route: string, name: string, raw: string) =>
export const parseToolInput = (
route: string,
tool: {
readonly id: string
readonly name: string
readonly providerExecuted?: boolean
readonly providerMetadata?: ProviderMetadata
},
raw: string,
) =>
Effect.try({
try: () => decodeJson(raw || "{}"),
catch: () =>
@ -161,10 +171,13 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
method: "stream",
reason: new InvalidProviderOutputReason({
route,
message: `Invalid JSON input for ${route} tool call ${name}`,
message: `Invalid JSON input for ${route} tool call ${tool.name}`,
raw,
source: "tool-input",
toolName: name,
toolCallID: tool.id,
toolName: tool.name,
providerExecuted: tool.providerExecuted,
providerMetadata: tool.providerMetadata,
}),
}),
})

View file

@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
})
@ -63,8 +64,9 @@ const inputDelta = (tool: PendingTool, text: string) =>
text,
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool, raw).pipe(
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
@ -75,7 +77,20 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
providerMetadata: tool.providerMetadata,
}),
),
Effect.match({
onFailure: (error) =>
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
message: error.reason.message,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
onSuccess: (event) => event,
}),
)
}
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@ -158,8 +173,8 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and return the optional public `tool-call` event. Missing keys are
* a no-op because some providers emit stop events for non-tool content blocks.
* from state, and emit either `tool-call` or `tool-input-error`. Missing keys
* are a no-op because some providers emit stop events for non-tool blocks.
*/
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
Effect.gen(function* () {
@ -186,7 +201,12 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
LLMEvent.toolInputEnd({
id: tool.id,
name: tool.name,
input,
providerMetadata: tool.providerMetadata,
}),
yield* toolCall(route, tool, input),
],
}

View file

@ -107,7 +107,9 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
source: Schema.optional(Schema.Literal("tool-input")),
toolCallID: Schema.optional(Schema.String),
toolName: Schema.optional(Schema.String),
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}

View file

@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
@ -145,10 +146,22 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
input: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
name: Schema.String,
raw: Schema.String,
message: Schema.String,
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputError" })
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
@ -216,6 +229,7 @@ const llmEventTagged = Schema.Union([
ToolInputStart,
ToolInputDelta,
ToolInputEnd,
ToolInputError,
ToolCall,
ToolResult,
ToolError,
@ -253,6 +267,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
ToolResult.make({
@ -283,6 +299,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
toolInputError: llmEventTagged.guards["tool-input-error"],
toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
@ -498,6 +515,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: {
...current,
name: event.name,
text: event.input ?? current.text,
providerMetadata: event.providerMetadata ?? current.providerMetadata,
},
},
@ -548,6 +566,8 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
return reduceToolInputDelta(next, event)
case "tool-input-end":
return reduceToolInputEnd(next, event)
case "tool-input-error":
return next
case "tool-call":
return reduceToolCall(next, event)
case "tool-result":

View file

@ -617,6 +617,43 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves provider execution identity for malformed server tool input", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id: "srvtoolu_malformed", name: "web_search" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find((event) => event.type === "tool-input-start")).toMatchObject({
type: "tool-input-start",
id: "srvtoolu_malformed",
providerExecuted: true,
})
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
id: "srvtoolu_malformed",
raw: '{"query":"partial',
providerExecuted: true,
})
}),
)
it.effect("decodes web_search_tool_result_error as provider-executed error result", () =>
Effect.gen(function* () {
const body = sseEvents(
@ -708,6 +745,55 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers synthetic server tool failures to valid Anthropic error payloads", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicBody>(
LLM.request({
id: "req_server_tool_input_error",
model,
messages: [
Message.assistant([
{
type: "tool-call",
id: "srvtoolu_malformed",
name: "web_search",
input: {},
providerExecuted: true,
},
{
type: "tool-result",
id: "srvtoolu_malformed",
name: "web_search",
result: {
type: "error",
value: {
error: { type: "provider.invalid-output" },
raw: '{"query":"partial',
},
},
providerExecuted: true,
},
]),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{
role: "assistant",
content: [
{ type: "server_tool_use", id: "srvtoolu_malformed", name: "web_search", input: {} },
{
type: "web_search_tool_result",
tool_use_id: "srvtoolu_malformed",
content: { type: "web_search_tool_result_error", error_code: "invalid_tool_input" },
},
],
},
])
}),
)
it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(

View file

@ -303,6 +303,36 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits malformed streamed tool input without a tool call", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_malformed", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* LLMClient.generate(
LLM.updateRequest(baseRequest, {
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedBytes(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
id: "tool_malformed",
raw: '{"query":"partial',
})
}),
)
it.effect("decodes reasoning deltas", () =>
Effect.gen(function* () {
const body = eventStreamBody(

View file

@ -490,6 +490,35 @@ describe("Gemini route", () => {
}),
)
it.effect("reports string-encoded function arguments without repairing them", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: '{"query":"partial' } }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
id: "tool_0",
name: "lookup",
raw: '{"query":"partial',
})
}),
)
it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({

View file

@ -606,6 +606,33 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves a valid parallel call when another call is malformed", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
role: "assistant",
tool_calls: [
{ index: 0, id: "call_valid", function: { name: "lookup", arguments: '{"query":"weather"}' } },
{ index: 1, id: "call_malformed", function: { name: "lookup", arguments: '{"query":"partial' } },
],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(
response.events.filter((event) => event.type === "tool-call" || event.type === "tool-input-error"),
).toMatchObject([
{ type: "tool-call", id: "call_valid", input: { query: "weather" } },
{ type: "tool-input-error", id: "call_malformed", raw: '{"query":"partial' },
])
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(

View file

@ -1238,6 +1238,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
input: '{"query":"weather"}',
providerMetadata: { openai: { itemId: "item_1" } },
},
{
@ -1259,6 +1260,87 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits malformed function input when output_item.done arrives without added", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_malformed",
call_id: "call_malformed",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(
response.events.filter(
(event) =>
event.type === "tool-input-start" || event.type === "tool-input-end" || event.type === "tool-input-error",
),
).toMatchObject([
{ type: "tool-input-start", id: "call_malformed", name: "lookup" },
{
type: "tool-input-end",
id: "call_malformed",
name: "lookup",
input: '{"query":"partial',
},
{
type: "tool-input-error",
id: "call_malformed",
name: "lookup",
raw: '{"query":"partial',
},
])
}),
)
it.effect("uses malformed final function input instead of valid streamed deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"valid"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find((event) => event.type === "tool-input-end")).toMatchObject({
type: "tool-input-end",
input: '{"query":"partial',
})
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
raw: '{"query":"partial',
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {

View file

@ -57,32 +57,61 @@ describe("ToolStream", () => {
expect(finished).toEqual({
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-input-end", id: "call_1", name: "lookup", input: '{"query":"final"}' },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } },
],
})
}),
)
it.effect("classifies malformed tool input with its raw arguments", () =>
it.effect("emits malformed tool input with stable identity and raw arguments", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_1",
name: "lookup",
input: '{"query":"partial',
})
const error = yield* ToolStream.finish(ADAPTER, tools, 0).pipe(Effect.flip)
const finished = yield* ToolStream.finish(ADAPTER, tools, 0)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
source: "tool-input",
toolName: "lookup",
raw: '{"query":"partial',
expect(finished).toMatchObject({
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
message: "Invalid JSON input for test-route tool call lookup",
},
],
})
}),
)
it.effect("preserves valid sibling calls when one input is malformed", () =>
Effect.gen(function* () {
const first = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
name: "lookup",
input: '{"query":"weather"}',
})
const tools = ToolStream.start(first, 1, {
id: "call_malformed",
name: "lookup",
input: '{"query":"partial',
})
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
expect(
finished.events.filter((event) => event.type === "tool-call" || event.type === "tool-input-error"),
).toMatchObject([
{ type: "tool-call", id: "call_valid", input: { query: "weather" } },
{ type: "tool-input-error", id: "call_malformed", raw: '{"query":"partial' },
])
}),
)
it.effect("preserves providerExecuted and clears all tools", () =>
Effect.gen(function* () {
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {