From a7c1f57aa7e1d0eda5f46fb6d48047dacd9f17b9 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Wed, 12 Aug 2026 21:47:47 -0700 Subject: [PATCH 1/2] fix(core): treat blank subagent optionals as absent GPT-family models (observed on gpt-5.6-sol in production) fill every tool schema property and send "" for fields they mean to omit. The forking subagent parser rejected these calls with 'agent_id must not be blank.', so every dispatch failed and sessions fell back to inline work. Normalize blank/whitespace optionals to undefined before validation: blank agent_id/subagent_type/skill/description parse as absent, blank fork_turns falls back to the 'all' default, and blank selectors no longer count toward the mutual-exclusivity check. A blank prompt is still an error. Co-Authored-By: Claude Fable 5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/019ff95b-c6a6-74f2-88bd-9e543222702e --- .../agentlayer-core/src/tools/subagent.ts | 22 ++++----- .../test/subagent-tool.test.ts | 45 ++++++++++++++++--- 2 files changed, 52 insertions(+), 15 deletions(-) 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..b8afa08 100644 --- a/packages/agentlayer-core/test/subagent-tool.test.ts +++ b/packages/agentlayer-core/test/subagent-tool.test.ts @@ -742,14 +742,49 @@ 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' }, From 3b6a0077a78773c6e54e42efc58267c5fc3282e8 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Wed, 12 Aug 2026 21:48:57 -0700 Subject: [PATCH 2/2] style: biome format Co-Authored-By: Claude Fable 5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/019ff95b-c6a6-74f2-88bd-9e543222702e --- packages/agentlayer-core/test/subagent-tool.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/agentlayer-core/test/subagent-tool.test.ts b/packages/agentlayer-core/test/subagent-tool.test.ts index b8afa08..0627c13 100644 --- a/packages/agentlayer-core/test/subagent-tool.test.ts +++ b/packages/agentlayer-core/test/subagent-tool.test.ts @@ -764,7 +764,13 @@ describe('forking subagent tool', () => { description: 'trace flow', }) expect( - parseSubagentCommand({ prompt: 'inspect it', agent_id: '', subagent_type: '', fork_turns: 'all', skill: '' }), + 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',