Skip to content
78 changes: 78 additions & 0 deletions src/agent/grok-residual.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
29 changes: 29 additions & 0 deletions src/agent/model-family-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,33 @@ 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();
const base = resolveModelFamilyPolicy({
providerName: "anthropic",
model: "claude-sonnet-4",
});
expect(base.promptResidual).toBeUndefined();
});
});
});
42 changes: 42 additions & 0 deletions src/agent/model-family-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -66,6 +74,16 @@ const DEFAULT_POLICY: Omit<ModelFamilyPolicy, "family"> = {
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
Expand All @@ -83,6 +101,8 @@ const GROK_POLICY: Omit<ModelFamilyPolicy, "family"> = {
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
Expand All @@ -109,6 +129,27 @@ const MUSE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
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");

export function resolveModelFamilyPolicy(input: {
providerName: string;
model?: string;
Expand All @@ -126,6 +167,7 @@ export function resolveModelFamilyPolicy(input: {
...policy,
applyGrokFinishBias: policy.applyGrokFinishBias && !orchestrator,
advertisedToolDeny: orchestrator ? [] : policy.advertisedToolDeny,
promptResidual: orchestrator ? undefined : policy.promptResidual,
};
}
case "kimi":
Expand Down
19 changes: 19 additions & 0 deletions src/agent/prompt-sizes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
);
});
});
7 changes: 6 additions & 1 deletion src/agent/prompt-sizes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -164,6 +168,7 @@ export function assembleDirectorPrompt(
orchestrator,
toolNames: canonicalToolNamesForDirector(pkg, family),
grokAntiThrash: shouldApplyGrokAntiThrash({ ...provider, orchestrator }),
promptResidual: policy.promptResidual,
},
);
}
Expand Down
26 changes: 26 additions & 0 deletions src/agent/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
});
});
21 changes: 14 additions & 7 deletions src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildWorkerContract,
buildWorkerToolNames,
} from "./worker-contract.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:
Expand Down Expand Up @@ -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 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;
}

export function buildSubAgentSystemPrompt(
Expand All @@ -530,6 +529,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 =
Expand All @@ -556,5 +560,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);
}
1 change: 1 addition & 0 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,7 @@ async function runSubAgentInner(
model: params.provider.model,
orchestrator: params.orchestrator === true,
}),
promptResidual: modelFamilyPolicy.promptResidual,
},
);

Expand Down
Loading