From 431075e8753340f03f532a1fa45cfbc48245f122 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 18:43:51 -0700 Subject: [PATCH] Name only the actually-missing task fields in rejections The task tool rejected a call missing prompt with "requires description (string) and prompt (string)" even when description was provided, so models could not tell which field was missing and retried the identical call. Rejections now name only the missing, invalid, or empty fields and echo the valid one back with a hint to keep it and add the other. --- src/subagent/task-tool.ts | 58 +++++++++++++++++++++++++++++++++++-- tests/unit/subagent.test.ts | 38 ++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 386b48dc4..a7e34fa1b 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -201,6 +201,51 @@ function taskToolResult(callId: string, content: string): ToolResult { return { callId, content, ...(isError ? { isError: true } : {}) }; } +type RequiredTaskField = "description" | "prompt"; + +const REQUIRED_TASK_FIELD_HINTS: Record = { + description: "a short label for the sub-agent job", + prompt: "the actionable goal for the worker", +}; + +/** Truncated echo of a received value so the rejection shows what arrived. */ +function receivedFieldPreview(value: string): string { + const trimmed = value.trim(); + return JSON.stringify(trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed); +} + +/** + * Rejection naming only the actually-bad required fields, echoing the valid + * one back. A generic "requires description and prompt" hid which field was + * missing, so models retried the identical call verbatim (CL-6901). + */ +function requiredTaskFieldsError( + args: Record, + bad: readonly RequiredTaskField[], +): string { + const parts = bad.map((name) => { + const value = args[name]; + const hint = REQUIRED_TASK_FIELD_HINTS[name]; + if (value === undefined) return `is missing ${name} (string): ${hint}`; + if (typeof value !== "string") return `has invalid ${name} (must be a string): ${hint}`; + return `requires a non-empty ${name}: ${hint}`; + }); + let message = `Error: task ${parts.join(" and ")}.`; + const good = (Object.keys(REQUIRED_TASK_FIELD_HINTS) as RequiredTaskField[]).filter( + (name) => + !bad.includes(name) && + typeof args[name] === "string" && + (args[name] as string).trim().length > 0, + ); + if (good.length > 0) { + const echo = good + .map((name) => `${name} ${receivedFieldPreview(args[name] as string)}`) + .join(" and "); + message += ` Received ${echo} — keep it and add ${bad.join(" and ")}.`; + } + return message; +} + export function createTaskTool(deps: TaskToolDeps): AgentTool { const run = deps.run; const telemetry = deps.telemetry ?? NOOP_TELEMETRY; @@ -212,7 +257,13 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { const args = call.arguments; const parsed = TaskToolArgs(args); if (parsed instanceof type.errors) { - return taskToolResult(call.id, "Error: task requires description (string) and prompt (string)."); + const bad = (["description", "prompt"] as const).filter( + (name) => typeof args[name] !== "string", + ); + if (bad.length === 0) { + return taskToolResult(call.id, `Error: task arguments invalid: ${parsed.summary}`); + } + return taskToolResult(call.id, requiredTaskFieldsError(args, bad)); } const { description: rawDesc, @@ -244,7 +295,10 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { .filter((d) => d.length > 0) ?? []; const reportFocus = rawReportFocus?.trim(); if (description.length === 0 || prompt.length === 0) { - return taskToolResult(call.id, "Error: task requires a non-empty description and prompt."); + const empty = (["description", "prompt"] as const).filter( + (name) => (name === "description" ? description : prompt).length === 0, + ); + return taskToolResult(call.id, requiredTaskFieldsError(args, empty)); } let provider: SubAgentProvider = diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index 906edda8f..d23c8f195 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -48,7 +48,7 @@ test("task tool definition requires description and prompt", () => { expect(taskToolDefinition.inputSchema.required).toEqual(["description", "prompt"]); }); -test("handler rejects empty description or prompt", async () => { +test("handler rejects empty description or prompt, naming only the empty field", async () => { const tool = createTaskTool({ permissionGate: testPermissionGate, cwd: "/repo", @@ -56,8 +56,40 @@ test("handler rejects empty description or prompt", async () => { provider, run: async () => "should not run", }); - expect(await callHandler(tool, { description: "", prompt: "do it" })).toContain("Error:"); - expect(await callHandler(tool, { description: "label", prompt: " " })).toContain("Error:"); + const emptyDesc = await callHandler(tool, { description: "", prompt: "do it" }); + expect(emptyDesc).toContain("Error: task requires a non-empty description"); + expect(emptyDesc).toContain('Received prompt "do it"'); + expect(emptyDesc).not.toContain("non-empty prompt"); + const emptyPrompt = await callHandler(tool, { description: "label", prompt: " " }); + expect(emptyPrompt).toContain("Error: task requires a non-empty prompt"); + expect(emptyPrompt).toContain('Received description "label" — keep it and add prompt.'); + expect(emptyPrompt).not.toContain("non-empty description"); +}); + +test("handler rejects missing required fields, naming only the missing ones", async () => { + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async () => "should not run", + }); + const missingPrompt = await callHandler(tool, { description: "Add GET /health route" }); + expect(missingPrompt).toContain( + "Error: task is missing prompt (string): the actionable goal for the worker.", + ); + expect(missingPrompt).toContain( + 'Received description "Add GET /health route" — keep it and add prompt.', + ); + expect(missingPrompt).not.toContain("missing description"); + const missingDesc = await callHandler(tool, { prompt: "do it" }); + expect(missingDesc).toContain("Error: task is missing description (string)"); + expect(missingDesc).toContain('Received prompt "do it" — keep it and add description.'); + expect(missingDesc).not.toContain("missing prompt"); + const missingBoth = await callHandler(tool, {}); + expect(missingBoth).toContain("Error: task is missing description (string)"); + expect(missingBoth).toContain("is missing prompt (string)"); + expect(missingBoth).not.toContain("Received"); }); test("generic leaf gets role-default medium even when parent effort is high", async () => {