From 63c23c98de3934c2f17f0235ed70614e17ed9981 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:56:53 -0500 Subject: [PATCH] feat(ai): parse partial tool input (#44830) --- .../ai/src/protocols/utils/tool-stream.ts | 11 +++-- packages/ai/src/schema/events.ts | 2 + .../test/provider/anthropic-messages.test.ts | 21 +++++---- .../ai/test/provider/bedrock-converse.test.ts | 10 ++++- packages/ai/test/provider/openai-chat.test.ts | 20 +++++++-- .../ai/test/provider/openai-responses.test.ts | 2 + packages/ai/test/tool-stream.test.ts | 45 ++++++++++++++++++- 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 80bd829b5c0..b88f35a8b85 100644 --- a/packages/ai/src/protocols/utils/tool-stream.ts +++ b/packages/ai/src/protocols/utils/tool-stream.ts @@ -1,8 +1,10 @@ -import { Effect } from "effect" +import { Effect, Option } from "effect" import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js" import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js" +import { parse } from "./partial-json.js" type StreamKey = string | number +const parsePartialInput = Option.liftThrowable(parse) /** * One pending streamed tool call. Providers emit the tool identity and JSON @@ -57,12 +59,15 @@ const inputStart = (tool: PendingTool) => providerMetadata: tool.providerMetadata, }) -const inputDelta = (tool: PendingTool, text: string) => - LLMEvent.toolInputDelta({ +const inputDelta = (tool: PendingTool, text: string) => { + const input = parsePartialInput(tool.input) + return LLMEvent.toolInputDelta({ id: tool.id, name: tool.name, text, + ...(Option.isSome(input) ? { input: input.value } : {}), }) +} const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => { const raw = inputOverride ?? tool.input diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index f0857840d41..516cc270276 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -152,6 +152,8 @@ export const ToolInputDelta = Schema.Struct({ id: ToolCallID, name: Schema.String, text: Schema.String, + /** Best-effort parse of all input fragments received through this delta. */ + input: Schema.optional(Schema.Unknown), }).annotate({ identifier: "LLM.Event.ToolInputDelta" }) export type ToolInputDelta = Schema.Schema.Type diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index e6a91ef05a1..6a10144c903 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -585,12 +585,11 @@ describe("Anthropic Messages route", () => { it.effect("infers empty-signature compatibility across Kimi providers", () => Effect.gen(function* () { - const coding = AnthropicMessages.route - .with({ - provider: "kimi-for-coding", - endpoint: { baseURL: "https://compatible.test/v1/" }, - auth: Auth.header("x-api-key", "test"), - }) + const coding = AnthropicMessages.route.with({ + provider: "kimi-for-coding", + endpoint: { baseURL: "https://compatible.test/v1/" }, + auth: Auth.header("x-api-key", "test"), + }) const moonshot = AnthropicMessages.route .with({ provider: "moonshotai", @@ -1129,8 +1128,14 @@ describe("Anthropic Messages route", () => { expect(response.events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup" }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: ':"weather"}', + input: { query: "weather" }, + }, { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-call", diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index fc2456c2e1e..3e2fa3df285 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -475,8 +475,14 @@ describe("Bedrock Converse route", () => { ]) const events = response.events.filter((event) => event.type === "tool-input-delta") expect(events).toEqual([ - { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, - { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} }, + { + type: "tool-input-delta", + id: "tool_1", + name: "lookup", + text: ':"weather"}', + input: { query: "weather" }, + }, ]) expect(response.events.at(-1)).toMatchObject({ type: "finish", diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index e3846a29895..4e9104ad95a 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -1164,8 +1164,14 @@ describe("OpenAI Chat route", () => { expect(response.events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: ':"weather"}', + input: { query: "weather" }, + }, { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-call", @@ -1265,8 +1271,14 @@ describe("OpenAI Chat route", () => { expect(response.events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: ':"weather"}', + input: { query: "weather" }, + }, { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-call", diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index fd10f609331..193d1a15ed5 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2806,12 +2806,14 @@ describe("OpenAI Responses route", () => { id: "call_1", name: "lookup", text: '{"query"', + input: {}, }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', + input: { query: "weather" }, }, { type: "tool-input-end", diff --git a/packages/ai/test/tool-stream.test.ts b/packages/ai/test/tool-stream.test.ts index f549bf04ad1..fdb4a5e5309 100644 --- a/packages/ai/test/tool-stream.test.ts +++ b/packages/ai/test/tool-stream.test.ts @@ -23,9 +23,11 @@ describe("ToolStream", () => { expect(first.events).toEqual([ { type: "tool-input-start", id: "call_1", name: "lookup" }, - { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} }, + ]) + expect(second.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } }, ]) - expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }]) expect(finished).toEqual({ tools: {}, events: [ @@ -36,6 +38,45 @@ describe("ToolStream", () => { }), ) + it.effect("exposes cumulative partial string values", () => + Effect.gen(function* () { + const result = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "lookup", text: '{"query":"wea' }, + "missing tool", + ) + if (ToolStream.isError(result)) return yield* result + + expect(result.events.at(-1)).toEqual({ + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query":"wea', + input: { query: "wea" }, + }) + }), + ) + + it.effect("omits partial input when the accumulated value cannot be parsed", () => + Effect.gen(function* () { + const result = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "lookup", text: "x" }, + "missing tool", + ) + if (ToolStream.isError(result)) return yield* result + + expect(result.events).toEqual([ + { type: "tool-input-start", id: "call_1", name: "lookup" }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" }, + ]) + }), + ) + it.effect("keeps accumulated identity when later deltas contain empty strings", () => Effect.gen(function* () { const first = ToolStream.appendOrStart(