Skip to content

Commit 359a039

Browse files
jahoomaclaude
andauthored
Parse stringified tool-call input before Zod validation (#536)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 53762bd commit 359a039

3 files changed

Lines changed: 177 additions & 1 deletion

File tree

packages/agent-runtime/src/__tests__/tool-validation-error.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,152 @@ describe('tool validation error handling', () => {
233233
expect(errorEvents.length).toBe(0)
234234
})
235235

236+
it('should parse input JSON string from AI SDK before validation', async () => {
237+
// The AI SDK can emit tool-call chunks with `input` as a raw JSON string
238+
// when upstream schema validation fails and the repair function returns
239+
// the original tool call unchanged. The stream parser should parse the
240+
// string into an object before handing it to the tool executor.
241+
const agentWithReadFiles: AgentTemplate = {
242+
...testAgentTemplate,
243+
toolNames: ['read_files', 'end_turn'],
244+
}
245+
246+
const stringInputToolCallChunk = {
247+
type: 'tool-call' as const,
248+
toolName: 'read_files',
249+
toolCallId: 'string-input-tool-call-id',
250+
input: JSON.stringify({ paths: ['test.ts'] }) as any,
251+
}
252+
253+
async function* mockStream() {
254+
yield stringInputToolCallChunk
255+
return promptSuccess('mock-message-id')
256+
}
257+
258+
const sessionState = getInitialSessionState(mockFileContext)
259+
const agentState = sessionState.mainAgentState
260+
261+
agentRuntimeImpl.requestFiles = async () => ({
262+
'test.ts': 'console.log("test")',
263+
})
264+
265+
const responseChunks: (string | PrintModeEvent)[] = []
266+
267+
await processStream({
268+
...agentRuntimeImpl,
269+
agentContext: {},
270+
agentState,
271+
agentStepId: 'test-step-id',
272+
agentTemplate: agentWithReadFiles,
273+
ancestorRunIds: [],
274+
clientSessionId: 'test-session',
275+
fileContext: mockFileContext,
276+
fingerprintId: 'test-fingerprint',
277+
fullResponse: '',
278+
localAgentTemplates: { 'test-agent': agentWithReadFiles },
279+
messages: [],
280+
prompt: 'test prompt',
281+
repoId: undefined,
282+
repoUrl: undefined,
283+
runId: 'test-run-id',
284+
signal: new AbortController().signal,
285+
stream: mockStream(),
286+
system: 'test system',
287+
tools: {},
288+
userId: 'test-user',
289+
userInputId: 'test-input-id',
290+
onCostCalculated: async () => {},
291+
onResponseChunk: (chunk) => {
292+
responseChunks.push(chunk)
293+
},
294+
})
295+
296+
const toolCallEvents = responseChunks.filter(
297+
(chunk): chunk is Extract<PrintModeEvent, { type: 'tool_call' }> =>
298+
typeof chunk !== 'string' && chunk.type === 'tool_call',
299+
)
300+
expect(toolCallEvents.length).toBe(1)
301+
expect(toolCallEvents[0].toolName).toBe('read_files')
302+
expect(toolCallEvents[0].input).toEqual({ paths: ['test.ts'] })
303+
304+
const errorEvents = responseChunks.filter(
305+
(chunk): chunk is Extract<PrintModeEvent, { type: 'error' }> =>
306+
typeof chunk !== 'string' && chunk.type === 'error',
307+
)
308+
expect(errorEvents.length).toBe(0)
309+
})
310+
311+
it('should emit a clear error when tool input is an unparseable string', async () => {
312+
const agentWithReadFiles: AgentTemplate = {
313+
...testAgentTemplate,
314+
toolNames: ['read_files', 'end_turn'],
315+
}
316+
317+
const invalidStringToolCallChunk = {
318+
type: 'tool-call' as const,
319+
toolName: 'read_files',
320+
toolCallId: 'invalid-string-tool-call-id',
321+
input: '{"paths": ["test.ts"' as any, // truncated/malformed JSON
322+
}
323+
324+
async function* mockStream() {
325+
yield invalidStringToolCallChunk
326+
return promptSuccess('mock-message-id')
327+
}
328+
329+
const sessionState = getInitialSessionState(mockFileContext)
330+
const agentState = sessionState.mainAgentState
331+
332+
const responseChunks: (string | PrintModeEvent)[] = []
333+
334+
const result = await processStream({
335+
...agentRuntimeImpl,
336+
agentContext: {},
337+
agentState,
338+
agentStepId: 'test-step-id',
339+
agentTemplate: agentWithReadFiles,
340+
ancestorRunIds: [],
341+
clientSessionId: 'test-session',
342+
fileContext: mockFileContext,
343+
fingerprintId: 'test-fingerprint',
344+
fullResponse: '',
345+
localAgentTemplates: { 'test-agent': agentWithReadFiles },
346+
messages: [],
347+
prompt: 'test prompt',
348+
repoId: undefined,
349+
repoUrl: undefined,
350+
runId: 'test-run-id',
351+
signal: new AbortController().signal,
352+
stream: mockStream(),
353+
system: 'test system',
354+
tools: {},
355+
userId: 'test-user',
356+
userInputId: 'test-input-id',
357+
onCostCalculated: async () => {},
358+
onResponseChunk: (chunk) => {
359+
responseChunks.push(chunk)
360+
},
361+
})
362+
363+
const errorEvents = responseChunks.filter(
364+
(chunk): chunk is Extract<PrintModeEvent, { type: 'error' }> =>
365+
typeof chunk !== 'string' && chunk.type === 'error',
366+
)
367+
expect(errorEvents.length).toBe(1)
368+
expect(errorEvents[0].message).toContain(
369+
'tool arguments were a string, not a JSON object',
370+
)
371+
expect(errorEvents[0].message).toContain('Original tool call input:')
372+
373+
expect(result.hadToolCallError).toBe(true)
374+
375+
const toolCallEvents = responseChunks.filter(
376+
(chunk): chunk is Extract<PrintModeEvent, { type: 'tool_call' }> =>
377+
typeof chunk !== 'string' && chunk.type === 'tool_call',
378+
)
379+
expect(toolCallEvents.length).toBe(0)
380+
})
381+
236382
it('should preserve tool_call/tool_result ordering when custom tool setup is async', async () => {
237383
const toolName = 'delayed_custom_tool'
238384
const agentWithCustomTool: AgentTemplate = {

packages/agent-runtime/src/tool-stream-parser.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,17 @@ export async function* processStreamWithTools(params: {
7777
input: any
7878
contents?: string
7979
}): Promise<void> {
80-
const { toolName, input, contents } = params
80+
const { toolName, contents } = params
81+
let { input } = params
82+
83+
// AI SDK sometimes emits tool-call chunks with a raw JSON string as `input`
84+
// when its repair pass can't produce a parsed object. Try to parse; if it
85+
// fails, leave as string — the executor surfaces a clear error.
86+
if (typeof input === 'string') {
87+
try {
88+
input = JSON.parse(input)
89+
} catch {}
90+
}
8191

8292
const processor = processors[toolName] ?? defaultProcessor(toolName)
8393

packages/agent-runtime/src/tools/tool-executor.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ export type ToolCallError = {
5151
error: string
5252
} & Pick<CodebuffToolCall, 'toolCallId'>
5353

54+
function stringInputError(
55+
toolName: string,
56+
toolCallId: string,
57+
): ToolCallError {
58+
return {
59+
toolName,
60+
toolCallId,
61+
input: {},
62+
error: `Invalid parameters for ${toolName}: tool arguments were a string, not a JSON object. This usually means the model emitted malformed JSON (e.g. unescaped newlines or quotes inside a string value). Re-issue the tool call with properly escaped JSON.`,
63+
}
64+
}
65+
5466
export function parseRawToolCall<T extends ToolName = ToolName>(params: {
5567
rawToolCall: {
5668
toolName: T
@@ -64,6 +76,10 @@ export function parseRawToolCall<T extends ToolName = ToolName>(params: {
6476
const processedParameters = rawToolCall.input
6577
const paramsSchema = toolParams[toolName].inputSchema
6678

79+
if (typeof processedParameters === 'string') {
80+
return stringInputError(toolName, rawToolCall.toolCallId)
81+
}
82+
6783
const result = paramsSchema.safeParse(processedParameters)
6884

6985
if (!result.success) {
@@ -388,6 +404,10 @@ export function parseRawCustomToolCall(params: {
388404
}
389405
}
390406

407+
if (typeof rawToolCall.input === 'string') {
408+
return stringInputError(toolName, rawToolCall.toolCallId)
409+
}
410+
391411
const processedParameters: Record<string, any> = {}
392412
for (const [param, val] of Object.entries(rawToolCall.input ?? {})) {
393413
processedParameters[param] = val

0 commit comments

Comments
 (0)