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
22 changes: 12 additions & 10 deletions packages/agentlayer-core/src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ export type SubagentCommand =
| { type: 'dispatch-role'; prompt: string; subagentType: string; description?: string; skill?: string }
| { type: 'resume'; prompt: string; agentId: string; skill?: string }

function optionalNonBlank(value: string | undefined, field: string): string | undefined {
// GPT-family models fill every schema property and send "" for fields they mean
// to omit, so a blank optional must parse as absent rather than error.
function normalizeOptional(value: string | undefined): string | undefined {
if (value === undefined) return undefined
const trimmed = value.trim()
if (!trimmed) throw new Error(`${field} must not be blank.`)
return trimmed
return trimmed === '' ? undefined : trimmed
}

function parseForkTurns(value: string | undefined): ForkTurns {
Expand All @@ -86,12 +87,13 @@ export function parseSubagentCommand(input: ForkingSubagentInput): SubagentComma
const prompt = input.prompt.trim()
if (!prompt) throw new Error('prompt must not be blank.')

const agentId = optionalNonBlank(input.agent_id, 'agent_id')
const subagentType = optionalNonBlank(input.subagent_type, 'subagent_type')
const description = optionalNonBlank(input.description, 'description')
const skill = optionalNonBlank(input.skill, 'skill')
const hasForkTurns = input.fork_turns !== undefined
const selectorCount = Number(agentId !== undefined) + Number(subagentType !== undefined) + Number(hasForkTurns)
const agentId = normalizeOptional(input.agent_id)
const subagentType = normalizeOptional(input.subagent_type)
const description = normalizeOptional(input.description)
const skill = normalizeOptional(input.skill)
const forkTurns = normalizeOptional(input.fork_turns)
const selectorCount =
Number(agentId !== undefined) + Number(subagentType !== undefined) + Number(forkTurns !== undefined)
if (selectorCount > 1) {
throw new Error('agent_id, fork_turns, and subagent_type are mutually exclusive; pass at most one.')
}
Expand All @@ -109,7 +111,7 @@ export function parseSubagentCommand(input: ForkingSubagentInput): SubagentComma
return {
type: 'fork',
prompt,
turns: parseForkTurns(input.fork_turns),
turns: parseForkTurns(forkTurns),
...(description ? { description } : {}),
...(skill ? { skill } : {}),
}
Expand Down
51 changes: 46 additions & 5 deletions packages/agentlayer-core/test/subagent-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,14 +742,55 @@ describe('forking subagent tool', () => {
parseSubagentCommand({ prompt: 'inspect it', fork_turns: 'all', subagent_type: 'worker' }),
).toThrow('mutually exclusive')
expect(() => parseSubagentCommand({ prompt: ' ' })).toThrow('prompt must not be blank')
expect(() => parseSubagentCommand({ prompt: 'inspect it', agent_id: '' })).toThrow('agent_id must not be blank')
expect(() => parseSubagentCommand({ prompt: 'inspect it', fork_turns: '' })).toThrow('fork_turns')
expect(() => parseSubagentCommand({ prompt: 'inspect it', subagent_type: '' })).toThrow(
'subagent_type must not be blank',
)
expect(() => parseSubagentCommand({ prompt: 'inspect it', fork_turns: '0' })).toThrow('fork_turns')
})

test('treats blank optionals as absent (GPT models send "" for omitted fields)', () => {
// Exact shape gpt-5.6-sol sends when dispatching a specialist: every
// property present, "" for the ones it means to omit.
expect(
parseSubagentCommand({
prompt: 'inspect it',
description: 'trace flow',
subagent_type: 'worker',
agent_id: '',
fork_turns: '',
skill: '',
}),
).toEqual({
type: 'dispatch-role',
prompt: 'inspect it',
subagentType: 'worker',
description: 'trace flow',
})
expect(
parseSubagentCommand({
prompt: 'inspect it',
agent_id: '',
subagent_type: '',
fork_turns: 'all',
skill: '',
}),
).toEqual({ type: 'fork', prompt: 'inspect it', turns: 'all' })
expect(parseSubagentCommand({ prompt: 'inspect it', agent_id: '' })).toEqual({
type: 'fork',
prompt: 'inspect it',
turns: 'all',
})
expect(parseSubagentCommand({ prompt: 'inspect it', fork_turns: ' ' })).toEqual({
type: 'fork',
prompt: 'inspect it',
turns: 'all',
})
expect(
parseSubagentCommand({ prompt: 'inspect it', agent_id: 'child-1', subagent_type: '', fork_turns: '' }),
).toEqual({ type: 'resume', prompt: 'inspect it', agentId: 'child-1' })
// Blank selectors do not count toward mutual exclusivity, but real ones still do.
expect(() =>
parseSubagentCommand({ prompt: 'inspect it', agent_id: 'child-1', fork_turns: 'all', subagent_type: '' }),
).toThrow('mutually exclusive')
})

test('projects only completed eligible turns and removes the triggering user plus invoking assistant', () => {
const messages: ModelMessage[] = [
{ role: 'user', content: 'old request' },
Expand Down
Loading