mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(ai): preserve provider tool input identity
This commit is contained in:
@@ -316,7 +316,21 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
|||||||
const wireType = serverToolResultType(part.name)
|
const wireType = serverToolResultType(part.name)
|
||||||
if (!wireType)
|
if (!wireType)
|
||||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
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) {
|
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",
|
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) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
const id = `tool_${nextToolCallId++}`
|
||||||
|
const providerMetadata = part.thoughtSignature
|
||||||
|
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
||||||
|
: undefined
|
||||||
lifecycle = Lifecycle.reasoningEnd(
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
lifecycle,
|
lifecycle,
|
||||||
events,
|
events,
|
||||||
@@ -448,14 +451,27 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||||
)
|
)
|
||||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
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(
|
events.push(
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
id,
|
id,
|
||||||
name: part.functionCall.name,
|
name: part.functionCall.name,
|
||||||
input,
|
input,
|
||||||
providerMetadata: part.thoughtSignature
|
providerMetadata,
|
||||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
|
||||||
: undefined,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
hasToolCalls = true
|
hasToolCalls = true
|
||||||
|
|||||||
@@ -820,22 +820,33 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
|||||||
|
|
||||||
if (item.type === "function_call") {
|
if (item.type === "function_call") {
|
||||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
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
|
? 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 =
|
const result =
|
||||||
item.arguments === undefined
|
item.arguments === undefined
|
||||||
? yield* ToolStream.finish(ADAPTER, tools, item.id)
|
? yield* ToolStream.finish(ADAPTER, tools, item.id)
|
||||||
: yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments)
|
: yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments)
|
||||||
const events: LLMEvent[] = []
|
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
|
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||||
events.push(...resultEvents)
|
events.push(...resultEvents)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle,
|
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,
|
tools: result.tools,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ContentPart,
|
type ContentPart,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
|
type ProviderMetadata,
|
||||||
type ToolFileContent,
|
type ToolFileContent,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolResultPart,
|
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
|
* input deltas (e.g. zero-arg tools). The error message is uniform across
|
||||||
* routes: `Invalid JSON input for <route> tool call <name>`.
|
* 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({
|
Effect.try({
|
||||||
try: () => decodeJson(raw || "{}"),
|
try: () => decodeJson(raw || "{}"),
|
||||||
catch: () =>
|
catch: () =>
|
||||||
@@ -161,10 +171,13 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
|
|||||||
method: "stream",
|
method: "stream",
|
||||||
reason: new InvalidProviderOutputReason({
|
reason: new InvalidProviderOutputReason({
|
||||||
route,
|
route,
|
||||||
message: `Invalid JSON input for ${route} tool call ${name}`,
|
message: `Invalid JSON input for ${route} tool call ${tool.name}`,
|
||||||
raw,
|
raw,
|
||||||
source: "tool-input",
|
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({
|
LLMEvent.toolInputStart({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,8 +64,9 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
|||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
const raw = inputOverride ?? tool.input
|
||||||
|
return parseToolInput(route, tool, raw).pipe(
|
||||||
Effect.map(
|
Effect.map(
|
||||||
(input): ToolCall =>
|
(input): ToolCall =>
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
@@ -75,7 +77,20 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
|||||||
providerMetadata: tool.providerMetadata,
|
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. */
|
/** Store the updated tool and produce the optional public delta event. */
|
||||||
const appendTool = <K extends StreamKey>(
|
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
|
* 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
|
* from state, and emit either `tool-call` or `tool-input-error`. Missing keys
|
||||||
* a no-op because some providers emit stop events for non-tool content blocks.
|
* 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) =>
|
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -186,7 +201,12 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
|||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
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),
|
yield* toolCall(route, tool, input),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
|||||||
route: Schema.optional(Schema.String),
|
route: Schema.optional(Schema.String),
|
||||||
raw: Schema.optional(Schema.String),
|
raw: Schema.optional(Schema.String),
|
||||||
source: Schema.optional(Schema.Literal("tool-input")),
|
source: Schema.optional(Schema.Literal("tool-input")),
|
||||||
|
toolCallID: Schema.optional(Schema.String),
|
||||||
toolName: Schema.optional(Schema.String),
|
toolName: Schema.optional(Schema.String),
|
||||||
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
|
|||||||
type: Schema.tag("tool-input-start"),
|
type: Schema.tag("tool-input-start"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||||
@@ -145,10 +146,22 @@ export const ToolInputEnd = Schema.Struct({
|
|||||||
type: Schema.tag("tool-input-end"),
|
type: Schema.tag("tool-input-end"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
input: Schema.optional(Schema.String),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||||
export type ToolInputEnd = Schema.Schema.Type<typeof 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({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.tag("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
@@ -216,6 +229,7 @@ const llmEventTagged = Schema.Union([
|
|||||||
ToolInputStart,
|
ToolInputStart,
|
||||||
ToolInputDelta,
|
ToolInputDelta,
|
||||||
ToolInputEnd,
|
ToolInputEnd,
|
||||||
|
ToolInputError,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
@@ -253,6 +267,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.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) }),
|
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
||||||
ToolResult.make({
|
ToolResult.make({
|
||||||
@@ -283,6 +299,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||||
|
toolInputError: llmEventTagged.guards["tool-input-error"],
|
||||||
toolCall: llmEventTagged.guards["tool-call"],
|
toolCall: llmEventTagged.guards["tool-call"],
|
||||||
toolResult: llmEventTagged.guards["tool-result"],
|
toolResult: llmEventTagged.guards["tool-result"],
|
||||||
toolError: llmEventTagged.guards["tool-error"],
|
toolError: llmEventTagged.guards["tool-error"],
|
||||||
@@ -498,6 +515,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
|||||||
[event.id]: {
|
[event.id]: {
|
||||||
...current,
|
...current,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
|
text: event.input ?? current.text,
|
||||||
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -548,6 +566,8 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
|||||||
return reduceToolInputDelta(next, event)
|
return reduceToolInputDelta(next, event)
|
||||||
case "tool-input-end":
|
case "tool-input-end":
|
||||||
return reduceToolInputEnd(next, event)
|
return reduceToolInputEnd(next, event)
|
||||||
|
case "tool-input-error":
|
||||||
|
return next
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
return reduceToolCall(next, event)
|
return reduceToolCall(next, event)
|
||||||
case "tool-result":
|
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", () =>
|
it.effect("decodes web_search_tool_result_error as provider-executed error result", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
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", () =>
|
it.effect("rejects round-trip for unknown server tool names", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.prepare(
|
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", () =>
|
it.effect("decodes reasoning deltas", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
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", () =>
|
it.effect("assigns unique ids to multiple streamed tool calls", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents({
|
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", () =>
|
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|||||||
@@ -1238,6 +1238,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
type: "tool-input-end",
|
type: "tool-input-end",
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
|
input: '{"query":"weather"}',
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
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", () =>
|
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
|
|||||||
@@ -57,32 +57,61 @@ describe("ToolStream", () => {
|
|||||||
expect(finished).toEqual({
|
expect(finished).toEqual({
|
||||||
tools: {},
|
tools: {},
|
||||||
events: [
|
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" } },
|
{ 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* () {
|
Effect.gen(function* () {
|
||||||
const tools = ToolStream.start(ToolStream.empty<number>(), 0, {
|
const tools = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
id: "call_1",
|
id: "call_1",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
input: '{"query":"partial',
|
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(finished).toMatchObject({
|
||||||
expect(error.reason).toMatchObject({
|
tools: {},
|
||||||
_tag: "InvalidProviderOutput",
|
events: [
|
||||||
source: "tool-input",
|
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||||
toolName: "lookup",
|
{
|
||||||
raw: '{"query":"partial',
|
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", () =>
|
it.effect("preserves providerExecuted and clears all tools", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ export type SessionMessageProviderState6 = { [x: string]: any }
|
|||||||
|
|
||||||
export type SessionMessageProviderState7 = { [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 EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||||
|
|
||||||
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
||||||
@@ -735,16 +737,6 @@ export type SessionTextEnded = {
|
|||||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
|
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 = {
|
export type SessionToolInputEnded = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
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 = {
|
export type SessionToolCalled = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1379,7 +1388,7 @@ export type SessionToolCalled = {
|
|||||||
callID: string
|
callID: string
|
||||||
input: { [x: string]: any }
|
input: { [x: string]: any }
|
||||||
executed: boolean
|
executed: boolean
|
||||||
state?: SessionMessageProviderState5
|
state?: SessionMessageProviderState6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,9 +1404,10 @@ export type SessionToolFailed = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
raw?: string
|
||||||
result?: any
|
result?: any
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState7
|
resultState?: SessionMessageProviderState8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1877,7 +1887,7 @@ export type SessionToolSuccess = {
|
|||||||
content: Array<LLMToolContent>
|
content: Array<LLMToolContent>
|
||||||
result?: any
|
result?: any
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState6
|
resultState?: SessionMessageProviderState7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: event.data.name,
|
name: event.data.name,
|
||||||
|
executed: event.data.executed,
|
||||||
|
providerState: event.data.state,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
||||||
}),
|
}),
|
||||||
@@ -396,7 +398,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
status: "error",
|
status: "error",
|
||||||
error: event.data.error,
|
error: event.data.error,
|
||||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
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 : {},
|
structured: match.state.status === "running" ? match.state.structured : {},
|
||||||
content: match.state.status === "running" ? match.state.content : [],
|
content: match.state.status === "running" ? match.state.content : [],
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { toSessionError } from "../to-session-error"
|
|||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import { SessionUsage } from "../usage"
|
import { SessionUsage } from "../usage"
|
||||||
|
|
||||||
|
const MAX_MALFORMED_TOOL_RETRIES = 4
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -80,6 +82,7 @@ const layer = Layer.effect(
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionPending.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
|
recoverMalformedToolInput: boolean,
|
||||||
recoverOverflow?: typeof compaction.compact,
|
recoverOverflow?: typeof compaction.compact,
|
||||||
assistantMessageID?: SessionMessage.ID,
|
assistantMessageID?: SessionMessage.ID,
|
||||||
) {
|
) {
|
||||||
@@ -143,7 +146,7 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield* publish(event)
|
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)
|
const tool = prepared.resolveToolCall(event.name)
|
||||||
if (tool.type === "reject") {
|
if (tool.type === "reject") {
|
||||||
yield* serialized(publisher.failUnsettledTools(tool.error))
|
yield* serialized(publisher.failUnsettledTools(tool.error))
|
||||||
@@ -244,13 +247,35 @@ const layer = Layer.effect(
|
|||||||
if (overflowFailure) yield* publish(overflowFailure)
|
if (overflowFailure) yield* publish(overflowFailure)
|
||||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||||
const malformedToolInput =
|
const malformedToolInput =
|
||||||
llmFailure?.reason._tag === "InvalidProviderOutput" && llmFailure.reason.source === "tool-input"
|
llmFailure?.reason._tag === "InvalidProviderOutput" &&
|
||||||
const recoveredMalformedToolInput = malformedToolInput
|
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(
|
? yield* serialized(
|
||||||
publisher.failUnsettledTools({
|
publisher.failMalformedToolInput(
|
||||||
type: "provider.invalid-output",
|
{
|
||||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
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
|
: false
|
||||||
if (llmFailure && !publisher.hasProviderError()) {
|
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.
|
// Provider error events only arrive from the stream, so the flag is final here.
|
||||||
const providerFailed = publisher.hasProviderError()
|
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
|
// 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.
|
// the individual fibers and await all exits before publishing the terminal step event.
|
||||||
@@ -346,6 +376,7 @@ const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
_tag: "Completed",
|
_tag: "Completed",
|
||||||
needsContinuation: needsContinuation || recoveredMalformedToolInput,
|
needsContinuation: needsContinuation || recoveredMalformedToolInput,
|
||||||
|
malformedToolInput: recoveredMalformedToolInput,
|
||||||
step: currentStep,
|
step: currentStep,
|
||||||
} as const
|
} as const
|
||||||
}),
|
}),
|
||||||
@@ -356,6 +387,7 @@ const layer = Layer.effect(
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionPending.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
|
recoverMalformedToolInput: boolean,
|
||||||
) {
|
) {
|
||||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
// 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
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
while (true) {
|
while (true) {
|
||||||
const attempt = yield* Effect.suspend(() =>
|
const attempt = yield* Effect.suspend(() =>
|
||||||
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
attemptStep(
|
||||||
|
sessionID,
|
||||||
|
currentPromotion,
|
||||||
|
currentStep,
|
||||||
|
recoverMalformedToolInput,
|
||||||
|
recoverOverflow,
|
||||||
|
assistantMessageID,
|
||||||
|
),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.tapError((error) =>
|
Effect.tapError((error) =>
|
||||||
error instanceof SessionRunnerRetry.RetryableFailure
|
error instanceof SessionRunnerRetry.RetryableFailure
|
||||||
@@ -388,7 +427,12 @@ const layer = Layer.effect(
|
|||||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
.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
|
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
currentPromotion = undefined
|
currentPromotion = undefined
|
||||||
@@ -446,12 +490,19 @@ const layer = Layer.effect(
|
|||||||
while (shouldRun) {
|
while (shouldRun) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
let step = 1
|
let step = 1
|
||||||
|
let malformedToolRetries = 0
|
||||||
// Repeat steps while continuation is needed. A step needs continuation only
|
// 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;
|
// 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
|
// malformed tool input can also continue within its bounded recovery budget.
|
||||||
// interjections are answered before the session goes idle.
|
// A provider error suppresses continuation. Pending steers also continue the
|
||||||
|
// loop so interjections are answered before the session goes idle.
|
||||||
while (needsContinuation) {
|
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
|
// 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.
|
// user message by this point, so the first-user-message check below is reliable.
|
||||||
if (!titleAttempted.has(input.sessionID)) {
|
if (!titleAttempted.has(input.sessionID)) {
|
||||||
@@ -459,6 +510,7 @@ const layer = Layer.effect(
|
|||||||
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
||||||
}
|
}
|
||||||
needsContinuation = result.needsContinuation
|
needsContinuation = result.needsContinuation
|
||||||
|
malformedToolRetries = result.malformedToolInput ? malformedToolRetries + 1 : 0
|
||||||
step = result.step + 1
|
step = result.step + 1
|
||||||
if (needsContinuation) {
|
if (needsContinuation) {
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
called: boolean
|
called: boolean
|
||||||
settled: boolean
|
settled: boolean
|
||||||
providerExecuted: boolean
|
providerExecuted: boolean
|
||||||
|
rawInput: string
|
||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
let assistantMessageID = input.assistantMessageID
|
let assistantMessageID = input.assistantMessageID
|
||||||
@@ -60,6 +61,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
let stepFailed = false
|
let stepFailed = false
|
||||||
let providerFailed = false
|
let providerFailed = false
|
||||||
let retryEvidence = false
|
let retryEvidence = false
|
||||||
|
let malformedToolInput = false
|
||||||
let stepFailure: SessionError.Error | undefined
|
let stepFailure: SessionError.Error | undefined
|
||||||
let stepSettlement:
|
let stepSettlement:
|
||||||
| {
|
| {
|
||||||
@@ -112,12 +114,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
if (state !== undefined) current.state = { ...current.state, ...state }
|
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||||
return Effect.succeed(current.ordinal)
|
return Effect.succeed(current.ordinal)
|
||||||
})
|
})
|
||||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
|
||||||
const current = chunks.get(id)
|
const current = chunks.get(id)
|
||||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||||
yield* ended(
|
yield* ended(
|
||||||
id,
|
id,
|
||||||
current.values.join(""),
|
value ?? current.values.join(""),
|
||||||
current.ordinal,
|
current.ordinal,
|
||||||
state === undefined ? current.state : { ...current.state, ...state },
|
state === undefined ? current.state : { ...current.state, ...state },
|
||||||
)
|
)
|
||||||
@@ -175,7 +177,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
yield* toolInput.flush()
|
yield* toolInput.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
const startToolInput = Effect.fnUntraced(function* (event: {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly providerExecuted?: boolean
|
||||||
|
readonly providerMetadata?: ProviderMetadata
|
||||||
|
}) {
|
||||||
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
tools.set(event.id, {
|
tools.set(event.id, {
|
||||||
@@ -183,7 +190,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
name: event.name,
|
name: event.name,
|
||||||
called: false,
|
called: false,
|
||||||
settled: false,
|
settled: false,
|
||||||
providerExecuted: false,
|
providerExecuted: event.providerExecuted === true,
|
||||||
|
rawInput: "",
|
||||||
})
|
})
|
||||||
yield* toolInput.start(event.id)
|
yield* toolInput.start(event.id)
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
@@ -191,16 +199,60 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
|
executed: event.providerExecuted,
|
||||||
|
state: providerState(event.providerMetadata),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
const endToolInput = Effect.fnUntraced(function* (event: {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly input?: string
|
||||||
|
}) {
|
||||||
const tool = tools.get(event.id)
|
const tool = tools.get(event.id)
|
||||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
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* () {
|
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
||||||
@@ -210,11 +262,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
||||||
let failed = false
|
let failed = false
|
||||||
for (const [callID, tool] of tools) {
|
for (const [callID, tool] of tools) {
|
||||||
if (
|
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
|
||||||
tool.settled ||
|
|
||||||
(mode === "hosted" && !tool.providerExecuted) ||
|
|
||||||
(mode === "uncalled" && tool.called)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
tool.settled = true
|
tool.settled = true
|
||||||
failed = true
|
failed = true
|
||||||
@@ -325,6 +373,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
|
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* toolInput.append(event.id, event.text)
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -337,16 +386,30 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
case "tool-input-end":
|
case "tool-input-end":
|
||||||
yield* endToolInput(event)
|
yield* endToolInput(event)
|
||||||
return
|
return
|
||||||
|
case "tool-input-error":
|
||||||
|
retryEvidence = true
|
||||||
|
if (
|
||||||
|
!(yield* failMalformedToolInput(
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
type: "provider.invalid-output",
|
||||||
|
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||||
|
},
|
||||||
|
{ type: "provider.invalid-output", message: event.message },
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return yield* Effect.die(new Error(`Malformed tool input after call settlement: ${event.id}`))
|
||||||
|
return
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
retryEvidence = true
|
retryEvidence = true
|
||||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||||
const tool = tools.get(event.id)!
|
const tool = tools.get(event.id)!
|
||||||
if (toolInput.has(event.id)) yield* endToolInput(event)
|
if (toolInput.has(event.id)) yield* endToolInput({ id: event.id, name: event.name })
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||||
tool.called = true
|
tool.called = true
|
||||||
tool.providerExecuted = event.providerExecuted === true
|
tool.providerExecuted = event.providerExecuted === true || tool.providerExecuted
|
||||||
yield* events.publish(SessionEvent.Tool.Called, {
|
yield* events.publish(SessionEvent.Tool.Called, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
@@ -436,10 +499,13 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
publish,
|
publish,
|
||||||
flush,
|
flush,
|
||||||
failAssistant,
|
failAssistant,
|
||||||
|
failMalformedToolInput,
|
||||||
publishStepFailure,
|
publishStepFailure,
|
||||||
failUnsettledTools,
|
failUnsettledTools,
|
||||||
hasProviderError: () => providerFailed,
|
hasProviderError: () => providerFailed,
|
||||||
|
hasMalformedToolInput: () => malformedToolInput,
|
||||||
hasRetryEvidence: () => retryEvidence,
|
hasRetryEvidence: () => retryEvidence,
|
||||||
|
isProviderExecuted: (callID: string) => tools.get(callID)?.providerExecuted === true,
|
||||||
stepFailure: () => stepFailure,
|
stepFailure: () => stepFailure,
|
||||||
stepSettlement: () => stepSettlement,
|
stepSettlement: () => stepSettlement,
|
||||||
startAssistant,
|
startAssistant,
|
||||||
|
|||||||
@@ -110,7 +110,12 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||||||
result:
|
result:
|
||||||
tool.executed === true && tool.state.result !== undefined
|
tool.executed === true && tool.state.result !== undefined
|
||||||
? tool.state.result
|
? 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",
|
resultType: "error",
|
||||||
providerExecuted: tool.executed,
|
providerExecuted: tool.executed,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
|
|||||||
@@ -4134,8 +4134,9 @@ describe("SessionRunnerLLM", () => {
|
|||||||
method: "stream",
|
method: "stream",
|
||||||
reason: new InvalidProviderOutputReason({
|
reason: new InvalidProviderOutputReason({
|
||||||
message: "Invalid JSON input for tool call echo",
|
message: "Invalid JSON input for tool call echo",
|
||||||
raw: '{"text":"partial',
|
raw: '{"text":"authoritative',
|
||||||
source: "tool-input",
|
source: "tool-input",
|
||||||
|
toolCallID: "call-malformed",
|
||||||
toolName: "echo",
|
toolName: "echo",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -4160,7 +4161,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: {},
|
input: {},
|
||||||
raw: '{"text":"partial',
|
raw: '{"text":"authoritative',
|
||||||
error: {
|
error: {
|
||||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||||
},
|
},
|
||||||
@@ -4182,6 +4183,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
error: {
|
error: {
|
||||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
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<void>()
|
||||||
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||||
|
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", () =>
|
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ export namespace Tool {
|
|||||||
schema: {
|
schema: {
|
||||||
...ToolBase,
|
...ToolBase,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
executed: Schema.Boolean.pipe(optional),
|
||||||
|
state: SessionMessage.ProviderState.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Started = typeof Started.Type
|
export type Started = typeof Started.Type
|
||||||
@@ -443,6 +445,7 @@ export namespace Tool {
|
|||||||
schema: {
|
schema: {
|
||||||
...ToolBase,
|
...ToolBase,
|
||||||
error: SessionError.Error,
|
error: SessionError.Error,
|
||||||
|
raw: Schema.String.pipe(optional),
|
||||||
result: Schema.Unknown.pipe(optional),
|
result: Schema.Unknown.pipe(optional),
|
||||||
executed: Schema.Boolean,
|
executed: Schema.Boolean,
|
||||||
resultState: SessionMessage.ProviderState.pipe(optional),
|
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||||
|
|||||||
@@ -1020,6 +1020,8 @@ export type GlobalEvent = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
name: string
|
name: string
|
||||||
|
executed?: boolean
|
||||||
|
state?: SessionMessageProviderState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -1093,6 +1095,7 @@ export type GlobalEvent = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
raw?: string
|
||||||
result?: unknown
|
result?: unknown
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState
|
resultState?: SessionMessageProviderState
|
||||||
@@ -4117,6 +4120,8 @@ export type SyncEventSessionToolInputStarted = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
name: string
|
name: string
|
||||||
|
executed?: boolean
|
||||||
|
state?: SessionMessageProviderState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4215,6 +4220,7 @@ export type SyncEventSessionToolFailed = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
raw?: string
|
||||||
result?: unknown
|
result?: unknown
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState
|
resultState?: SessionMessageProviderState
|
||||||
@@ -4639,6 +4645,7 @@ export type SessionMessageToolStateError = {
|
|||||||
input: {
|
input: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
raw?: string
|
||||||
content: Array<LlmToolContent>
|
content: Array<LlmToolContent>
|
||||||
structured: {
|
structured: {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
@@ -5275,6 +5282,8 @@ export type SessionToolInputStarted = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
name: string
|
name: string
|
||||||
|
executed?: boolean
|
||||||
|
state?: SessionMessageProviderState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5393,6 +5402,7 @@ export type SessionToolFailed = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
raw?: string
|
||||||
result?: unknown
|
result?: unknown
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState
|
resultState?: SessionMessageProviderState
|
||||||
@@ -7479,6 +7489,8 @@ export type EventSessionToolInputStarted = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
name: string
|
name: string
|
||||||
|
executed?: boolean
|
||||||
|
state?: SessionMessageProviderState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7558,6 +7570,7 @@ export type EventSessionToolFailed = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
raw?: string
|
||||||
result?: unknown
|
result?: unknown
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState
|
resultState?: SessionMessageProviderState
|
||||||
@@ -9607,6 +9620,10 @@ export type SessionReasoningEndedV2 = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionMessageProviderState5 = {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionToolInputStartedV2 = {
|
export type SessionToolInputStartedV2 = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -9625,6 +9642,8 @@ export type SessionToolInputStartedV2 = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
name: string
|
name: string
|
||||||
|
executed?: boolean
|
||||||
|
state?: SessionMessageProviderState5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9649,7 +9668,7 @@ export type SessionToolInputEndedV2 = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageProviderState5 = {
|
export type SessionMessageProviderState6 = {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9674,7 +9693,7 @@ export type SessionToolCalledV2 = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
executed: boolean
|
executed: boolean
|
||||||
state?: SessionMessageProviderState5
|
state?: SessionMessageProviderState6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9702,7 +9721,7 @@ export type SessionToolProgressV2 = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageProviderState6 = {
|
export type SessionMessageProviderState7 = {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9729,11 +9748,11 @@ export type SessionToolSuccessV2 = {
|
|||||||
content: Array<LlmToolContent>
|
content: Array<LlmToolContent>
|
||||||
result?: unknown
|
result?: unknown
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState6
|
resultState?: SessionMessageProviderState7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageProviderState7 = {
|
export type SessionMessageProviderState8 = {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9755,9 +9774,10 @@ export type SessionToolFailedV2 = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
raw?: string
|
||||||
result?: unknown
|
result?: unknown
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState7
|
resultState?: SessionMessageProviderState8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user