From 91bf1a31cd3f3d3b460169477141d68aeb29c4e7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:13:17 -0700 Subject: [PATCH 01/17] test(prompts): promptResidual appended once for grok leaf --- src/agent/model-family-policy.test.ts | 27 +++++++++++++++++++++++++++ src/agent/prompt-sizes.test.ts | 19 +++++++++++++++++++ src/agent/prompts.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index ed2843717..d59e52c10 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -102,4 +102,31 @@ describe("resolveModelFamilyPolicy", () => { expect(muse.toolDisciplineRules).toContain("Never re-read a file"); expect(base.toolDisciplineRules).toBeUndefined(); }); + + describe("promptResidual (CL-8297)", () => { + test("grok leaf carries the generic 4-line tool-budget residual", () => { + const leaf = resolveModelFamilyPolicy({ + providerName: "xai/default", + model: "grok-4.6", + }); + expect(leaf.family).toBe("grok"); + expect(leaf.promptResidual).toBeDefined(); + expect(leaf.promptResidual!.split("\n")).toHaveLength(4); + expect(leaf.promptResidual).toContain("Tool budget:"); + }); + + test("grok orchestrators and default family carry no residual", () => { + const orchestrator = resolveModelFamilyPolicy({ + providerName: "xai/default", + model: "grok-4.6", + orchestrator: true, + }); + expect(orchestrator.promptResidual).toBeUndefined(); + const base = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + }); + expect(base.promptResidual).toBeUndefined(); + }); + }); }); diff --git a/src/agent/prompt-sizes.test.ts b/src/agent/prompt-sizes.test.ts index deef84e8e..92d2b40e2 100644 --- a/src/agent/prompt-sizes.test.ts +++ b/src/agent/prompt-sizes.test.ts @@ -332,3 +332,22 @@ describe("skywalker grok prefix (infer envelope vs trimmed director)", () => { } }); }); + +describe("grok tool-budget residual (CL-8297)", () => { + const countOccurrences = (haystack: string, needle: string): number => + haystack.split(needle).length - 1; + + test("a grok leaf director prompt contains the tool budget exactly once", () => { + const prompt = assembleDirectorPrompt("builder", "grok"); + expect(countOccurrences(prompt, "Tool budget:")).toBe(1); + }); + + test("default-family and orchestrator prompts carry no tool budget", () => { + expect(assembleDirectorPrompt("builder", "default")).not.toContain( + "Tool budget:", + ); + expect(assembleDirectorPrompt("skywalker", "grok")).not.toContain( + "Tool budget:", + ); + }); +}); diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index bc36b8d0e..2a05ccafc 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -370,3 +370,29 @@ describe("grok finish-bias residual gating (extends existing provider-family tes expect(prompt.toLowerCase()).not.toContain("kimi"); }); }); + +describe("promptResidual assembly (CL-8297)", () => { + const TOOL_BUDGET = + "Tool budget:\n" + + "- Batch independent tool calls into a single turn.\n" + + "- Never re-issue a tool call whose result you already have.\n" + + "- When the next call would only repeat prior work, write the report instead."; + + it("appends promptResidual exactly once at the tail for a grok leaf", () => { + const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + grokAntiThrash: true, + promptResidual: TOOL_BUDGET, + }); + expect(countOccurrences(prompt, TOOL_BUDGET)).toBe(1); + expect(prompt.trimEnd().endsWith(TOOL_BUDGET)).toBe(true); + }); + + it("omits the tool budget when promptResidual is unset", () => { + const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + grokAntiThrash: true, + }); + expect(prompt).not.toContain("Tool budget:"); + }); +}); From d001f4817db7bb6b8c172c3f058a7bcaa01c3e31 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:17:08 -0700 Subject: [PATCH 02/17] feat(prompts): promptResidual with grok tool budget, appended once --- src/agent/model-family-policy.ts | 21 +++++++++++++++++++++ src/agent/prompt-sizes.ts | 7 ++++++- src/agent/prompts.ts | 8 ++++++++ src/subagent/run.ts | 1 + 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index e77057594..916f76488 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -38,6 +38,14 @@ export interface ModelFamilyPolicy { * at the tail so it cannot disturb the cached prompt prefix. */ toolDisciplineRules?: string; + /** + * Provider-family residual appended once to the assembled leaf system + * prompt (CL-8297). Generic tool-budget text today (grok only); the + * ceremony / Claude / GPT seams stay unfilled in sibling lanes. Withheld + * from orchestrators and appended at the tail so it cannot disturb the + * cached prompt prefix. Undefined for families that need none. + */ + promptResidual?: string | undefined; } const DEFAULT_WRAP_UP_NUDGE_TEXT = @@ -66,6 +74,16 @@ const DEFAULT_POLICY: Omit = { advertisedToolDeny: [], }; +// Generic 4-line tool-budget residual (CL-8297). Grok leaves get this via +// promptResidual today; other families leave the seam unfilled until their +// own lanes land. Deliberately free of ceremony lines and family-specific +// routing — pure tool-loop budget. +export const GROK_TOOL_BUDGET_RESIDUAL = + "Tool budget:\n" + + "- Batch independent tool calls into a single turn.\n" + + "- Never re-issue a tool call whose result you already have.\n" + + "- When the next call would only repeat prior work, write the report instead."; + // A directly observed 14-turn pure-tool-call session for this family // previously motivated a tightened nudge/pause pair here (6/10). That pair // was miscalibrated: it fired on a session that was making real progress @@ -83,6 +101,8 @@ const GROK_POLICY: Omit = { applyGrokFinishBias: true, // Leaf value; the resolver clears it for orchestrators below. advertisedToolDeny: ["skill_search"], + // Leaf value; the resolver clears it for orchestrators below. + promptResidual: GROK_TOOL_BUDGET_RESIDUAL, }; // Kimi (Moonshot) detection ships now so callers can branch on family, but @@ -126,6 +146,7 @@ export function resolveModelFamilyPolicy(input: { ...policy, applyGrokFinishBias: policy.applyGrokFinishBias && !orchestrator, advertisedToolDeny: orchestrator ? [] : policy.advertisedToolDeny, + promptResidual: orchestrator ? undefined : policy.promptResidual, }; } case "kimi": diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index a9721a554..cc705a3f2 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -17,6 +17,7 @@ import { MAX_AGENTS_MD_BYTES, } from "./context-extensions.js"; import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js"; +import { resolveModelFamilyPolicy } from "./model-family-policy.js"; import { isCodexProviderName } from "../config/codex-providers.js"; import { shellCollectDefinition } from "./background-shell-tool.js"; import { @@ -35,7 +36,9 @@ import { webSearchDefinition } from "../tools/web-search.js"; * Assembles each director prompt exactly as src/subagent/run.ts does: * extensions=[director systemPromptRole] + environment + tools + * appendix, with the Grok finish-bias note gated by - * shouldApplyGrokAntiThrash (leaves on Grok-family providers only). + * shouldApplyGrokAntiThrash (leaves on Grok-family providers only) and the + * family promptResidual (CL-8297 tool budget, grok leaves only) resolved + * from the model family policy. * * The env and provider inputs are pinned here so sizes never drift with the * machine, date, or checkout — only real prompt changes move the numbers. @@ -156,6 +159,7 @@ export function assembleDirectorPrompt( const pkg = DIRECTOR_REGISTRY[directorId]; const orchestrator = pkg.spawn.maySpawn; const provider = family === "grok" ? GROK_PROVIDER : DEFAULT_PROVIDER; + const policy = resolveModelFamilyPolicy({ ...provider, orchestrator }); return buildSubAgentSystemPrompt( [formatDirectorSystemPrompt(pkg)], CANONICAL_PROMPT_ENV, @@ -164,6 +168,7 @@ export function assembleDirectorPrompt( orchestrator, toolNames: canonicalToolNamesForDirector(pkg, family), grokAntiThrash: shouldApplyGrokAntiThrash({ ...provider, orchestrator }), + promptResidual: policy.promptResidual, }, ); } diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index fab10da33..2022e7cbc 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -530,6 +530,11 @@ export function buildSubAgentSystemPrompt( toolNames?: readonly string[]; /** When true, append the tiny Grok/xAI finish-bias note (provider residual). */ grokAntiThrash?: boolean; + /** + * Family policy residual (CL-8297) appended once at the tail so it + * cannot disturb the cached prompt prefix. Unset for families with none. + */ + promptResidual?: string | undefined; } = {}, ): string { const toolListForPrompt = @@ -556,5 +561,8 @@ export function buildSubAgentSystemPrompt( if (opts.grokAntiThrash === true) { sections.push(buildGrokLeafAntiThrashNote()); } + if (opts.promptResidual !== undefined && opts.promptResidual.length > 0) { + sections.push(opts.promptResidual); + } return joinSections(sections); } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 5373793f2..48db889ff 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -1055,6 +1055,7 @@ async function runSubAgentInner( model: params.provider.model, orchestrator: params.orchestrator === true, }), + promptResidual: modelFamilyPolicy.promptResidual, }, ); From 0bac9ea249f7cbdf6423c82b4c7ffac4b81d2b9a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:26:57 -0700 Subject: [PATCH 03/17] style(tests): narrow promptResidual instead of non-null assertion --- src/agent/model-family-policy.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index d59e52c10..8a5d8a552 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -111,7 +111,8 @@ describe("resolveModelFamilyPolicy", () => { }); expect(leaf.family).toBe("grok"); expect(leaf.promptResidual).toBeDefined(); - expect(leaf.promptResidual!.split("\n")).toHaveLength(4); + if (!leaf.promptResidual) throw new Error("expected promptResidual to be defined"); + expect(leaf.promptResidual.split("\n")).toHaveLength(4); expect(leaf.promptResidual).toContain("Tool budget:"); }); From eaf7ca9d9650bc5f877304eb932c630f2e5d849d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:29:38 -0700 Subject: [PATCH 04/17] style(tests): oxfmt model-family-policy guard --- src/agent/model-family-policy.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index 8a5d8a552..0271bf360 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -111,7 +111,8 @@ describe("resolveModelFamilyPolicy", () => { }); expect(leaf.family).toBe("grok"); expect(leaf.promptResidual).toBeDefined(); - if (!leaf.promptResidual) throw new Error("expected promptResidual to be defined"); + if (!leaf.promptResidual) + throw new Error("expected promptResidual to be defined"); expect(leaf.promptResidual.split("\n")).toHaveLength(4); expect(leaf.promptResidual).toContain("Tool budget:"); }); From 29cb5578fb600bd51347827f339bf5176e6ae46e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:15:15 -0700 Subject: [PATCH 05/17] test(agent): red test for single merged grok residual with ceremony lines --- src/agent/grok-residual.test.ts | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/agent/grok-residual.test.ts diff --git a/src/agent/grok-residual.test.ts b/src/agent/grok-residual.test.ts new file mode 100644 index 000000000..336dfd2b0 --- /dev/null +++ b/src/agent/grok-residual.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { promptResidual } from "./model-family-policy.js"; +import { + buildGrokLeafAntiThrashNote, + buildSubAgentSystemPrompt, +} from "./prompts.js"; + +// The three Grok ceremony lines (CL-7768 Design, merged by CL-8296): no git, +// no pre-plan, verify once. +const CEREMONY_LINES = [ + "- Never run git add, git commit, git stash, or any other state-changing git command unless the user asks.", + "- Do not narrate a plan before acting on a small task; act, then report.", + "- Verify with the test command once at the end, not after every edit.", +] as const; + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +describe("grok ceremony merge (CL-8296)", () => { + it("exposes a single grok residual with each ceremony line exactly once", () => { + const residual = promptResidual("grok"); + expect(residual).toContain("Finish bias (xAI / Grok worker):"); + for (const line of CEREMONY_LINES) { + expect(countOccurrences(residual, line)).toBe(1); + } + }); + + it("keeps the don't re-read line exactly once — no duplicate", () => { + const residual = promptResidual("grok"); + expect(countOccurrences(residual, "re-open paths you already read")).toBe( + 1, + ); + }); + + it("is grok-only: every other family resolves to an empty residual", () => { + expect(promptResidual("default")).toBe(""); + expect(promptResidual("kimi")).toBe(""); + expect(promptResidual("muse")).toBe(""); + }); + + it("buildGrokLeafAntiThrashNote is the same single residual (one source of truth)", () => { + expect(buildGrokLeafAntiThrashNote()).toBe(promptResidual("grok")); + }); + + it("the assembled grok worker prompt carries the merged residual exactly once", () => { + const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + grokAntiThrash: true, + }); + expect( + countOccurrences(prompt, "Finish bias (xAI / Grok worker):"), + ).toBe(1); + for (const line of CEREMONY_LINES) { + expect(countOccurrences(prompt, line)).toBe(1); + } + }); +}); From d9fd440d88a3dab0a4bf92f2d66b6e229955fc99 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:17:01 -0700 Subject: [PATCH 06/17] feat(agent): merge grok ceremony lines into the one residual --- src/agent/model-family-policy.ts | 28 ++++++++++++++++++++++++++++ src/agent/prompts.ts | 13 ++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 916f76488..cdf9411d5 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -129,6 +129,34 @@ const MUSE_POLICY: Omit = { toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES, }; +// Single grok prompt residual (CL-8296): the finish-bias bullets plus the +// three ceremony lines from the CL-7768 design (no git, no pre-plan, verify +// once), merged into one block with no line twice. The don't re-read idea +// appears exactly once (the "re-open paths" bullet) — it is not repeated. +// Grok-only: detectModelFamily has no glm family, so per the <30min rule no +// GLM row ships here. The text lives here once; buildGrokLeafAntiThrashNote +// (prompts.ts) returns it verbatim, so the prompt carries exactly one grok +// residual and the sibling CL-8297 hook resolves to the same block. +const GROK_PROMPT_RESIDUAL = [ + "Finish bias (xAI / Grok worker):", + "- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.", + "- If the next call would only re-open paths you already read, write the report instead.", + "- When the dispatch brief's done-definition is met, write the report envelope instead of making one more search or micro-edit.", + "- Route file and web work through the dedicated tools, never run_shell — mining showed grok reaching for shell first when a typed tool already covered the job.", + "- Never run git add, git commit, git stash, or any other state-changing git command unless the user asks.", + "- Do not narrate a plan before acting on a small task; act, then report.", + "- Verify with the test command once at the end, not after every edit.", +].join("\n"); + +/** + * Minimal per-family prompt residual hook. The sibling CL-8297 owns the + * canonical hook; this local copy keeps the branch self-contained so tests + * pass standalone — the two reconcile when CL-8297 lands. + */ +export function promptResidual(family: ModelFamily): string { + return family === "grok" ? GROK_PROMPT_RESIDUAL : ""; +} + export function resolveModelFamilyPolicy(input: { providerName: string; model?: string; diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 2022e7cbc..fcd1ec241 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -11,6 +11,7 @@ import { buildWorkerContract, buildWorkerToolNames, } from "./worker-contract.js"; +import { promptResidual } from "./model-family-policy.js"; // Advertise every gated core tool when the caller has no session-start facts // (tests, ad-hoc prompt previews) — except wait_agents, which is mount-gated: @@ -511,14 +512,12 @@ export function buildSubAgentReportContract( // Tiny residual for Grok/xAI workers: mining showed higher tools-only thrash // than Codex on the same harness. Shared thrash harness + spawn contracts do // the structural work; this is only a finish-bias nudge, not a full rewrite. +// Single source of truth is the promptResidual("grok") block in +// model-family-policy.ts (CL-8296 merged the three ceremony lines into it); +// this composes that block verbatim so the prompt carries one grok residual +// with no line twice. export function buildGrokLeafAntiThrashNote(): string { - return [ - "Finish bias (xAI / Grok worker):", - "- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.", - "- If the next call would only re-open paths you already read, write the report instead.", - "- When the dispatch brief's done-definition is met, write the report envelope instead of making one more search or micro-edit.", - "- Route file and web work through the dedicated tools, never run_shell — mining showed grok reaching for shell first when a typed tool already covered the job.", - ].join("\n"); + return promptResidual("grok"); } export function buildSubAgentSystemPrompt( From 2350730f1785e364342fdce8a7a80be976c5e931 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:26:49 -0700 Subject: [PATCH 07/17] style(agent): oxfmt fix-up for grok-residual test --- src/agent/grok-residual.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent/grok-residual.test.ts b/src/agent/grok-residual.test.ts index 336dfd2b0..afacba997 100644 --- a/src/agent/grok-residual.test.ts +++ b/src/agent/grok-residual.test.ts @@ -48,9 +48,9 @@ describe("grok ceremony merge (CL-8296)", () => { orchestrator: false, grokAntiThrash: true, }); - expect( - countOccurrences(prompt, "Finish bias (xAI / Grok worker):"), - ).toBe(1); + expect(countOccurrences(prompt, "Finish bias (xAI / Grok worker):")).toBe( + 1, + ); for (const line of CEREMONY_LINES) { expect(countOccurrences(prompt, line)).toBe(1); } From d9be39ac1584816953fb944354737e9c7ca61545 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 19:55:18 -0700 Subject: [PATCH 08/17] fix(agent): single-source grok residual, drop duplicate promptResidual seam --- src/agent/grok-residual.test.ts | 46 +++++++++++++++++++++++--------- src/agent/model-family-policy.ts | 29 ++++++++------------ src/agent/prompts.ts | 8 +++--- 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/src/agent/grok-residual.test.ts b/src/agent/grok-residual.test.ts index afacba997..9a50612e6 100644 --- a/src/agent/grok-residual.test.ts +++ b/src/agent/grok-residual.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { promptResidual } from "./model-family-policy.js"; +import { GROK_PROMPT_RESIDUAL } from "./model-family-policy.js"; import { buildGrokLeafAntiThrashNote, buildSubAgentSystemPrompt, } from "./prompts.js"; +import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js"; // The three Grok ceremony lines (CL-7768 Design, merged by CL-8296): no git, // no pre-plan, verify once. @@ -19,28 +20,47 @@ function countOccurrences(haystack: string, needle: string): number { describe("grok ceremony merge (CL-8296)", () => { it("exposes a single grok residual with each ceremony line exactly once", () => { - const residual = promptResidual("grok"); - expect(residual).toContain("Finish bias (xAI / Grok worker):"); + expect(GROK_PROMPT_RESIDUAL).toContain("Finish bias (xAI / Grok worker):"); for (const line of CEREMONY_LINES) { - expect(countOccurrences(residual, line)).toBe(1); + expect(countOccurrences(GROK_PROMPT_RESIDUAL, line)).toBe(1); } }); it("keeps the don't re-read line exactly once — no duplicate", () => { - const residual = promptResidual("grok"); - expect(countOccurrences(residual, "re-open paths you already read")).toBe( - 1, - ); + expect( + countOccurrences(GROK_PROMPT_RESIDUAL, "re-open paths you already read"), + ).toBe(1); }); - it("is grok-only: every other family resolves to an empty residual", () => { - expect(promptResidual("default")).toBe(""); - expect(promptResidual("kimi")).toBe(""); - expect(promptResidual("muse")).toBe(""); + it("is grok-only: the finish-bias gate fires for grok leaves alone", () => { + expect( + shouldApplyGrokAntiThrash({ + providerName: "xai/default", + model: "grok-4.6", + orchestrator: false, + }), + ).toBe(true); + for (const input of [ + { providerName: "anthropic", model: "claude-sonnet-4" }, + { providerName: "moonshot", model: "kimi-k2" }, + { providerName: "opencode-go", model: "muse-spark-1.3-contributor" }, + { providerName: "openai", model: "gpt-4.1" }, + ] as const) { + expect(shouldApplyGrokAntiThrash({ ...input, orchestrator: false })).toBe( + false, + ); + } + expect( + shouldApplyGrokAntiThrash({ + providerName: "xai/default", + model: "grok-4.6", + orchestrator: true, + }), + ).toBe(false); }); it("buildGrokLeafAntiThrashNote is the same single residual (one source of truth)", () => { - expect(buildGrokLeafAntiThrashNote()).toBe(promptResidual("grok")); + expect(buildGrokLeafAntiThrashNote()).toBe(GROK_PROMPT_RESIDUAL); }); it("the assembled grok worker prompt carries the merged residual exactly once", () => { diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index cdf9411d5..a3bd854f0 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -129,15 +129,17 @@ const MUSE_POLICY: Omit = { toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES, }; -// Single grok prompt residual (CL-8296): the finish-bias bullets plus the -// three ceremony lines from the CL-7768 design (no git, no pre-plan, verify -// once), merged into one block with no line twice. The don't re-read idea -// appears exactly once (the "re-open paths" bullet) — it is not repeated. -// Grok-only: detectModelFamily has no glm family, so per the <30min rule no -// GLM row ships here. The text lives here once; buildGrokLeafAntiThrashNote -// (prompts.ts) returns it verbatim, so the prompt carries exactly one grok -// residual and the sibling CL-8297 hook resolves to the same block. -const GROK_PROMPT_RESIDUAL = [ +// Single grok finish-bias + ceremony residual (CL-8296): the finish-bias +// bullets plus the three ceremony lines from the CL-7768 design (no git, no +// pre-plan, verify once), merged into one block with no line twice. The don't +// re-read idea appears exactly once (the "re-open paths" bullet) — it is not +// repeated. Grok-only: detectModelFamily has no glm family, so per the <30min +// rule no GLM row ships here. The text lives here once; exported for +// buildGrokLeafAntiThrashNote (prompts.ts), which returns it verbatim, so the +// prompt carries exactly one copy. This is a different block from the CL-8297 +// tool-budget hook (GROK_TOOL_BUDGET_RESIDUAL, surfaced via the +// ModelFamilyPolicy.promptResidual field): grok leaves carry both, each once. +export const GROK_PROMPT_RESIDUAL = [ "Finish bias (xAI / Grok worker):", "- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.", "- If the next call would only re-open paths you already read, write the report instead.", @@ -148,15 +150,6 @@ const GROK_PROMPT_RESIDUAL = [ "- Verify with the test command once at the end, not after every edit.", ].join("\n"); -/** - * Minimal per-family prompt residual hook. The sibling CL-8297 owns the - * canonical hook; this local copy keeps the branch self-contained so tests - * pass standalone — the two reconcile when CL-8297 lands. - */ -export function promptResidual(family: ModelFamily): string { - return family === "grok" ? GROK_PROMPT_RESIDUAL : ""; -} - export function resolveModelFamilyPolicy(input: { providerName: string; model?: string; diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index fcd1ec241..8f7402261 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -11,7 +11,7 @@ import { buildWorkerContract, buildWorkerToolNames, } from "./worker-contract.js"; -import { promptResidual } from "./model-family-policy.js"; +import { GROK_PROMPT_RESIDUAL } from "./model-family-policy.js"; // Advertise every gated core tool when the caller has no session-start facts // (tests, ad-hoc prompt previews) — except wait_agents, which is mount-gated: @@ -512,12 +512,12 @@ export function buildSubAgentReportContract( // Tiny residual for Grok/xAI workers: mining showed higher tools-only thrash // than Codex on the same harness. Shared thrash harness + spawn contracts do // the structural work; this is only a finish-bias nudge, not a full rewrite. -// Single source of truth is the promptResidual("grok") block in +// Single source of truth is the GROK_PROMPT_RESIDUAL block in // model-family-policy.ts (CL-8296 merged the three ceremony lines into it); -// this composes that block verbatim so the prompt carries one grok residual +// this returns that block verbatim so the prompt carries one grok residual // with no line twice. export function buildGrokLeafAntiThrashNote(): string { - return promptResidual("grok"); + return GROK_PROMPT_RESIDUAL; } export function buildSubAgentSystemPrompt( From c79522f1676a508b49cae5e219299532d695455c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:17:22 -0700 Subject: [PATCH 09/17] test(agents): cover claude xml task guidance residual --- src/agent/model-family-policy.test.ts | 42 ++++++++++++++++++++++ src/agent/prompts.test.ts | 36 +++++++++++++++++++ src/subagent/provider-family.test.ts | 52 +++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index 0271bf360..b0b9e032b 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -131,4 +131,46 @@ describe("resolveModelFamilyPolicy", () => { expect(base.promptResidual).toBeUndefined(); }); }); + + test("claude leaves carry the XML task_guidance residual; orchestrators do not", () => { + const leaf = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + orchestrator: false, + }); + expect(leaf.family).toBe("claude"); + expect(leaf.promptResidual).toContain(""); + expect(leaf.promptResidual).toContain(""); + const orchestrator = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + orchestrator: true, + }); + expect(orchestrator.promptResidual).toBeUndefined(); + }); + + // The gpt family row has NOT landed yet (#1135): openai/gpt-4.1 and + // codex/gpt-5.1 are default-family probes here, asserting they resolve to + // the default family with no residual. Grok keeps its CL-8297 tool-budget + // residual — the "no residual" claim below is default-family-only. + test("gpt probes resolve to default with no residual; grok keeps its tool budget", () => { + for (const input of [ + { providerName: "openai", model: "gpt-4.1" }, + { providerName: "codex", model: "gpt-5.1" }, + ] as const) { + const policy = resolveModelFamilyPolicy({ + ...input, + orchestrator: false, + }); + expect(policy.family).toBe("default"); + expect(policy.promptResidual).toBeUndefined(); + } + const grok = resolveModelFamilyPolicy({ + providerName: "xai/default", + model: "grok-4.6", + orchestrator: false, + }); + expect(grok.family).toBe("grok"); + expect(grok.promptResidual).toContain("Tool budget:"); + }); }); diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 2a05ccafc..73038a8a7 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { buildChatSystemPrompt, + buildClaudeTaskGuidanceNote, buildGrokLeafAntiThrashNote, buildGuidelines, buildPromptDisciplineBlock, @@ -396,3 +397,38 @@ describe("promptResidual assembly (CL-8297)", () => { expect(prompt).not.toContain("Tool budget:"); }); }); + +describe("claude XML task_guidance residual (provider residual, not a prompt fork)", () => { + it("appends exactly one balanced block for a claude worker", () => { + const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + promptResidual: buildClaudeTaskGuidanceNote(), + }); + expect(countOccurrences(prompt, "")).toBe(1); + expect(countOccurrences(prompt, "")).toBe(1); + }); + + it("is absent without promptResidual — grok, gpt, and orchestrator rows untouched", () => { + for (const opts of [ + { orchestrator: false }, + { orchestrator: false, grokAntiThrash: true }, + { orchestrator: true }, + ] as const) { + const prompt = buildSubAgentSystemPrompt( + undefined, + undefined, + undefined, + opts, + ); + expect(prompt).not.toContain(""); + expect(prompt).not.toContain(""); + } + }); + + it("emits one block with balanced tags, never a full-prompt XML renderer", () => { + const note = buildClaudeTaskGuidanceNote(); + expect(countOccurrences(note, "")).toBe(1); + expect(countOccurrences(note, "")).toBe(1); + expect(note).not.toMatch(/||/); + }); +}); diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index 19b7bfc42..e350fc129 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { detectModelFamily, + isClaudeLeafProvider, isKimiLeafProvider, isXaiGrokLeafProvider, shouldApplyGrokAntiThrash, @@ -139,3 +140,54 @@ describe("detectModelFamily", () => { ).toBe("default"); }); }); + +describe("isClaudeLeafProvider", () => { + test("matches anthropic provider names and claude model ids", () => { + expect(isClaudeLeafProvider({ providerName: "anthropic" })).toBe(true); + expect(isClaudeLeafProvider({ providerName: "ANTHROPIC" })).toBe(true); + expect( + isClaudeLeafProvider({ + providerName: "openai-compat", + model: "claude-sonnet-4", + }), + ).toBe(true); + }); + + test("rejects grok, gpt, kimi, and muse rows", () => { + expect( + isClaudeLeafProvider({ providerName: "xai/default", model: "grok-4.5" }), + ).toBe(false); + expect( + isClaudeLeafProvider({ providerName: "openai", model: "gpt-4.1" }), + ).toBe(false); + expect( + isClaudeLeafProvider({ providerName: "codex", model: "gpt-5.1" }), + ).toBe(false); + expect( + isClaudeLeafProvider({ providerName: "moonshot", model: "kimi-k2" }), + ).toBe(false); + expect( + isClaudeLeafProvider({ + providerName: "opencode-go/abklabs", + model: "muse-spark-1.3-contributor", + }), + ).toBe(false); + }); +}); + +describe("detectModelFamily claude row", () => { + test("resolves anthropic/claude to the claude family", () => { + expect( + detectModelFamily({ + providerName: "anthropic", + model: "claude-sonnet-4", + }), + ).toBe("claude"); + expect( + detectModelFamily({ + providerName: "openai-compat", + model: "claude-opus-4-6", + }), + ).toBe("claude"); + }); +}); From 3a62572b6e84131054f53ae6ec2e0b13424f20b4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:25:10 -0700 Subject: [PATCH 10/17] feat(agents): claude xml task_guidance prompt residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-family leaf workers get one XML block appended once at the tail of the system prompt via the ModelFamilyPolicy promptResidual seam. A prose residual did nothing; one XML block cut Sonnet tokens. Balanced tags, leaf-only, never applied to grok/gpt — and never a full-prompt XML renderer. --- src/agent/model-family-policy.test.ts | 9 ++++---- src/agent/model-family-policy.ts | 33 +++++++++++++++++++++++---- src/agent/prompt-sizes.ts | 7 +++--- src/agent/prompts.ts | 16 ++++++++++++- src/subagent/provider-family.test.ts | 5 +++- src/subagent/provider-family.ts | 15 +++++++++++- 6 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index b0b9e032b..cb191c32b 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -4,8 +4,8 @@ import { resolveModelFamilyPolicy } from "./model-family-policy.js"; describe("resolveModelFamilyPolicy", () => { test("defaults are permissive for an unrecognized provider", () => { const policy = resolveModelFamilyPolicy({ - providerName: "anthropic", - model: "claude-sonnet-4", + providerName: "openai", + model: "gpt-4.1", }); expect(policy.family).toBe("default"); expect(policy.applyGrokFinishBias).toBe(false); @@ -55,8 +55,8 @@ describe("resolveModelFamilyPolicy", () => { test("advertisedToolDeny is empty by default and never contains use_skill", () => { const leaf = resolveModelFamilyPolicy({ - providerName: "anthropic", - model: "claude-opus-4-6", + providerName: "openai", + model: "gpt-4.1", orchestrator: false, }); expect(leaf.advertisedToolDeny).toEqual([]); @@ -141,6 +141,7 @@ describe("resolveModelFamilyPolicy", () => { expect(leaf.family).toBe("claude"); expect(leaf.promptResidual).toContain(""); expect(leaf.promptResidual).toContain(""); + expect(leaf.advertisedToolDeny).toEqual([]); const orchestrator = resolveModelFamilyPolicy({ providerName: "anthropic", model: "claude-sonnet-4", diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index a3bd854f0..592a31168 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -40,10 +40,10 @@ export interface ModelFamilyPolicy { toolDisciplineRules?: string; /** * Provider-family residual appended once to the assembled leaf system - * prompt (CL-8297). Generic tool-budget text today (grok only); the - * ceremony / Claude / GPT seams stay unfilled in sibling lanes. Withheld - * from orchestrators and appended at the tail so it cannot disturb the - * cached prompt prefix. Undefined for families that need none. + * prompt (CL-8297). Tool-budget text for grok, the XML task_guidance block + * for claude; the GPT seam stays unfilled until its lane lands (#1135). + * Withheld from orchestrators and appended at the tail so it cannot + * disturb the cached prompt prefix. Undefined for families that need none. */ promptResidual?: string | undefined; } @@ -66,6 +66,7 @@ const GROK_WRAP_UP_NUDGE_TEXT = // sits comfortably above the observed healthy ceiling; the nudge is a // check-in, not a stop, so erring high costs nothing. Tightened only for // families with observed runaway tool-only behavior (see grok below). +/** Default policy: permissive, no finish bias, no prompt residual. */ const DEFAULT_POLICY: Omit = { toolOnlyTurnNudgeAt: 25, wrapUpNudgeText: DEFAULT_WRAP_UP_NUDGE_TEXT, @@ -149,6 +150,23 @@ export const GROK_PROMPT_RESIDUAL = [ "- Do not narrate a plan before acting on a small task; act, then report.", "- Verify with the test command once at the end, not after every edit.", ].join("\n"); +// Claude (Anthropic) ships one XML residual, not prose: a prose residual did +// nothing, but a single block cut Sonnet tokens. The block is +// the whole residual — never a full-prompt XML renderer. The text lives here +// (policy owns data); buildClaudeTaskGuidanceNote (prompts.ts) returns it +// verbatim so the prompt carries exactly one copy. +export const CLAUDE_TASK_GUIDANCE_NOTE = [ + "", + "- Follow the dispatch brief exactly; its Success criteria are the done-definition.", + "- When the done-definition is met, stop calling tools and write the structured report envelope.", + "- Batch independent tool calls into a single turn; never re-read a file you already read this session.", + "", +].join("\n"); + +const CLAUDE_POLICY: Omit = { + ...DEFAULT_POLICY, + promptResidual: CLAUDE_TASK_GUIDANCE_NOTE, +}; export function resolveModelFamilyPolicy(input: { providerName: string; @@ -178,6 +196,13 @@ export function resolveModelFamilyPolicy(input: { }; case "muse": return { family, ...MUSE_POLICY }; + case "claude": + // Like the grok finish-bias residual, the task_guidance block only makes + // sense on leaf workers — orchestrators dispatch rather than doing the + // work directly, so they resolve to the permissive default (no residual). + return orchestrator + ? { family, ...DEFAULT_POLICY } + : { family, ...CLAUDE_POLICY }; default: return { family: "default", ...DEFAULT_POLICY }; } diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index cc705a3f2..d8ee88a2b 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -12,6 +12,7 @@ import { type DirectorPackage, } from "./directors/types.js"; import { buildChatSystemPrompt, buildSubAgentSystemPrompt } from "./prompts.js"; +import { resolveModelFamilyPolicy } from "./model-family-policy.js"; import { formatAgentsMdExtension, MAX_AGENTS_MD_BYTES, @@ -37,8 +38,9 @@ import { webSearchDefinition } from "../tools/web-search.js"; * extensions=[director systemPromptRole] + environment + tools + * appendix, with the Grok finish-bias note gated by * shouldApplyGrokAntiThrash (leaves on Grok-family providers only) and the - * family promptResidual (CL-8297 tool budget, grok leaves only) resolved - * from the model family policy. + * family promptResidual (CL-8297 tool budget for grok leaves, XML + * task_guidance block for claude leaves) resolved from the model family + * policy. * * The env and provider inputs are pinned here so sizes never drift with the * machine, date, or checkout — only real prompt changes move the numbers. @@ -172,7 +174,6 @@ export function assembleDirectorPrompt( }, ); } - /** * Skywalker primary infer envelope: the chat system prompt plus the * AGENTS.md extension. Family-agnostic — Grok does not substitute the diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 8f7402261..d4a5275fe 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -11,7 +11,10 @@ import { buildWorkerContract, buildWorkerToolNames, } from "./worker-contract.js"; -import { GROK_PROMPT_RESIDUAL } from "./model-family-policy.js"; +import { + CLAUDE_TASK_GUIDANCE_NOTE, + GROK_PROMPT_RESIDUAL, +} from "./model-family-policy.js"; // Advertise every gated core tool when the caller has no session-start facts // (tests, ad-hoc prompt previews) — except wait_agents, which is mount-gated: @@ -520,6 +523,16 @@ export function buildGrokLeafAntiThrashNote(): string { return GROK_PROMPT_RESIDUAL; } +// Single XML residual for Claude-family workers: a prose residual did +// nothing, but one block cut Sonnet tokens. One block only — +// never a full-prompt XML renderer, never applied outside the claude family. +// Single source of truth is the CLAUDE_TASK_GUIDANCE_NOTE block in +// model-family-policy.ts (policy owns data); this returns that block verbatim +// so the prompt carries one claude residual with no line twice. +export function buildClaudeTaskGuidanceNote(): string { + return CLAUDE_TASK_GUIDANCE_NOTE; +} + export function buildSubAgentSystemPrompt( extensions?: string[], env?: EnvironmentInfo, @@ -565,3 +578,4 @@ export function buildSubAgentSystemPrompt( } return joinSections(sections); } +} diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index e350fc129..dc88a4409 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -122,7 +122,7 @@ describe("isKimiLeafProvider", () => { }); describe("detectModelFamily", () => { - test("detects grok, kimi, and default", () => { + test("detects grok, kimi, claude, and default", () => { expect( detectModelFamily({ providerName: "xai/default", model: "grok-4.5" }), ).toBe("grok"); @@ -137,6 +137,9 @@ describe("detectModelFamily", () => { providerName: "anthropic", model: "claude-sonnet-4", }), + ).toBe("claude"); + expect( + detectModelFamily({ providerName: "openai", model: "gpt-4.1" }), ).toBe("default"); }); }); diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 6165fabf2..b42a5aebd 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -37,8 +37,20 @@ export function isMuseSparkLeafProvider(input: { return input.model !== undefined && /^muse-spark/i.test(input.model.trim()); } +/** True when the provider/model is Anthropic's Claude family. */ +export function isClaudeLeafProvider(input: { + providerName: string; + model?: string; +}): boolean { + const name = input.providerName.toLowerCase(); + if (name.includes("anthropic") || name.includes("claude")) return true; + if (input.model !== undefined && /^claude/i.test(input.model.trim())) + return true; + return false; +} + /** Model families the shared directors branch on via ModelFamilyPolicy. */ -export type ModelFamily = "grok" | "kimi" | "muse" | "default"; +export type ModelFamily = "grok" | "kimi" | "muse" | "claude" | "default"; /** * Resolves a provider/model to a ModelFamily. Generalizes @@ -53,6 +65,7 @@ export function detectModelFamily(input: { if (isXaiGrokLeafProvider(input)) return "grok"; if (isKimiLeafProvider(input)) return "kimi"; if (isMuseSparkLeafProvider(input)) return "muse"; + if (isClaudeLeafProvider(input)) return "claude"; return "default"; } From 72efaaa5261213d3872b2093dc037d81540789d8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 19 Sep 2026 07:56:10 -0700 Subject: [PATCH 11/17] feat(agents): rebuild claude task_guidance block from prompting docs Rebuild the Claude XML residual end to end from Anthropic's prompting docs while keeping CL-7775's measured shape: rationale first, numbered approach, named output contract, one wrapper, leaf-only tail placement. Every line is positively framed and scope-explicit for Sonnet's literal instruction-following; per-line provenance in the PR body. --- src/agent/model-family-policy.test.ts | 8 ++++++-- src/agent/model-family-policy.ts | 7 ++++--- src/agent/prompt-sizes.ts | 1 - src/agent/prompts.test.ts | 20 ++++++++++++++++++++ src/agent/prompts.ts | 4 +++- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index cb191c32b..469eecf0d 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -124,10 +124,14 @@ describe("resolveModelFamilyPolicy", () => { orchestrator: true, }); expect(orchestrator.promptResidual).toBeUndefined(); + // Default-family probe: anthropic/claude-sonnet-4 would hit the claude + // row now, and the gpt row has NOT landed yet (#1135), so openai/gpt-4.1 + // is the probe that still resolves to the default family. const base = resolveModelFamilyPolicy({ - providerName: "anthropic", - model: "claude-sonnet-4", + providerName: "openai", + model: "gpt-4.1", }); + expect(base.family).toBe("default"); expect(base.promptResidual).toBeUndefined(); }); }); diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 592a31168..08df66450 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -157,9 +157,10 @@ export const GROK_PROMPT_RESIDUAL = [ // verbatim so the prompt carries exactly one copy. export const CLAUDE_TASK_GUIDANCE_NOTE = [ "", - "- Follow the dispatch brief exactly; its Success criteria are the done-definition.", - "- When the done-definition is met, stop calling tools and write the structured report envelope.", - "- Batch independent tool calls into a single turn; never re-read a file you already read this session.", + "Autonomous coding turn: finish the work in this turn on your best judgment.", + "1. Follow the dispatch brief exactly; its Success criteria are the done-definition.", + "2. Batch independent tool calls into a single turn; work from files already read this session.", + "3. Finish the task when the done-definition is met: prefer the structured report envelope over another tool call.", "", ].join("\n"); diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index d8ee88a2b..e8a2b33d8 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -18,7 +18,6 @@ import { MAX_AGENTS_MD_BYTES, } from "./context-extensions.js"; import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js"; -import { resolveModelFamilyPolicy } from "./model-family-policy.js"; import { isCodexProviderName } from "../config/codex-providers.js"; import { shellCollectDefinition } from "./background-shell-tool.js"; import { diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 73038a8a7..ca380922a 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -431,4 +431,24 @@ describe("claude XML task_guidance residual (provider residual, not a prompt for expect(countOccurrences(note, "")).toBe(1); expect(note).not.toMatch(/||/); }); + + it("keeps the measured CL-7775 shape: rationale first, numbered approach, named output contract, positively framed", () => { + const lines = buildClaudeTaskGuidanceNote().split("\n"); + // Rationale first: the lead line frames the turn before any directive. + expect(lines[1]).toMatch(/^Autonomous coding turn:/); + // Numbered approach, not bullets. + expect(lines.slice(2, 5).map((l) => l.split(".")[0])).toEqual([ + "1", + "2", + "3", + ]); + // Named output contract. + expect(buildClaudeTaskGuidanceNote()).toContain( + "structured report envelope", + ); + // Positive framing: no negative imperatives. + expect(buildClaudeTaskGuidanceNote()).not.toMatch( + /\b(do not|don't|never|stop calling)\b/i, + ); + }); }); diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index d4a5275fe..f7b742841 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -526,6 +526,9 @@ export function buildGrokLeafAntiThrashNote(): string { // Single XML residual for Claude-family workers: a prose residual did // nothing, but one block cut Sonnet tokens. One block only — // never a full-prompt XML renderer, never applied outside the claude family. +// Rebuilt end to end from Anthropic's prompting docs (CL-8309): rationale +// first, numbered approach, named output contract; every line is positively +// framed and scope-explicit for Sonnet's literal instruction-following. // Single source of truth is the CLAUDE_TASK_GUIDANCE_NOTE block in // model-family-policy.ts (policy owns data); this returns that block verbatim // so the prompt carries one claude residual with no line twice. @@ -578,4 +581,3 @@ export function buildSubAgentSystemPrompt( } return joinSections(sections); } -} From 0d135956959497c150fbc5b7efd3513bd3e0b436 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 20:06:31 -0700 Subject: [PATCH 12/17] style(prompts): restore blank line before Skywalker envelope comment --- src/agent/prompt-sizes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index e8a2b33d8..c56f34699 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -173,6 +173,7 @@ export function assembleDirectorPrompt( }, ); } + /** * Skywalker primary infer envelope: the chat system prompt plus the * AGENTS.md extension. Family-agnostic — Grok does not substitute the From cd4fa7a6cd9b33e502f7361c72e2162d858565ba Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 20:44:14 -0700 Subject: [PATCH 13/17] fix(agents): default size probe stays residual-free after claude row The anthropic/claude-sonnet-4 fixture now resolves to the claude family, so the default column silently measured the task_guidance block. Probe openai/gpt-4.1 instead (default family until the CL-8310 gpt row lands) and assert the default column carries no family residual. --- src/agent/prompt-sizes.test.ts | 9 ++++++--- src/agent/prompt-sizes.ts | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/agent/prompt-sizes.test.ts b/src/agent/prompt-sizes.test.ts index 92d2b40e2..a0658fb69 100644 --- a/src/agent/prompt-sizes.test.ts +++ b/src/agent/prompt-sizes.test.ts @@ -343,9 +343,12 @@ describe("grok tool-budget residual (CL-8297)", () => { }); test("default-family and orchestrator prompts carry no tool budget", () => { - expect(assembleDirectorPrompt("builder", "default")).not.toContain( - "Tool budget:", - ); + const defaultPrompt = assembleDirectorPrompt("builder", "default"); + expect(defaultPrompt).not.toContain("Tool budget:"); + // The default probe (openai/gpt-4.1) resolves to the default family, so + // the default column carries no family residual at all — not the claude + // task_guidance block either. + expect(defaultPrompt).not.toContain(""); expect(assembleDirectorPrompt("skywalker", "grok")).not.toContain( "Tool budget:", ); diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index c56f34699..2154e29eb 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -57,9 +57,11 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = { }; const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" }; +// Default-family probe: openai/gpt-4.1 resolves to the default family (the +// gpt row lands later in CL-8310), so the default column carries no residual. const DEFAULT_PROVIDER = { - providerName: "anthropic", - model: "claude-sonnet-4", + providerName: "openai", + model: "gpt-4.1", }; /** Families in the size table: default assembly vs Grok (+finish-bias note). */ From b2a16ea55c00f9b31b39a1100109834ace17a404 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:15:54 -0700 Subject: [PATCH 14/17] test(cl-8310): failing gpt narrate-before-tools residual tests --- src/agent/model-family-policy.test.ts | 20 +++++++ src/agent/prompts.test.ts | 78 +++++++++++++++++++++++++++ src/subagent/provider-family.test.ts | 61 +++++++++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index 469eecf0d..df8359c95 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -178,4 +178,24 @@ describe("resolveModelFamilyPolicy", () => { expect(grok.family).toBe("grok"); expect(grok.promptResidual).toContain("Tool budget:"); }); + test("gpt resolves its own family on permissive default thresholds (CL-8310)", () => { + const gpt = resolveModelFamilyPolicy({ + providerName: "codex/default", + model: "gpt-5.5", + }); + const base = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + }); + expect(gpt.family).toBe("gpt"); + // No eval characterization for gpt tool-only stretches yet: ship the + // permissive default, no finish-bias, no discipline rules. The + // narrate-before-tools residual is prompt-level (see prompts.ts), not a + // threshold. + expect(gpt.toolOnlyTurnNudgeAt).toBe(base.toolOnlyTurnNudgeAt); + expect(gpt.subAgentStallTimeoutMs).toBe(base.subAgentStallTimeoutMs); + expect(gpt.applyGrokFinishBias).toBe(false); + expect(gpt.toolDisciplineRules).toBeUndefined(); + expect(gpt.advertisedToolDeny).toEqual([]); + }); }); diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index ca380922a..27a53ee06 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { buildChatSystemPrompt, buildClaudeTaskGuidanceNote, + buildGptNarrateBeforeToolsNote, buildGrokLeafAntiThrashNote, buildGuidelines, buildPromptDisciplineBlock, @@ -372,6 +373,7 @@ describe("grok finish-bias residual gating (extends existing provider-family tes }); }); +<<<<<<< HEAD describe("promptResidual assembly (CL-8297)", () => { const TOOL_BUDGET = "Tool budget:\n" + @@ -452,3 +454,79 @@ describe("claude XML task_guidance residual (provider residual, not a prompt for ); }); }); + +describe("gpt narrate-before-tools residual (CL-8310)", () => { + it("is a 3-line narrate-before-tools note, not manage_tasks ceremony", () => { + const note = buildGptNarrateBeforeToolsNote(); + expect(note).toContain("Narrate before tools (GPT worker):"); + expect(note).toMatch(/before.*tool call.*one short line/is); + expect(note).toMatch(/no narration between them/i); + expect(note).toContain("write the report envelope"); + expect(note.toLowerCase()).not.toContain("manage_tasks"); + }); + + it("appears exactly once on a gpt leaf prompt", () => { + const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + gptNarrateBeforeTools: true, + }); + const note = buildGptNarrateBeforeToolsNote(); + expect(countOccurrences(prompt, note)).toBe(1); + expect(prompt.trimEnd().endsWith(note)).toBe(true); + }); + + it("appears exactly once on a gpt primary prompt", () => { + const prompt = buildChatSystemPrompt( + undefined, + undefined, + undefined, + [], + "orchestrator", + undefined, + undefined, + { gptNarrateBeforeTools: true }, + ); + const note = buildGptNarrateBeforeToolsNote(); + expect(countOccurrences(prompt, note)).toBe(1); + }); + + it("is absent by default on both primary and leaf", () => { + const leaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + grokAntiThrash: false, + }); + const primary = buildChatSystemPrompt( + undefined, + undefined, + undefined, + [], + "orchestrator", + ); + expect(leaf).not.toContain("Narrate before tools (GPT worker):"); + expect(primary).not.toContain("Narrate before tools (GPT worker):"); + }); + + it("is absent on grok and claude prompts", () => { + const grokLeaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + grokAntiThrash: true, + }); + const claudeLeaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, { + orchestrator: false, + grokAntiThrash: false, + }); + const claudePrimary = buildChatSystemPrompt( + undefined, + undefined, + undefined, + [], + "orchestrator", + ); + for (const prompt of [grokLeaf, claudeLeaf, claudePrimary]) { + expect(prompt).not.toContain("Narrate before tools (GPT worker):"); + expect(prompt).not.toContain("Narrate before tools (GPT"); + } + // The grok row keeps its own residual, untouched. + expect(grokLeaf).toContain("Finish bias (xAI / Grok worker):"); + }); +}); diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index dc88a4409..7cc5289e0 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test"; import { detectModelFamily, isClaudeLeafProvider, + isGptProvider, isKimiLeafProvider, isXaiGrokLeafProvider, shouldApplyGrokAntiThrash, } from "./provider-family.js"; +import { CODEX_DEFAULT_MODELS } from "../auth/codex/constants.js"; describe("isXaiGrokLeafProvider", () => { test("matches xai/ OAuth provider names", () => { @@ -194,3 +196,62 @@ describe("detectModelFamily claude row", () => { ).toBe("claude"); }); }); + +describe("isGptProvider (CL-8310)", () => { + test("matches codex OAuth provider names", () => { + expect(isGptProvider({ providerName: "codex/default" })).toBe(true); + expect(isGptProvider({ providerName: "codex/work" })).toBe(true); + }); + + test("matches codex adapter ids and bare codex names", () => { + expect(isGptProvider({ providerName: "codex-responses" })).toBe(true); + expect(isGptProvider({ providerName: "codex" })).toBe(true); + }); + + test("matches gpt-* model ids on any provider", () => { + expect( + isGptProvider({ providerName: "openai", model: "gpt-5.5" }), + ).toBe(true); + expect( + isGptProvider({ providerName: "opencode-go", model: "gpt-5.1" }), + ).toBe(true); + expect( + isGptProvider({ providerName: "openai-compat", model: "gpt-5.6-luna" }), + ).toBe(true); + }); + + test("covers every in-tree codex catalog model without naming cells", () => { + // No terra/sol/astra special-casing: every served codex id resolves via + // the generic codex-provider / gpt-* match, so future cells ride along. + for (const model of CODEX_DEFAULT_MODELS) { + expect(isGptProvider({ providerName: "codex/default", model })).toBe( + true, + ); + expect(detectModelFamily({ providerName: "codex/default", model })).toBe( + "gpt", + ); + } + }); + + test("rejects grok, kimi, muse, and claude", () => { + expect( + isGptProvider({ providerName: "xai/default", model: "grok-4.6" }), + ).toBe(false); + expect(isGptProvider({ providerName: "moonshot", model: "kimi-k2" })).toBe( + false, + ); + expect( + isGptProvider({ + providerName: "opencode-go", + model: "muse-spark-1.3-contributor", + }), + ).toBe(false); + expect( + isGptProvider({ + providerName: "anthropic", + model: "claude-sonnet-4", + }), + ).toBe(false); + expect(isGptProvider({ providerName: "anthropic" })).toBe(false); + }); +}); From d808aed1cd005291852173cffbb4a9cb46e683a9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:18:35 -0700 Subject: [PATCH 15/17] feat(prompt): add GPT narrate-before-tools family residual Primary and leaf prompts carry a 3-line narrate-before-tools residual when the active session resolves to the gpt family (codex, gpt-*); grok and claude prompts are untouched and the residual appears exactly once. Fixes CL-8310 --- src/agent/model-family-policy.test.ts | 39 ++++++++++++++------------- src/agent/model-family-policy.ts | 33 ++++++++++++++++++++++- src/agent/prompts.test.ts | 5 ++-- src/agent/prompts.ts | 22 +++++++++++++++ src/exec/runner.ts | 2 ++ src/session/runtime-assembly.ts | 19 +++++++++++++ src/subagent/provider-family.test.ts | 8 +++++- src/subagent/provider-family.ts | 33 ++++++++++++++++++++++- src/tui/runner/session.ts | 2 ++ 9 files changed, 138 insertions(+), 25 deletions(-) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index df8359c95..88eb8d938 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -4,8 +4,8 @@ import { resolveModelFamilyPolicy } from "./model-family-policy.js"; describe("resolveModelFamilyPolicy", () => { test("defaults are permissive for an unrecognized provider", () => { const policy = resolveModelFamilyPolicy({ - providerName: "openai", - model: "gpt-4.1", + providerName: "unknown-provider", + model: "unknown-model", }); expect(policy.family).toBe("default"); expect(policy.applyGrokFinishBias).toBe(false); @@ -55,8 +55,8 @@ describe("resolveModelFamilyPolicy", () => { test("advertisedToolDeny is empty by default and never contains use_skill", () => { const leaf = resolveModelFamilyPolicy({ - providerName: "openai", - model: "gpt-4.1", + providerName: "unknown-provider", + model: "unknown-model", orchestrator: false, }); expect(leaf.advertisedToolDeny).toEqual([]); @@ -124,12 +124,12 @@ describe("resolveModelFamilyPolicy", () => { orchestrator: true, }); expect(orchestrator.promptResidual).toBeUndefined(); - // Default-family probe: anthropic/claude-sonnet-4 would hit the claude - // row now, and the gpt row has NOT landed yet (#1135), so openai/gpt-4.1 - // is the probe that still resolves to the default family. + // Default-family probe: anthropic/claude-sonnet-4 hits the claude row + // and openai/gpt-4.1 hits the gpt row (#1135), so an unrecognized + // provider is the probe that still resolves to the default family. const base = resolveModelFamilyPolicy({ - providerName: "openai", - model: "gpt-4.1", + providerName: "unknown-provider", + model: "unknown-model", }); expect(base.family).toBe("default"); expect(base.promptResidual).toBeUndefined(); @@ -154,21 +154,22 @@ describe("resolveModelFamilyPolicy", () => { expect(orchestrator.promptResidual).toBeUndefined(); }); - // The gpt family row has NOT landed yet (#1135): openai/gpt-4.1 and - // codex/gpt-5.1 are default-family probes here, asserting they resolve to - // the default family with no residual. Grok keeps its CL-8297 tool-budget + // The gpt family row has landed (#1135): openai/gpt-4.1 and codex/gpt-5.1 + // resolve to the gpt family with the narrate-before-tools residual, leaf + // and orchestrator alike (no carve-out). Grok keeps its CL-8297 tool-budget // residual — the "no residual" claim below is default-family-only. - test("gpt probes resolve to default with no residual; grok keeps its tool budget", () => { + test("gpt probes resolve to gpt with the narrate residual; grok keeps its tool budget", () => { for (const input of [ { providerName: "openai", model: "gpt-4.1" }, { providerName: "codex", model: "gpt-5.1" }, ] as const) { - const policy = resolveModelFamilyPolicy({ - ...input, - orchestrator: false, - }); - expect(policy.family).toBe("default"); - expect(policy.promptResidual).toBeUndefined(); + for (const orchestrator of [false, true]) { + const policy = resolveModelFamilyPolicy({ ...input, orchestrator }); + expect(policy.family).toBe("gpt"); + expect(policy.promptResidual).toContain( + "Narrate before tools (GPT worker):", + ); + } } const grok = resolveModelFamilyPolicy({ providerName: "xai/default", diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 08df66450..870f7ec45 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -41,7 +41,8 @@ export interface ModelFamilyPolicy { /** * Provider-family residual appended once to the assembled leaf system * prompt (CL-8297). Tool-budget text for grok, the XML task_guidance block - * for claude; the GPT seam stays unfilled until its lane lands (#1135). + * for claude, the narrate-before-tools note for gpt (CL-8310, primary and + * leaf alike). * Withheld from orchestrators and appended at the tail so it cannot * disturb the cached prompt prefix. Undefined for families that need none. */ @@ -169,6 +170,33 @@ const CLAUDE_POLICY: Omit = { promptResidual: CLAUDE_TASK_GUIDANCE_NOTE, }; +// Tiny narrate-before-tools residual for GPT workers (CL-8310): GPT-5.5 runs +// showed 6–13 silent tool-only turns. Shared thrash harness + spawn contracts +// do the structural work; this is only a narrate-before-tools nudge. +// Deliberately not manage_tasks ceremony — that is CL-7769, not this text. +// The text lives here (policy owns data); buildGptNarrateBeforeToolsNote +// (prompts.ts) returns it verbatim so the prompt carries exactly one copy. +// Served cells (astra/sol/terra/…) are never named here — CL-8265 +// characterizes them later. +export const GPT_NARRATE_BEFORE_TOOLS_NOTE = [ + "Narrate before tools (GPT worker):", + "- Before each tool call, write one short line saying what you are doing and why.", + "- Never make back-to-back tool calls with no narration between them.", + "- When the dispatch brief's done-definition is met, write the report envelope instead of making another tool call.", +].join("\n"); + +// GPT (Codex / gpt-*) thresholds are provisional: we have no eval +// characterization yet for how GPT behaves under tool-only stretches or +// background-run stalls. Ship the permissive default rather than guessing at +// a tightened number; the narrate-before-tools residual is prompt-level (see +// GPT_NARRATE_BEFORE_TOOLS_NOTE above), not a threshold. +const GPT_POLICY: Omit = { + ...DEFAULT_POLICY, + // Primary and leaf alike, so unlike the grok finish-bias there is no + // orchestrator carve-out: the resolver below returns this as-is. + promptResidual: GPT_NARRATE_BEFORE_TOOLS_NOTE, +}; + export function resolveModelFamilyPolicy(input: { providerName: string; model?: string; @@ -204,6 +232,9 @@ export function resolveModelFamilyPolicy(input: { return orchestrator ? { family, ...DEFAULT_POLICY } : { family, ...CLAUDE_POLICY }; + case "gpt": + // Primary and leaf alike: no orchestrator carve-out. + return { family, ...GPT_POLICY }; default: return { family: "default", ...DEFAULT_POLICY }; } diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 27a53ee06..e1de8805d 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -373,7 +373,6 @@ describe("grok finish-bias residual gating (extends existing provider-family tes }); }); -<<<<<<< HEAD describe("promptResidual assembly (CL-8297)", () => { const TOOL_BUDGET = "Tool budget:\n" + @@ -468,7 +467,7 @@ describe("gpt narrate-before-tools residual (CL-8310)", () => { it("appears exactly once on a gpt leaf prompt", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: false, - gptNarrateBeforeTools: true, + promptResidual: buildGptNarrateBeforeToolsNote(), }); const note = buildGptNarrateBeforeToolsNote(); expect(countOccurrences(prompt, note)).toBe(1); @@ -484,7 +483,7 @@ describe("gpt narrate-before-tools residual (CL-8310)", () => { "orchestrator", undefined, undefined, - { gptNarrateBeforeTools: true }, + { promptResidual: buildGptNarrateBeforeToolsNote() }, ); const note = buildGptNarrateBeforeToolsNote(); expect(countOccurrences(prompt, note)).toBe(1); diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index f7b742841..ce81caeff 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -13,6 +13,7 @@ import { } from "./worker-contract.js"; import { CLAUDE_TASK_GUIDANCE_NOTE, + GPT_NARRATE_BEFORE_TOOLS_NOTE, GROK_PROMPT_RESIDUAL, } from "./model-family-policy.js"; @@ -459,6 +460,13 @@ export function buildChatSystemPrompt( sessionMode: SessionMode = "orchestrator", toolAvailability: ToolAvailability = DEFAULT_TOOL_AVAILABILITY, guidelineConfig?: GuidelineConfig, + opts: { + /** + * Family policy residual (CL-8297) appended once at the tail so it + * cannot disturb the cached prompt prefix. Unset for families with none. + */ + promptResidual?: string | undefined; + } = {}, ): string { const sections = [ baseSection( @@ -477,6 +485,9 @@ export function buildChatSystemPrompt( if (extensions !== undefined && extensions.length > 0) { sections.push(...extensions); } + if (opts.promptResidual !== undefined && opts.promptResidual.length > 0) { + sections.push(opts.promptResidual); + } return joinSections(sections); } @@ -536,6 +547,17 @@ export function buildClaudeTaskGuidanceNote(): string { return CLAUDE_TASK_GUIDANCE_NOTE; } +// Tiny residual for GPT workers (CL-8310): GPT-5.5/5.6-luna runs showed 6–13 +// silent tool-only turns. Shared thrash harness + spawn contracts do the +// structural work; this is only a narrate-before-tools nudge. Deliberately +// not manage_tasks ceremony — that is CL-7769, not this text. +// Single source of truth is the GPT_NARRATE_BEFORE_TOOLS_NOTE block in +// model-family-policy.ts (policy owns data); this returns that block verbatim +// so the prompt carries one gpt residual with no line twice. +export function buildGptNarrateBeforeToolsNote(): string { + return GPT_NARRATE_BEFORE_TOOLS_NOTE; +} + export function buildSubAgentSystemPrompt( extensions?: string[], env?: EnvironmentInfo, diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 1a3dc85e9..c7b67af12 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -704,6 +704,8 @@ export async function runExec(config: Config): Promise { sessionMode, toolAvailability, skills: agentToolset.skills, + providerName: config.providerName, + model: config.model, }) ).systemPrompt; diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 21c411eb7..5c340375e 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -15,6 +15,7 @@ import type { Compactor } from "@intx/types/runtime"; import { buildChatSystemPrompt } from "../agent/prompts.js"; import type { GuidelineSubBlockId } from "../agent/prompts.js"; +import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js"; import type { ToolAvailability } from "../agent/tool-search.js"; import { gatherEnvironment } from "../agent/environment.js"; import { @@ -293,6 +294,10 @@ export interface SessionChatPromptArgs { // Guideline sub-block ids to drop (see GUIDELINE_SUB_BLOCK_IDS). // Omitted = full guidelines. promptSectionOmit?: readonly GuidelineSubBlockId[]; + // Active session provider/model, for family residuals on the primary + // prompt (CL-8310: GPT narrate-before-tools). Omitted = no family residual. + providerName?: string; + model?: string; // Session-start snapshot from createAgentToolset. When provided, skip // rediscovery so the prompt listing and skill_search share one catalog. skills?: readonly SkillSummary[]; @@ -320,6 +325,19 @@ export async function loadSessionChatPrompt( ...(args.systemPromptExtensions ?? []), ...overrides.append, ]; + // Family residual on the primary prompt (CL-8310): resolved from the model + // family policy as an orchestrator — primaries dispatch rather than doing + // the work directly, so grok/claude primaries stay untouched (their rows + // withhold the residual from orchestrators) while the gpt row carries its + // narrate-before-tools note primary and leaf alike. + const { promptResidual } = + args.providerName !== undefined + ? resolveModelFamilyPolicy({ + providerName: args.providerName, + ...(args.model !== undefined ? { model: args.model } : {}), + orchestrator: true, + }) + : { promptResidual: undefined }; return { systemPrompt: buildChatSystemPrompt( extensions.length > 0 ? extensions : undefined, @@ -331,6 +349,7 @@ export async function loadSessionChatPrompt( args.promptSectionOmit !== undefined ? { omit: args.promptSectionOmit } : undefined, + { promptResidual }, ), skills, }; diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index 7cc5289e0..6e3fde5b1 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -124,7 +124,7 @@ describe("isKimiLeafProvider", () => { }); describe("detectModelFamily", () => { - test("detects grok, kimi, claude, and default", () => { + test("detects grok, kimi, claude, gpt, and default", () => { expect( detectModelFamily({ providerName: "xai/default", model: "grok-4.5" }), ).toBe("grok"); @@ -142,6 +142,12 @@ describe("detectModelFamily", () => { ).toBe("claude"); expect( detectModelFamily({ providerName: "openai", model: "gpt-4.1" }), + ).toBe("gpt"); + expect( + detectModelFamily({ + providerName: "unknown-provider", + model: "unknown-model", + }), ).toBe("default"); }); }); diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index b42a5aebd..18648a8b4 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -1,5 +1,6 @@ import { GROK_RESPONSES_PROVIDER } from "../provider/grok-responses.js"; import { isXaiProviderName } from "../config/xai-providers.js"; +import { isCodexProviderName } from "../config/codex-providers.js"; /** * True when the leaf inference path is xAI / Grok family. @@ -49,8 +50,37 @@ export function isClaudeLeafProvider(input: { return false; } +/** + * True when the inference path is the GPT family: a Codex provider name + * (codex/ OAuth profiles, the codex-responses adapter, bare codex) or a + * gpt-* model id on any provider. Served codex cells (astra/sol/terra/luna) + * all match the generic gpt-* model shape — never name them here; CL-8265 + * characterizes cells later. + */ +export function isGptProvider(input: { + providerName: string; + model?: string; +}): boolean { + const name = input.providerName.toLowerCase(); + if ( + isCodexProviderName(name) || + name === "codex" || + name.includes("codex") + ) + return true; + if (input.model !== undefined && /^gpt-/i.test(input.model.trim())) + return true; + return false; +} + /** Model families the shared directors branch on via ModelFamilyPolicy. */ -export type ModelFamily = "grok" | "kimi" | "muse" | "claude" | "default"; +export type ModelFamily = + | "grok" + | "kimi" + | "muse" + | "claude" + | "gpt" + | "default"; /** * Resolves a provider/model to a ModelFamily. Generalizes @@ -66,6 +96,7 @@ export function detectModelFamily(input: { if (isKimiLeafProvider(input)) return "kimi"; if (isMuseSparkLeafProvider(input)) return "muse"; if (isClaudeLeafProvider(input)) return "claude"; + if (isGptProvider(input)) return "gpt"; return "default"; } diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 488a89c13..c4b72b4d0 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -447,6 +447,8 @@ export async function assembleTUISession( sessionMode: liveSessionMode, toolAvailability, skills: toolset.skills, + providerName: config.providerName, + model: config.model, }); const directorHolder: { instance?: ReturnType } = From f0fa3f40af5e1483195b7f8da3abb6ffb0d8a10b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 15:26:57 -0700 Subject: [PATCH 16/17] style(agent): apply oxfmt formatting to CL-8310 files --- src/agent/prompts.test.ts | 26 ++++++++++++++++++-------- src/subagent/provider-family.test.ts | 6 +++--- src/subagent/provider-family.ts | 6 +----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index e1de8805d..a9061fa80 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -506,14 +506,24 @@ describe("gpt narrate-before-tools residual (CL-8310)", () => { }); it("is absent on grok and claude prompts", () => { - const grokLeaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, { - orchestrator: false, - grokAntiThrash: true, - }); - const claudeLeaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, { - orchestrator: false, - grokAntiThrash: false, - }); + const grokLeaf = buildSubAgentSystemPrompt( + undefined, + undefined, + undefined, + { + orchestrator: false, + grokAntiThrash: true, + }, + ); + const claudeLeaf = buildSubAgentSystemPrompt( + undefined, + undefined, + undefined, + { + orchestrator: false, + grokAntiThrash: false, + }, + ); const claudePrimary = buildChatSystemPrompt( undefined, undefined, diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index 6e3fde5b1..f7156de84 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -215,9 +215,9 @@ describe("isGptProvider (CL-8310)", () => { }); test("matches gpt-* model ids on any provider", () => { - expect( - isGptProvider({ providerName: "openai", model: "gpt-5.5" }), - ).toBe(true); + expect(isGptProvider({ providerName: "openai", model: "gpt-5.5" })).toBe( + true, + ); expect( isGptProvider({ providerName: "opencode-go", model: "gpt-5.1" }), ).toBe(true); diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 18648a8b4..c1ac21076 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -62,11 +62,7 @@ export function isGptProvider(input: { model?: string; }): boolean { const name = input.providerName.toLowerCase(); - if ( - isCodexProviderName(name) || - name === "codex" || - name.includes("codex") - ) + if (isCodexProviderName(name) || name === "codex" || name.includes("codex")) return true; if (input.model !== undefined && /^gpt-/i.test(input.model.trim())) return true; From 2880019e16c261c35f401cfaf74e654226ec1a30 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 20:44:44 -0700 Subject: [PATCH 17/17] fix(prompt): default size probe survives the gpt row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai/gpt-4.1 now resolves to the gpt family, so the default column measured the narrate-before-tools nudge. Probe an unrecognized provider instead — default family regardless of shipped rows — and assert the default column carries neither the claude block nor the gpt nudge. --- src/agent/prompt-sizes.test.ts | 7 ++++--- src/agent/prompt-sizes.ts | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/agent/prompt-sizes.test.ts b/src/agent/prompt-sizes.test.ts index a0658fb69..719e336ef 100644 --- a/src/agent/prompt-sizes.test.ts +++ b/src/agent/prompt-sizes.test.ts @@ -345,10 +345,11 @@ describe("grok tool-budget residual (CL-8297)", () => { test("default-family and orchestrator prompts carry no tool budget", () => { const defaultPrompt = assembleDirectorPrompt("builder", "default"); expect(defaultPrompt).not.toContain("Tool budget:"); - // The default probe (openai/gpt-4.1) resolves to the default family, so - // the default column carries no family residual at all — not the claude - // task_guidance block either. + // The default probe resolves to the default family, so the default + // column carries no family residual — neither the claude task_guidance + // block nor the gpt narrate-before-tools nudge. expect(defaultPrompt).not.toContain(""); + expect(defaultPrompt).not.toContain("Narrate before tools (GPT worker):"); expect(assembleDirectorPrompt("skywalker", "grok")).not.toContain( "Tool budget:", ); diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts index 2154e29eb..8a2944656 100644 --- a/src/agent/prompt-sizes.ts +++ b/src/agent/prompt-sizes.ts @@ -57,11 +57,12 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = { }; const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" }; -// Default-family probe: openai/gpt-4.1 resolves to the default family (the -// gpt row lands later in CL-8310), so the default column carries no residual. +// Default-family probe: an unrecognized provider stays on the default +// family no matter how many family rows land (claude/gpt already ship), +// so the default column carries no residual. const DEFAULT_PROVIDER = { - providerName: "openai", - model: "gpt-4.1", + providerName: "unknown-provider", + model: "unknown-model", }; /** Families in the size table: default assembly vs Grok (+finish-bias note). */