fix(ai): preserve provider tool input identity
This commit is contained in:
parent
7840a0d378
commit
0f26246bfd
21 changed files with 833 additions and 74 deletions
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue