Skip to content
Merged
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
146 changes: 146 additions & 0 deletions packages/agent-runtime/src/__tests__/tool-validation-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrintModeEvent, { type: 'tool_call' }> =>
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<PrintModeEvent, { type: 'error' }> =>
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<PrintModeEvent, { type: 'error' }> =>
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<PrintModeEvent, { type: 'tool_call' }> =>
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 = {
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-runtime/src/tool-stream-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,17 @@ export async function* processStreamWithTools(params: {
input: any
contents?: string
}): Promise<void> {
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 {}
}
Comment on lines +86 to +90
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.

P2 Non-object parsed values bypass the string-input guard

JSON.parse can succeed but return a non-object (null, a number, a boolean, an array). In those cases input is no longer a string, so both parseRawToolCall and parseRawCustomToolCall skip the stringInputError path and fall through to Zod validation, which will surface a less-clear error (e.g. "expected object, received null").

For the realistic AI-SDK case (a stringified object) this is fine, but a guard on the parsed result would future-proof it:

Suggested change
if (typeof input === 'string') {
try {
input = JSON.parse(input)
} catch {}
}
if (typeof input === 'string') {
try {
const parsed = JSON.parse(input)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
input = parsed
}
} catch {}
}


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

Expand Down
20 changes: 20 additions & 0 deletions packages/agent-runtime/src/tools/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ export type ToolCallError = {
error: string
} & Pick<CodebuffToolCall, 'toolCallId'>

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<T extends ToolName = ToolName>(params: {
rawToolCall: {
toolName: T
Expand All @@ -64,6 +76,10 @@ export function parseRawToolCall<T extends ToolName = ToolName>(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) {
Expand Down Expand Up @@ -388,6 +404,10 @@ export function parseRawCustomToolCall(params: {
}
}

if (typeof rawToolCall.input === 'string') {
return stringInputError(toolName, rawToolCall.toolCallId)
}

const processedParameters: Record<string, any> = {}
for (const [param, val] of Object.entries(rawToolCall.input ?? {})) {
processedParameters[param] = val
Expand Down
Loading