From 228cd488523bbabb29115a1232e723dfe4674ddf Mon Sep 17 00:00:00 2001 From: Alexander Davydov Date: Mon, 16 Mar 2026 15:08:10 +0300 Subject: [PATCH] GigaChat: preserve parallel tool history --- src/agents/gigachat-stream.tool-calls.test.ts | 83 +++++++++++++++++++ src/agents/gigachat-stream.ts | 47 +++++++---- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/src/agents/gigachat-stream.tool-calls.test.ts b/src/agents/gigachat-stream.tool-calls.test.ts index e27c2fa3095..9a78e1d1d31 100644 --- a/src/agents/gigachat-stream.tool-calls.test.ts +++ b/src/agents/gigachat-stream.tool-calls.test.ts @@ -150,4 +150,87 @@ describe("createGigachatStreamFn tool calling", () => { ); expect(event.content).toEqual([{ type: "text", text: "done" }]); }); + + it("preserves all historical tool calls from a single assistant turn", async () => { + request.mockResolvedValueOnce({ + status: 200, + data: createSseStream(['data: {"choices":[{"delta":{"content":"done"}}]}', "data: [DONE]"]), + }); + + const streamFn = createGigachatStreamFn({ + baseUrl: "https://gigachat.devices.sberbank.ru/api/v1", + authMode: "oauth", + }); + + const stream = streamFn( + { api: "gigachat", provider: "gigachat", id: "GigaChat-2-Max" } as never, + { + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "Working on it" }, + { + type: "toolCall", + id: "call_1", + name: "llm-task", + arguments: { prompt: "first" }, + }, + { + type: "toolCall", + id: "call_2", + name: "web_search", + arguments: { query: "second" }, + }, + ], + }, + ], + tools: [ + { + name: "llm-task", + description: "Run a task", + parameters: { + type: "object", + properties: { + prompt: { type: "string" }, + }, + }, + }, + { + name: "web_search", + description: "Search the web", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + }, + }, + ], + } as never, + { apiKey: "token" } as never, + ); + + const event = await stream.result(); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + messages: [ + expect.objectContaining({ + role: "assistant", + content: "Working on it", + function_call: expect.objectContaining({ name: "llm_task" }), + }), + expect.objectContaining({ + role: "assistant", + content: "", + function_call: expect.objectContaining({ name: "gpt2giga_user_search_web" }), + }), + ], + }), + }), + ); + expect(event.content).toEqual([{ type: "text", text: "done" }]); + }); }); diff --git a/src/agents/gigachat-stream.ts b/src/agents/gigachat-stream.ts index ceb6117cb11..caf67c8ddeb 100644 --- a/src/agents/gigachat-stream.ts +++ b/src/agents/gigachat-stream.ts @@ -39,6 +39,10 @@ export type GigachatStreamOptions = { scope?: string; }; +function stripLeakedFunctionCallPrelude(text: string): string { + return text.replace(/^\s*assistant\s+function\s+call(?:recipient)?\{\s*/i, ""); +} + // ── Function name sanitization ────────────────────────────────────────────── // GigaChat requires function names to be alphanumeric + underscore only. @@ -485,26 +489,34 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) .join(""); - const toolCall = contentParts.find((c): c is ToolCall => c.type === "toolCall"); + const toolCalls = contentParts.filter((c): c is ToolCall => c.type === "toolCall"); - if (toolCall && toolCall.name && functionsEnabled) { - const gigaToolName = rememberToolNameMapping( - toolNameToGiga, - gigaToToolName, - toolCall.name, - ); - messages.push({ - role: "assistant", - content: text ? sanitizeContent(text) : "", - function_call: { - name: gigaToolName, - arguments: toolCall.arguments ?? {}, - }, - }); + if (toolCalls.length > 0 && functionsEnabled) { + for (const [index, toolCall] of toolCalls.entries()) { + if (!toolCall.name) { + continue; + } + const gigaToolName = rememberToolNameMapping( + toolNameToGiga, + gigaToToolName, + toolCall.name, + ); + messages.push({ + role: "assistant", + content: index === 0 && text ? sanitizeContent(text) : "", + function_call: { + name: gigaToolName, + arguments: toolCall.arguments ?? {}, + }, + }); + } } else if (text) { messages.push({ role: "assistant", content: sanitizeContent(text) }); - } else if (toolCall && !functionsEnabled) { - messages.push({ role: "assistant", content: `[Called ${toolCall.name}]` }); + } else if (toolCalls.length > 0 && !functionsEnabled) { + messages.push({ + role: "assistant", + content: toolCalls.map((toolCall) => `[Called ${toolCall.name}]`).join(" "), + }); } } else if (msg.role === "toolResult" && functionsEnabled) { const toolName = msg.toolName ?? "unknown"; @@ -729,6 +741,7 @@ export function createGigachatStreamFn(opts: GigachatStreamOptions): StreamFn { } if (functionCallBuffer && functionCallBuffer.name) { + accumulatedContent = stripLeakedFunctionCallPrelude(accumulatedContent); let parsedArgs: Record = {}; try { if (functionCallBuffer.arguments) {