From 4c4cfe8f0d8e713629e014366913edb5a49ff50f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 13:33:09 +0200 Subject: [PATCH 1/3] fix(ai, ai-openai): normalize null tool input to empty object When a model produces a tool_use block with no input (or literal null), JSON.parse('null') returns null, which fails Zod schema validation and silently kills the agent loop. Normalize null/non-object parsed tool input to {} in four locations: - executeToolCalls(): after JSON.parse of arguments string - ToolCallManager.completeToolCall(): before JSON.stringify of event input - ToolCallManager.executeTools(): replace fragile string comparison - OpenAI adapter: in TOOL_CALL_END emission (matching existing Anthropic fix) Fixes #265 --- .../typescript/ai-openai/src/adapters/text.ts | 3 +- .../src/activities/chat/tools/tool-calls.ts | 14 +- .../ai/tests/tool-calls-null-input.test.ts | 163 ++++++++++++++++++ 3 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 packages/typescript/ai/tests/tool-calls-null-input.test.ts diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 1747ce4ec..09dd1472f 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -566,7 +566,8 @@ export class OpenAITextAdapter< // Parse arguments let parsedInput: unknown = {} try { - parsedInput = chunk.arguments ? JSON.parse(chunk.arguments) : {} + const parsed = chunk.arguments ? JSON.parse(chunk.arguments) : {} + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} } catch { parsedInput = {} } diff --git a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts index c05b759cf..d7b782028 100644 --- a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts @@ -127,7 +127,10 @@ export class ToolCallManager { for (const [, toolCall] of this.toolCallsMap.entries()) { if (toolCall.id === event.toolCallId) { if (event.input !== undefined) { - toolCall.function.arguments = JSON.stringify(event.input) + // Normalize null/non-object to {} (e.g. Anthropic empty tool_use blocks) + const normalized = + event.input && typeof event.input === 'object' ? event.input : {} + toolCall.function.arguments = JSON.stringify(normalized) } break } @@ -167,11 +170,12 @@ export class ToolCallManager { let toolResultContent: string if (tool?.execute) { try { - // Parse arguments (normalize "null" to "{}" for empty tool_use blocks) + // Parse arguments (normalize null/non-object to {} for empty tool_use blocks) let args: unknown try { const argsString = toolCall.function.arguments.trim() || '{}' - args = JSON.parse(argsString === 'null' ? '{}' : argsString) + const parsed = JSON.parse(argsString) + args = parsed && typeof parsed === 'object' ? parsed : {} } catch (parseError) { throw new Error( `Failed to parse tool arguments as JSON: ${toolCall.function.arguments}`, @@ -543,7 +547,9 @@ export async function* executeToolCalls( const argsStr = toolCall.function.arguments.trim() || '{}' if (argsStr) { try { - input = JSON.parse(argsStr) + const parsed = JSON.parse(argsStr) + // Normalize null/non-object to {} (e.g. Anthropic empty tool_use blocks) + input = parsed && typeof parsed === 'object' ? parsed : {} } catch (parseError) { // If parsing fails, throw error to fail fast throw new Error(`Failed to parse tool arguments as JSON: ${argsStr}`) diff --git a/packages/typescript/ai/tests/tool-calls-null-input.test.ts b/packages/typescript/ai/tests/tool-calls-null-input.test.ts new file mode 100644 index 000000000..413576ce0 --- /dev/null +++ b/packages/typescript/ai/tests/tool-calls-null-input.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ToolCallManager, + executeToolCalls, +} from '../src/activities/chat/tools/tool-calls' +import type { RunFinishedEvent, Tool, ToolCall } from '../src/types' + +/** + * Drain an async generator and return its final return value. + */ +async function drainGenerator( + gen: AsyncGenerator, +): Promise { + while (true) { + const next = await gen.next() + if (next.done) return next.value + } +} + +describe('null tool input normalization', () => { + const mockFinishedEvent: RunFinishedEvent = { + type: 'RUN_FINISHED', + runId: 'test-run', + model: 'test', + timestamp: Date.now(), + finishReason: 'tool_calls', + } + + describe('executeToolCalls', () => { + it('should normalize "null" arguments to empty object', async () => { + const receivedInput = vi.fn() + + const tool: Tool = { + name: 'test_tool', + description: 'test', + execute: async (input: unknown) => { + receivedInput(input) + return { ok: true } + }, + } + + const toolCalls: Array = [ + { + id: 'tc-1', + type: 'function', + function: { name: 'test_tool', arguments: 'null' }, + }, + ] + + const result = await drainGenerator(executeToolCalls(toolCalls, [tool])) + expect(receivedInput).toHaveBeenCalledWith({}) + expect(result.results).toHaveLength(1) + expect(result.results[0]!.state).toBeUndefined() + }) + + it('should normalize empty arguments to empty object', async () => { + const receivedInput = vi.fn() + + const tool: Tool = { + name: 'test_tool', + description: 'test', + execute: async (input: unknown) => { + receivedInput(input) + return { ok: true } + }, + } + + const toolCalls: Array = [ + { + id: 'tc-1', + type: 'function', + function: { name: 'test_tool', arguments: '' }, + }, + ] + + const result = await drainGenerator(executeToolCalls(toolCalls, [tool])) + expect(receivedInput).toHaveBeenCalledWith({}) + }) + + it('should pass through valid object arguments unchanged', async () => { + const receivedInput = vi.fn() + + const tool: Tool = { + name: 'test_tool', + description: 'test', + execute: async (input: unknown) => { + receivedInput(input) + return { ok: true } + }, + } + + const toolCalls: Array = [ + { + id: 'tc-1', + type: 'function', + function: { + name: 'test_tool', + arguments: '{"location":"NYC"}', + }, + }, + ] + + const result = await drainGenerator(executeToolCalls(toolCalls, [tool])) + expect(receivedInput).toHaveBeenCalledWith({ location: 'NYC' }) + }) + }) + + describe('ToolCallManager.completeToolCall', () => { + it('should normalize null input to empty object', () => { + const manager = new ToolCallManager([], mockFinishedEvent) + + // Register a tool call + manager.addToolCallStartEvent({ + type: 'TOOL_CALL_START', + toolCallId: 'tc-1', + toolName: 'test_tool', + model: 'test', + timestamp: Date.now(), + index: 0, + }) + + // Complete with null input (simulating Anthropic empty tool_use) + manager.completeToolCall({ + type: 'TOOL_CALL_END', + toolCallId: 'tc-1', + toolName: 'test_tool', + model: 'test', + timestamp: Date.now(), + input: null as unknown, + }) + + const toolCalls = manager.getToolCalls() + expect(toolCalls).toHaveLength(1) + // Should be "{}" not "null" + expect(toolCalls[0]!.function.arguments).toBe('{}') + }) + + it('should preserve valid object input', () => { + const manager = new ToolCallManager([], mockFinishedEvent) + + manager.addToolCallStartEvent({ + type: 'TOOL_CALL_START', + toolCallId: 'tc-1', + toolName: 'test_tool', + model: 'test', + timestamp: Date.now(), + index: 0, + }) + + manager.completeToolCall({ + type: 'TOOL_CALL_END', + toolCallId: 'tc-1', + toolName: 'test_tool', + model: 'test', + timestamp: Date.now(), + input: { location: 'NYC' }, + }) + + const toolCalls = manager.getToolCalls() + expect(toolCalls[0]!.function.arguments).toBe('{"location":"NYC"}') + }) + }) +}) From 418820370a92a9973e730567917cd4cee8912682 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 13:39:15 +0200 Subject: [PATCH 2/3] changeset: fix null tool input normalization --- .changeset/fix-null-tool-input-normalization.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/fix-null-tool-input-normalization.md diff --git a/.changeset/fix-null-tool-input-normalization.md b/.changeset/fix-null-tool-input-normalization.md new file mode 100644 index 000000000..aea55dd9a --- /dev/null +++ b/.changeset/fix-null-tool-input-normalization.md @@ -0,0 +1,8 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-openai': patch +--- + +fix(ai, ai-openai): normalize null tool input to empty object + +When a model produces a `tool_use` block with no input, `JSON.parse('null')` returns `null` which fails Zod schema validation and silently kills the agent loop. Normalize null/non-object parsed tool input to `{}` in `executeToolCalls`, `ToolCallManager.completeToolCall`, `ToolCallManager.executeTools`, and the OpenAI adapter's `TOOL_CALL_END` emission. From 80fb196d8f5b641e3223a74d69dc86b54961eae4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 13:50:30 +0200 Subject: [PATCH 3/3] fix: resolve type errors in null input test --- .../ai/tests/tool-calls-null-input.test.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/typescript/ai/tests/tool-calls-null-input.test.ts b/packages/typescript/ai/tests/tool-calls-null-input.test.ts index 413576ce0..92066ffb0 100644 --- a/packages/typescript/ai/tests/tool-calls-null-input.test.ts +++ b/packages/typescript/ai/tests/tool-calls-null-input.test.ts @@ -3,7 +3,7 @@ import { ToolCallManager, executeToolCalls, } from '../src/activities/chat/tools/tool-calls' -import type { RunFinishedEvent, Tool, ToolCall } from '../src/types' +import type { Tool, ToolCall } from '../src/types' /** * Drain an async generator and return its final return value. @@ -18,14 +18,6 @@ async function drainGenerator( } describe('null tool input normalization', () => { - const mockFinishedEvent: RunFinishedEvent = { - type: 'RUN_FINISHED', - runId: 'test-run', - model: 'test', - timestamp: Date.now(), - finishReason: 'tool_calls', - } - describe('executeToolCalls', () => { it('should normalize "null" arguments to empty object', async () => { const receivedInput = vi.fn() @@ -73,7 +65,7 @@ describe('null tool input normalization', () => { }, ] - const result = await drainGenerator(executeToolCalls(toolCalls, [tool])) + await drainGenerator(executeToolCalls(toolCalls, [tool])) expect(receivedInput).toHaveBeenCalledWith({}) }) @@ -100,14 +92,14 @@ describe('null tool input normalization', () => { }, ] - const result = await drainGenerator(executeToolCalls(toolCalls, [tool])) + await drainGenerator(executeToolCalls(toolCalls, [tool])) expect(receivedInput).toHaveBeenCalledWith({ location: 'NYC' }) }) }) describe('ToolCallManager.completeToolCall', () => { it('should normalize null input to empty object', () => { - const manager = new ToolCallManager([], mockFinishedEvent) + const manager = new ToolCallManager([]) // Register a tool call manager.addToolCallStartEvent({ @@ -136,7 +128,7 @@ describe('null tool input normalization', () => { }) it('should preserve valid object input', () => { - const manager = new ToolCallManager([], mockFinishedEvent) + const manager = new ToolCallManager([]) manager.addToolCallStartEvent({ type: 'TOOL_CALL_START',