diff --git a/packages/agentlayer-core/src/tools/subagent.ts b/packages/agentlayer-core/src/tools/subagent.ts index e34e830..5f234cb 100644 --- a/packages/agentlayer-core/src/tools/subagent.ts +++ b/packages/agentlayer-core/src/tools/subagent.ts @@ -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 { @@ -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.') } @@ -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 } : {}), } diff --git a/packages/agentlayer-core/test/subagent-tool.test.ts b/packages/agentlayer-core/test/subagent-tool.test.ts index 88396a0..0627c13 100644 --- a/packages/agentlayer-core/test/subagent-tool.test.ts +++ b/packages/agentlayer-core/test/subagent-tool.test.ts @@ -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' },