Skip to content

Commit 17262dc

Browse files
committed
Allow underscored spawn agent IDs
1 parent cb598db commit 17262dc

5 files changed

Lines changed: 149 additions & 24 deletions

File tree

common/src/util/agent-id-parsing.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,38 @@ export function parsePublishedAgentId(fullAgentId: string): {
9999
version,
100100
}
101101
}
102+
103+
/**
104+
* Normalizes an agent ID for lookup by accepting underscores as aliases for
105+
* hyphens in the agent-name segment. Publisher IDs and version strings are
106+
* preserved as written.
107+
*/
108+
export function normalizeAgentIdForLookup(fullAgentId: string): string {
109+
const parts = fullAgentId.split('/')
110+
if (parts.length > 2) {
111+
return fullAgentId
112+
}
113+
114+
const normalizeNameWithVersion = (agentNameWithVersion: string) => {
115+
const versionStart = agentNameWithVersion.indexOf('@')
116+
const agentName =
117+
versionStart === -1
118+
? agentNameWithVersion
119+
: agentNameWithVersion.slice(0, versionStart)
120+
const version =
121+
versionStart === -1 ? '' : agentNameWithVersion.slice(versionStart)
122+
123+
return `${agentName.replace(/_/g, '-')}${version}`
124+
}
125+
126+
if (parts.length === 1) {
127+
return normalizeNameWithVersion(fullAgentId)
128+
}
129+
130+
const [publisherId, agentNameWithVersion] = parts
131+
if (!publisherId || !agentNameWithVersion) {
132+
return fullAgentId
133+
}
134+
135+
return `${publisherId}/${normalizeNameWithVersion(agentNameWithVersion)}`
136+
}

packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,10 @@ describe('Spawn Agents Permissions', () => {
9494
...options.agentState,
9595
messageHistory: [assistantMessage('Mock agent response')],
9696
},
97-
output: { type: 'lastMessage', value: [assistantMessage('Mock agent response')] },
97+
output: {
98+
type: 'lastMessage',
99+
value: [assistantMessage('Mock agent response')],
100+
},
98101
}
99102
})
100103
})
@@ -189,12 +192,33 @@ describe('Spawn Agents Permissions', () => {
189192
expect(result).toBe('thinker')
190193
})
191194

195+
it('should match underscored agent name to hyphenated spawnable agent', () => {
196+
const spawnableAgents = ['thinker', 'reviewer', 'file-picker']
197+
const result = getMatchingSpawn(spawnableAgents, 'file_picker')
198+
expect(result).toBe('file-picker')
199+
})
200+
192201
it('should match simple agent name when spawnable has publisher', () => {
193202
const spawnableAgents = ['codebuff/thinker@1.0.0', 'reviewer']
194203
const result = getMatchingSpawn(spawnableAgents, 'thinker')
195204
expect(result).toBe('codebuff/thinker@1.0.0')
196205
})
197206

207+
it('should match underscored agent name when spawnable has publisher and version', () => {
208+
const spawnableAgents = ['codebuff/file-picker@1.0.0', 'reviewer']
209+
const result = getMatchingSpawn(spawnableAgents, 'file_picker')
210+
expect(result).toBe('codebuff/file-picker@1.0.0')
211+
})
212+
213+
it('should match underscored published agent ID to hyphenated spawnable agent', () => {
214+
const spawnableAgents = ['codebuff/file-picker@1.0.0']
215+
const result = getMatchingSpawn(
216+
spawnableAgents,
217+
'codebuff/file_picker@1.0.0',
218+
)
219+
expect(result).toBe('codebuff/file-picker@1.0.0')
220+
})
221+
198222
it('should match simple agent name when spawnable has version', () => {
199223
const spawnableAgents = ['thinker@1.0.0', 'reviewer']
200224
const result = getMatchingSpawn(spawnableAgents, 'thinker')
@@ -274,6 +298,50 @@ describe('Spawn Agents Permissions', () => {
274298
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
275299
})
276300

301+
it('should allow underscored agent_type when hyphenated agent is spawnable', async () => {
302+
const parentAgent = createMockAgent('parent', ['file-picker'])
303+
const childAgent = createMockAgent('file-picker')
304+
const sessionState = getInitialSessionState(mockFileContext)
305+
const toolCall = createSpawnToolCall('file_picker')
306+
307+
const { output } = await handleSpawnAgents({
308+
...handleSpawnAgentsBaseParams,
309+
agentState: sessionState.mainAgentState,
310+
agentTemplate: parentAgent,
311+
localAgentTemplates: { 'file-picker': childAgent },
312+
toolCall,
313+
})
314+
315+
expect(JSON.stringify(output)).toContain('Mock agent response')
316+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
317+
expect(mockLoopAgentSteps.mock.calls[0][0].agentState.agentType).toBe(
318+
'file-picker',
319+
)
320+
})
321+
322+
it('should allow underscored published agent_type when hyphenated agent is spawnable', async () => {
323+
const parentAgent = createMockAgent('parent', [
324+
'codebuff/file-picker@1.0.0',
325+
])
326+
const childAgent = createMockAgent('codebuff/file-picker@1.0.0')
327+
const sessionState = getInitialSessionState(mockFileContext)
328+
const toolCall = createSpawnToolCall('codebuff/file_picker@1.0.0')
329+
330+
const { output } = await handleSpawnAgents({
331+
...handleSpawnAgentsBaseParams,
332+
agentState: sessionState.mainAgentState,
333+
agentTemplate: parentAgent,
334+
localAgentTemplates: { 'codebuff/file-picker@1.0.0': childAgent },
335+
toolCall,
336+
})
337+
338+
expect(JSON.stringify(output)).toContain('Mock agent response')
339+
expect(mockLoopAgentSteps).toHaveBeenCalledTimes(1)
340+
expect(mockLoopAgentSteps.mock.calls[0][0].agentState.agentType).toBe(
341+
'codebuff/file-picker@1.0.0',
342+
)
343+
})
344+
277345
it('should reject spawning when agent is not in spawnableAgents list', async () => {
278346
const parentAgent = createMockAgent('parent', ['thinker']) // Only allows thinker
279347
const childAgent = createMockAgent('reviewer')

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { validateAgents } from '@codebuff/common/templates/agent-validation'
2-
import { parsePublishedAgentId } from '@codebuff/common/util/agent-id-parsing'
2+
import {
3+
normalizeAgentIdForLookup,
4+
parsePublishedAgentId,
5+
} from '@codebuff/common/util/agent-id-parsing'
36
import { DEFAULT_ORG_PREFIX } from '@codebuff/common/util/agent-name-normalization'
47

58
import type { DynamicAgentValidationError } from '@codebuff/common/templates/agent-validation'
@@ -31,20 +34,32 @@ export async function getAgentTemplate(
3134
databaseAgentCache,
3235
logger,
3336
} = params
37+
const normalizedAgentId = normalizeAgentIdForLookup(agentId)
38+
3439
// 1. Check localAgentTemplates first (dynamic agents + static templates)
3540
if (localAgentTemplates[agentId]) {
3641
return localAgentTemplates[agentId]
3742
}
43+
if (normalizedAgentId !== agentId && localAgentTemplates[normalizedAgentId]) {
44+
return localAgentTemplates[normalizedAgentId]
45+
}
46+
3847
// 2. Check database cache
3948
if (databaseAgentCache.has(agentId)) {
4049
return databaseAgentCache.get(agentId) || null
4150
}
51+
if (
52+
normalizedAgentId !== agentId &&
53+
databaseAgentCache.has(normalizedAgentId)
54+
) {
55+
return databaseAgentCache.get(normalizedAgentId) || null
56+
}
4257

43-
const parsed = parsePublishedAgentId(agentId)
58+
const parsed = parsePublishedAgentId(normalizedAgentId)
4459
if (!parsed) {
4560
// If agentId doesn't parse as publisher/agent format, try as codebuff/agentId
4661
const codebuffParsed = parsePublishedAgentId(
47-
`${DEFAULT_ORG_PREFIX}${agentId}`,
62+
`${DEFAULT_ORG_PREFIX}${normalizedAgentId}`,
4863
)
4964
if (codebuffParsed) {
5065
const dbAgent = await fetchAgentFromDatabase({

packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { MAX_AGENT_STEPS_DEFAULT } from '@codebuff/common/constants/agents'
22
import { toolNames } from '@codebuff/common/tools/constants'
3-
import { parseAgentId } from '@codebuff/common/util/agent-id-parsing'
3+
import {
4+
normalizeAgentIdForLookup,
5+
parseAgentId,
6+
} from '@codebuff/common/util/agent-id-parsing'
47
import { generateCompactId } from '@codebuff/common/util/string'
58

69
import { loopAgentSteps } from '../../../run-agent-step'
@@ -115,7 +118,7 @@ export function getMatchingSpawn(
115118
publisherId: childPublisherId,
116119
agentId: childAgentId,
117120
version: childVersion,
118-
} = parseAgentId(childFullAgentId)
121+
} = parseAgentId(normalizeAgentIdForLookup(childFullAgentId))
119122

120123
if (!childAgentId) {
121124
return null
@@ -126,7 +129,7 @@ export function getMatchingSpawn(
126129
publisherId: spawnablePublisherId,
127130
agentId: spawnableAgentId,
128131
version: spawnableVersion,
129-
} = parseAgentId(spawnableAgent)
132+
} = parseAgentId(normalizeAgentIdForLookup(spawnableAgent))
130133

131134
if (!spawnableAgentId) {
132135
continue
@@ -177,9 +180,26 @@ export async function validateAndGetAgentTemplate(
177180
} & ParamsExcluding<typeof getAgentTemplate, 'agentId'>,
178181
): Promise<{ agentTemplate: AgentTemplate; agentType: string }> {
179182
const { agentTypeStr, parentAgentTemplate } = params
183+
const BASE_AGENTS = ['base', 'base-free', 'base-max', 'base-experimental']
184+
const isBaseAgent = BASE_AGENTS.includes(parentAgentTemplate.id)
185+
const agentType = isBaseAgent
186+
? normalizeAgentIdForLookup(agentTypeStr)
187+
: getMatchingSpawn(parentAgentTemplate.spawnableAgents, agentTypeStr)
188+
189+
if (!agentType) {
190+
if (toolNames.includes(agentTypeStr as any)) {
191+
throw new Error(
192+
`"${agentTypeStr}" is a tool, not an agent. Call it directly as a tool instead of wrapping it in spawn_agents.`,
193+
)
194+
}
195+
throw new Error(
196+
`Agent type ${parentAgentTemplate.id} is not allowed to spawn child agent type ${agentTypeStr}.`,
197+
)
198+
}
199+
180200
const agentTemplate = await getAgentTemplate({
181201
...params,
182-
agentId: agentTypeStr,
202+
agentId: agentType,
183203
})
184204

185205
if (!agentTemplate) {
@@ -190,21 +210,6 @@ export async function validateAndGetAgentTemplate(
190210
}
191211
throw new Error(`Agent type ${agentTypeStr} not found.`)
192212
}
193-
const BASE_AGENTS = ['base', 'base-free', 'base-max', 'base-experimental']
194-
// Base agent can spawn any agent
195-
if (BASE_AGENTS.includes(parentAgentTemplate.id)) {
196-
return { agentTemplate, agentType: agentTypeStr }
197-
}
198-
199-
const agentType = getMatchingSpawn(
200-
parentAgentTemplate.spawnableAgents,
201-
agentTypeStr,
202-
)
203-
if (!agentType) {
204-
throw new Error(
205-
`Agent type ${parentAgentTemplate.id} is not allowed to spawn child agent type ${agentTypeStr}.`,
206-
)
207-
}
208213

209214
return { agentTemplate, agentType }
210215
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export async function executeToolCall<T extends ToolName>(
229229
}
230230
}
231231

232+
let agentIdToLoad = agentTypeStr
232233
if (!isBaseAgent) {
233234
const matchingSpawn = getMatchingSpawn(
234235
agentTemplate.spawnableAgents,
@@ -246,11 +247,12 @@ export async function executeToolCall<T extends ToolName>(
246247
error: `Agent "${agentTypeStr}" is not available to spawn`,
247248
}
248249
}
250+
agentIdToLoad = matchingSpawn
249251
}
250252

251253
try {
252254
const template = await getAgentTemplate({
253-
agentId: agentTypeStr,
255+
agentId: agentIdToLoad,
254256
localAgentTemplates: params.localAgentTemplates,
255257
fetchAgentFromDatabase: params.fetchAgentFromDatabase,
256258
databaseAgentCache: params.databaseAgentCache,

0 commit comments

Comments
 (0)