fix(ai,chord,durable,coding-agent): parse streamed tool-call arguments incrementally (fixes #9265)

- ai: StreamingJsonParser / ToolCallArgumentsStream read each argument delta once (tolerant,
  immutable snapshots, linear cost); every provider, pi-messages and the faux provider use it
  and keep stream state (partialJson, partialArgs, index, customInput, streamIndex, Bedrock
  reasoning bytes) off the content blocks; grammar tool input appends per delta
- agent: streamProxy uses the same parser
- chord: Delta append ops own their text (ownedSlice), so ops and replicas no longer keep the
  whole growing string alive
- durable, ai, mcp: bounded error excerpts and summaries own their characters
- coding-agent: tool calls render streamed arguments once per frame, not once per delta
This commit is contained in:
Mario Zechner 2026-10-24 08:00:07 +00:00
parent 136b87e8a7
commit f715a15c10
47 changed files with 3187 additions and 455 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `streamProxy()` re-parsing the accumulated tool-call arguments on every delta and keeping their raw text on the partial content block ([#9265](https://github.com/earendil-works/pi/issues/9265))
## [1.4.0] - 2026-10-21
### Added

View file

@ -3,17 +3,16 @@
* The server manages auth and proxies requests to LLM providers.
*/
// Internal import for JSON parsing utility
import {
type AssistantMessage,
type AssistantMessageEvent,
type AssistantMessageEventStream,
createAssistantMessageEventStream,
type Model,
parseStreamingJson,
type SimpleStreamOptions,
type StopReason,
type ToolCall,
ToolCallArgumentsStream,
type TranscriptContext,
} from "@earendil-works/pi-ai";
@ -175,13 +174,15 @@ export function streamProxy(
const decoder = new TextDecoder();
let buffer = "";
let sawTerminalEvent = false;
// Argument streams of open tool calls by content index, kept off the content blocks.
const toolArguments = new Map<number, ToolCallArgumentsStream>();
const processLine = (line: string): void => {
if (!line.startsWith("data: ")) return;
const data = line.slice(6).trim();
if (!data) return;
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
const event = processProxyEvent(proxyEvent, partial);
const event = processProxyEvent(proxyEvent, partial, toolArguments);
if (event) {
if (event.type === "done" || event.type === "error") sawTerminalEvent = true;
stream.push(event);
@ -257,6 +258,7 @@ export function streamProxy(
function processProxyEvent(
proxyEvent: ProxyAssistantMessageEvent,
partial: AssistantMessage,
toolArguments: Map<number, ToolCallArgumentsStream>,
): AssistantMessageEvent | undefined {
switch (proxyEvent.type) {
case "start":
@ -332,16 +334,16 @@ function processProxyEvent(
id: proxyEvent.id,
name: proxyEvent.toolName,
arguments: {},
partialJson: "",
} satisfies ToolCall & { partialJson: string } as ToolCall;
};
toolArguments.set(proxyEvent.contentIndex, new ToolCallArgumentsStream());
return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
case "toolcall_delta": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "toolCall") {
(content as any).partialJson += proxyEvent.delta;
content.arguments = parseStreamingJson((content as any).partialJson) || {};
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
const args = toolArguments.get(proxyEvent.contentIndex);
if (content?.type === "toolCall" && args !== undefined) {
// A new block object, so reactive consumers see the change.
partial.content[proxyEvent.contentIndex] = { ...content, arguments: args.append(proxyEvent.delta) };
return {
type: "toolcall_delta",
contentIndex: proxyEvent.contentIndex,
@ -356,7 +358,7 @@ function processProxyEvent(
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "toolCall") {
Object.assign(content, proxyEvent.toolCall);
delete (content as any).partialJson;
toolArguments.delete(proxyEvent.contentIndex);
return {
type: "toolcall_end",
contentIndex: proxyEvent.contentIndex,

View file

@ -115,4 +115,57 @@ describe("streamProxy", () => {
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Connection closed by proxy server");
});
// #9265
it("shows growing partial arguments without keeping the raw text on the block, in linear time", async () => {
const content = 'line "quoted" \\ end\n'.repeat(20_000);
const text = JSON.stringify({ path: "a.ts", content });
const proxyEvents: ProxyAssistantMessageEvent[] = [
{ type: "start" },
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "write" },
];
for (let index = 0; index < text.length; index += 32) {
proxyEvents.push({ type: "toolcall_delta", contentIndex: 0, delta: text.slice(index, index + 32) });
}
proxyEvents.push(
{
type: "toolcall_end",
contentIndex: 0,
toolCall: { type: "toolCall", id: "call_1", name: "write", arguments: { path: "a.ts", content } },
},
{ type: "done", reason: "toolUse", usage },
);
const body = proxyEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body, { status: 200 })),
);
const started = performance.now();
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
authToken: "test-token",
proxyUrl: "https://proxy.example.com",
});
let shown = 0;
let deltas = 0;
for await (const event of stream) {
if (event.type !== "toolcall_delta") continue;
const block = event.partial.content[0];
expect(Object.keys(block!).sort()).toEqual(["arguments", "id", "name", "type"]);
const partial = block?.type === "toolCall" ? block.arguments.content : undefined;
if (typeof partial !== "string") continue;
// Comparing the whole text on every delta would make this test itself quadratic.
if (deltas++ % 500 === 0) expect(content.startsWith(partial)).toBe(true);
expect(partial.length).toBeGreaterThanOrEqual(shown);
shown = partial.length;
}
const result = await stream.result();
expect(shown).toBeGreaterThan(content.length - 64);
expect(result.content).toEqual([
{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "a.ts", content } },
]);
// Re-parsing the accumulated 520 KB of arguments on each of its 16,000 deltas took minutes.
expect(performance.now() - started).toBeLessThan(10_000);
});
});