diff --git a/.changeset/fix-streaming-json-parse.md b/.changeset/fix-streaming-json-parse.md new file mode 100644 index 000000000..1a673392a --- /dev/null +++ b/.changeset/fix-streaming-json-parse.md @@ -0,0 +1,5 @@ +--- +"@iqai/adk": patch +--- + +fix: guard streaming tool call JSON.parse to prevent generator crashes diff --git a/packages/adk/src/models/anthropic-llm.ts b/packages/adk/src/models/anthropic-llm.ts index 6914950b0..ce4644fde 100644 --- a/packages/adk/src/models/anthropic-llm.ts +++ b/packages/adk/src/models/anthropic-llm.ts @@ -6,6 +6,7 @@ import type { BaseLLMConnection } from "./base-llm-connection"; import { RateLimitError } from "./errors"; import type { LlmRequest } from "./llm-request"; import { LlmResponse } from "./llm-response"; +import { safeParseToolArgs } from "./llm-utils"; type AnthropicRole = "user" | "assistant"; @@ -279,7 +280,7 @@ export class AnthropicLlm extends BaseLlm { functionCall: { id: block.id, name: block.name, - args: JSON.parse(block.inputJson || "{}"), + args: safeParseToolArgs(block.inputJson, this.logger), }, }); } diff --git a/packages/adk/src/models/llm-utils.ts b/packages/adk/src/models/llm-utils.ts new file mode 100644 index 000000000..0df95feab --- /dev/null +++ b/packages/adk/src/models/llm-utils.ts @@ -0,0 +1,20 @@ +import type { Logger } from "@adk/logger"; + +/** + * Safely parse tool call arguments JSON, falling back to empty args on failure. + * Prevents malformed streaming JSON from crashing async generators. + */ +export function safeParseToolArgs( + json: string | undefined, + logger: Logger, +): Record { + try { + return JSON.parse(json || "{}"); + } catch (error) { + logger.warn("Failed to parse tool call arguments, using empty args", { + rawArgs: json, + error: String(error), + }); + return {}; + } +} diff --git a/packages/adk/src/models/openai-llm.ts b/packages/adk/src/models/openai-llm.ts index 1c826912f..e95a3b3d4 100644 --- a/packages/adk/src/models/openai-llm.ts +++ b/packages/adk/src/models/openai-llm.ts @@ -4,6 +4,7 @@ import type { BaseLLMConnection } from "./base-llm-connection"; import { RateLimitError } from "./errors"; import type { LlmRequest } from "./llm-request"; import { LlmResponse } from "./llm-response"; +import { safeParseToolArgs } from "./llm-utils"; type OpenAIRole = "user" | "assistant" | "system"; @@ -185,7 +186,10 @@ export class OpenAiLlm extends BaseLlm { functionCall: { id: toolCall.id, name: toolCall.function.name, - args: JSON.parse(toolCall.function.arguments || "{}"), + args: safeParseToolArgs( + toolCall.function.arguments, + this.logger, + ), }, }); } @@ -296,7 +300,7 @@ export class OpenAiLlm extends BaseLlm { functionCall: { id: toolCall.id || "", name: toolCall.function.name, - args: JSON.parse(toolCall.function.arguments || "{}"), + args: safeParseToolArgs(toolCall.function.arguments, this.logger), }, }); } @@ -345,7 +349,7 @@ export class OpenAiLlm extends BaseLlm { functionCall: { id: toolCall.id, name: toolCall.function.name, - args: JSON.parse(toolCall.function.arguments || "{}"), + args: safeParseToolArgs(toolCall.function.arguments, this.logger), }, }); } diff --git a/packages/adk/src/tests/models/anthropic-llm.test.ts b/packages/adk/src/tests/models/anthropic-llm.test.ts index fd12fd971..3d690c467 100644 --- a/packages/adk/src/tests/models/anthropic-llm.test.ts +++ b/packages/adk/src/tests/models/anthropic-llm.test.ts @@ -10,6 +10,7 @@ vi.mock("@adk/helpers/logger", () => ({ Logger: vi.fn(() => ({ debug: vi.fn(), error: vi.fn(), + warn: vi.fn(), })), })); @@ -307,6 +308,65 @@ describe("AnthropicLlm", () => { }); }); + it("should handle malformed tool call JSON without crashing", async () => { + const streamEvents = [ + { + type: "message_start", + message: { usage: { input_tokens: 20 } }, + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool_bad", + name: "get_weather", + }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '{"location": "Tok', + }, + }, + // Stream cuts off — no closing brace + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + usage: { output_tokens: 10 }, + delta: { stop_reason: "tool_use" }, + }, + { type: "message_stop" }, + ]; + + mockMessagesCreate.mockResolvedValue(createMockStream(streamEvents)); + + const anthropicLlm = new AnthropicLlm(); + const generator = anthropicLlm["generateContentAsyncImpl"]( + mockLlmRequest, + true, + ); + + const responses: LlmResponse[] = []; + for await (const response of generator) { + responses.push(response); + } + + // Should not crash — returns response with empty args + expect(responses).toHaveLength(1); + const final = responses[0]; + expect(final.content?.parts).toHaveLength(1); + expect(final.content?.parts?.[0]).toEqual({ + functionCall: { + id: "tool_bad", + name: "get_weather", + args: {}, + }, + }); + }); + it("should handle streaming with text and tool calls", async () => { const streamEvents = [ { diff --git a/packages/adk/src/tests/models/openai-llm.test.ts b/packages/adk/src/tests/models/openai-llm.test.ts index 29bac29a2..8d5384b7f 100644 --- a/packages/adk/src/tests/models/openai-llm.test.ts +++ b/packages/adk/src/tests/models/openai-llm.test.ts @@ -6,6 +6,7 @@ vi.mock("@adk/helpers/logger", () => ({ Logger: vi.fn(() => ({ debug: vi.fn(), error: vi.fn(), + warn: vi.fn(), })), })); vi.mock("openai", () => ({ @@ -243,6 +244,41 @@ describe("OpenAiLlm", () => { }); }); + describe("openAiMessageToLlmResponse", () => { + it("should handle malformed tool call JSON without crashing", () => { + const choice = { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_bad", + type: "function", + function: { + name: "get_weather", + arguments: '{"location": "Tok', + }, + }, + ], + }, + finish_reason: "tool_calls", + index: 0, + }; + + const response = (llm as any).openAiMessageToLlmResponse(choice); + + expect(response).toBeInstanceOf(LlmResponse); + const toolPart = response.content.parts.find((p: any) => p.functionCall); + expect(toolPart).toEqual({ + functionCall: { + id: "call_bad", + name: "get_weather", + args: {}, + }, + }); + }); + }); + describe("connect", () => { it("should throw error", () => { expect(() => llm.connect({} as any)).toThrow(