Skip to content

Commit c780bc1

Browse files
fix(ai, ai-openai): normalize null tool input to empty object (#430)
* 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 * changeset: fix null tool input normalization * fix: resolve type errors in null input test * fix(ai-gemini, ai-ollama): normalize null tool input + add e2e regression test Extend the null tool input normalization to the Gemini and Ollama adapters (both were missing the guard in TOOL_CALL_END emission). Add an e2e regression test for issue #265 that verifies the full flow: aimock returns a tool call with "null" arguments → adapter normalizes null → {} → tool executes successfully → agent loop continues → follow-up text response is received. * ci: apply automated fixes * test: increase timeout for null-tool-input e2e test in CI The selectScenario + waitForTestComplete calls can be slow on CI cold-start. Increase timeout from 15s to 30s to avoid flaky failures. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 5f0beaf commit c780bc1

9 files changed

Lines changed: 333 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/ai': patch
3+
'@tanstack/ai-openai': patch
4+
'@tanstack/ai-gemini': patch
5+
'@tanstack/ai-ollama': patch
6+
---
7+
8+
fix(ai, ai-openai, ai-gemini, ai-ollama): normalize null tool input to empty object
9+
10+
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.

packages/typescript/ai-gemini/src/adapters/text.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,10 +412,12 @@ export class GeminiTextAdapter<
412412
// Emit TOOL_CALL_END with parsed input
413413
let parsedInput: unknown = {}
414414
try {
415-
parsedInput =
415+
const parsed =
416416
typeof functionArgs === 'string'
417417
? JSON.parse(functionArgs)
418418
: functionArgs
419+
parsedInput =
420+
parsed && typeof parsed === 'object' ? parsed : {}
419421
} catch {
420422
parsedInput = {}
421423
}
@@ -437,7 +439,8 @@ export class GeminiTextAdapter<
437439
for (const [toolCallId, toolCallData] of toolCallMap.entries()) {
438440
let parsedInput: unknown = {}
439441
try {
440-
parsedInput = JSON.parse(toolCallData.args)
442+
const parsed = JSON.parse(toolCallData.args)
443+
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
441444
} catch {
442445
parsedInput = {}
443446
}

packages/typescript/ai-ollama/src/adapters/text.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ export class OllamaTextAdapter<TModel extends string> extends BaseTextAdapter<
249249
? actualToolCall.function.arguments
250250
: JSON.stringify(actualToolCall.function.arguments)
251251
try {
252-
parsedInput = JSON.parse(argsStr)
252+
const parsed = JSON.parse(argsStr)
253+
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
253254
} catch {
254255
parsedInput = actualToolCall.function.arguments
255256
}

packages/typescript/ai-openai/src/adapters/text.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,8 @@ export class OpenAITextAdapter<
566566
// Parse arguments
567567
let parsedInput: unknown = {}
568568
try {
569-
parsedInput = chunk.arguments ? JSON.parse(chunk.arguments) : {}
569+
const parsed = chunk.arguments ? JSON.parse(chunk.arguments) : {}
570+
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
570571
} catch {
571572
parsedInput = {}
572573
}

packages/typescript/ai/src/activities/chat/tools/tool-calls.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ export class ToolCallManager {
127127
for (const [, toolCall] of this.toolCallsMap.entries()) {
128128
if (toolCall.id === event.toolCallId) {
129129
if (event.input !== undefined) {
130-
toolCall.function.arguments = JSON.stringify(event.input)
130+
// Normalize null/non-object to {} (e.g. Anthropic empty tool_use blocks)
131+
const normalized =
132+
event.input && typeof event.input === 'object' ? event.input : {}
133+
toolCall.function.arguments = JSON.stringify(normalized)
131134
}
132135
break
133136
}
@@ -167,11 +170,12 @@ export class ToolCallManager {
167170
let toolResultContent: string
168171
if (tool?.execute) {
169172
try {
170-
// Parse arguments (normalize "null" to "{}" for empty tool_use blocks)
173+
// Parse arguments (normalize null/non-object to {} for empty tool_use blocks)
171174
let args: unknown
172175
try {
173176
const argsString = toolCall.function.arguments.trim() || '{}'
174-
args = JSON.parse(argsString === 'null' ? '{}' : argsString)
177+
const parsed = JSON.parse(argsString)
178+
args = parsed && typeof parsed === 'object' ? parsed : {}
175179
} catch (parseError) {
176180
throw new Error(
177181
`Failed to parse tool arguments as JSON: ${toolCall.function.arguments}`,
@@ -543,7 +547,9 @@ export async function* executeToolCalls(
543547
const argsStr = toolCall.function.arguments.trim() || '{}'
544548
if (argsStr) {
545549
try {
546-
input = JSON.parse(argsStr)
550+
const parsed = JSON.parse(argsStr)
551+
// Normalize null/non-object to {} (e.g. Anthropic empty tool_use blocks)
552+
input = parsed && typeof parsed === 'object' ? parsed : {}
547553
} catch (parseError) {
548554
// If parsing fails, throw error to fail fast
549555
throw new Error(`Failed to parse tool arguments as JSON: ${argsStr}`)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import {
3+
ToolCallManager,
4+
executeToolCalls,
5+
} from '../src/activities/chat/tools/tool-calls'
6+
import type { Tool, ToolCall } from '../src/types'
7+
8+
/**
9+
* Drain an async generator and return its final return value.
10+
*/
11+
async function drainGenerator<TChunk, TResult>(
12+
gen: AsyncGenerator<TChunk, TResult, void>,
13+
): Promise<TResult> {
14+
while (true) {
15+
const next = await gen.next()
16+
if (next.done) return next.value
17+
}
18+
}
19+
20+
describe('null tool input normalization', () => {
21+
describe('executeToolCalls', () => {
22+
it('should normalize "null" arguments to empty object', async () => {
23+
const receivedInput = vi.fn()
24+
25+
const tool: Tool = {
26+
name: 'test_tool',
27+
description: 'test',
28+
execute: async (input: unknown) => {
29+
receivedInput(input)
30+
return { ok: true }
31+
},
32+
}
33+
34+
const toolCalls: Array<ToolCall> = [
35+
{
36+
id: 'tc-1',
37+
type: 'function',
38+
function: { name: 'test_tool', arguments: 'null' },
39+
},
40+
]
41+
42+
const result = await drainGenerator(executeToolCalls(toolCalls, [tool]))
43+
expect(receivedInput).toHaveBeenCalledWith({})
44+
expect(result.results).toHaveLength(1)
45+
expect(result.results[0]!.state).toBeUndefined()
46+
})
47+
48+
it('should normalize empty arguments to empty object', async () => {
49+
const receivedInput = vi.fn()
50+
51+
const tool: Tool = {
52+
name: 'test_tool',
53+
description: 'test',
54+
execute: async (input: unknown) => {
55+
receivedInput(input)
56+
return { ok: true }
57+
},
58+
}
59+
60+
const toolCalls: Array<ToolCall> = [
61+
{
62+
id: 'tc-1',
63+
type: 'function',
64+
function: { name: 'test_tool', arguments: '' },
65+
},
66+
]
67+
68+
await drainGenerator(executeToolCalls(toolCalls, [tool]))
69+
expect(receivedInput).toHaveBeenCalledWith({})
70+
})
71+
72+
it('should pass through valid object arguments unchanged', async () => {
73+
const receivedInput = vi.fn()
74+
75+
const tool: Tool = {
76+
name: 'test_tool',
77+
description: 'test',
78+
execute: async (input: unknown) => {
79+
receivedInput(input)
80+
return { ok: true }
81+
},
82+
}
83+
84+
const toolCalls: Array<ToolCall> = [
85+
{
86+
id: 'tc-1',
87+
type: 'function',
88+
function: {
89+
name: 'test_tool',
90+
arguments: '{"location":"NYC"}',
91+
},
92+
},
93+
]
94+
95+
await drainGenerator(executeToolCalls(toolCalls, [tool]))
96+
expect(receivedInput).toHaveBeenCalledWith({ location: 'NYC' })
97+
})
98+
})
99+
100+
describe('ToolCallManager.completeToolCall', () => {
101+
it('should normalize null input to empty object', () => {
102+
const manager = new ToolCallManager([])
103+
104+
// Register a tool call
105+
manager.addToolCallStartEvent({
106+
type: 'TOOL_CALL_START',
107+
toolCallId: 'tc-1',
108+
toolName: 'test_tool',
109+
model: 'test',
110+
timestamp: Date.now(),
111+
index: 0,
112+
})
113+
114+
// Complete with null input (simulating Anthropic empty tool_use)
115+
manager.completeToolCall({
116+
type: 'TOOL_CALL_END',
117+
toolCallId: 'tc-1',
118+
toolName: 'test_tool',
119+
model: 'test',
120+
timestamp: Date.now(),
121+
input: null as unknown,
122+
})
123+
124+
const toolCalls = manager.getToolCalls()
125+
expect(toolCalls).toHaveLength(1)
126+
// Should be "{}" not "null"
127+
expect(toolCalls[0]!.function.arguments).toBe('{}')
128+
})
129+
130+
it('should preserve valid object input', () => {
131+
const manager = new ToolCallManager([])
132+
133+
manager.addToolCallStartEvent({
134+
type: 'TOOL_CALL_START',
135+
toolCallId: 'tc-1',
136+
toolName: 'test_tool',
137+
model: 'test',
138+
timestamp: Date.now(),
139+
index: 0,
140+
})
141+
142+
manager.completeToolCall({
143+
type: 'TOOL_CALL_END',
144+
toolCallId: 'tc-1',
145+
toolName: 'test_tool',
146+
model: 'test',
147+
timestamp: Date.now(),
148+
input: { location: 'NYC' },
149+
})
150+
151+
const toolCalls = manager.getToolCalls()
152+
expect(toolCalls[0]!.function.arguments).toBe('{"location":"NYC"}')
153+
})
154+
})
155+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "[null-tool-input] run test",
6+
"sequenceIndex": 0
7+
},
8+
"response": {
9+
"content": "Let me check the system status.",
10+
"toolCalls": [{ "name": "check_status", "arguments": "null" }]
11+
}
12+
},
13+
{
14+
"match": {
15+
"userMessage": "[null-tool-input] run test",
16+
"sequenceIndex": 1
17+
},
18+
"response": {
19+
"content": "The system status check is complete. Everything is working normally."
20+
}
21+
}
22+
]
23+
}

testing/e2e/src/lib/tools-test-tools.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { z } from 'zod'
55
* Server-side tool definitions (for tools that execute on the server)
66
*/
77
export const serverTools = {
8+
check_status: toolDefinition({
9+
name: 'check_status',
10+
description: 'Check system status (no required input)',
11+
inputSchema: z.object({
12+
component: z.string().optional(),
13+
}),
14+
}).server(async (args) => {
15+
return JSON.stringify({
16+
status: 'ok',
17+
component: args.component || 'all',
18+
timestamp: Date.now(),
19+
})
20+
}),
21+
822
get_weather: toolDefinition({
923
name: 'get_weather',
1024
description: 'Get weather for a city',
@@ -158,6 +172,11 @@ export const SCENARIO_LIST = [
158172
label: 'Tool Throws Error',
159173
category: 'basic',
160174
},
175+
{
176+
id: 'null-tool-input',
177+
label: 'Null Tool Input (Regression #265)',
178+
category: 'basic',
179+
},
161180
// Race condition / event flow scenarios
162181
{
163182
id: 'sequential-client-tools',
@@ -264,6 +283,9 @@ export function getToolsForScenario(scenario: string) {
264283
case 'tool-error':
265284
return [failingTool]
266285

286+
case 'null-tool-input':
287+
return [serverTools.check_status]
288+
267289
default:
268290
return []
269291
}

0 commit comments

Comments
 (0)