Skip to content

Commit ad603fd

Browse files
author
nix-auto-updates[bot]
committed
Merge remote-tracking branch 'upstream/v2' into nix-auto-updates
* upstream/v2: mini: fix shell tool output display (anomalyco#37711) fix(core): detach disposed MCP registrations from root scope (anomalyco#37660) fix(core): preserve the first terminal failure (anomalyco#37705) fix(core): continue after malformed tool input (anomalyco#37701) fix(core): safely recover malformed tool input (anomalyco#37698) fix(simulation): render screenshot symbol glyphs (anomalyco#37691) fix(core): authorize relative external paths (anomalyco#37689) fix(tui): auto-approve permissions in auto mode feat(core): allow MCP Code Mode opt-out (anomalyco#37681) fix(codemode): stop leaking undefined into tool arguments (anomalyco#37652) fix(tui): style interrupted compaction neutrally (anomalyco#37655) fix(cli): harden managed service election (anomalyco#37645)
2 parents 57347c0 + cf6e5b3 commit ad603fd

51 files changed

Lines changed: 1359 additions & 168 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ai/src/protocols/anthropic-messages.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,14 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
703703
providerExecuted: block.type === "server_tool_use",
704704
}),
705705
},
706-
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
706+
[
707+
...events,
708+
LLMEvent.toolInputStart({
709+
id: block.id ?? String(event.index),
710+
name: block.name ?? "",
711+
providerExecuted: block.type === "server_tool_use" ? true : undefined,
712+
}),
713+
],
707714
]
708715
}
709716

packages/ai/src/protocols/bedrock-converse.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
561561
return [
562562
{
563563
...state,
564-
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
564+
hasToolCalls:
565+
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
566+
state.hasToolCalls,
565567
lifecycle,
566568
tools: result.tools,
567569
reasoningSignatures: Object.fromEntries(

packages/ai/src/protocols/openai-chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
464464
}
465465

466466
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
467-
// JSON parse failures fail the stream at the boundary rather than at halt.
467+
// valid calls and malformed local calls settle independently.
468468
const finished =
469469
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
470470
? yield* ToolStream.finishAll(ADAPTER, tools)

packages/ai/src/protocols/openai-responses.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,9 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
835835
{
836836
...state,
837837
lifecycle,
838-
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
838+
hasFunctionCall:
839+
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
840+
state.hasFunctionCall,
839841
tools: result.tools,
840842
},
841843
events,

packages/ai/src/protocols/utils/tool-stream.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Effect } from "effect"
2-
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
2+
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
33
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
44

55
type StreamKey = string | number
@@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
5353
LLMEvent.toolInputStart({
5454
id: tool.id,
5555
name: tool.name,
56+
providerExecuted: tool.providerExecuted ? true : undefined,
5657
providerMetadata: tool.providerMetadata,
5758
})
5859

@@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
6364
text,
6465
})
6566

66-
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
67-
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
68-
Effect.map(
69-
(input): ToolCall =>
70-
LLMEvent.toolCall({
71-
id: tool.id,
72-
name: tool.name,
73-
input,
74-
providerExecuted: tool.providerExecuted ? true : undefined,
75-
providerMetadata: tool.providerMetadata,
76-
}),
67+
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
68+
const raw = inputOverride ?? tool.input
69+
return parseToolInput(route, tool.name, raw).pipe(
70+
Effect.map((input): ToolCall | ToolInputError =>
71+
LLMEvent.toolCall({
72+
id: tool.id,
73+
name: tool.name,
74+
input,
75+
providerExecuted: tool.providerExecuted ? true : undefined,
76+
providerMetadata: tool.providerMetadata,
77+
}),
78+
),
79+
Effect.catch((error) =>
80+
tool.providerExecuted
81+
? Effect.fail(error)
82+
: Effect.succeed(
83+
LLMEvent.toolInputError({
84+
id: tool.id,
85+
name: tool.name,
86+
raw,
87+
}),
88+
),
7789
),
7890
)
91+
}
92+
93+
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
94+
event.type === "tool-input-error"
95+
? [event]
96+
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
7997

8098
/** Store the updated tool and produce the optional public delta event. */
8199
const appendTool = <K extends StreamKey>(
@@ -158,19 +176,17 @@ export const appendExisting = <K extends StreamKey>(
158176

159177
/**
160178
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
161-
* from state, and return the optional public `tool-call` event. Missing keys are
162-
* a no-op because some providers emit stop events for non-tool content blocks.
179+
* from state, and return either a call or a non-executable local input error.
180+
* Missing keys are a no-op because some providers emit stop events for
181+
* non-tool content blocks.
163182
*/
164183
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
165184
Effect.gen(function* () {
166185
const tool = tools[key]
167186
if (!tool) return { tools }
168187
return {
169188
tools: withoutTool(tools, key),
170-
events: [
171-
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
172-
yield* toolCall(route, tool),
173-
],
189+
events: finishEvents(tool, yield* toolCall(route, tool)),
174190
}
175191
})
176192

@@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
185201
if (!tool) return { tools }
186202
return {
187203
tools: withoutTool(tools, key),
188-
events: [
189-
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
190-
yield* toolCall(route, tool, input),
191-
],
204+
events: finishEvents(tool, yield* toolCall(route, tool, input)),
192205
}
193206
})
194207

195208
/**
196209
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
197-
* not emit per-tool stop events, so all accumulated calls finish when the choice
198-
* receives a terminal `finish_reason`.
210+
* not emit per-tool stop events, so all accumulated calls finish independently
211+
* when the choice receives a terminal `finish_reason`.
199212
*/
200213
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
201214
Effect.gen(function* () {
@@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
205218
return {
206219
tools: empty<K>(),
207220
events: yield* Effect.forEach(pending, (tool) =>
208-
toolCall(route, tool).pipe(
209-
Effect.map((call) => [
210-
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
211-
call,
212-
]),
213-
),
221+
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
214222
).pipe(Effect.map((events) => events.flat())),
215223
}
216224
})

packages/ai/src/schema/events.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
129129
type: Schema.tag("tool-input-start"),
130130
id: ToolCallID,
131131
name: Schema.String,
132+
providerExecuted: Schema.optional(Schema.Boolean),
132133
providerMetadata: Schema.optional(ProviderMetadata),
133134
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
134135
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
@@ -149,6 +150,15 @@ export const ToolInputEnd = Schema.Struct({
149150
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
150151
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
151152

153+
/** A local tool call whose final input could not be decoded. */
154+
export const ToolInputError = Schema.Struct({
155+
type: Schema.tag("tool-input-error"),
156+
id: ToolCallID,
157+
name: Schema.String,
158+
raw: Schema.String,
159+
}).annotate({ identifier: "LLM.Event.ToolInputError" })
160+
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
161+
152162
export const ToolCall = Schema.Struct({
153163
type: Schema.tag("tool-call"),
154164
id: ToolCallID,
@@ -216,6 +226,7 @@ const llmEventTagged = Schema.Union([
216226
ToolInputStart,
217227
ToolInputDelta,
218228
ToolInputEnd,
229+
ToolInputError,
219230
ToolCall,
220231
ToolResult,
221232
ToolError,
@@ -253,6 +264,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
253264
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
254265
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
255266
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
267+
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
268+
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
256269
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
257270
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
258271
ToolResult.make({
@@ -283,6 +296,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
283296
toolInputStart: llmEventTagged.guards["tool-input-start"],
284297
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
285298
toolInputEnd: llmEventTagged.guards["tool-input-end"],
299+
toolInputError: llmEventTagged.guards["tool-input-error"],
286300
toolCall: llmEventTagged.guards["tool-call"],
287301
toolResult: llmEventTagged.guards["tool-result"],
288302
toolError: llmEventTagged.guards["tool-error"],
@@ -548,6 +562,10 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
548562
return reduceToolInputDelta(next, event)
549563
case "tool-input-end":
550564
return reduceToolInputEnd(next, event)
565+
case "tool-input-error": {
566+
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
567+
return { ...next, toolInputs }
568+
}
551569
case "tool-call":
552570
return reduceToolCall(next, event)
553571
case "tool-result":

packages/ai/test/provider/anthropic-messages.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,30 @@ describe("Anthropic Messages route", () => {
484484
}),
485485
)
486486

487+
it.effect("keeps malformed server tool input terminal", () =>
488+
Effect.gen(function* () {
489+
const body = sseEvents(
490+
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
491+
{
492+
type: "content_block_start",
493+
index: 0,
494+
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
495+
},
496+
{
497+
type: "content_block_delta",
498+
index: 0,
499+
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
500+
},
501+
{ type: "content_block_stop", index: 0 },
502+
)
503+
504+
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
505+
506+
expect(error).toBeInstanceOf(LLMError)
507+
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
508+
}),
509+
)
510+
487511
it.effect("fails with a typed provider error for stream error frames", () =>
488512
Effect.gen(function* () {
489513
const error = yield* LLMClient.generate(request).pipe(

packages/ai/test/provider/bedrock-converse.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,32 @@ describe("Bedrock Converse route", () => {
303303
}),
304304
)
305305

306+
it.effect("emits malformed tool input as an unexecuted tool error", () =>
307+
Effect.gen(function* () {
308+
const body = eventStreamBody(
309+
["messageStart", { role: "assistant" }],
310+
[
311+
"contentBlockStart",
312+
{
313+
contentBlockIndex: 0,
314+
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
315+
},
316+
],
317+
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
318+
["contentBlockStop", { contentBlockIndex: 0 }],
319+
["messageStop", { stopReason: "end_turn" }],
320+
)
321+
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
322+
323+
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
324+
id: "tool_1",
325+
name: "lookup",
326+
raw: '{"query":"partial',
327+
})
328+
expect(response.finishReason).toBe("tool-calls")
329+
}),
330+
)
331+
306332
it.effect("decodes reasoning deltas", () =>
307333
Effect.gen(function* () {
308334
const body = eventStreamBody(

packages/ai/test/provider/openai-responses.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect } from "bun:test"
22
import { ConfigProvider, Effect, Layer, Stream } from "effect"
33
import { Headers, HttpClientRequest } from "effect/unstable/http"
4-
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
4+
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
55
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
66
import * as Azure from "../../src/providers/azure"
77
import * as OpenAI from "../../src/providers/openai"
@@ -1259,6 +1259,69 @@ describe("OpenAI Responses route", () => {
12591259
}),
12601260
)
12611261

1262+
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
1263+
Effect.gen(function* () {
1264+
const body = sseEvents(
1265+
{
1266+
type: "response.output_item.added",
1267+
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
1268+
},
1269+
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
1270+
{
1271+
type: "response.output_item.done",
1272+
item: {
1273+
type: "function_call",
1274+
id: "item_1",
1275+
call_id: "call_1",
1276+
name: "lookup",
1277+
arguments: '{"query":"partial',
1278+
},
1279+
},
1280+
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
1281+
)
1282+
const response = yield* LLMClient.generate(
1283+
LLM.updateRequest(request, {
1284+
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
1285+
}),
1286+
).pipe(Effect.provide(fixedResponse(body)))
1287+
1288+
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
1289+
type: "tool-input-error",
1290+
id: "call_1",
1291+
name: "lookup",
1292+
raw: '{"query":"partial',
1293+
})
1294+
expect(response.finishReason).toBe("tool-calls")
1295+
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
1296+
}),
1297+
)
1298+
1299+
it.effect("settles malformed function arguments when output_item.added is absent", () =>
1300+
Effect.gen(function* () {
1301+
const body = sseEvents(
1302+
{
1303+
type: "response.output_item.done",
1304+
item: {
1305+
type: "function_call",
1306+
id: "item_1",
1307+
call_id: "call_1",
1308+
name: "lookup",
1309+
arguments: '{"query":"partial',
1310+
},
1311+
},
1312+
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
1313+
)
1314+
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
1315+
1316+
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
1317+
id: "call_1",
1318+
name: "lookup",
1319+
raw: '{"query":"partial',
1320+
})
1321+
expect(response.finishReason).toBe("tool-calls")
1322+
}),
1323+
)
1324+
12621325
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
12631326
Effect.gen(function* () {
12641327
const item = {

0 commit comments

Comments
 (0)