diff --git a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts index d3d1d65bd..eb982d368 100644 --- a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +++ b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts @@ -233,6 +233,152 @@ describe('tool validation error handling', () => { expect(errorEvents.length).toBe(0) }) + it('should parse input JSON string from AI SDK before validation', async () => { + // The AI SDK can emit tool-call chunks with `input` as a raw JSON string + // when upstream schema validation fails and the repair function returns + // the original tool call unchanged. The stream parser should parse the + // string into an object before handing it to the tool executor. + const agentWithReadFiles: AgentTemplate = { + ...testAgentTemplate, + toolNames: ['read_files', 'end_turn'], + } + + const stringInputToolCallChunk = { + type: 'tool-call' as const, + toolName: 'read_files', + toolCallId: 'string-input-tool-call-id', + input: JSON.stringify({ paths: ['test.ts'] }) as any, + } + + async function* mockStream() { + yield stringInputToolCallChunk + return promptSuccess('mock-message-id') + } + + const sessionState = getInitialSessionState(mockFileContext) + const agentState = sessionState.mainAgentState + + agentRuntimeImpl.requestFiles = async () => ({ + 'test.ts': 'console.log("test")', + }) + + const responseChunks: (string | PrintModeEvent)[] = [] + + await processStream({ + ...agentRuntimeImpl, + agentContext: {}, + agentState, + agentStepId: 'test-step-id', + agentTemplate: agentWithReadFiles, + ancestorRunIds: [], + clientSessionId: 'test-session', + fileContext: mockFileContext, + fingerprintId: 'test-fingerprint', + fullResponse: '', + localAgentTemplates: { 'test-agent': agentWithReadFiles }, + messages: [], + prompt: 'test prompt', + repoId: undefined, + repoUrl: undefined, + runId: 'test-run-id', + signal: new AbortController().signal, + stream: mockStream(), + system: 'test system', + tools: {}, + userId: 'test-user', + userInputId: 'test-input-id', + onCostCalculated: async () => {}, + onResponseChunk: (chunk) => { + responseChunks.push(chunk) + }, + }) + + const toolCallEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'tool_call', + ) + expect(toolCallEvents.length).toBe(1) + expect(toolCallEvents[0].toolName).toBe('read_files') + expect(toolCallEvents[0].input).toEqual({ paths: ['test.ts'] }) + + const errorEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'error', + ) + expect(errorEvents.length).toBe(0) + }) + + it('should emit a clear error when tool input is an unparseable string', async () => { + const agentWithReadFiles: AgentTemplate = { + ...testAgentTemplate, + toolNames: ['read_files', 'end_turn'], + } + + const invalidStringToolCallChunk = { + type: 'tool-call' as const, + toolName: 'read_files', + toolCallId: 'invalid-string-tool-call-id', + input: '{"paths": ["test.ts"' as any, // truncated/malformed JSON + } + + async function* mockStream() { + yield invalidStringToolCallChunk + return promptSuccess('mock-message-id') + } + + const sessionState = getInitialSessionState(mockFileContext) + const agentState = sessionState.mainAgentState + + const responseChunks: (string | PrintModeEvent)[] = [] + + const result = await processStream({ + ...agentRuntimeImpl, + agentContext: {}, + agentState, + agentStepId: 'test-step-id', + agentTemplate: agentWithReadFiles, + ancestorRunIds: [], + clientSessionId: 'test-session', + fileContext: mockFileContext, + fingerprintId: 'test-fingerprint', + fullResponse: '', + localAgentTemplates: { 'test-agent': agentWithReadFiles }, + messages: [], + prompt: 'test prompt', + repoId: undefined, + repoUrl: undefined, + runId: 'test-run-id', + signal: new AbortController().signal, + stream: mockStream(), + system: 'test system', + tools: {}, + userId: 'test-user', + userInputId: 'test-input-id', + onCostCalculated: async () => {}, + onResponseChunk: (chunk) => { + responseChunks.push(chunk) + }, + }) + + const errorEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'error', + ) + expect(errorEvents.length).toBe(1) + expect(errorEvents[0].message).toContain( + 'tool arguments were a string, not a JSON object', + ) + expect(errorEvents[0].message).toContain('Original tool call input:') + + expect(result.hadToolCallError).toBe(true) + + const toolCallEvents = responseChunks.filter( + (chunk): chunk is Extract => + typeof chunk !== 'string' && chunk.type === 'tool_call', + ) + expect(toolCallEvents.length).toBe(0) + }) + it('should preserve tool_call/tool_result ordering when custom tool setup is async', async () => { const toolName = 'delayed_custom_tool' const agentWithCustomTool: AgentTemplate = { diff --git a/packages/agent-runtime/src/tool-stream-parser.ts b/packages/agent-runtime/src/tool-stream-parser.ts index 82a37111b..cd4ca58df 100644 --- a/packages/agent-runtime/src/tool-stream-parser.ts +++ b/packages/agent-runtime/src/tool-stream-parser.ts @@ -77,7 +77,17 @@ export async function* processStreamWithTools(params: { input: any contents?: string }): Promise { - const { toolName, input, contents } = params + const { toolName, contents } = params + let { input } = params + + // AI SDK sometimes emits tool-call chunks with a raw JSON string as `input` + // when its repair pass can't produce a parsed object. Try to parse; if it + // fails, leave as string — the executor surfaces a clear error. + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch {} + } const processor = processors[toolName] ?? defaultProcessor(toolName) diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index da0cfbd3b..78906f4ab 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -51,6 +51,18 @@ export type ToolCallError = { error: string } & Pick +function stringInputError( + toolName: string, + toolCallId: string, +): ToolCallError { + return { + toolName, + toolCallId, + input: {}, + 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.`, + } +} + export function parseRawToolCall(params: { rawToolCall: { toolName: T @@ -64,6 +76,10 @@ export function parseRawToolCall(params: { const processedParameters = rawToolCall.input const paramsSchema = toolParams[toolName].inputSchema + if (typeof processedParameters === 'string') { + return stringInputError(toolName, rawToolCall.toolCallId) + } + const result = paramsSchema.safeParse(processedParameters) if (!result.success) { @@ -388,6 +404,10 @@ export function parseRawCustomToolCall(params: { } } + if (typeof rawToolCall.input === 'string') { + return stringInputError(toolName, rawToolCall.toolCallId) + } + const processedParameters: Record = {} for (const [param, val] of Object.entries(rawToolCall.input ?? {})) { processedParameters[param] = val