Skip to content

Commit 3ba040b

Browse files
committed
Coerce stringified spawn agent params
1 parent 99b3d0a commit 3ba040b

7 files changed

Lines changed: 188 additions & 73 deletions

File tree

common/src/tools/params/__tests__/coerce-to-array.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, it } from 'bun:test'
22
import z from 'zod/v4'
33

4-
import { coerceToArray, normalizeReplacementAliases } from '../utils'
4+
import {
5+
coerceToArray,
6+
coerceToObject,
7+
normalizeReplacementAliases,
8+
} from '../utils'
59

610
describe('coerceToArray', () => {
711
it('passes through arrays unchanged', () => {
@@ -50,6 +54,25 @@ describe('coerceToArray', () => {
5054
})
5155
})
5256

57+
describe('coerceToObject', () => {
58+
it('passes through objects unchanged', () => {
59+
expect(coerceToObject({ key: 'value' })).toEqual({ key: 'value' })
60+
})
61+
62+
it('parses a stringified JSON object', () => {
63+
expect(coerceToObject('{"key": "value"}')).toEqual({ key: 'value' })
64+
})
65+
66+
it('leaves non-JSON strings untouched', () => {
67+
expect(coerceToObject('not-json')).toBe('not-json')
68+
})
69+
70+
it('passes through arrays and primitives so validation can reject them', () => {
71+
expect(coerceToObject(['a'])).toEqual(['a'])
72+
expect(coerceToObject(1)).toBe(1)
73+
})
74+
})
75+
5376
describe('coerceToArray with Zod schemas', () => {
5477
it('coerces a single string into an array for z.array(z.string())', () => {
5578
const schema = z.object({
@@ -124,6 +147,23 @@ describe('coerceToArray with Zod schemas', () => {
124147
})
125148
})
126149

150+
describe('coerceToObject with Zod schemas', () => {
151+
it('produces identical JSON schema with or without preprocess', () => {
152+
const plain = z.object({
153+
params: z.record(z.string(), z.any()).optional(),
154+
})
155+
const coerced = z.object({
156+
params: z
157+
.preprocess(coerceToObject, z.record(z.string(), z.any()))
158+
.optional(),
159+
})
160+
161+
const plainSchema = z.toJSONSchema(plain, { io: 'input' })
162+
const coercedSchema = z.toJSONSchema(coerced, { io: 'input' })
163+
expect(coercedSchema).toEqual(plainSchema)
164+
})
165+
})
166+
127167
describe('normalizeReplacementAliases', () => {
128168
it('maps old_str and new_str onto the documented replacement keys', () => {
129169
expect(

common/src/tools/params/tool/spawn-agent-inline.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import z from 'zod/v4'
22

33
import {
44
$getNativeToolCallExampleString,
5+
coerceToObject,
56
textToolResultSchema,
67
} from '../utils'
78

@@ -14,7 +15,7 @@ const inputSchema = z
1415
agent_type: z.string().describe('Agent to spawn'),
1516
prompt: z.string().optional().describe('Prompt to send to the agent'),
1617
params: z
17-
.record(z.string(), z.any())
18+
.preprocess(coerceToObject, z.record(z.string(), z.any()))
1819
.optional()
1920
.describe('Parameters object for the agent (if any)'),
2021
})

common/src/tools/params/tool/spawn-agents.ts

Lines changed: 71 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { jsonObjectSchema } from '../../../types/json'
44
import {
55
$getNativeToolCallExampleString,
66
coerceToArray,
7+
coerceToObject,
78
jsonToolResultSchema,
89
} from '../utils'
910

@@ -27,70 +28,76 @@ const inputSchema = z
2728
agent_type: z.string().describe('Agent to spawn'),
2829
prompt: z.string().optional().describe('Prompt to send to the agent'),
2930
params: z
30-
.object({
31-
// Common agent fields (all optional hints — each agent validates its own required fields)
32-
command: z
33-
.string()
34-
.optional()
35-
.describe('Terminal command to run (basher, tmux-cli)'),
36-
what_to_summarize: z
37-
.string()
38-
.optional()
39-
.describe(
40-
'What information from the command output is desired (basher)',
41-
),
42-
timeout_seconds: z
43-
.number()
44-
.optional()
45-
.describe(
46-
'Timeout for command. Set to -1 for no timeout. Default 30 (basher)',
47-
),
48-
searchQueries: z
49-
.array(
50-
z.object({
51-
pattern: z.string().describe('The pattern to search for'),
52-
flags: z
53-
.string()
54-
.optional()
55-
.describe(
56-
'Optional ripgrep flags (e.g., "-i", "-g *.ts")',
57-
),
58-
cwd: z
59-
.string()
60-
.optional()
61-
.describe(
62-
'Optional working directory relative to project root',
63-
),
64-
maxResults: z
65-
.number()
66-
.optional()
67-
.describe('Max results per file. Default 15'),
68-
}),
69-
)
70-
.optional()
71-
.describe('Array of code search queries (code-searcher)'),
72-
filePaths: z
73-
.array(z.string())
74-
.optional()
75-
.describe(
76-
'Relevant file paths to read (opus-agent, gpt-5-agent)',
77-
),
78-
directories: z
79-
.array(z.string())
80-
.optional()
81-
.describe('Directories to search within (file-picker)'),
82-
url: z
83-
.string()
84-
.optional()
85-
.describe('Starting URL to navigate to (browser-use)'),
86-
prompts: z
87-
.array(z.string())
88-
.optional()
89-
.describe(
90-
'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)',
91-
),
92-
})
93-
.catchall(z.any())
31+
.preprocess(
32+
coerceToObject,
33+
z
34+
.object({
35+
// Common agent fields (all optional hints — each agent validates its own required fields)
36+
command: z
37+
.string()
38+
.optional()
39+
.describe('Terminal command to run (basher, tmux-cli)'),
40+
what_to_summarize: z
41+
.string()
42+
.optional()
43+
.describe(
44+
'What information from the command output is desired (basher)',
45+
),
46+
timeout_seconds: z
47+
.number()
48+
.optional()
49+
.describe(
50+
'Timeout for command. Set to -1 for no timeout. Default 30 (basher)',
51+
),
52+
searchQueries: z
53+
.array(
54+
z.object({
55+
pattern: z
56+
.string()
57+
.describe('The pattern to search for'),
58+
flags: z
59+
.string()
60+
.optional()
61+
.describe(
62+
'Optional ripgrep flags (e.g., "-i", "-g *.ts")',
63+
),
64+
cwd: z
65+
.string()
66+
.optional()
67+
.describe(
68+
'Optional working directory relative to project root',
69+
),
70+
maxResults: z
71+
.number()
72+
.optional()
73+
.describe('Max results per file. Default 15'),
74+
}),
75+
)
76+
.optional()
77+
.describe('Array of code search queries (code-searcher)'),
78+
filePaths: z
79+
.array(z.string())
80+
.optional()
81+
.describe(
82+
'Relevant file paths to read (opus-agent, gpt-5-agent)',
83+
),
84+
directories: z
85+
.array(z.string())
86+
.optional()
87+
.describe('Directories to search within (file-picker)'),
88+
url: z
89+
.string()
90+
.optional()
91+
.describe('Starting URL to navigate to (browser-use)'),
92+
prompts: z
93+
.array(z.string())
94+
.optional()
95+
.describe(
96+
'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)',
97+
),
98+
})
99+
.catchall(z.any()),
100+
)
94101
.optional()
95102
.describe('Parameters object for the agent'),
96103
})

common/src/tools/params/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ export function coerceToArray(val: unknown): unknown {
3232
return val
3333
}
3434

35+
/**
36+
* Coerces a stringified JSON object into an object.
37+
* This is intentionally narrow so malformed values still fail validation.
38+
*/
39+
export function coerceToObject(val: unknown): unknown {
40+
if (typeof val !== 'string') {
41+
return val
42+
}
43+
44+
try {
45+
const parsed = JSON.parse(val)
46+
if (
47+
parsed != null &&
48+
typeof parsed === 'object' &&
49+
!Array.isArray(parsed)
50+
) {
51+
return parsed
52+
}
53+
} catch {
54+
// Leave the original value untouched so schema validation can reject it.
55+
}
56+
57+
return val
58+
}
59+
3560
/**
3661
* Handles common replacement-key aliases emitted by some models while keeping
3762
* the documented schema stable.

freebuff/e2e/tests/help-command.e2e.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync } from 'child_process'
1+
import { execFileSync } from 'node:child_process'
22

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

@@ -9,7 +9,7 @@ const TEST_TIMEOUT = 60_000
99
describe('Freebuff: --help flag', () => {
1010
test('shows CLI usage information', () => {
1111
const binary = requireFreebuffBinary()
12-
const output = execSync(`'${binary}' --help`, {
12+
const output = execFileSync(binary, ['--help'], {
1313
encoding: 'utf-8',
1414
timeout: 10_000,
1515
})
@@ -23,7 +23,7 @@ describe('Freebuff: --help flag', () => {
2323

2424
test('does not reference Codebuff', () => {
2525
const binary = requireFreebuffBinary()
26-
const output = execSync(`'${binary}' --help`, {
26+
const output = execFileSync(binary, ['--help'], {
2727
encoding: 'utf-8',
2828
timeout: 10_000,
2929
})
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync } from 'child_process'
1+
import { execFileSync } from 'node:child_process'
22

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

@@ -7,7 +7,7 @@ import { requireFreebuffBinary } from '../utils'
77
describe('Freebuff: --version', () => {
88
test('outputs a version string', () => {
99
const binary = requireFreebuffBinary()
10-
const output = execSync(`'${binary}' --version`, {
10+
const output = execFileSync(binary, ['--version'], {
1111
encoding: 'utf-8',
1212
timeout: 10_000,
1313
}).trim()
@@ -18,7 +18,7 @@ describe('Freebuff: --version', () => {
1818

1919
test('exits with code 0', () => {
2020
const binary = requireFreebuffBinary()
21-
// execSync throws on non-zero exit codes, so if this doesn't throw, it exited 0
22-
execSync(`'${binary}' --version`, { encoding: 'utf-8', timeout: 10_000 })
21+
// execFileSync throws on non-zero exit codes, so if this doesn't throw, it exited 0
22+
execFileSync(binary, ['--version'], { encoding: 'utf-8', timeout: 10_000 })
2323
})
2424
})

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,48 @@ describe('tool validation error handling', () => {
124124
expect('error' in result).toBe(true)
125125
})
126126

127+
it('should parse stringified params for spawn_agents entries', () => {
128+
const result = parseRawToolCall({
129+
rawToolCall: {
130+
toolName: 'spawn_agents',
131+
toolCallId: 'spawn-agents-stringified-params-tool-call-id',
132+
input: {
133+
agents: [
134+
{
135+
agent_type: 'basher',
136+
prompt: 'Run tests',
137+
params: '{"command":"bun test"}',
138+
},
139+
],
140+
},
141+
},
142+
})
143+
144+
expect('error' in result).toBe(false)
145+
if (!('error' in result)) {
146+
expect(result.input.agents[0].params).toEqual({ command: 'bun test' })
147+
}
148+
})
149+
150+
it('should parse stringified params for spawn_agent_inline', () => {
151+
const result = parseRawToolCall({
152+
rawToolCall: {
153+
toolName: 'spawn_agent_inline',
154+
toolCallId: 'spawn-agent-inline-stringified-params-tool-call-id',
155+
input: {
156+
agent_type: 'basher',
157+
prompt: 'Run tests',
158+
params: '{"command":"bun test"}',
159+
},
160+
},
161+
})
162+
163+
expect('error' in result).toBe(false)
164+
if (!('error' in result)) {
165+
expect(result.input.params).toEqual({ command: 'bun test' })
166+
}
167+
})
168+
127169
it('should accept old_str/new_str aliases for str_replace replacements', () => {
128170
const result = parseRawToolCall({
129171
rawToolCall: {

0 commit comments

Comments
 (0)