Skip to content
Closed
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
21 changes: 20 additions & 1 deletion common/src/tools/params/__tests__/coerce-to-array.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down
6 changes: 5 additions & 1 deletion common/src/tools/params/tool/spawn-agent-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import z from 'zod/v4'

import {
$getNativeToolCallExampleString,
coerceToObject,
textToolResultSchema,
} from '../utils'

Expand All @@ -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)'),
})
Expand Down
145 changes: 71 additions & 74 deletions common/src/tools/params/tool/spawn-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { jsonObjectSchema } from '../../../types/json'
import {
$getNativeToolCallExampleString,
coerceToArray,
coerceToObject,
jsonToolResultSchema,
} from '../utils'

Expand All @@ -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.`,
Expand Down
21 changes: 21 additions & 0 deletions common/src/tools/params/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions freebuff/e2e/tests/help-command.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { execFileSync } from 'node:child_process'

import { afterEach, describe, expect, test } from 'bun:test'

Expand All @@ -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,
})
Expand All @@ -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,
})
Expand Down
8 changes: 4 additions & 4 deletions freebuff/e2e/tests/version.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { execFileSync } from 'node:child_process'

import { describe, expect, test } from 'bun:test'

Expand All @@ -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()
Expand All @@ -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 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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"}',
},
Comment on lines +159 to +163
],
},
},
})

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: {
Expand Down Expand Up @@ -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')
Expand Down