diff --git a/src/agent/grok-residual.test.ts b/src/agent/grok-residual.test.ts new file mode 100644 index 000000000..9a50612e6 --- /dev/null +++ b/src/agent/grok-residual.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +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. +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", () => { + expect(GROK_PROMPT_RESIDUAL).toContain("Finish bias (xAI / Grok worker):"); + for (const line of CEREMONY_LINES) { + expect(countOccurrences(GROK_PROMPT_RESIDUAL, line)).toBe(1); + } + }); + + it("keeps the don't re-read line exactly once — no duplicate", () => { + expect( + countOccurrences(GROK_PROMPT_RESIDUAL, "re-open paths you already read"), + ).toBe(1); + }); + + 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(GROK_PROMPT_RESIDUAL); + }); + + 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); + } + }); +}); diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index ed2843717..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: "anthropic", - model: "claude-sonnet-4", + 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: "anthropic", - model: "claude-opus-4-6", + providerName: "unknown-provider", + model: "unknown-model", orchestrator: false, }); expect(leaf.advertisedToolDeny).toEqual([]); @@ -102,4 +102,101 @@ 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(); + if (!leaf.promptResidual) + throw new Error("expected promptResidual to be defined"); + 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(); + // 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: "unknown-provider", + model: "unknown-model", + }); + expect(base.family).toBe("default"); + 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(""); + expect(leaf.advertisedToolDeny).toEqual([]); + const orchestrator = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + orchestrator: true, + }); + expect(orchestrator.promptResidual).toBeUndefined(); + }); + + // 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 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) { + 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", + model: "grok-4.6", + orchestrator: false, + }); + 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/model-family-policy.ts b/src/agent/model-family-policy.ts index e77057594..870f7ec45 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -38,6 +38,15 @@ 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). Tool-budget text for grok, the XML task_guidance block + * 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. + */ + promptResidual?: string | undefined; } const DEFAULT_WRAP_UP_NUDGE_TEXT = @@ -58,6 +67,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, @@ -66,6 +76,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 +103,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 @@ -109,6 +131,72 @@ const MUSE_POLICY: Omit = { toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES, }; +// 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.", + "- 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"); +// 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 = [ + "", + "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"); + +const CLAUDE_POLICY: Omit = { + ...DEFAULT_POLICY, + 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; @@ -126,6 +214,7 @@ export function resolveModelFamilyPolicy(input: { ...policy, applyGrokFinishBias: policy.applyGrokFinishBias && !orchestrator, advertisedToolDeny: orchestrator ? [] : policy.advertisedToolDeny, + promptResidual: orchestrator ? undefined : policy.promptResidual, }; } case "kimi": @@ -136,6 +225,16 @@ 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 }; + 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/prompt-sizes.test.ts b/src/agent/prompt-sizes.test.ts index deef84e8e..719e336ef 100644 --- a/src/agent/prompt-sizes.test.ts +++ b/src/agent/prompt-sizes.test.ts @@ -332,3 +332,26 @@ 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", () => { + const defaultPrompt = assembleDirectorPrompt("builder", "default"); + expect(defaultPrompt).not.toContain("Tool budget:"); + // 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 a9721a554..8a2944656 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, @@ -35,7 +36,10 @@ 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 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. @@ -53,9 +57,12 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = { }; const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" }; +// 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: "anthropic", - model: "claude-sonnet-4", + providerName: "unknown-provider", + model: "unknown-model", }; /** Families in the size table: default assembly vs Grok (+finish-bias note). */ @@ -156,6 +163,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 +172,7 @@ export function assembleDirectorPrompt( orchestrator, toolNames: canonicalToolNamesForDirector(pkg, family), grokAntiThrash: shouldApplyGrokAntiThrash({ ...provider, orchestrator }), + promptResidual: policy.promptResidual, }, ); } diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index bc36b8d0e..a9061fa80 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "bun:test"; import { buildChatSystemPrompt, + buildClaudeTaskGuidanceNote, + buildGptNarrateBeforeToolsNote, buildGrokLeafAntiThrashNote, buildGuidelines, buildPromptDisciplineBlock, @@ -370,3 +372,170 @@ 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:"); + }); +}); + +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(/||/); + }); + + 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, + ); + }); +}); + +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, + promptResidual: buildGptNarrateBeforeToolsNote(), + }); + 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, + { promptResidual: buildGptNarrateBeforeToolsNote() }, + ); + 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/agent/prompts.ts b/src/agent/prompts.ts index fab10da33..ce81caeff 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -11,6 +11,11 @@ import { buildWorkerContract, buildWorkerToolNames, } from "./worker-contract.js"; +import { + CLAUDE_TASK_GUIDANCE_NOTE, + GPT_NARRATE_BEFORE_TOOLS_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: @@ -455,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( @@ -473,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); } @@ -511,14 +526,36 @@ 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 GROK_PROMPT_RESIDUAL block in +// model-family-policy.ts (CL-8296 merged the three ceremony lines into it); +// this returns 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 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. +// 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. +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( @@ -530,6 +567,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 +598,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/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 19b7bfc42..f7156de84 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -1,10 +1,13 @@ 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", () => { @@ -121,7 +124,7 @@ describe("isKimiLeafProvider", () => { }); describe("detectModelFamily", () => { - test("detects grok, kimi, and default", () => { + test("detects grok, kimi, claude, gpt, and default", () => { expect( detectModelFamily({ providerName: "xai/default", model: "grok-4.5" }), ).toBe("grok"); @@ -136,6 +139,125 @@ describe("detectModelFamily", () => { providerName: "anthropic", model: "claude-sonnet-4", }), + ).toBe("claude"); + expect( + detectModelFamily({ providerName: "openai", model: "gpt-4.1" }), + ).toBe("gpt"); + expect( + detectModelFamily({ + providerName: "unknown-provider", + model: "unknown-model", + }), ).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"); + }); +}); + +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); + }); +}); diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 6165fabf2..c1ac21076 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. @@ -37,8 +38,45 @@ 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; +} + +/** + * 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" | "default"; +export type ModelFamily = + | "grok" + | "kimi" + | "muse" + | "claude" + | "gpt" + | "default"; /** * Resolves a provider/model to a ModelFamily. Generalizes @@ -53,6 +91,8 @@ export function detectModelFamily(input: { if (isXaiGrokLeafProvider(input)) return "grok"; 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/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, }, ); 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 } =