diff --git a/common/src/tools/params/tool/spawn-agents.ts b/common/src/tools/params/tool/spawn-agents.ts index 0ba3e9268f..6102e15cd3 100644 --- a/common/src/tools/params/tool/spawn-agents.ts +++ b/common/src/tools/params/tool/spawn-agents.ts @@ -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' @@ -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() @@ -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\`. diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 60970db02d..6d371bf59e 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -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, @@ -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 | undefined>, + params: problematicSchema as unknown as z.ZodType< + Record | undefined + >, }, outputMode: 'last_message', includeMessageHistory: false, @@ -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', () => { @@ -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 @@ -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({ @@ -356,7 +388,10 @@ describe('Schema handling error recovery', () => { const parsed = outputValue.value as { found: boolean agent?: { - outputSchema?: { type?: string; properties?: Record } + outputSchema?: { + type?: string + properties?: Record + } inputSchema?: { prompt?: unknown; params?: unknown } } } diff --git a/packages/agent-runtime/src/templates/prompts.ts b/packages/agent-runtime/src/templates/prompts.ts index 4c148eec6c..d4e96faa03 100644 --- a/packages/agent-runtime/src/templates/prompts.ts +++ b/packages/agent-runtime/src/templates/prompts.ts @@ -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 } @@ -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. @@ -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`, diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 78906f4ab6..670a0d0f70 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -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' @@ -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, @@ -51,10 +52,7 @@ export type ToolCallError = { error: string } & Pick -function stringInputError( - toolName: string, - toolCallId: string, -): ToolCallError { +function stringInputError(toolName: string, toolCallId: string): ToolCallError { return { toolName, toolCallId, @@ -215,12 +213,7 @@ export async function executeToolCall( if (toolName === 'spawn_agents') { const agents = (input as Record).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( @@ -230,7 +223,10 @@ export async function executeToolCall( } const agentTypeStr = (agent as Record).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) { @@ -240,9 +236,15 @@ export async function executeToolCall( ) 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` } } } @@ -257,12 +259,21 @@ export async function executeToolCall( }) 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 } @@ -326,7 +337,6 @@ export async function executeToolCall( toolCallsToAddToMessageHistory.push(finalToolCall) } - const toolResultPromise = handler({ ...params, toolCall: finalToolCall, @@ -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[] @@ -599,20 +614,20 @@ export function tryTransformAgentToolCall(params: { }): { toolName: 'spawn_agents'; input: Record } | 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 = { - agent_type: fullAgentType || toolName, + agent_type: fullAgentType, } if (typeof input.prompt === 'string') { agentEntry.prompt = input.prompt