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
35 changes: 35 additions & 0 deletions common/src/util/agent-id-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
},
}
})
})
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
21 changes: 18 additions & 3 deletions packages/agent-runtime/src/templates/agent-registry.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -115,7 +118,7 @@ export function getMatchingSpawn(
publisherId: childPublisherId,
agentId: childAgentId,
version: childVersion,
} = parseAgentId(childFullAgentId)
} = parseAgentId(normalizeAgentIdForLookup(childFullAgentId))

if (!childAgentId) {
return null
Expand All @@ -126,7 +129,7 @@ export function getMatchingSpawn(
publisherId: spawnablePublisherId,
agentId: spawnableAgentId,
version: spawnableVersion,
} = parseAgentId(spawnableAgent)
} = parseAgentId(normalizeAgentIdForLookup(spawnableAgent))

if (!spawnableAgentId) {
continue
Expand Down Expand Up @@ -177,9 +180,26 @@ export async function validateAndGetAgentTemplate(
} & ParamsExcluding<typeof getAgentTemplate, 'agentId'>,
): 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) {
Expand All @@ -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 }
}
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-runtime/src/tools/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export async function executeToolCall<T extends ToolName>(
}
}

let agentIdToLoad = agentTypeStr
if (!isBaseAgent) {
const matchingSpawn = getMatchingSpawn(
agentTemplate.spawnableAgents,
Expand All @@ -246,11 +247,12 @@ export async function executeToolCall<T extends ToolName>(
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,
Expand Down
Loading