Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
10 changes: 10 additions & 0 deletions .changeset/fix-null-tool-input-normalization.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/typescript/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
}
Expand All @@ -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 = {}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/typescript/ai-ollama/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ export class OllamaTextAdapter<TModel extends string> 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
}
Comment on lines 251 to 256
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 | 🟠 Major

Catch fallback should keep TOOL_CALL_END.input object-shaped

On Line 255, parsedInput = actualToolCall.function.arguments can emit a string/undefined after parse failure, which can still break downstream tool-input validation. Normalize catch fallback to {} to keep the invariant consistent.

Suggested fix
         try {
           const parsed = JSON.parse(argsStr)
           parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
         } catch {
-          parsedInput = actualToolCall.function.arguments
+          parsedInput = {}
         }
📝 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
try {
parsedInput = JSON.parse(argsStr)
const parsed = JSON.parse(argsStr)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
parsedInput = actualToolCall.function.arguments
}
try {
const parsed = JSON.parse(argsStr)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
parsedInput = {}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-ollama/src/adapters/text.ts` around lines 251 - 256,
The catch fallback currently assigns parsedInput =
actualToolCall.function.arguments which may be a string or undefined; change the
catch to normalize parsedInput to an object by using an object-shaped fallback
(e.g., {} or Object(actualToolCall.function.arguments) guarded to ensure it's an
object) so TOOL_CALL_END.input always remains an object; update the catch block
that reads argsStr/parsedInput (referencing parsedInput, argsStr, and
actualToolCall.function.arguments) to enforce parsedInput is an object and
default to {} when it is not.

Expand Down
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"}')
})
})
})
23 changes: 23 additions & 0 deletions testing/e2e/fixtures/tools-test/null-tool-input.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
]
}
22 changes: 22 additions & 0 deletions testing/e2e/src/lib/tools-test-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -264,6 +283,9 @@ export function getToolsForScenario(scenario: string) {
case 'tool-error':
return [failingTool]

case 'null-tool-input':
return [serverTools.check_status]

default:
return []
}
Expand Down
Loading
Loading