diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index b2d8fa70dc..3abc8e9fb9 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -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, + }), + ], ] } diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 82a69b059e..458b2e47e7 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -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 diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index d03cb003ff..15b930c3e0 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -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, diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 3072af836f..aad8fce5f9 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -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 tool call `. */ -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, }), }), }) diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 8e07a64bfe..233713f0d5 100644 --- a/packages/ai/src/protocols/utils/tool-stream.ts +++ b/packages/ai/src/protocols/utils/tool-stream.ts @@ -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 = ( @@ -158,8 +173,8 @@ export const appendExisting = ( /** * 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 = (route: string, tools: State, key: K) => Effect.gen(function* () { @@ -186,7 +201,12 @@ export const finishWithInput = (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), ], } diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index d4bb693a32..39b185726b 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -107,7 +107,9 @@ export class InvalidProviderOutputReason extends Schema.Class @@ -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 +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 + 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.make({ ...input, id: toolCallID(input.id) }), toolInputEnd: (input: WithID) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }), + toolInputError: (input: WithID) => + ToolInputError.make({ ...input, id: toolCallID(input.id) }), toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), toolResult: (input: WithID) => 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": diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index dcb20aa0ae..2fd0628593 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -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( + 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( diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index c7b519d368..222981bb99 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -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( diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 1dc253c0ea..ff38a7aaa5 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -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({ diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index e2efa1717d..64f6456445 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -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( diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 7548340690..b3bc39128c 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -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 = { diff --git a/packages/ai/test/tool-stream.test.ts b/packages/ai/test/tool-stream.test.ts index 487bc9624c..27721952fe 100644 --- a/packages/ai/test/tool-stream.test.ts +++ b/packages/ai/test/tool-stream.test.ts @@ -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(), 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(), 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 = ToolStream.start(ToolStream.empty(), 0, { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index fc8720d212..3ae799a87e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -161,6 +161,8 @@ export type SessionMessageProviderState6 = { [x: string]: any } export type SessionMessageProviderState7 = { [x: string]: any } +export type SessionMessageProviderState8 = { [x: string]: any } + export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number } export type ModelCapabilities = { tools: boolean; input: Array; output: Array } @@ -735,16 +737,6 @@ export type SessionTextEnded = { data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string } } -export type SessionToolInputStarted = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.tool.input.started" - durable: { aggregateID: string; seq: number; version: 1 } - location?: LocationRef - data: { sessionID: string; assistantMessageID: string; callID: string; name: string } -} - export type SessionToolInputEnded = { id: string created: number @@ -1366,6 +1358,23 @@ export type SessionReasoningEnded = { } } +export type SessionToolInputStarted = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.input.started" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + callID: string + name: string + executed?: boolean + state?: SessionMessageProviderState5 + } +} + export type SessionToolCalled = { id: string created: number @@ -1379,7 +1388,7 @@ export type SessionToolCalled = { callID: string input: { [x: string]: any } executed: boolean - state?: SessionMessageProviderState5 + state?: SessionMessageProviderState6 } } @@ -1395,9 +1404,10 @@ export type SessionToolFailed = { assistantMessageID: string callID: string error: SessionStructuredError + raw?: string result?: any executed: boolean - resultState?: SessionMessageProviderState7 + resultState?: SessionMessageProviderState8 } } @@ -1877,7 +1887,7 @@ export type SessionToolSuccess = { content: Array result?: any executed: boolean - resultState?: SessionMessageProviderState6 + resultState?: SessionMessageProviderState7 } } diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 4b463e872e..6b85b7fd56 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -324,6 +324,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "tool", id: event.data.callID, name: event.data.name, + executed: event.data.executed, + providerState: event.data.state, time: { created: event.created }, state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), }), @@ -396,7 +398,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, - raw: typeof match.state.input === "string" ? match.state.input : undefined, + raw: event.data.raw ?? (typeof match.state.input === "string" ? match.state.input : undefined), structured: match.state.status === "running" ? match.state.structured : {}, content: match.state.status === "running" ? match.state.content : [], result: event.data.result, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 96155ef06e..62c80554f1 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -28,6 +28,8 @@ import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { SessionUsage } from "../usage" +const MAX_MALFORMED_TOOL_RETRIES = 4 + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -80,6 +82,7 @@ const layer = Layer.effect( sessionID: SessionSchema.ID, promotion: SessionPending.Delivery | undefined, step: number, + recoverMalformedToolInput: boolean, recoverOverflow?: typeof compaction.compact, assistantMessageID?: SessionMessage.ID, ) { @@ -143,7 +146,7 @@ const layer = Layer.effect( } } yield* publish(event) - if (event.type !== "tool-call" || event.providerExecuted) return + if (event.type !== "tool-call" || publisher.isProviderExecuted(event.id)) return const tool = prepared.resolveToolCall(event.name) if (tool.type === "reject") { yield* serialized(publisher.failUnsettledTools(tool.error)) @@ -244,13 +247,35 @@ const layer = Layer.effect( if (overflowFailure) yield* publish(overflowFailure) const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined const malformedToolInput = - llmFailure?.reason._tag === "InvalidProviderOutput" && llmFailure.reason.source === "tool-input" - const recoveredMalformedToolInput = malformedToolInput + llmFailure?.reason._tag === "InvalidProviderOutput" && + llmFailure.reason.source === "tool-input" && + llmFailure.reason.toolCallID !== undefined && + llmFailure.reason.toolName !== undefined && + llmFailure.reason.raw !== undefined + ? { + id: llmFailure.reason.toolCallID, + name: llmFailure.reason.toolName, + raw: llmFailure.reason.raw, + providerExecuted: llmFailure.reason.providerExecuted, + providerMetadata: llmFailure.reason.providerMetadata, + } + : undefined + const failedMalformedToolInput = malformedToolInput ? yield* serialized( - publisher.failUnsettledTools({ - type: "provider.invalid-output", - message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", - }), + publisher.failMalformedToolInput( + { + id: malformedToolInput.id, + name: malformedToolInput.name, + raw: malformedToolInput.raw, + providerExecuted: malformedToolInput.providerExecuted, + providerMetadata: malformedToolInput.providerMetadata, + }, + { + type: "provider.invalid-output", + message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", + }, + toSessionError(llmFailure), + ), ) : false if (llmFailure && !publisher.hasProviderError()) { @@ -267,6 +292,11 @@ const layer = Layer.effect( } // Provider error events only arrive from the stream, so the flag is final here. const providerFailed = publisher.hasProviderError() + const recoveredMalformedToolInput = + recoverMalformedToolInput && + !providerFailed && + !streamInterrupted && + (failedMalformedToolInput || (stream._tag === "Success" && publisher.hasMalformedToolInput())) // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain // the individual fibers and await all exits before publishing the terminal step event. @@ -346,6 +376,7 @@ const layer = Layer.effect( return { _tag: "Completed", needsContinuation: needsContinuation || recoveredMalformedToolInput, + malformedToolInput: recoveredMalformedToolInput, step: currentStep, } as const }), @@ -356,6 +387,7 @@ const layer = Layer.effect( sessionID: SessionSchema.ID, promotion: SessionPending.Delivery | undefined, step: number, + recoverMalformedToolInput: boolean, ) { // Compaction restarts rebuild the request from compacted history without re-promoting. // Overflow recovery is one-shot: a post-compaction attempt must not recover another @@ -366,7 +398,14 @@ const layer = Layer.effect( let assistantMessageID: SessionMessage.ID | undefined while (true) { const attempt = yield* Effect.suspend(() => - attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID), + attemptStep( + sessionID, + currentPromotion, + currentStep, + recoverMalformedToolInput, + recoverOverflow, + assistantMessageID, + ), ).pipe( Effect.tapError((error) => error instanceof SessionRunnerRetry.RetryableFailure @@ -388,7 +427,12 @@ const layer = Layer.effect( .pipe(Effect.andThen(Effect.fail(error.cause))) }), ) - if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step } + if (attempt._tag === "Completed") + return { + needsContinuation: attempt.needsContinuation, + malformedToolInput: attempt.malformedToolInput, + step: attempt.step, + } if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined yield* Effect.yieldNow currentPromotion = undefined @@ -446,12 +490,19 @@ const layer = Layer.effect( while (shouldRun) { let needsContinuation = true let step = 1 + let malformedToolRetries = 0 // Repeat steps while continuation is needed. A step needs continuation only // when it recorded local tool calls whose results the model has not yet seen; - // a provider error suppresses it. Pending steers also continue the loop so - // interjections are answered before the session goes idle. + // malformed tool input can also continue within its bounded recovery budget. + // A provider error suppresses continuation. Pending steers also continue the + // loop so interjections are answered before the session goes idle. while (needsContinuation) { - const result = yield* runStep(input.sessionID, promotion, step) + const result = yield* runStep( + input.sessionID, + promotion, + step, + malformedToolRetries < MAX_MALFORMED_TOOL_RETRIES, + ) // Steer/queue promotion inside runStep has already made the pending input a visible // user message by this point, so the first-user-message check below is reliable. if (!titleAttempted.has(input.sessionID)) { @@ -459,6 +510,7 @@ const layer = Layer.effect( forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore)) } needsContinuation = result.needsContinuation + malformedToolRetries = result.malformedToolInput ? malformedToolRetries + 1 : 0 step = result.step + 1 if (needsContinuation) { yield* runPendingCompaction(input.sessionID) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 6a71f5d888..711853ff65 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -53,6 +53,7 @@ export const createLLMEventPublisher = (events: Pick() let assistantMessageID = input.assistantMessageID @@ -60,6 +61,7 @@ export const createLLMEventPublisher = (events: Pick) { + const end = Effect.fnUntraced(function* (id: string, state?: Record, value?: string) { const current = chunks.get(id) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) yield* ended( id, - current.values.join(""), + value ?? current.values.join(""), current.ordinal, state === undefined ? current.state : { ...current.state, ...state }, ) @@ -175,7 +177,12 @@ export const createLLMEventPublisher = (events: Pick ${event.name}`)) if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`)) - yield* toolInput.end(event.id) + tool.rawInput = event.input ?? tool.rawInput + yield* toolInput.end(event.id, undefined, event.input) + }) + + const failMalformedToolInput = Effect.fnUntraced(function* ( + event: { + readonly id: string + readonly name: string + readonly raw: string + readonly providerExecuted?: boolean + readonly providerMetadata?: ProviderMetadata + }, + error: SessionError.Error, + assistantError: SessionError.Error, + ) { + if (!tools.has(event.id)) yield* startToolInput(event) + const tool = tools.get(event.id) + if (!tool || tool.called || tool.settled) return false + if (tool.name !== event.name) + return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)) + tool.providerExecuted = event.providerExecuted === true || tool.providerExecuted + const correctedRaw = !toolInput.has(event.id) && tool.rawInput !== event.raw ? event.raw : undefined + if (toolInput.has(event.id)) { + tool.rawInput = event.raw + yield* toolInput.end(event.id, undefined, event.raw) + } + tool.settled = true + malformedToolInput = true + yield* events.publish(SessionEvent.Tool.Failed, { + sessionID: input.sessionID, + assistantMessageID: tool.assistantMessageID, + callID: event.id, + error, + raw: correctedRaw, + executed: tool.providerExecuted, + }) + yield* startAssistant() + if (stepFailure === undefined) stepFailure = assistantError + return true }) const flush = Effect.fn("SessionRunner.flush")(function* () { @@ -210,11 +262,7 @@ export const createLLMEventPublisher = (events: Pick ${event.name}`)) if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`)) + tool.rawInput += event.text yield* toolInput.append(event.id, event.text) yield* events.publish(SessionEvent.Tool.Input.Delta, { sessionID: input.sessionID, @@ -337,16 +386,30 @@ export const createLLMEventPublisher = (events: Pick ${event.name}`)) if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) tool.called = true - tool.providerExecuted = event.providerExecuted === true + tool.providerExecuted = event.providerExecuted === true || tool.providerExecuted yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, @@ -436,10 +499,13 @@ export const createLLMEventPublisher = (events: Pick providerFailed, + hasMalformedToolInput: () => malformedToolInput, hasRetryEvidence: () => retryEvidence, + isProviderExecuted: (callID: string) => tools.get(callID)?.providerExecuted === true, stepFailure: () => stepFailure, stepSettlement: () => stepSettlement, startAssistant, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index e91eef2c28..5fe57b611e 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -110,7 +110,12 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid result: tool.executed === true && tool.state.result !== undefined ? tool.state.result - : { error: tool.state.error, content: tool.state.content, structured: tool.state.structured }, + : { + error: tool.state.error, + content: tool.state.content, + structured: tool.state.structured, + ...(tool.state.raw === undefined ? {} : { raw: tool.state.raw }), + }, resultType: "error", providerExecuted: tool.executed, providerMetadata, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index da67e9e0c1..fd7039b554 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -4134,8 +4134,9 @@ describe("SessionRunnerLLM", () => { method: "stream", reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo", - raw: '{"text":"partial', + raw: '{"text":"authoritative', source: "tool-input", + toolCallID: "call-malformed", toolName: "echo", }), }) @@ -4160,7 +4161,7 @@ describe("SessionRunnerLLM", () => { state: { status: "error", input: {}, - raw: '{"text":"partial', + raw: '{"text":"authoritative', error: { message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", }, @@ -4182,6 +4183,7 @@ describe("SessionRunnerLLM", () => { error: { message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", }, + raw: '{"text":"authoritative', }, }, }, @@ -4209,6 +4211,219 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not recover malformed tool input from an interrupted stream", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Interrupt malformed tool input") + const failure = new LLMError({ + module: "test", + method: "stream", + reason: new InvalidProviderOutputReason({ + message: "Invalid JSON input for tool call echo", + raw: '{"text":"partial', + source: "tool-input", + toolCallID: "call-malformed-interrupted", + toolName: "echo", + }), + }) + responseStream = Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-malformed-interrupted", name: "echo" }), + LLMEvent.toolInputDelta({ + id: "call-malformed-interrupted", + name: "echo", + text: '{"text":"partial', + }), + ]).pipe( + Stream.concat( + Stream.failCause( + Cause.fromReasons([Cause.makeFailReason(failure), Cause.makeInterruptReason(1)]), + ), + ), + ) + + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(requests).toHaveLength(1) + }), + ) + + it.effect("settles a valid sibling tool before recovering malformed input", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Run one valid and one malformed tool") + toolExecutionGate = yield* Deferred.make() + toolExecutionsStarted = yield* Deferred.make() + toolExecutionsReady = 1 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }), + LLMEvent.toolInputStart({ id: "call-malformed-sibling", name: "echo" }), + LLMEvent.toolInputDelta({ + id: "call-malformed-sibling", + name: "echo", + text: '{"text":"partial', + }), + LLMEvent.toolInputEnd({ id: "call-malformed-sibling", name: "echo" }), + LLMEvent.toolInputError({ + id: "call-malformed-sibling", + name: "echo", + raw: '{"text":"partial', + message: "Invalid JSON input for tool call echo", + }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + reply.stop(), + ] + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(toolExecutionsStarted) + yield* Deferred.succeed(toolExecutionGate, undefined) + yield* Fiber.join(run) + toolExecutionGate = undefined + toolExecutionsStarted = undefined + + expect(requests).toHaveLength(2) + expect(executions).toEqual(["valid"]) + expect(requireAssistant(yield* session.context(sessionID)).content).toMatchObject([ + { id: "call-valid", state: { status: "completed", input: { text: "valid" } } }, + { + id: "call-malformed-sibling", + state: { status: "error", raw: '{"text":"partial' }, + }, + ]) + }), + ) + + it.effect("limits malformed tool input recovery to four retries", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Keep calling malformed tools") + responses = Array.from({ length: 5 }, (_, index) => { + const id = `call-malformed-${index}` + return [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id, name: "echo" }), + LLMEvent.toolInputDelta({ id, name: "echo", text: '{"text":"partial' }), + LLMEvent.toolInputEnd({ id, name: "echo" }), + LLMEvent.toolInputError({ + id, + name: "echo", + raw: '{"text":"partial', + message: "Invalid JSON input for tool call echo", + }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ] + }) + + expect(yield* session.resume(sessionID).pipe(Effect.flip)).toMatchObject({ + _tag: "Session.StepFailedError", + error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" }, + }) + expect(requests).toHaveLength(5) + expect(executions).toEqual([]) + }), + ) + + it.effect("replays malformed hosted tool input as provider-executed", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Recover a malformed hosted tool") + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ + id: "call-hosted-malformed", + name: "web_search", + providerExecuted: true, + }), + LLMEvent.toolInputDelta({ + id: "call-hosted-malformed", + name: "web_search", + text: '{"query":"partial', + }), + LLMEvent.toolInputEnd({ id: "call-hosted-malformed", name: "web_search" }), + LLMEvent.toolInputError({ + id: "call-hosted-malformed", + name: "web_search", + raw: '{"query":"partial', + message: "Invalid JSON input for Anthropic Messages tool call web_search", + providerExecuted: true, + }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + reply.stop(), + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[1].messages).toMatchObject([ + { role: "user" }, + { + role: "assistant", + content: [ + { + type: "tool-call", + id: "call-hosted-malformed", + providerExecuted: true, + }, + { + type: "tool-result", + id: "call-hosted-malformed", + providerExecuted: true, + }, + ], + }, + ]) + expect(requireAssistant(yield* session.context(sessionID)).content).toMatchObject([ + { id: "call-hosted-malformed", executed: true, state: { status: "error" } }, + ]) + yield* replaySessionProjection(sessionID) + expect(requireAssistant(yield* session.context(sessionID)).content).toMatchObject([ + { id: "call-hosted-malformed", executed: true, state: { status: "error" } }, + ]) + }), + ) + + it.effect("preserves provider execution identity when the final tool events omit it", () => + Effect.gen(function* () { + const session = yield* setup + yield* admit(session, "Keep hosted tool identity") + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-hosted-start-only", name: "echo", providerExecuted: true }), + LLMEvent.toolInputDelta({ id: "call-hosted-start-only", name: "echo", text: '{"text":"hosted"}' }), + LLMEvent.toolInputEnd({ id: "call-hosted-start-only", name: "echo" }), + LLMEvent.toolCall({ id: "call-hosted-start-only", name: "echo", input: { text: "hosted" } }), + LLMEvent.toolResult({ + id: "call-hosted-start-only", + name: "echo", + result: { type: "json", value: { text: "hosted" } }, + }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(executions).toEqual([]) + expect(requireAssistant(yield* session.context(sessionID)).content).toMatchObject([ + { + id: "call-hosted-start-only", + executed: true, + state: { status: "completed", input: { text: "hosted" } }, + }, + ]) + }), + ) + it.effect("does not continue automatically after a provider error follows a local tool call", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index aaeb6565c3..e22f7bc09b 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -371,6 +371,8 @@ export namespace Tool { schema: { ...ToolBase, name: Schema.String, + executed: Schema.Boolean.pipe(optional), + state: SessionMessage.ProviderState.pipe(optional), }, }) export type Started = typeof Started.Type @@ -443,6 +445,7 @@ export namespace Tool { schema: { ...ToolBase, error: SessionError.Error, + raw: Schema.String.pipe(optional), result: Schema.Unknown.pipe(optional), executed: Schema.Boolean, resultState: SessionMessage.ProviderState.pipe(optional), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b742ed2f40..760ea5c700 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1020,6 +1020,8 @@ export type GlobalEvent = { assistantMessageID: string callID: string name: string + executed?: boolean + state?: SessionMessageProviderState } } | { @@ -1093,6 +1095,7 @@ export type GlobalEvent = { assistantMessageID: string callID: string error: SessionStructuredError + raw?: string result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -4117,6 +4120,8 @@ export type SyncEventSessionToolInputStarted = { assistantMessageID: string callID: string name: string + executed?: boolean + state?: SessionMessageProviderState } } } @@ -4215,6 +4220,7 @@ export type SyncEventSessionToolFailed = { assistantMessageID: string callID: string error: SessionStructuredError + raw?: string result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -4639,6 +4645,7 @@ export type SessionMessageToolStateError = { input: { [key: string]: unknown } + raw?: string content: Array structured: { [key: string]: unknown @@ -5275,6 +5282,8 @@ export type SessionToolInputStarted = { assistantMessageID: string callID: string name: string + executed?: boolean + state?: SessionMessageProviderState } } @@ -5393,6 +5402,7 @@ export type SessionToolFailed = { assistantMessageID: string callID: string error: SessionStructuredError + raw?: string result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -7479,6 +7489,8 @@ export type EventSessionToolInputStarted = { assistantMessageID: string callID: string name: string + executed?: boolean + state?: SessionMessageProviderState } } @@ -7558,6 +7570,7 @@ export type EventSessionToolFailed = { assistantMessageID: string callID: string error: SessionStructuredError + raw?: string result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -9607,6 +9620,10 @@ export type SessionReasoningEndedV2 = { } } +export type SessionMessageProviderState5 = { + [key: string]: unknown +} + export type SessionToolInputStartedV2 = { id: string created: number @@ -9625,6 +9642,8 @@ export type SessionToolInputStartedV2 = { assistantMessageID: string callID: string name: string + executed?: boolean + state?: SessionMessageProviderState5 } } @@ -9649,7 +9668,7 @@ export type SessionToolInputEndedV2 = { } } -export type SessionMessageProviderState5 = { +export type SessionMessageProviderState6 = { [key: string]: unknown } @@ -9674,7 +9693,7 @@ export type SessionToolCalledV2 = { [key: string]: unknown } executed: boolean - state?: SessionMessageProviderState5 + state?: SessionMessageProviderState6 } } @@ -9702,7 +9721,7 @@ export type SessionToolProgressV2 = { } } -export type SessionMessageProviderState6 = { +export type SessionMessageProviderState7 = { [key: string]: unknown } @@ -9729,11 +9748,11 @@ export type SessionToolSuccessV2 = { content: Array result?: unknown executed: boolean - resultState?: SessionMessageProviderState6 + resultState?: SessionMessageProviderState7 } } -export type SessionMessageProviderState7 = { +export type SessionMessageProviderState8 = { [key: string]: unknown } @@ -9755,9 +9774,10 @@ export type SessionToolFailedV2 = { assistantMessageID: string callID: string error: SessionStructuredError + raw?: string result?: unknown executed: boolean - resultState?: SessionMessageProviderState7 + resultState?: SessionMessageProviderState8 } }