mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
136b87e8a7
commit
f715a15c10
47 changed files with 3187 additions and 455 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue