Skip to content

Commit 1a69ead

Browse files
Merge pull request #1137 from corbitsdev/cl-8309-0330-promptresidual-claude-xml-task_guidance-after-grok-row
feat(agents): claude xml task_guidance prompt residual
2 parents ea9f69c + cd4fa7a commit 1a69ead

8 files changed

Lines changed: 240 additions & 21 deletions

‎src/agent/model-family-policy.test.ts‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { resolveModelFamilyPolicy } from "./model-family-policy.js";
44
describe("resolveModelFamilyPolicy", () => {
55
test("defaults are permissive for an unrecognized provider", () => {
66
const policy = resolveModelFamilyPolicy({
7-
providerName: "anthropic",
8-
model: "claude-sonnet-4",
7+
providerName: "openai",
8+
model: "gpt-4.1",
99
});
1010
expect(policy.family).toBe("default");
1111
expect(policy.applyGrokFinishBias).toBe(false);
@@ -55,8 +55,8 @@ describe("resolveModelFamilyPolicy", () => {
5555

5656
test("advertisedToolDeny is empty by default and never contains use_skill", () => {
5757
const leaf = resolveModelFamilyPolicy({
58-
providerName: "anthropic",
59-
model: "claude-opus-4-6",
58+
providerName: "openai",
59+
model: "gpt-4.1",
6060
orchestrator: false,
6161
});
6262
expect(leaf.advertisedToolDeny).toEqual([]);
@@ -124,11 +124,58 @@ describe("resolveModelFamilyPolicy", () => {
124124
orchestrator: true,
125125
});
126126
expect(orchestrator.promptResidual).toBeUndefined();
127+
// Default-family probe: anthropic/claude-sonnet-4 would hit the claude
128+
// row now, and the gpt row has NOT landed yet (#1135), so openai/gpt-4.1
129+
// is the probe that still resolves to the default family.
127130
const base = resolveModelFamilyPolicy({
128-
providerName: "anthropic",
129-
model: "claude-sonnet-4",
131+
providerName: "openai",
132+
model: "gpt-4.1",
130133
});
134+
expect(base.family).toBe("default");
131135
expect(base.promptResidual).toBeUndefined();
132136
});
133137
});
138+
139+
test("claude leaves carry the XML task_guidance residual; orchestrators do not", () => {
140+
const leaf = resolveModelFamilyPolicy({
141+
providerName: "anthropic",
142+
model: "claude-sonnet-4",
143+
orchestrator: false,
144+
});
145+
expect(leaf.family).toBe("claude");
146+
expect(leaf.promptResidual).toContain("<task_guidance>");
147+
expect(leaf.promptResidual).toContain("</task_guidance>");
148+
expect(leaf.advertisedToolDeny).toEqual([]);
149+
const orchestrator = resolveModelFamilyPolicy({
150+
providerName: "anthropic",
151+
model: "claude-sonnet-4",
152+
orchestrator: true,
153+
});
154+
expect(orchestrator.promptResidual).toBeUndefined();
155+
});
156+
157+
// The gpt family row has NOT landed yet (#1135): openai/gpt-4.1 and
158+
// codex/gpt-5.1 are default-family probes here, asserting they resolve to
159+
// the default family with no residual. Grok keeps its CL-8297 tool-budget
160+
// residual — the "no residual" claim below is default-family-only.
161+
test("gpt probes resolve to default with no residual; grok keeps its tool budget", () => {
162+
for (const input of [
163+
{ providerName: "openai", model: "gpt-4.1" },
164+
{ providerName: "codex", model: "gpt-5.1" },
165+
] as const) {
166+
const policy = resolveModelFamilyPolicy({
167+
...input,
168+
orchestrator: false,
169+
});
170+
expect(policy.family).toBe("default");
171+
expect(policy.promptResidual).toBeUndefined();
172+
}
173+
const grok = resolveModelFamilyPolicy({
174+
providerName: "xai/default",
175+
model: "grok-4.6",
176+
orchestrator: false,
177+
});
178+
expect(grok.family).toBe("grok");
179+
expect(grok.promptResidual).toContain("Tool budget:");
180+
});
134181
});

‎src/agent/model-family-policy.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ export interface ModelFamilyPolicy {
4040
toolDisciplineRules?: string;
4141
/**
4242
* Provider-family residual appended once to the assembled leaf system
43-
* prompt (CL-8297). Generic tool-budget text today (grok only); the
44-
* ceremony / Claude / GPT seams stay unfilled in sibling lanes. Withheld
45-
* from orchestrators and appended at the tail so it cannot disturb the
46-
* cached prompt prefix. Undefined for families that need none.
43+
* prompt (CL-8297). Tool-budget text for grok, the XML task_guidance block
44+
* for claude; the GPT seam stays unfilled until its lane lands (#1135).
45+
* Withheld from orchestrators and appended at the tail so it cannot
46+
* disturb the cached prompt prefix. Undefined for families that need none.
4747
*/
4848
promptResidual?: string | undefined;
4949
}
@@ -66,6 +66,7 @@ const GROK_WRAP_UP_NUDGE_TEXT =
6666
// sits comfortably above the observed healthy ceiling; the nudge is a
6767
// check-in, not a stop, so erring high costs nothing. Tightened only for
6868
// families with observed runaway tool-only behavior (see grok below).
69+
/** Default policy: permissive, no finish bias, no prompt residual. */
6970
const DEFAULT_POLICY: Omit<ModelFamilyPolicy, "family"> = {
7071
toolOnlyTurnNudgeAt: 25,
7172
wrapUpNudgeText: DEFAULT_WRAP_UP_NUDGE_TEXT,
@@ -149,6 +150,24 @@ export const GROK_PROMPT_RESIDUAL = [
149150
"- Do not narrate a plan before acting on a small task; act, then report.",
150151
"- Verify with the test command once at the end, not after every edit.",
151152
].join("\n");
153+
// Claude (Anthropic) ships one XML residual, not prose: a prose residual did
154+
// nothing, but a single <task_guidance> block cut Sonnet tokens. The block is
155+
// the whole residual — never a full-prompt XML renderer. The text lives here
156+
// (policy owns data); buildClaudeTaskGuidanceNote (prompts.ts) returns it
157+
// verbatim so the prompt carries exactly one copy.
158+
export const CLAUDE_TASK_GUIDANCE_NOTE = [
159+
"<task_guidance>",
160+
"Autonomous coding turn: finish the work in this turn on your best judgment.",
161+
"1. Follow the dispatch brief exactly; its Success criteria are the done-definition.",
162+
"2. Batch independent tool calls into a single turn; work from files already read this session.",
163+
"3. Finish the task when the done-definition is met: prefer the structured report envelope over another tool call.",
164+
"</task_guidance>",
165+
].join("\n");
166+
167+
const CLAUDE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
168+
...DEFAULT_POLICY,
169+
promptResidual: CLAUDE_TASK_GUIDANCE_NOTE,
170+
};
152171

153172
export function resolveModelFamilyPolicy(input: {
154173
providerName: string;
@@ -178,6 +197,13 @@ export function resolveModelFamilyPolicy(input: {
178197
};
179198
case "muse":
180199
return { family, ...MUSE_POLICY };
200+
case "claude":
201+
// Like the grok finish-bias residual, the task_guidance block only makes
202+
// sense on leaf workers — orchestrators dispatch rather than doing the
203+
// work directly, so they resolve to the permissive default (no residual).
204+
return orchestrator
205+
? { family, ...DEFAULT_POLICY }
206+
: { family, ...CLAUDE_POLICY };
181207
default:
182208
return { family: "default", ...DEFAULT_POLICY };
183209
}

‎src/agent/prompt-sizes.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,12 @@ describe("grok tool-budget residual (CL-8297)", () => {
343343
});
344344

345345
test("default-family and orchestrator prompts carry no tool budget", () => {
346-
expect(assembleDirectorPrompt("builder", "default")).not.toContain(
347-
"Tool budget:",
348-
);
346+
const defaultPrompt = assembleDirectorPrompt("builder", "default");
347+
expect(defaultPrompt).not.toContain("Tool budget:");
348+
// The default probe (openai/gpt-4.1) resolves to the default family, so
349+
// the default column carries no family residual at all — not the claude
350+
// task_guidance block either.
351+
expect(defaultPrompt).not.toContain("<task_guidance>");
349352
expect(assembleDirectorPrompt("skywalker", "grok")).not.toContain(
350353
"Tool budget:",
351354
);

‎src/agent/prompt-sizes.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {
1212
type DirectorPackage,
1313
} from "./directors/types.js";
1414
import { buildChatSystemPrompt, buildSubAgentSystemPrompt } from "./prompts.js";
15+
import { resolveModelFamilyPolicy } from "./model-family-policy.js";
1516
import {
1617
formatAgentsMdExtension,
1718
MAX_AGENTS_MD_BYTES,
1819
} from "./context-extensions.js";
1920
import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js";
20-
import { resolveModelFamilyPolicy } from "./model-family-policy.js";
2121
import { isCodexProviderName } from "../config/codex-providers.js";
2222
import { shellCollectDefinition } from "./background-shell-tool.js";
2323
import {
@@ -37,8 +37,9 @@ import { webSearchDefinition } from "../tools/web-search.js";
3737
* extensions=[director systemPromptRole] + environment + tools +
3838
* appendix, with the Grok finish-bias note gated by
3939
* shouldApplyGrokAntiThrash (leaves on Grok-family providers only) and the
40-
* family promptResidual (CL-8297 tool budget, grok leaves only) resolved
41-
* from the model family policy.
40+
* family promptResidual (CL-8297 tool budget for grok leaves, XML
41+
* task_guidance block for claude leaves) resolved from the model family
42+
* policy.
4243
*
4344
* The env and provider inputs are pinned here so sizes never drift with the
4445
* machine, date, or checkout — only real prompt changes move the numbers.
@@ -56,9 +57,11 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = {
5657
};
5758

5859
const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" };
60+
// Default-family probe: openai/gpt-4.1 resolves to the default family (the
61+
// gpt row lands later in CL-8310), so the default column carries no residual.
5962
const DEFAULT_PROVIDER = {
60-
providerName: "anthropic",
61-
model: "claude-sonnet-4",
63+
providerName: "openai",
64+
model: "gpt-4.1",
6265
};
6366

6467
/** Families in the size table: default assembly vs Grok (+finish-bias note). */

‎src/agent/prompts.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "bun:test";
22
import {
33
buildChatSystemPrompt,
4+
buildClaudeTaskGuidanceNote,
45
buildGrokLeafAntiThrashNote,
56
buildGuidelines,
67
buildPromptDisciplineBlock,
@@ -396,3 +397,58 @@ describe("promptResidual assembly (CL-8297)", () => {
396397
expect(prompt).not.toContain("Tool budget:");
397398
});
398399
});
400+
401+
describe("claude XML task_guidance residual (provider residual, not a prompt fork)", () => {
402+
it("appends exactly one balanced <task_guidance> block for a claude worker", () => {
403+
const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
404+
orchestrator: false,
405+
promptResidual: buildClaudeTaskGuidanceNote(),
406+
});
407+
expect(countOccurrences(prompt, "<task_guidance>")).toBe(1);
408+
expect(countOccurrences(prompt, "</task_guidance>")).toBe(1);
409+
});
410+
411+
it("is absent without promptResidual — grok, gpt, and orchestrator rows untouched", () => {
412+
for (const opts of [
413+
{ orchestrator: false },
414+
{ orchestrator: false, grokAntiThrash: true },
415+
{ orchestrator: true },
416+
] as const) {
417+
const prompt = buildSubAgentSystemPrompt(
418+
undefined,
419+
undefined,
420+
undefined,
421+
opts,
422+
);
423+
expect(prompt).not.toContain("<task_guidance>");
424+
expect(prompt).not.toContain("</task_guidance>");
425+
}
426+
});
427+
428+
it("emits one block with balanced tags, never a full-prompt XML renderer", () => {
429+
const note = buildClaudeTaskGuidanceNote();
430+
expect(countOccurrences(note, "<task_guidance>")).toBe(1);
431+
expect(countOccurrences(note, "</task_guidance>")).toBe(1);
432+
expect(note).not.toMatch(/<system_prompt>|<prompt>|<identity>/);
433+
});
434+
435+
it("keeps the measured CL-7775 shape: rationale first, numbered approach, named output contract, positively framed", () => {
436+
const lines = buildClaudeTaskGuidanceNote().split("\n");
437+
// Rationale first: the lead line frames the turn before any directive.
438+
expect(lines[1]).toMatch(/^Autonomous coding turn:/);
439+
// Numbered approach, not bullets.
440+
expect(lines.slice(2, 5).map((l) => l.split(".")[0])).toEqual([
441+
"1",
442+
"2",
443+
"3",
444+
]);
445+
// Named output contract.
446+
expect(buildClaudeTaskGuidanceNote()).toContain(
447+
"structured report envelope",
448+
);
449+
// Positive framing: no negative imperatives.
450+
expect(buildClaudeTaskGuidanceNote()).not.toMatch(
451+
/\b(do not|don't|never|stop calling)\b/i,
452+
);
453+
});
454+
});

‎src/agent/prompts.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
buildWorkerContract,
1212
buildWorkerToolNames,
1313
} from "./worker-contract.js";
14-
import { GROK_PROMPT_RESIDUAL } from "./model-family-policy.js";
14+
import {
15+
CLAUDE_TASK_GUIDANCE_NOTE,
16+
GROK_PROMPT_RESIDUAL,
17+
} from "./model-family-policy.js";
1518

1619
// Advertise every gated core tool when the caller has no session-start facts
1720
// (tests, ad-hoc prompt previews) — except wait_agents, which is mount-gated:
@@ -520,6 +523,19 @@ export function buildGrokLeafAntiThrashNote(): string {
520523
return GROK_PROMPT_RESIDUAL;
521524
}
522525

526+
// Single XML residual for Claude-family workers: a prose residual did
527+
// nothing, but one <task_guidance> block cut Sonnet tokens. One block only —
528+
// never a full-prompt XML renderer, never applied outside the claude family.
529+
// Rebuilt end to end from Anthropic's prompting docs (CL-8309): rationale
530+
// first, numbered approach, named output contract; every line is positively
531+
// framed and scope-explicit for Sonnet's literal instruction-following.
532+
// Single source of truth is the CLAUDE_TASK_GUIDANCE_NOTE block in
533+
// model-family-policy.ts (policy owns data); this returns that block verbatim
534+
// so the prompt carries one claude residual with no line twice.
535+
export function buildClaudeTaskGuidanceNote(): string {
536+
return CLAUDE_TASK_GUIDANCE_NOTE;
537+
}
538+
523539
export function buildSubAgentSystemPrompt(
524540
extensions?: string[],
525541
env?: EnvironmentInfo,

‎src/subagent/provider-family.test.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import {
33
detectModelFamily,
4+
isClaudeLeafProvider,
45
isKimiLeafProvider,
56
isXaiGrokLeafProvider,
67
shouldApplyGrokAntiThrash,
@@ -121,7 +122,7 @@ describe("isKimiLeafProvider", () => {
121122
});
122123

123124
describe("detectModelFamily", () => {
124-
test("detects grok, kimi, and default", () => {
125+
test("detects grok, kimi, claude, and default", () => {
125126
expect(
126127
detectModelFamily({ providerName: "xai/default", model: "grok-4.5" }),
127128
).toBe("grok");
@@ -136,6 +137,60 @@ describe("detectModelFamily", () => {
136137
providerName: "anthropic",
137138
model: "claude-sonnet-4",
138139
}),
140+
).toBe("claude");
141+
expect(
142+
detectModelFamily({ providerName: "openai", model: "gpt-4.1" }),
139143
).toBe("default");
140144
});
141145
});
146+
147+
describe("isClaudeLeafProvider", () => {
148+
test("matches anthropic provider names and claude model ids", () => {
149+
expect(isClaudeLeafProvider({ providerName: "anthropic" })).toBe(true);
150+
expect(isClaudeLeafProvider({ providerName: "ANTHROPIC" })).toBe(true);
151+
expect(
152+
isClaudeLeafProvider({
153+
providerName: "openai-compat",
154+
model: "claude-sonnet-4",
155+
}),
156+
).toBe(true);
157+
});
158+
159+
test("rejects grok, gpt, kimi, and muse rows", () => {
160+
expect(
161+
isClaudeLeafProvider({ providerName: "xai/default", model: "grok-4.5" }),
162+
).toBe(false);
163+
expect(
164+
isClaudeLeafProvider({ providerName: "openai", model: "gpt-4.1" }),
165+
).toBe(false);
166+
expect(
167+
isClaudeLeafProvider({ providerName: "codex", model: "gpt-5.1" }),
168+
).toBe(false);
169+
expect(
170+
isClaudeLeafProvider({ providerName: "moonshot", model: "kimi-k2" }),
171+
).toBe(false);
172+
expect(
173+
isClaudeLeafProvider({
174+
providerName: "opencode-go/abklabs",
175+
model: "muse-spark-1.3-contributor",
176+
}),
177+
).toBe(false);
178+
});
179+
});
180+
181+
describe("detectModelFamily claude row", () => {
182+
test("resolves anthropic/claude to the claude family", () => {
183+
expect(
184+
detectModelFamily({
185+
providerName: "anthropic",
186+
model: "claude-sonnet-4",
187+
}),
188+
).toBe("claude");
189+
expect(
190+
detectModelFamily({
191+
providerName: "openai-compat",
192+
model: "claude-opus-4-6",
193+
}),
194+
).toBe("claude");
195+
});
196+
});

0 commit comments

Comments
 (0)