Skip to content
Merged
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
81 changes: 66 additions & 15 deletions common/src/tools/params/tool/spawn-agents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import z from 'zod/v4'

import { jsonObjectSchema } from '../../../types/json'
import { $getNativeToolCallExampleString, coerceToArray, jsonToolResultSchema } from '../utils'
import {
$getNativeToolCallExampleString,
coerceToArray,
jsonToolResultSchema,
} from '../utils'

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

Expand All @@ -25,19 +29,66 @@ const inputSchema = z
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)'),
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())
.optional()
Expand All @@ -58,7 +109,7 @@ Each agent available is already defined as another tool, or, dynamically defined

**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.

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.
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.

**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\`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { describe, test, expect, mock } from 'bun:test'
import { convertJsonSchemaToZod } from 'zod-from-json-schema'
import { z } from 'zod/v4'

import { buildAgentToolInputSchema, buildAgentToolSet } from '../templates/prompts'
import {
buildAgentToolInputSchema,
buildAgentToolSet,
} from '../templates/prompts'
import { tryTransformAgentToolCall } from '../tools/tool-executor'
import { handleLookupAgentInfo } from '../tools/handlers/tool/lookup-agent-info'
import {
ensureZodSchema,
Expand Down Expand Up @@ -35,7 +39,9 @@ describe('Schema handling error recovery', () => {
model: 'gpt-4o-mini',
inputSchema: {
prompt: z.string().describe('A test prompt'),
params: problematicSchema as unknown as z.ZodType<Record<string, unknown> | undefined>,
params: problematicSchema as unknown as z.ZodType<
Record<string, unknown> | undefined
>,
},
outputMode: 'last_message',
includeMessageHistory: false,
Expand All @@ -60,7 +66,8 @@ describe('Schema handling error recovery', () => {
})

// Should have created a tool without throwing
expect(toolSet['test-agent']).toBeDefined()
expect(toolSet['test_agent']).toBeDefined()
expect(toolSet['test-agent']).toBeUndefined()
})

test('buildAgentToolInputSchema handles valid schemas', () => {
Expand Down Expand Up @@ -115,6 +122,28 @@ describe('Schema handling error recovery', () => {
})
})

describe('direct subagent tool names', () => {
test('uses underscored tool aliases while preserving hyphenated agent IDs', () => {
const transformed = tryTransformAgentToolCall({
toolName: 'file_picker',
input: { prompt: 'Find relevant files' },
spawnableAgents: ['codebuff/file-picker@1.0.0'],
})

expect(transformed).toEqual({
toolName: 'spawn_agents',
input: {
agents: [
{
agent_type: 'codebuff/file-picker@1.0.0',
prompt: 'Find relevant files',
},
],
},
})
})
})

describe('ensureJsonSchemaCompatible in tools/prompts.ts', () => {
test('buildToolDescription handles problematic schemas gracefully', () => {
// z.promise() cannot be converted to JSON Schema
Expand Down Expand Up @@ -295,7 +324,10 @@ describe('Schema handling error recovery', () => {
const outputValue = result.output[0]
expect(outputValue.type).toBe('json')
if (outputValue.type === 'json') {
const parsed = outputValue.value as { found: boolean; agent?: { outputSchema?: unknown } }
const parsed = outputValue.value as {
found: boolean
agent?: { outputSchema?: unknown }
}
expect(parsed.found).toBe(true)
// The outputSchema should be the fallback
expect(parsed.agent?.outputSchema).toEqual({
Expand Down Expand Up @@ -356,7 +388,10 @@ describe('Schema handling error recovery', () => {
const parsed = outputValue.value as {
found: boolean
agent?: {
outputSchema?: { type?: string; properties?: Record<string, unknown> }
outputSchema?: {
type?: string
properties?: Record<string, unknown>
}
inputSchema?: { prompt?: unknown; params?: unknown }
}
}
Expand Down
13 changes: 10 additions & 3 deletions packages/agent-runtime/src/templates/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export function getAgentShortName(agentType: AgentTemplateType): string {
return parts[parts.length - 1]
}

/**
* Converts an agent ID into the provider-facing tool name used for direct
* subagent calls. Agent IDs remain hyphenated; tool names use underscores.
*/
export function getAgentToolName(agentType: AgentTemplateType): string {
return getAgentShortName(agentType).replace(/-/g, '_')
}

/**
* Builds an input schema for an agent tool with prompt and params as top-level fields.
* This matches the spawn_agents schema structure: { prompt?: string, params?: object }
Expand Down Expand Up @@ -59,7 +67,6 @@ export function buildAgentToolInputSchema(
)
}


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

if (!agentTemplate) continue

const shortName = getAgentShortName(agentType)
const toolName = getAgentToolName(agentType)
const inputSchema = ensureJsonSchemaCompatible(
buildAgentToolInputSchema(agentTemplate),
)

// Use the same structure as other tools in toolParams
toolSet[shortName] = {
toolSet[toolName] = {
description:
agentTemplate.spawnerPrompt ||
`Spawn the ${agentTemplate.displayName} agent`,
Expand Down
81 changes: 48 additions & 33 deletions packages/agent-runtime/src/tools/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@ import { cloneDeep } from 'lodash'

import { getMCPToolData } from '../mcp'
import { MCP_TOOL_SEPARATOR } from '../mcp-constants'
import { getAgentShortName } from '../templates/prompts'
import { getAgentShortName, getAgentToolName } from '../templates/prompts'
import { formatValueForError } from '../util/format-value'
import { codebuffToolHandlers } from './handlers/list'
import {
getMatchingSpawn,
} from './handlers/tool/spawn-agent-utils'
import { getMatchingSpawn } from './handlers/tool/spawn-agent-utils'
import { getAgentTemplate } from '../templates/agent-registry'
import { ensureZodSchema } from './prompts'


import type { AgentTemplate } from '../templates/types'
import type { CodebuffToolHandlerFunction } from './handlers/handler-function-type'
import type { FileProcessingState } from './handlers/tool/write-file'
Expand All @@ -33,7 +30,11 @@ import type { Logger } from '@codebuff/common/types/contracts/logger'
import type { ToolMessage } from '@codebuff/common/types/messages/codebuff-message'
import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part'
import type { PrintModeEvent } from '@codebuff/common/types/print-mode'
import type { AgentTemplateType, AgentState, Subgoal } from '@codebuff/common/types/session-state'
import type {
AgentTemplateType,
AgentState,
Subgoal,
} from '@codebuff/common/types/session-state'
import type {
CustomToolDefinitions,
ProjectFileContext,
Expand All @@ -51,10 +52,7 @@ export type ToolCallError = {
error: string
} & Pick<CodebuffToolCall, 'toolCallId'>

function stringInputError(
toolName: string,
toolCallId: string,
): ToolCallError {
function stringInputError(toolName: string, toolCallId: string): ToolCallError {
return {
toolName,
toolCallId,
Expand Down Expand Up @@ -215,12 +213,7 @@ export async function executeToolCall<T extends ToolName>(
if (toolName === 'spawn_agents') {
const agents = (input as Record<string, unknown>).agents
if (Array.isArray(agents)) {
const BASE_AGENTS = [
'base',
'base-free',
'base-max',
'base-experimental',
]
const BASE_AGENTS = ['base', 'base-free', 'base-max', 'base-experimental']
const isBaseAgent = BASE_AGENTS.includes(agentTemplate.id)

const validationResults = await Promise.allSettled(
Expand All @@ -230,7 +223,10 @@ export async function executeToolCall<T extends ToolName>(
}
const agentTypeStr = (agent as Record<string, unknown>).agent_type
if (typeof agentTypeStr !== 'string' || !agentTypeStr) {
return { valid: false as const, error: 'Agent entry missing agent_type' }
return {
valid: false as const,
error: 'Agent entry missing agent_type',
}
}

if (!isBaseAgent) {
Expand All @@ -240,9 +236,15 @@ export async function executeToolCall<T extends ToolName>(
)
if (!matchingSpawn) {
if (toolNames.includes(agentTypeStr as ToolName)) {
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.` }
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.`,
}
}
return {
valid: false as const,
error: `Agent "${agentTypeStr}" is not available to spawn`,
}
return { valid: false as const, error: `Agent "${agentTypeStr}" is not available to spawn` }
}
}

Expand All @@ -257,12 +259,21 @@ export async function executeToolCall<T extends ToolName>(
})
if (!template) {
if (toolNames.includes(agentTypeStr as ToolName)) {
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.` }
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.`,
}
}
return {
valid: false as const,
error: `Agent "${agentTypeStr}" does not exist`,
}
return { valid: false as const, error: `Agent "${agentTypeStr}" does not exist` }
}
} catch {
return { valid: false as const, error: `Agent "${agentTypeStr}" could not be loaded` }
return {
valid: false as const,
error: `Agent "${agentTypeStr}" could not be loaded`,
}
}

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


const toolResultPromise = handler({
...params,
toolCall: finalToolCall,
Expand Down Expand Up @@ -545,14 +555,19 @@ export async function executeCustomToolCall(
}

const toolName = toolCall.toolName.includes(MCP_TOOL_SEPARATOR)
? toolCall.toolName.split(MCP_TOOL_SEPARATOR).slice(1).join(MCP_TOOL_SEPARATOR)
? toolCall.toolName
.split(MCP_TOOL_SEPARATOR)
.slice(1)
.join(MCP_TOOL_SEPARATOR)
: toolCall.toolName
const clientToolResult = await requestToolCall({
userInputId,
toolName,
input: toolCall.input,
mcpConfig: toolCall.toolName.includes(MCP_TOOL_SEPARATOR)
? agentTemplate.mcpServers[toolCall.toolName.split(MCP_TOOL_SEPARATOR)[0]]
? agentTemplate.mcpServers[
toolCall.toolName.split(MCP_TOOL_SEPARATOR)[0]
]
: undefined,
})
return clientToolResult.output satisfies ToolResultOutput[]
Expand Down Expand Up @@ -599,20 +614,20 @@ export function tryTransformAgentToolCall(params: {
}): { toolName: 'spawn_agents'; input: Record<string, unknown> } | null {
const { toolName, input, spawnableAgents } = params

const agentShortNames = spawnableAgents.map(getAgentShortName)
if (!agentShortNames.includes(toolName)) {
const matchesAgentToolName = (agentType: AgentTemplateType) =>
getAgentToolName(agentType) === toolName ||
getAgentShortName(agentType) === toolName

// Find the full agent type for this direct-call alias.
const fullAgentType = spawnableAgents.find(matchesAgentToolName)
if (!fullAgentType) {
return null
}

// Find the full agent type for this short name
const fullAgentType = spawnableAgents.find(
(agentType) => getAgentShortName(agentType) === toolName,
)

// Convert to spawn_agents call - input already has prompt and params as top-level fields
// (consistent with spawn_agents schema)
const agentEntry: Record<string, unknown> = {
agent_type: fullAgentType || toolName,
agent_type: fullAgentType,
}
if (typeof input.prompt === 'string') {
agentEntry.prompt = input.prompt
Expand Down
Loading