Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-null-tool-input-normalization.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/typescript/ai-openai/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
}
Expand Down
14 changes: 10 additions & 4 deletions packages/typescript/ai/src/activities/chat/tools/tool-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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}`)
Expand Down
155 changes: 155 additions & 0 deletions packages/typescript/ai/tests/tool-calls-null-input.test.ts
Original file line number Diff line number Diff line change
@@ -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<TChunk, TResult>(
gen: AsyncGenerator<TChunk, TResult, void>,
): Promise<TResult> {
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<ToolCall> = [
{
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<ToolCall> = [
{
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<ToolCall> = [
{
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('{}')
})
Comment on lines +100 to +128
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify ToolCallManager constructor signature
ast-grep --pattern 'class ToolCallManager {
  $$$
  constructor($PARAMS) {
    $$$
  }
  $$$
}'

Repository: TanStack/ai

Length of output: 17852


🏁 Script executed:

cat -n packages/typescript/ai/tests/tool-calls-null-input.test.ts

Repository: TanStack/ai

Length of output: 5661


Constructor signature mismatch — tests will not compile.

ToolCallManager constructor accepts only one parameter (tools: ReadonlyArray<Tool>), but the tests at lines 110 and 139 pass a second argument (mockFinishedEvent). This causes TypeScript compilation errors.

🐛 Proposed fix
  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({
    it('should preserve valid object input', () => {
-      const manager = new ToolCallManager([], mockFinishedEvent)
+      const manager = new ToolCallManager([])

      manager.addToolCallStartEvent({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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('{}')
})
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('{}')
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/tests/tool-calls-null-input.test.ts` around lines 108
- 136, The tests fail to compile because the ToolCallManager constructor
currently accepts only one parameter (tools: ReadonlyArray<Tool>) but tests call
new ToolCallManager(..., mockFinishedEvent); update the ToolCallManager
constructor signature to accept an optional second parameter (e.g.,
finishedEvent or initialFinishedEvent) with the same type as mockFinishedEvent,
adjust the constructor implementation to handle when that second argument is
provided (store or process it the same way tests expect), and ensure all usages
and exports of ToolCallManager are updated to the new signature so the tests
compiling calls to ToolCallManager(..., mockFinishedEvent) succeed.


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"}')
})
})
})
Loading