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. 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..92066ffb0 --- /dev/null +++ b/packages/typescript/ai/tests/tool-calls-null-input.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ToolCallManager, + executeToolCalls, +} from '../src/activities/chat/tools/tool-calls' +import type { 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', () => { + 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: '' }, + }, + ] + + 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"}', + }, + }, + ] + + 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([]) + + // 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([]) + + 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"}') + }) + }) +})