Skip to content

Commit 6bfbf4e

Browse files
committed
fix: add object coercion for nested agent params fixes Spawning Multiple Agents Routinely Fails #635
1 parent 361e2df commit 6bfbf4e

5 files changed

Lines changed: 146 additions & 81 deletions

File tree

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

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

4-
import { coerceToArray, normalizeReplacementAliases } from '../utils'
4+
import { coerceToArray, coerceToObject, normalizeReplacementAliases } from '../utils'
55

66
describe('coerceToArray', () => {
77
it('passes through arrays unchanged', () => {
@@ -50,6 +50,25 @@ describe('coerceToArray', () => {
5050
})
5151
})
5252

53+
describe('coerceToObject', () => {
54+
it('passes through objects unchanged', () => {
55+
expect(coerceToObject({ key: 'value' })).toEqual({ key: 'value' })
56+
})
57+
58+
it('parses a stringified JSON object', () => {
59+
expect(coerceToObject('{"key": "value"}')).toEqual({ key: 'value' })
60+
})
61+
62+
it('leaves non-JSON strings untouched', () => {
63+
expect(coerceToObject('not-json')).toBe('not-json')
64+
})
65+
66+
it('passes through arrays and primitives so validation can reject them', () => {
67+
expect(coerceToObject(['a'])).toEqual(['a'])
68+
expect(coerceToObject(1)).toBe(1)
69+
})
70+
})
71+
5372
describe('coerceToArray with Zod schemas', () => {
5473
it('coerces a single string into an array for z.array(z.string())', () => {
5574
const schema = z.object({

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

Lines changed: 5 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,10 @@ 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(
19+
coerceToObject,
20+
z.record(z.string(), z.any()),
21+
)
1822
.optional()
1923
.describe('Parameters object for the agent (if any)'),
2024
})

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

Lines changed: 71 additions & 74 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

@@ -18,84 +19,80 @@ export const spawnAgentsOutputSchema = z
1819

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

common/src/tools/params/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ 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 (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
47+
return parsed
48+
}
49+
} catch {
50+
// Leave the original value untouched so schema validation can reject it.
51+
}
52+
53+
return val
54+
}
55+
3556
/**
3657
* Handles common replacement-key aliases emitted by some models while keeping
3758
* the documented schema stable.

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,29 @@ describe('tool validation error handling', () => {
149149
}
150150
})
151151

152+
it('should parse stringified params for spawn_agents entries', () => {
153+
const result = parseRawToolCall({
154+
rawToolCall: {
155+
toolName: 'spawn_agents',
156+
toolCallId: 'spawn-agents-stringified-params-tool-call-id',
157+
input: {
158+
agents: [
159+
{
160+
agent_type: 'basher',
161+
prompt: 'Run tests',
162+
params: '{"command":"bun test"}',
163+
},
164+
],
165+
},
166+
},
167+
})
168+
169+
expect('error' in result).toBe(false)
170+
if (!('error' in result)) {
171+
expect(result.input.agents[0].params).toEqual({ command: 'bun test' })
172+
}
173+
})
174+
152175
it('should accept old/new aliases for str_replace replacements', () => {
153176
const result = parseRawToolCall({
154177
rawToolCall: {
@@ -384,11 +407,12 @@ describe('tool validation error handling', () => {
384407
(m) => m.role === 'user',
385408
)
386409
const errorUserMessage = userMessages.find((m) => {
387-
const contentStr = Array.isArray(m.content)
388-
? m.content.map((p) => ('text' in p ? p.text : '')).join('')
389-
: typeof m.content === 'string'
390-
? m.content
391-
: ''
410+
let contentStr = ''
411+
if (Array.isArray(m.content)) {
412+
contentStr = m.content.map((p) => ('text' in p ? p.text : '')).join('')
413+
} else if (typeof m.content === 'string') {
414+
contentStr = m.content
415+
}
392416
return (
393417
contentStr.includes('Error during tool call') &&
394418
contentStr.includes('Invalid parameters for spawn_agents')

0 commit comments

Comments
 (0)