From 3822bda1f758532d7bde9ae53909a85ac5d7e60f Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:08:10 +0530 Subject: [PATCH] =?UTF-8?q?fix(x):=20spawn-agent=20works=20from=20task=20a?= =?UTF-8?q?lone=20=E2=80=94=20instructions=20now=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real-world use showed the model calling spawn-agent with {name, tools, task} and no instructions — a complete spec by any reasonable reading — and burning a correction round-trip on the "exactly one of agent_id or instructions" rejection (both children then succeeded on retry). Requiring instructions bought nothing: the task is the spec. Omitting both agent_id and instructions now spawns a general-purpose worker with a default headless prompt; only supplying BOTH remains an error. Co-Authored-By: Claude Fable 5 --- .../core/src/agents/spawn-agent.test.ts | 36 ++++++++++++------- .../x/packages/core/src/agents/spawn-agent.ts | 20 ++++++++--- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/apps/x/packages/core/src/agents/spawn-agent.test.ts b/apps/x/packages/core/src/agents/spawn-agent.test.ts index 55e093f7..fc5b91a3 100644 --- a/apps/x/packages/core/src/agents/spawn-agent.test.ts +++ b/apps/x/packages/core/src/agents/spawn-agent.test.ts @@ -140,20 +140,32 @@ describe("runSpawnedAgent", () => { expect(started[0].maxModelCalls).toBe(5); }); - it("requires exactly one of agent_id and instructions", async () => { + it("rejects agent_id and instructions together", async () => { const { services } = fakeServices({}); - for (const input of [ - { task: "t" }, + const result = await runSpawnedAgent( { task: "t", agent_id: "a", instructions: "b" }, - ]) { - const result = await runSpawnedAgent(input, { - parentTurnId: "parent-1", - signal, - services, - }); - expect(result.isError).toBe(true); - expect(result.output).toMatch(/exactly one/); - } + { parentTurnId: "parent-1", signal, services }, + ); + expect(result.isError).toBe(true); + expect(result.output).toMatch(/at most one/); + }); + + it("spawns a default worker when neither agent_id nor instructions is given", async () => { + // Models routinely treat task (+ name/tools) as a complete spec; the + // task-only form must work rather than cost a correction round-trip. + const { services, started } = fakeServices({}); + const result = await runSpawnedAgent( + { task: "find the weather", name: "london-weather", tools: ["web-search"] }, + { parentTurnId: "parent-1", signal, services }, + ); + expect(result.isError).toBe(false); + const agent = started[0].agent as { + inline: { name: string; instructions: string; tools?: string[] }; + }; + expect(agent.inline.name).toBe("london-weather"); + expect(agent.inline.tools).toEqual(["web-search"]); + expect(agent.inline.instructions).toMatch(/london-weather/); + expect(agent.inline.instructions).toMatch(/headlessly/); }); it("rejects a model-call budget above the cap at the schema boundary", async () => { diff --git a/apps/x/packages/core/src/agents/spawn-agent.ts b/apps/x/packages/core/src/agents/spawn-agent.ts index 4466e62c..432edaab 100644 --- a/apps/x/packages/core/src/agents/spawn-agent.ts +++ b/apps/x/packages/core/src/agents/spawn-agent.ts @@ -34,7 +34,7 @@ export const SpawnAgentInput = z.object({ .string() .optional() .describe( - "System instructions for an agent constructed on the fly. Mutually exclusive with `agent_id`.", + "System instructions for an agent constructed on the fly. Mutually exclusive with `agent_id`. Optional: omitting both `agent_id` and `instructions` spawns a general-purpose worker driven by `task` alone.", ), model: z .string() @@ -96,9 +96,9 @@ export async function runSpawnedAgent( return spawnError(`invalid input: ${parsed.error.message}`); } const input = parsed.data; - if (!input.agent_id === !input.instructions) { + if (input.agent_id && input.instructions) { return spawnError( - "provide exactly one of `agent_id` or `instructions`", + "provide at most one of `agent_id` or `instructions`", ); } @@ -149,7 +149,11 @@ export async function runSpawnedAgent( : { inline: { name: agentName, - instructions: input.instructions!, + // The task alone is usually a complete spec — models + // routinely omit `instructions` for ad-hoc workers, and + // rejecting that just costs a correction round-trip. + instructions: + input.instructions ?? defaultWorkerInstructions(agentName), ...(model ? { model } : {}), ...(input.tools ? { tools: input.tools } : {}), }, @@ -231,6 +235,14 @@ function spawnError(message: string): z.infer { return { output: `spawn-agent: ${message}`, isError: true }; } +function defaultWorkerInstructions(name: string): string { + return ( + `You are ${name}, a sub-agent spawned to complete a single task. ` + + "You run headlessly: no user is available to clarify or approve, and your final message is your only output. " + + "Work autonomously with the tools you have, then end with a complete, self-contained answer to the task." + ); +} + // True for any child-shaped parent: inline agents only exist as spawned // children, and by-id children carry the subagent composition flag. function hasSubagentFlag(