Skip to content

Commit a299268

Browse files
committed
Use underscored direct subagent tool names
1 parent a02f7a8 commit a299268

4 files changed

Lines changed: 164 additions & 56 deletions

File tree

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

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import z from 'zod/v4'
22

33
import { jsonObjectSchema } from '../../../types/json'
4-
import { $getNativeToolCallExampleString, coerceToArray, jsonToolResultSchema } from '../utils'
4+
import {
5+
$getNativeToolCallExampleString,
6+
coerceToArray,
7+
jsonToolResultSchema,
8+
} from '../utils'
59

610
import type { $ToolParams } from '../../constants'
711

@@ -25,19 +29,66 @@ const inputSchema = z
2529
params: z
2630
.object({
2731
// Common agent fields (all optional hints — each agent validates its own required fields)
28-
command: z.string().optional().describe('Terminal command to run (basher, tmux-cli)'),
29-
what_to_summarize: z.string().optional().describe('What information from the command output is desired (basher)'),
30-
timeout_seconds: z.number().optional().describe('Timeout for command. Set to -1 for no timeout. Default 30 (basher)'),
31-
searchQueries: z.array(z.object({
32-
pattern: z.string().describe('The pattern to search for'),
33-
flags: z.string().optional().describe('Optional ripgrep flags (e.g., "-i", "-g *.ts")'),
34-
cwd: z.string().optional().describe('Optional working directory relative to project root'),
35-
maxResults: z.number().optional().describe('Max results per file. Default 15'),
36-
})).optional().describe('Array of code search queries (code-searcher)'),
37-
filePaths: z.array(z.string()).optional().describe('Relevant file paths to read (opus-agent, gpt-5-agent)'),
38-
directories: z.array(z.string()).optional().describe('Directories to search within (file-picker)'),
39-
url: z.string().optional().describe('Starting URL to navigate to (browser-use)'),
40-
prompts: z.array(z.string()).optional().describe('Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)'),
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+
),
4192
})
4293
.catchall(z.any())
4394
.optional()
@@ -58,7 +109,7 @@ Each agent available is already defined as another tool, or, dynamically defined
58109
59110
**IMPORTANT**: \`agent_type\` must be an actual agent name (e.g., \`basher\`, \`code-searcher\`, \`opus-agent\`), NOT a tool name like \`read_files\`, \`str_replace\`, \`code_search\`, etc. If you need to call a tool, use it directly as a tool call instead of wrapping it in spawn_agents.
60111
61-
You can call agents either as direct tool calls (e.g., \`example-agent\`) or use \`spawn_agents\`. Both formats work, but **prefer using spawn_agents** because it allows you to spawn multiple agents in parallel for better performance. Both use the same schema with nested \`prompt\` and \`params\` fields.
112+
You can call agents either as direct tool calls (using the listed tool name, e.g. \`example_agent\`) or use \`spawn_agents\` with the canonical agent name in \`agent_type\` (e.g. \`example-agent\`). Both formats work, but **prefer using spawn_agents** because it allows you to spawn multiple agents in parallel for better performance. Both use the same schema with nested \`prompt\` and \`params\` fields.
62113
63114
**IMPORTANT**: Many agents have REQUIRED fields in their params schema. Check the agent's schema before spawning - if params has required fields, you MUST include them in the params object. For example, code-searcher requires \`searchQueries\`, basher requires \`command\`.
64115

packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { describe, test, expect, mock } from 'bun:test'
33
import { convertJsonSchemaToZod } from 'zod-from-json-schema'
44
import { z } from 'zod/v4'
55

6-
import { buildAgentToolInputSchema, buildAgentToolSet } from '../templates/prompts'
6+
import {
7+
buildAgentToolInputSchema,
8+
buildAgentToolSet,
9+
} from '../templates/prompts'
10+
import { tryTransformAgentToolCall } from '../tools/tool-executor'
711
import { handleLookupAgentInfo } from '../tools/handlers/tool/lookup-agent-info'
812
import {
913
ensureZodSchema,
@@ -35,7 +39,9 @@ describe('Schema handling error recovery', () => {
3539
model: 'gpt-4o-mini',
3640
inputSchema: {
3741
prompt: z.string().describe('A test prompt'),
38-
params: problematicSchema as unknown as z.ZodType<Record<string, unknown> | undefined>,
42+
params: problematicSchema as unknown as z.ZodType<
43+
Record<string, unknown> | undefined
44+
>,
3945
},
4046
outputMode: 'last_message',
4147
includeMessageHistory: false,
@@ -60,7 +66,8 @@ describe('Schema handling error recovery', () => {
6066
})
6167

6268
// Should have created a tool without throwing
63-
expect(toolSet['test-agent']).toBeDefined()
69+
expect(toolSet['test_agent']).toBeDefined()
70+
expect(toolSet['test-agent']).toBeUndefined()
6471
})
6572

6673
test('buildAgentToolInputSchema handles valid schemas', () => {
@@ -115,6 +122,28 @@ describe('Schema handling error recovery', () => {
115122
})
116123
})
117124

125+
describe('direct subagent tool names', () => {
126+
test('uses underscored tool aliases while preserving hyphenated agent IDs', () => {
127+
const transformed = tryTransformAgentToolCall({
128+
toolName: 'file_picker',
129+
input: { prompt: 'Find relevant files' },
130+
spawnableAgents: ['codebuff/file-picker@1.0.0'],
131+
})
132+
133+
expect(transformed).toEqual({
134+
toolName: 'spawn_agents',
135+
input: {
136+
agents: [
137+
{
138+
agent_type: 'codebuff/file-picker@1.0.0',
139+
prompt: 'Find relevant files',
140+
},
141+
],
142+
},
143+
})
144+
})
145+
})
146+
118147
describe('ensureJsonSchemaCompatible in tools/prompts.ts', () => {
119148
test('buildToolDescription handles problematic schemas gracefully', () => {
120149
// z.promise() cannot be converted to JSON Schema
@@ -295,7 +324,10 @@ describe('Schema handling error recovery', () => {
295324
const outputValue = result.output[0]
296325
expect(outputValue.type).toBe('json')
297326
if (outputValue.type === 'json') {
298-
const parsed = outputValue.value as { found: boolean; agent?: { outputSchema?: unknown } }
327+
const parsed = outputValue.value as {
328+
found: boolean
329+
agent?: { outputSchema?: unknown }
330+
}
299331
expect(parsed.found).toBe(true)
300332
// The outputSchema should be the fallback
301333
expect(parsed.agent?.outputSchema).toEqual({
@@ -356,7 +388,10 @@ describe('Schema handling error recovery', () => {
356388
const parsed = outputValue.value as {
357389
found: boolean
358390
agent?: {
359-
outputSchema?: { type?: string; properties?: Record<string, unknown> }
391+
outputSchema?: {
392+
type?: string
393+
properties?: Record<string, unknown>
394+
}
360395
inputSchema?: { prompt?: unknown; params?: unknown }
361396
}
362397
}

packages/agent-runtime/src/templates/prompts.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export function getAgentShortName(agentType: AgentTemplateType): string {
3030
return parts[parts.length - 1]
3131
}
3232

33+
/**
34+
* Converts an agent ID into the provider-facing tool name used for direct
35+
* subagent calls. Agent IDs remain hyphenated; tool names use underscores.
36+
*/
37+
export function getAgentToolName(agentType: AgentTemplateType): string {
38+
return getAgentShortName(agentType).replace(/-/g, '_')
39+
}
40+
3341
/**
3442
* Builds an input schema for an agent tool with prompt and params as top-level fields.
3543
* This matches the spawn_agents schema structure: { prompt?: string, params?: object }
@@ -59,7 +67,6 @@ export function buildAgentToolInputSchema(
5967
)
6068
}
6169

62-
6370
/**
6471
* Builds AI SDK tool definitions for spawnable agents.
6572
* These tools allow the model to call agents directly as tool calls.
@@ -87,13 +94,13 @@ export async function buildAgentToolSet(
8794

8895
if (!agentTemplate) continue
8996

90-
const shortName = getAgentShortName(agentType)
97+
const toolName = getAgentToolName(agentType)
9198
const inputSchema = ensureJsonSchemaCompatible(
9299
buildAgentToolInputSchema(agentTemplate),
93100
)
94101

95102
// Use the same structure as other tools in toolParams
96-
toolSet[shortName] = {
103+
toolSet[toolName] = {
97104
description:
98105
agentTemplate.spawnerPrompt ||
99106
`Spawn the ${agentTemplate.displayName} agent`,

packages/agent-runtime/src/tools/tool-executor.ts

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,13 @@ import { cloneDeep } from 'lodash'
55

66
import { getMCPToolData } from '../mcp'
77
import { MCP_TOOL_SEPARATOR } from '../mcp-constants'
8-
import { getAgentShortName } from '../templates/prompts'
8+
import { getAgentShortName, getAgentToolName } from '../templates/prompts'
99
import { formatValueForError } from '../util/format-value'
1010
import { codebuffToolHandlers } from './handlers/list'
11-
import {
12-
getMatchingSpawn,
13-
} from './handlers/tool/spawn-agent-utils'
11+
import { getMatchingSpawn } from './handlers/tool/spawn-agent-utils'
1412
import { getAgentTemplate } from '../templates/agent-registry'
1513
import { ensureZodSchema } from './prompts'
1614

17-
1815
import type { AgentTemplate } from '../templates/types'
1916
import type { CodebuffToolHandlerFunction } from './handlers/handler-function-type'
2017
import type { FileProcessingState } from './handlers/tool/write-file'
@@ -33,7 +30,11 @@ import type { Logger } from '@codebuff/common/types/contracts/logger'
3330
import type { ToolMessage } from '@codebuff/common/types/messages/codebuff-message'
3431
import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part'
3532
import type { PrintModeEvent } from '@codebuff/common/types/print-mode'
36-
import type { AgentTemplateType, AgentState, Subgoal } from '@codebuff/common/types/session-state'
33+
import type {
34+
AgentTemplateType,
35+
AgentState,
36+
Subgoal,
37+
} from '@codebuff/common/types/session-state'
3738
import type {
3839
CustomToolDefinitions,
3940
ProjectFileContext,
@@ -51,10 +52,7 @@ export type ToolCallError = {
5152
error: string
5253
} & Pick<CodebuffToolCall, 'toolCallId'>
5354

54-
function stringInputError(
55-
toolName: string,
56-
toolCallId: string,
57-
): ToolCallError {
55+
function stringInputError(toolName: string, toolCallId: string): ToolCallError {
5856
return {
5957
toolName,
6058
toolCallId,
@@ -215,12 +213,7 @@ export async function executeToolCall<T extends ToolName>(
215213
if (toolName === 'spawn_agents') {
216214
const agents = (input as Record<string, unknown>).agents
217215
if (Array.isArray(agents)) {
218-
const BASE_AGENTS = [
219-
'base',
220-
'base-free',
221-
'base-max',
222-
'base-experimental',
223-
]
216+
const BASE_AGENTS = ['base', 'base-free', 'base-max', 'base-experimental']
224217
const isBaseAgent = BASE_AGENTS.includes(agentTemplate.id)
225218

226219
const validationResults = await Promise.allSettled(
@@ -230,7 +223,10 @@ export async function executeToolCall<T extends ToolName>(
230223
}
231224
const agentTypeStr = (agent as Record<string, unknown>).agent_type
232225
if (typeof agentTypeStr !== 'string' || !agentTypeStr) {
233-
return { valid: false as const, error: 'Agent entry missing agent_type' }
226+
return {
227+
valid: false as const,
228+
error: 'Agent entry missing agent_type',
229+
}
234230
}
235231

236232
if (!isBaseAgent) {
@@ -240,9 +236,15 @@ export async function executeToolCall<T extends ToolName>(
240236
)
241237
if (!matchingSpawn) {
242238
if (toolNames.includes(agentTypeStr as ToolName)) {
243-
return { valid: false as const, error: `"${agentTypeStr}" is a tool, not an agent. Call it directly as a tool instead of wrapping it in spawn_agents.` }
239+
return {
240+
valid: false as const,
241+
error: `"${agentTypeStr}" is a tool, not an agent. Call it directly as a tool instead of wrapping it in spawn_agents.`,
242+
}
243+
}
244+
return {
245+
valid: false as const,
246+
error: `Agent "${agentTypeStr}" is not available to spawn`,
244247
}
245-
return { valid: false as const, error: `Agent "${agentTypeStr}" is not available to spawn` }
246248
}
247249
}
248250

@@ -257,12 +259,21 @@ export async function executeToolCall<T extends ToolName>(
257259
})
258260
if (!template) {
259261
if (toolNames.includes(agentTypeStr as ToolName)) {
260-
return { valid: false as const, error: `"${agentTypeStr}" is a tool, not an agent. Call it directly as a tool instead of wrapping it in spawn_agents.` }
262+
return {
263+
valid: false as const,
264+
error: `"${agentTypeStr}" is a tool, not an agent. Call it directly as a tool instead of wrapping it in spawn_agents.`,
265+
}
266+
}
267+
return {
268+
valid: false as const,
269+
error: `Agent "${agentTypeStr}" does not exist`,
261270
}
262-
return { valid: false as const, error: `Agent "${agentTypeStr}" does not exist` }
263271
}
264272
} catch {
265-
return { valid: false as const, error: `Agent "${agentTypeStr}" could not be loaded` }
273+
return {
274+
valid: false as const,
275+
error: `Agent "${agentTypeStr}" could not be loaded`,
276+
}
266277
}
267278

268279
return { valid: true as const, agent }
@@ -326,7 +337,6 @@ export async function executeToolCall<T extends ToolName>(
326337
toolCallsToAddToMessageHistory.push(finalToolCall)
327338
}
328339

329-
330340
const toolResultPromise = handler({
331341
...params,
332342
toolCall: finalToolCall,
@@ -545,14 +555,19 @@ export async function executeCustomToolCall(
545555
}
546556

547557
const toolName = toolCall.toolName.includes(MCP_TOOL_SEPARATOR)
548-
? toolCall.toolName.split(MCP_TOOL_SEPARATOR).slice(1).join(MCP_TOOL_SEPARATOR)
558+
? toolCall.toolName
559+
.split(MCP_TOOL_SEPARATOR)
560+
.slice(1)
561+
.join(MCP_TOOL_SEPARATOR)
549562
: toolCall.toolName
550563
const clientToolResult = await requestToolCall({
551564
userInputId,
552565
toolName,
553566
input: toolCall.input,
554567
mcpConfig: toolCall.toolName.includes(MCP_TOOL_SEPARATOR)
555-
? agentTemplate.mcpServers[toolCall.toolName.split(MCP_TOOL_SEPARATOR)[0]]
568+
? agentTemplate.mcpServers[
569+
toolCall.toolName.split(MCP_TOOL_SEPARATOR)[0]
570+
]
556571
: undefined,
557572
})
558573
return clientToolResult.output satisfies ToolResultOutput[]
@@ -599,20 +614,20 @@ export function tryTransformAgentToolCall(params: {
599614
}): { toolName: 'spawn_agents'; input: Record<string, unknown> } | null {
600615
const { toolName, input, spawnableAgents } = params
601616

602-
const agentShortNames = spawnableAgents.map(getAgentShortName)
603-
if (!agentShortNames.includes(toolName)) {
617+
const matchesAgentToolName = (agentType: AgentTemplateType) =>
618+
getAgentToolName(agentType) === toolName ||
619+
getAgentShortName(agentType) === toolName
620+
621+
// Find the full agent type for this direct-call alias.
622+
const fullAgentType = spawnableAgents.find(matchesAgentToolName)
623+
if (!fullAgentType) {
604624
return null
605625
}
606626

607-
// Find the full agent type for this short name
608-
const fullAgentType = spawnableAgents.find(
609-
(agentType) => getAgentShortName(agentType) === toolName,
610-
)
611-
612627
// Convert to spawn_agents call - input already has prompt and params as top-level fields
613628
// (consistent with spawn_agents schema)
614629
const agentEntry: Record<string, unknown> = {
615-
agent_type: fullAgentType || toolName,
630+
agent_type: fullAgentType,
616631
}
617632
if (typeof input.prompt === 'string') {
618633
agentEntry.prompt = input.prompt

0 commit comments

Comments
 (0)