From bd58529be391c29193489a44697e9f861b0e974e Mon Sep 17 00:00:00 2001 From: "Marvel.Codes" Date: Fri, 20 Mar 2026 00:50:05 +0100 Subject: [PATCH 1/2] fix: wrap streaming tool call JSON.parse in try/catch to prevent generator crashes Malformed streaming tool call arguments from OpenAI or Anthropic APIs would throw an uncaught SyntaxError, terminating the async generator and losing all accumulated state. Now falls back to empty args with a warning log. --- packages/adk/src/models/anthropic-llm.ts | 17 +++++- packages/adk/src/models/openai-llm.ts | 25 +++++++- .../src/tests/models/anthropic-llm.test.ts | 60 +++++++++++++++++++ .../adk/src/tests/models/openai-llm.test.ts | 36 +++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/adk/src/models/anthropic-llm.ts b/packages/adk/src/models/anthropic-llm.ts index 6914950b0..724de0b30 100644 --- a/packages/adk/src/models/anthropic-llm.ts +++ b/packages/adk/src/models/anthropic-llm.ts @@ -7,6 +7,21 @@ import { RateLimitError } from "./errors"; import type { LlmRequest } from "./llm-request"; import { LlmResponse } from "./llm-response"; +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 {}; + } +} + type AnthropicRole = "user" | "assistant"; const DEFAULT_MAX_OUTPUT_TOKENS = 1024; @@ -279,7 +294,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/openai-llm.ts b/packages/adk/src/models/openai-llm.ts index 1c826912f..0613af46b 100644 --- a/packages/adk/src/models/openai-llm.ts +++ b/packages/adk/src/models/openai-llm.ts @@ -1,3 +1,4 @@ +import { Logger } from "@adk/logger"; import OpenAI from "openai"; import { BaseLlm } from "./base-llm"; import type { BaseLLMConnection } from "./base-llm-connection"; @@ -7,6 +8,21 @@ import { LlmResponse } from "./llm-response"; type OpenAIRole = "user" | "assistant" | "system"; +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 {}; + } +} + /** * OpenAI LLM implementation using GPT models * Enhanced with comprehensive debug logging similar to Google LLM @@ -185,7 +201,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 +315,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 +364,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( From d35873ab923ba03c15fcaab9321434e433841d60 Mon Sep 17 00:00:00 2001 From: "Marvel.Codes" Date: Fri, 20 Mar 2026 01:02:07 +0100 Subject: [PATCH 2/2] refactor: extract safeParseToolArgs into shared llm-utils module Addresses review feedback to deduplicate the helper. Also adds changeset. --- .changeset/fix-streaming-json-parse.md | 5 +++++ packages/adk/src/models/anthropic-llm.ts | 16 +--------------- packages/adk/src/models/llm-utils.ts | 20 ++++++++++++++++++++ packages/adk/src/models/openai-llm.ts | 17 +---------------- 4 files changed, 27 insertions(+), 31 deletions(-) create mode 100644 .changeset/fix-streaming-json-parse.md create mode 100644 packages/adk/src/models/llm-utils.ts 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 724de0b30..ce4644fde 100644 --- a/packages/adk/src/models/anthropic-llm.ts +++ b/packages/adk/src/models/anthropic-llm.ts @@ -6,21 +6,7 @@ import type { BaseLLMConnection } from "./base-llm-connection"; import { RateLimitError } from "./errors"; import type { LlmRequest } from "./llm-request"; import { LlmResponse } from "./llm-response"; - -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 {}; - } -} +import { safeParseToolArgs } from "./llm-utils"; type AnthropicRole = "user" | "assistant"; 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 0613af46b..e95a3b3d4 100644 --- a/packages/adk/src/models/openai-llm.ts +++ b/packages/adk/src/models/openai-llm.ts @@ -1,28 +1,13 @@ -import { Logger } from "@adk/logger"; import OpenAI from "openai"; import { BaseLlm } from "./base-llm"; 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"; -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 {}; - } -} - /** * OpenAI LLM implementation using GPT models * Enhanced with comprehensive debug logging similar to Google LLM