From 17262dc44e7042b909e0cefabc96e30039b03904 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 29 Apr 2026 20:56:40 -0700 Subject: [PATCH] Allow underscored spawn agent IDs --- common/src/util/agent-id-parsing.ts | 35 ++++++++++ .../spawn-agents-permissions.test.ts | 70 ++++++++++++++++++- .../src/templates/agent-registry.ts | 21 +++++- .../tools/handlers/tool/spawn-agent-utils.ts | 43 +++++++----- .../agent-runtime/src/tools/tool-executor.ts | 4 +- 5 files changed, 149 insertions(+), 24 deletions(-) diff --git a/common/src/util/agent-id-parsing.ts b/common/src/util/agent-id-parsing.ts index dd64bc9832..2a494ad990 100644 --- a/common/src/util/agent-id-parsing.ts +++ b/common/src/util/agent-id-parsing.ts @@ -99,3 +99,38 @@ export function parsePublishedAgentId(fullAgentId: string): { version, } } + +/** + * Normalizes an agent ID for lookup by accepting underscores as aliases for + * hyphens in the agent-name segment. Publisher IDs and version strings are + * preserved as written. + */ +export function normalizeAgentIdForLookup(fullAgentId: string): string { + const parts = fullAgentId.split('/') + if (parts.length > 2) { + return fullAgentId + } + + const normalizeNameWithVersion = (agentNameWithVersion: string) => { + const versionStart = agentNameWithVersion.indexOf('@') + const agentName = + versionStart === -1 + ? agentNameWithVersion + : agentNameWithVersion.slice(0, versionStart) + const version = + versionStart === -1 ? '' : agentNameWithVersion.slice(versionStart) + + return `${agentName.replace(/_/g, '-')}${version}` + } + + if (parts.length === 1) { + return normalizeNameWithVersion(fullAgentId) + } + + const [publisherId, agentNameWithVersion] = parts + if (!publisherId || !agentNameWithVersion) { + return fullAgentId + } + + return `${publisherId}/${normalizeNameWithVersion(agentNameWithVersion)}` +} diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts index c5d920c8ff..d87dfaac96 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts @@ -94,7 +94,10 @@ describe('Spawn Agents Permissions', () => { ...options.agentState, messageHistory: [assistantMessage('Mock agent response')], }, - output: { type: 'lastMessage', value: [assistantMessage('Mock agent response')] }, + output: { + type: 'lastMessage', + value: [assistantMessage('Mock agent response')], + }, } }) }) @@ -189,12 +192,33 @@ describe('Spawn Agents Permissions', () => { expect(result).toBe('thinker') }) + it('should match underscored agent name to hyphenated spawnable agent', () => { + const spawnableAgents = ['thinker', 'reviewer', 'file-picker'] + const result = getMatchingSpawn(spawnableAgents, 'file_picker') + expect(result).toBe('file-picker') + }) + it('should match simple agent name when spawnable has publisher', () => { const spawnableAgents = ['codebuff/thinker@1.0.0', 'reviewer'] const result = getMatchingSpawn(spawnableAgents, 'thinker') expect(result).toBe('codebuff/thinker@1.0.0') }) + it('should match underscored agent name when spawnable has publisher and version', () => { + const spawnableAgents = ['codebuff/file-picker@1.0.0', 'reviewer'] + const result = getMatchingSpawn(spawnableAgents, 'file_picker') + expect(result).toBe('codebuff/file-picker@1.0.0') + }) + + it('should match underscored published agent ID to hyphenated spawnable agent', () => { + const spawnableAgents = ['codebuff/file-picker@1.0.0'] + const result = getMatchingSpawn( + spawnableAgents, + 'codebuff/file_picker@1.0.0', + ) + expect(result).toBe('codebuff/file-picker@1.0.0') + }) + it('should match simple agent name when spawnable has version', () => { const spawnableAgents = ['thinker@1.0.0', 'reviewer'] const result = getMatchingSpawn(spawnableAgents, 'thinker') @@ -274,6 +298,50 @@ describe('Spawn Agents Permissions', () => { expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1) }) + it('should allow underscored agent_type when hyphenated agent is spawnable', async () => { + const parentAgent = createMockAgent('parent', ['file-picker']) + const childAgent = createMockAgent('file-picker') + const sessionState = getInitialSessionState(mockFileContext) + const toolCall = createSpawnToolCall('file_picker') + + const { output } = await handleSpawnAgents({ + ...handleSpawnAgentsBaseParams, + agentState: sessionState.mainAgentState, + agentTemplate: parentAgent, + localAgentTemplates: { 'file-picker': childAgent }, + toolCall, + }) + + expect(JSON.stringify(output)).toContain('Mock agent response') + expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1) + expect(mockLoopAgentSteps.mock.calls[0][0].agentState.agentType).toBe( + 'file-picker', + ) + }) + + it('should allow underscored published agent_type when hyphenated agent is spawnable', async () => { + const parentAgent = createMockAgent('parent', [ + 'codebuff/file-picker@1.0.0', + ]) + const childAgent = createMockAgent('codebuff/file-picker@1.0.0') + const sessionState = getInitialSessionState(mockFileContext) + const toolCall = createSpawnToolCall('codebuff/file_picker@1.0.0') + + const { output } = await handleSpawnAgents({ + ...handleSpawnAgentsBaseParams, + agentState: sessionState.mainAgentState, + agentTemplate: parentAgent, + localAgentTemplates: { 'codebuff/file-picker@1.0.0': childAgent }, + toolCall, + }) + + expect(JSON.stringify(output)).toContain('Mock agent response') + expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1) + expect(mockLoopAgentSteps.mock.calls[0][0].agentState.agentType).toBe( + 'codebuff/file-picker@1.0.0', + ) + }) + it('should reject spawning when agent is not in spawnableAgents list', async () => { const parentAgent = createMockAgent('parent', ['thinker']) // Only allows thinker const childAgent = createMockAgent('reviewer') diff --git a/packages/agent-runtime/src/templates/agent-registry.ts b/packages/agent-runtime/src/templates/agent-registry.ts index b257c40bc6..b94e3bd7a1 100644 --- a/packages/agent-runtime/src/templates/agent-registry.ts +++ b/packages/agent-runtime/src/templates/agent-registry.ts @@ -1,5 +1,8 @@ import { validateAgents } from '@codebuff/common/templates/agent-validation' -import { parsePublishedAgentId } from '@codebuff/common/util/agent-id-parsing' +import { + normalizeAgentIdForLookup, + parsePublishedAgentId, +} from '@codebuff/common/util/agent-id-parsing' import { DEFAULT_ORG_PREFIX } from '@codebuff/common/util/agent-name-normalization' import type { DynamicAgentValidationError } from '@codebuff/common/templates/agent-validation' @@ -31,20 +34,32 @@ export async function getAgentTemplate( databaseAgentCache, logger, } = params + const normalizedAgentId = normalizeAgentIdForLookup(agentId) + // 1. Check localAgentTemplates first (dynamic agents + static templates) if (localAgentTemplates[agentId]) { return localAgentTemplates[agentId] } + if (normalizedAgentId !== agentId && localAgentTemplates[normalizedAgentId]) { + return localAgentTemplates[normalizedAgentId] + } + // 2. Check database cache if (databaseAgentCache.has(agentId)) { return databaseAgentCache.get(agentId) || null } + if ( + normalizedAgentId !== agentId && + databaseAgentCache.has(normalizedAgentId) + ) { + return databaseAgentCache.get(normalizedAgentId) || null + } - const parsed = parsePublishedAgentId(agentId) + const parsed = parsePublishedAgentId(normalizedAgentId) if (!parsed) { // If agentId doesn't parse as publisher/agent format, try as codebuff/agentId const codebuffParsed = parsePublishedAgentId( - `${DEFAULT_ORG_PREFIX}${agentId}`, + `${DEFAULT_ORG_PREFIX}${normalizedAgentId}`, ) if (codebuffParsed) { const dbAgent = await fetchAgentFromDatabase({ diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts index 879422d9cd..1223b131ff 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts @@ -1,6 +1,9 @@ import { MAX_AGENT_STEPS_DEFAULT } from '@codebuff/common/constants/agents' import { toolNames } from '@codebuff/common/tools/constants' -import { parseAgentId } from '@codebuff/common/util/agent-id-parsing' +import { + normalizeAgentIdForLookup, + parseAgentId, +} from '@codebuff/common/util/agent-id-parsing' import { generateCompactId } from '@codebuff/common/util/string' import { loopAgentSteps } from '../../../run-agent-step' @@ -115,7 +118,7 @@ export function getMatchingSpawn( publisherId: childPublisherId, agentId: childAgentId, version: childVersion, - } = parseAgentId(childFullAgentId) + } = parseAgentId(normalizeAgentIdForLookup(childFullAgentId)) if (!childAgentId) { return null @@ -126,7 +129,7 @@ export function getMatchingSpawn( publisherId: spawnablePublisherId, agentId: spawnableAgentId, version: spawnableVersion, - } = parseAgentId(spawnableAgent) + } = parseAgentId(normalizeAgentIdForLookup(spawnableAgent)) if (!spawnableAgentId) { continue @@ -177,9 +180,26 @@ export async function validateAndGetAgentTemplate( } & ParamsExcluding, ): Promise<{ agentTemplate: AgentTemplate; agentType: string }> { const { agentTypeStr, parentAgentTemplate } = params + const BASE_AGENTS = ['base', 'base-free', 'base-max', 'base-experimental'] + const isBaseAgent = BASE_AGENTS.includes(parentAgentTemplate.id) + const agentType = isBaseAgent + ? normalizeAgentIdForLookup(agentTypeStr) + : getMatchingSpawn(parentAgentTemplate.spawnableAgents, agentTypeStr) + + if (!agentType) { + if (toolNames.includes(agentTypeStr as any)) { + throw new Error( + `"${agentTypeStr}" is a tool, not an agent. Call it directly as a tool instead of wrapping it in spawn_agents.`, + ) + } + throw new Error( + `Agent type ${parentAgentTemplate.id} is not allowed to spawn child agent type ${agentTypeStr}.`, + ) + } + const agentTemplate = await getAgentTemplate({ ...params, - agentId: agentTypeStr, + agentId: agentType, }) if (!agentTemplate) { @@ -190,21 +210,6 @@ export async function validateAndGetAgentTemplate( } throw new Error(`Agent type ${agentTypeStr} not found.`) } - const BASE_AGENTS = ['base', 'base-free', 'base-max', 'base-experimental'] - // Base agent can spawn any agent - if (BASE_AGENTS.includes(parentAgentTemplate.id)) { - return { agentTemplate, agentType: agentTypeStr } - } - - const agentType = getMatchingSpawn( - parentAgentTemplate.spawnableAgents, - agentTypeStr, - ) - if (!agentType) { - throw new Error( - `Agent type ${parentAgentTemplate.id} is not allowed to spawn child agent type ${agentTypeStr}.`, - ) - } return { agentTemplate, agentType } } diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 670a0d0f70..fdcf0e7096 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -229,6 +229,7 @@ export async function executeToolCall( } } + let agentIdToLoad = agentTypeStr if (!isBaseAgent) { const matchingSpawn = getMatchingSpawn( agentTemplate.spawnableAgents, @@ -246,11 +247,12 @@ export async function executeToolCall( error: `Agent "${agentTypeStr}" is not available to spawn`, } } + agentIdToLoad = matchingSpawn } try { const template = await getAgentTemplate({ - agentId: agentTypeStr, + agentId: agentIdToLoad, localAgentTemplates: params.localAgentTemplates, fetchAgentFromDatabase: params.fetchAgentFromDatabase, databaseAgentCache: params.databaseAgentCache,