diff --git a/.changeset/fix-null-tool-input-normalization.md b/.changeset/fix-null-tool-input-normalization.md new file mode 100644 index 000000000..b15c356f6 --- /dev/null +++ b/.changeset/fix-null-tool-input-normalization.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-openai': patch +'@tanstack/ai-gemini': patch +'@tanstack/ai-ollama': patch +--- + +fix(ai, ai-openai, ai-gemini, ai-ollama): 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/Gemini/Ollama adapter `TOOL_CALL_END` emissions. The Anthropic adapter already had this fix. diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index 844c4a12f..af596aa35 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -412,10 +412,12 @@ export class GeminiTextAdapter< // Emit TOOL_CALL_END with parsed input let parsedInput: unknown = {} try { - parsedInput = + const parsed = typeof functionArgs === 'string' ? JSON.parse(functionArgs) : functionArgs + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} } catch { parsedInput = {} } @@ -437,7 +439,8 @@ export class GeminiTextAdapter< for (const [toolCallId, toolCallData] of toolCallMap.entries()) { let parsedInput: unknown = {} try { - parsedInput = JSON.parse(toolCallData.args) + const parsed = JSON.parse(toolCallData.args) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} } catch { parsedInput = {} } diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 41e08122b..43e97355e 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -249,7 +249,8 @@ export class OllamaTextAdapter extends BaseTextAdapter< ? actualToolCall.function.arguments : JSON.stringify(actualToolCall.function.arguments) try { - parsedInput = JSON.parse(argsStr) + const parsed = JSON.parse(argsStr) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} } catch { parsedInput = actualToolCall.function.arguments } 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"}') + }) + }) +}) diff --git a/testing/e2e/fixtures/tools-test/null-tool-input.json b/testing/e2e/fixtures/tools-test/null-tool-input.json new file mode 100644 index 000000000..8400e21bc --- /dev/null +++ b/testing/e2e/fixtures/tools-test/null-tool-input.json @@ -0,0 +1,23 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[null-tool-input] run test", + "sequenceIndex": 0 + }, + "response": { + "content": "Let me check the system status.", + "toolCalls": [{ "name": "check_status", "arguments": "null" }] + } + }, + { + "match": { + "userMessage": "[null-tool-input] run test", + "sequenceIndex": 1 + }, + "response": { + "content": "The system status check is complete. Everything is working normally." + } + } + ] +} diff --git a/testing/e2e/src/lib/tools-test-tools.ts b/testing/e2e/src/lib/tools-test-tools.ts index 6e2a16067..553617392 100644 --- a/testing/e2e/src/lib/tools-test-tools.ts +++ b/testing/e2e/src/lib/tools-test-tools.ts @@ -5,6 +5,20 @@ import { z } from 'zod' * Server-side tool definitions (for tools that execute on the server) */ export const serverTools = { + check_status: toolDefinition({ + name: 'check_status', + description: 'Check system status (no required input)', + inputSchema: z.object({ + component: z.string().optional(), + }), + }).server(async (args) => { + return JSON.stringify({ + status: 'ok', + component: args.component || 'all', + timestamp: Date.now(), + }) + }), + get_weather: toolDefinition({ name: 'get_weather', description: 'Get weather for a city', @@ -158,6 +172,11 @@ export const SCENARIO_LIST = [ label: 'Tool Throws Error', category: 'basic', }, + { + id: 'null-tool-input', + label: 'Null Tool Input (Regression #265)', + category: 'basic', + }, // Race condition / event flow scenarios { id: 'sequential-client-tools', @@ -264,6 +283,9 @@ export function getToolsForScenario(scenario: string) { case 'tool-error': return [failingTool] + case 'null-tool-input': + return [serverTools.check_status] + default: return [] } diff --git a/testing/e2e/tests/tools-test/null-tool-input.spec.ts b/testing/e2e/tests/tools-test/null-tool-input.spec.ts new file mode 100644 index 000000000..369e77535 --- /dev/null +++ b/testing/e2e/tests/tools-test/null-tool-input.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '../fixtures' +import { + selectScenario, + runTest, + waitForTestComplete, + getMetadata, + getToolCalls, +} from './helpers' + +/** + * Null Tool Input E2E Tests + * + * Regression test for GitHub issue #265 / PR #430. + * + * When a model (e.g. Anthropic Claude) produces a tool_use block with no input + * or literal null, JSON.parse('null') returns JavaScript null. Before the fix, + * this would fail Zod schema validation and silently kill the agent loop — + * the user would see "Let me check that..." then silence. + * + * The fix normalizes null/non-object parsed tool input to {} so the agent loop + * continues and the tool executes successfully. + * + * This test verifies the full end-to-end flow: + * 1. aimock returns a tool call with "null" as arguments + * 2. The adapter + server-side chat() normalizes null → {} + * 3. The tool executes successfully (schema has only optional fields) + * 4. The agent loop continues to the next iteration + * 5. The follow-up text response is received by the client + */ + +test.describe('Null Tool Input E2E Tests (Regression #265)', () => { + test('server tool with null arguments executes and agent loop continues', async ({ + page, + testId, + aimockPort, + }) => { + await selectScenario(page, 'null-tool-input', testId, aimockPort) + await runTest(page) + await waitForTestComplete(page, 30000) + + // Verify the test completed (agent loop didn't die) + const metadata = await getMetadata(page) + expect(metadata.testComplete).toBe('true') + expect(metadata.isLoading).toBe('false') + + // Verify the tool call was made and completed + expect(parseInt(metadata.toolCallCount)).toBeGreaterThanOrEqual(1) + expect(parseInt(metadata.completeToolCount)).toBeGreaterThanOrEqual(1) + + // Verify check_status tool was called and completed + const toolCalls = await getToolCalls(page) + const statusTool = toolCalls.find((tc) => tc.name === 'check_status') + expect(statusTool).toBeTruthy() + expect(['complete', 'input-complete', 'output-available']).toContain( + statusTool?.state, + ) + + // Verify the tool result does NOT contain a validation error + // (proves null→{} normalization worked and the tool executed successfully) + const messages = await page.evaluate(() => { + const el = document.getElementById('messages-json-content') + if (!el) return [] + try { + return JSON.parse(el.textContent || '[]') + } catch { + return [] + } + }) + const toolResults = messages.flatMap((m: any) => + m.parts.filter((p: any) => p.type === 'tool-result'), + ) + expect(toolResults.length).toBeGreaterThanOrEqual(1) + expect(toolResults[0].content).not.toContain('Input validation failed') + expect(toolResults[0].content).toContain('"status":"ok"') + + // Verify the follow-up text was received (proves agent loop continued past + // the null-input tool call to produce the second iteration's response) + const assistantMessages = messages.filter( + (m: any) => m.role === 'assistant', + ) + expect(assistantMessages.length).toBeGreaterThanOrEqual(2) + + const lastAssistant = assistantMessages[assistantMessages.length - 1] + const textParts = lastAssistant.parts.filter((p: any) => p.type === 'text') + const allText = textParts.map((p: any) => p.content).join(' ') + expect(allText).toContain('status check is complete') + }) + + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status !== testInfo.expectedStatus) { + await page.screenshot({ + path: `test-results/null-tool-input-failure-${testInfo.title.replace(/\s+/g, '-')}.png`, + fullPage: true, + }) + + const toolCalls = await getToolCalls(page) + const metadata = await getMetadata(page) + + console.log('Test failed. Debug info:') + console.log('Metadata:', metadata) + console.log('Tool calls:', toolCalls) + } + }) +})