diff --git a/common/src/tools/params/__tests__/coerce-to-array.test.ts b/common/src/tools/params/__tests__/coerce-to-array.test.ts index ccd80ce6bf..216c478f97 100644 --- a/common/src/tools/params/__tests__/coerce-to-array.test.ts +++ b/common/src/tools/params/__tests__/coerce-to-array.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import z from 'zod/v4' -import { coerceToArray, normalizeReplacementAliases } from '../utils' +import { coerceToArray, coerceToObject, normalizeReplacementAliases } from '../utils' describe('coerceToArray', () => { it('passes through arrays unchanged', () => { @@ -50,6 +50,25 @@ describe('coerceToArray', () => { }) }) +describe('coerceToObject', () => { + it('passes through objects unchanged', () => { + expect(coerceToObject({ key: 'value' })).toEqual({ key: 'value' }) + }) + + it('parses a stringified JSON object', () => { + expect(coerceToObject('{"key": "value"}')).toEqual({ key: 'value' }) + }) + + it('leaves non-JSON strings untouched', () => { + expect(coerceToObject('not-json')).toBe('not-json') + }) + + it('passes through arrays and primitives so validation can reject them', () => { + expect(coerceToObject(['a'])).toEqual(['a']) + expect(coerceToObject(1)).toBe(1) + }) +}) + describe('coerceToArray with Zod schemas', () => { it('coerces a single string into an array for z.array(z.string())', () => { const schema = z.object({ diff --git a/common/src/tools/params/tool/spawn-agent-inline.ts b/common/src/tools/params/tool/spawn-agent-inline.ts index 60e2345943..9bd94c9be4 100644 --- a/common/src/tools/params/tool/spawn-agent-inline.ts +++ b/common/src/tools/params/tool/spawn-agent-inline.ts @@ -2,6 +2,7 @@ import z from 'zod/v4' import { $getNativeToolCallExampleString, + coerceToObject, textToolResultSchema, } from '../utils' @@ -14,7 +15,10 @@ const inputSchema = z agent_type: z.string().describe('Agent to spawn'), prompt: z.string().optional().describe('Prompt to send to the agent'), params: z - .record(z.string(), z.any()) + .preprocess( + coerceToObject, + z.record(z.string(), z.any()), + ) .optional() .describe('Parameters object for the agent (if any)'), }) diff --git a/common/src/tools/params/tool/spawn-agents.ts b/common/src/tools/params/tool/spawn-agents.ts index 6102e15cd3..8beccab225 100644 --- a/common/src/tools/params/tool/spawn-agents.ts +++ b/common/src/tools/params/tool/spawn-agents.ts @@ -4,6 +4,7 @@ import { jsonObjectSchema } from '../../../types/json' import { $getNativeToolCallExampleString, coerceToArray, + coerceToObject, jsonToolResultSchema, } from '../utils' @@ -18,84 +19,80 @@ export const spawnAgentsOutputSchema = z const toolName = 'spawn_agents' const endsAgentStep = true -const inputSchema = z - .object({ - agents: z.preprocess( - coerceToArray, +const agentInputSchema = z.object({ + agent_type: z.string().describe('Agent to spawn'), + prompt: z.string().optional().describe('Prompt to send to the agent'), + params: z + .preprocess( + coerceToObject, z .object({ - agent_type: z.string().describe('Agent to spawn'), - prompt: z.string().optional().describe('Prompt to send to the agent'), - params: z - .object({ - // Common agent fields (all optional hints — each agent validates its own required fields) - command: z - .string() - .optional() - .describe('Terminal command to run (basher, tmux-cli)'), - what_to_summarize: z - .string() - .optional() - .describe( - 'What information from the command output is desired (basher)', - ), - timeout_seconds: z - .number() - .optional() - .describe( - 'Timeout for command. Set to -1 for no timeout. Default 30 (basher)', - ), - searchQueries: z - .array( - z.object({ - pattern: z.string().describe('The pattern to search for'), - flags: z - .string() - .optional() - .describe( - 'Optional ripgrep flags (e.g., "-i", "-g *.ts")', - ), - cwd: z - .string() - .optional() - .describe( - 'Optional working directory relative to project root', - ), - maxResults: z - .number() - .optional() - .describe('Max results per file. Default 15'), - }), - ) - .optional() - .describe('Array of code search queries (code-searcher)'), - filePaths: z - .array(z.string()) - .optional() - .describe( - 'Relevant file paths to read (opus-agent, gpt-5-agent)', - ), - directories: z - .array(z.string()) - .optional() - .describe('Directories to search within (file-picker)'), - url: z - .string() - .optional() - .describe('Starting URL to navigate to (browser-use)'), - prompts: z - .array(z.string()) - .optional() - .describe( - 'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)', - ), - }) - .catchall(z.any()) + // Common agent fields (all optional hints — each agent validates its own required fields) + command: z + .string() + .optional() + .describe('Terminal command to run (basher, tmux-cli)'), + what_to_summarize: z + .string() + .optional() + .describe( + 'What information from the command output is desired (basher)', + ), + timeout_seconds: z + .number() + .optional() + .describe( + 'Timeout for command. Set to -1 for no timeout. Default 30 (basher)', + ), + searchQueries: z + .array( + z.object({ + pattern: z.string().describe('The pattern to search for'), + flags: z + .string() + .optional() + .describe('Optional ripgrep flags (e.g., "-i", "-g *.ts")'), + cwd: z + .string() + .optional() + .describe('Optional working directory relative to project root'), + maxResults: z + .number() + .optional() + .describe('Max results per file. Default 15'), + }), + ) + .optional() + .describe('Array of code search queries (code-searcher)'), + filePaths: z + .array(z.string()) .optional() - .describe('Parameters object for the agent'), + .describe('Relevant file paths to read (opus-agent, gpt-5-agent)'), + directories: z + .array(z.string()) + .optional() + .describe('Directories to search within (file-picker)'), + url: z + .string() + .optional() + .describe('Starting URL to navigate to (browser-use)'), + prompts: z + .array(z.string()) + .optional() + .describe( + 'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)', + ), }) - .array(), - ), + .catchall(z.any()) + .optional() + .describe('Parameters object for the agent'), + ) + .optional() + .describe('Parameters object for the agent'), +}) +const inputSchema = z + .object({ + agents: z.preprocess(coerceToArray, agentInputSchema.array()), }) .describe( `Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.`, diff --git a/common/src/tools/params/utils.ts b/common/src/tools/params/utils.ts index 9b275aa8c2..87e0e4f247 100644 --- a/common/src/tools/params/utils.ts +++ b/common/src/tools/params/utils.ts @@ -32,6 +32,27 @@ export function coerceToArray(val: unknown): unknown { return val } +/** + * Coerces a stringified JSON object into an object. + * This is intentionally narrow so malformed values still fail validation. + */ +export function coerceToObject(val: unknown): unknown { + if (typeof val !== 'string') { + return val + } + + try { + const parsed = JSON.parse(val) + if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed + } + } catch { + // Leave the original value untouched so schema validation can reject it. + } + + return val +} + /** * Handles common replacement-key aliases emitted by some models while keeping * the documented schema stable. diff --git a/freebuff/e2e/tests/help-command.e2e.test.ts b/freebuff/e2e/tests/help-command.e2e.test.ts index 7c93d795f1..f119502561 100644 --- a/freebuff/e2e/tests/help-command.e2e.test.ts +++ b/freebuff/e2e/tests/help-command.e2e.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process' +import { execFileSync } from 'node:child_process' import { afterEach, describe, expect, test } from 'bun:test' @@ -9,7 +9,7 @@ const TEST_TIMEOUT = 60_000 describe('Freebuff: --help flag', () => { test('shows CLI usage information', () => { const binary = requireFreebuffBinary() - const output = execSync(`'${binary}' --help`, { + const output = execFileSync(binary, ['--help'], { encoding: 'utf-8', timeout: 10_000, }) @@ -23,7 +23,7 @@ describe('Freebuff: --help flag', () => { test('does not reference Codebuff', () => { const binary = requireFreebuffBinary() - const output = execSync(`'${binary}' --help`, { + const output = execFileSync(binary, ['--help'], { encoding: 'utf-8', timeout: 10_000, }) diff --git a/freebuff/e2e/tests/version.e2e.test.ts b/freebuff/e2e/tests/version.e2e.test.ts index d204bd684e..2e01990c9d 100644 --- a/freebuff/e2e/tests/version.e2e.test.ts +++ b/freebuff/e2e/tests/version.e2e.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process' +import { execFileSync } from 'node:child_process' import { describe, expect, test } from 'bun:test' @@ -7,7 +7,7 @@ import { requireFreebuffBinary } from '../utils' describe('Freebuff: --version', () => { test('outputs a version string', () => { const binary = requireFreebuffBinary() - const output = execSync(`'${binary}' --version`, { + const output = execFileSync(binary, ['--version'], { encoding: 'utf-8', timeout: 10_000, }).trim() @@ -18,7 +18,7 @@ describe('Freebuff: --version', () => { test('exits with code 0', () => { const binary = requireFreebuffBinary() - // execSync throws on non-zero exit codes, so if this doesn't throw, it exited 0 - execSync(`'${binary}' --version`, { encoding: 'utf-8', timeout: 10_000 }) + // execFileSync throws on non-zero exit codes, so if this doesn't throw, it exited 0 + execFileSync(binary, ['--version'], { encoding: 'utf-8', timeout: 10_000 }) }) }) 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 c07ce42cbc..8c10ddaa0a 100644 --- a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +++ b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts @@ -149,6 +149,29 @@ describe('tool validation error handling', () => { } }) + it('should parse stringified params for spawn_agents entries', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-stringified-params-tool-call-id', + input: { + agents: [ + { + agent_type: 'basher', + prompt: 'Run tests', + params: '{"command":"bun test"}', + }, + ], + }, + }, + }) + + expect('error' in result).toBe(false) + if (!('error' in result)) { + expect(result.input.agents[0].params).toEqual({ command: 'bun test' }) + } + }) + it('should accept old/new aliases for str_replace replacements', () => { const result = parseRawToolCall({ rawToolCall: { @@ -384,11 +407,12 @@ describe('tool validation error handling', () => { (m) => m.role === 'user', ) const errorUserMessage = userMessages.find((m) => { - const contentStr = Array.isArray(m.content) - ? m.content.map((p) => ('text' in p ? p.text : '')).join('') - : typeof m.content === 'string' - ? m.content - : '' + let contentStr = '' + if (Array.isArray(m.content)) { + contentStr = m.content.map((p) => ('text' in p ? p.text : '')).join('') + } else if (typeof m.content === 'string') { + contentStr = m.content + } return ( contentStr.includes('Error during tool call') && contentStr.includes('Invalid parameters for spawn_agents')