Skip to content

Commit 44f4376

Browse files
committed
feat(prompt-variance): extract family prompt variance as a versioned package
Directors resolve default/muse/grok rows and assemble prompts through packages/prompt-variance instead of hand-concatenated grokAntiThrash booleans. Default/grok director output is byte-identical (sha256 match across all directors); muse rules now also render in prompt-size rows. Verification: bun test src/agent src/subagent src/prompts.test.ts packages/prompt-variance/ — 1416 pass, 0 fail. tsc --noEmit reports only the pre-existing vendor/intx-types semver error, identical on main.
1 parent 818b909 commit 44f4376

10 files changed

Lines changed: 265 additions & 36 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "prompt-variance",
3+
"version": "1.4.0",
4+
"private": true,
5+
"type": "module",
6+
"license": "SEE LICENSE IN LICENSE.md",
7+
"exports": {
8+
".": {
9+
"types": "./src/index.ts",
10+
"default": "./src/index.ts"
11+
}
12+
}
13+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { PromptVarianceRow } from "./rows.js";
2+
3+
export interface AssembledPromptVariance {
4+
systemPrompt: string;
5+
toolNames: readonly string[];
6+
}
7+
8+
/**
9+
* Assemble a prompt from sections plus a family variance row (CL-8269).
10+
* The row residual renders last; advertised denies filter the mounted
11+
* tool names. Variance is subtractive only: the output names are always
12+
* a subset of the input names, in input order.
13+
*/
14+
export function assemble(
15+
sections: readonly string[],
16+
familyRow: PromptVarianceRow,
17+
tools: readonly string[],
18+
): AssembledPromptVariance {
19+
if (familyRow.advertisedToolDeny.includes("use_skill")) {
20+
throw new Error(
21+
`prompt-variance: row "${familyRow.id}" denies use_skill — brief-named skills always load directly`,
22+
);
23+
}
24+
const systemPrompt =
25+
familyRow.residual.length > 0
26+
? [...sections, familyRow.residual].join("\n\n")
27+
: sections.join("\n\n");
28+
const deny = new Set(familyRow.advertisedToolDeny);
29+
return {
30+
systemPrompt,
31+
toolNames: tools.filter((name) => !deny.has(name)),
32+
};
33+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export { assemble, type AssembledPromptVariance } from "./assemble.js";
2+
export {
3+
applyRowOverride,
4+
resolvePromptVariance,
5+
} from "./resolve.js";
6+
export {
7+
defaultRow,
8+
FAMILY_IDS,
9+
FAMILY_ROWS,
10+
grokRow,
11+
museRow,
12+
type PromptVarianceFamily,
13+
type PromptVarianceRender,
14+
type PromptVarianceRow,
15+
type PromptVarianceRowOverride,
16+
} from "./rows.js";
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
defaultRow,
3+
FAMILY_ROWS,
4+
type PromptVarianceFamily,
5+
type PromptVarianceRow,
6+
} from "./rows.js";
7+
8+
/**
9+
* Apply a row's per-model-id override, if any. Model ids match
10+
* case-insensitively against the row's lowercase override keys; an
11+
* unknown id resolves to the base row unchanged.
12+
*/
13+
export function applyRowOverride(
14+
row: PromptVarianceRow,
15+
model?: string,
16+
): PromptVarianceRow {
17+
if (model === undefined) return row;
18+
const override = row.overrides?.[model.trim().toLowerCase()];
19+
if (override === undefined) return row;
20+
return {
21+
...row,
22+
residual: override.residual ?? row.residual,
23+
advertisedToolDeny: override.advertisedToolDeny ?? row.advertisedToolDeny,
24+
sectionOmit: override.sectionOmit ?? row.sectionOmit,
25+
};
26+
}
27+
28+
/**
29+
* Resolve the variance row for a family, mirroring the leaves-only gate:
30+
* the grok finish-bias residual only makes sense on leaf workers, so
31+
* orchestrators fall back to the default shape. Muse keeps its residual
32+
* on both — its constructors append the same text at the tail either way.
33+
*/
34+
export function resolvePromptVariance(input: {
35+
family: PromptVarianceFamily;
36+
orchestrator?: boolean;
37+
model?: string;
38+
}): PromptVarianceRow {
39+
if (input.family === "grok" && input.orchestrator === true) {
40+
return applyRowOverride(defaultRow, input.model);
41+
}
42+
return applyRowOverride(FAMILY_ROWS[input.family], input.model);
43+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Versioned model-family prompt variance (CL-8269). One row per tuned
3+
* family: a tail residual plus the tool names the family must not
4+
* advertise. Directors assemble prompts through `assemble`, never by
5+
* hand-concatenating family booleans.
6+
*
7+
* Only families with shipped eval numbers live here: default/muse/grok.
8+
* glm/claude/gpt rows land when CL-8265 / 7772 / 7775 characterize them.
9+
*/
10+
11+
/** Families with a shipped variance row. */
12+
export type PromptVarianceFamily = "default" | "muse" | "grok";
13+
14+
/** Residuals render at the tail so they cannot disturb the cached prefix. */
15+
export type PromptVarianceRender = "tail";
16+
17+
export interface PromptVarianceRowOverride {
18+
residual?: string;
19+
advertisedToolDeny?: readonly string[];
20+
sectionOmit?: readonly string[];
21+
}
22+
23+
export interface PromptVarianceRow {
24+
id: PromptVarianceFamily;
25+
render: PromptVarianceRender;
26+
/** Tail text appended to the assembled prompt. Empty when the family needs none. */
27+
residual: string;
28+
/**
29+
* Tool names to drop from the advertised set (CL-7668). Subtractive
30+
* only — a row can never grant a tool `assemble` was not given.
31+
* `use_skill` is never denied: brief-named skills load directly.
32+
*/
33+
advertisedToolDeny: readonly string[];
34+
/** Assembly section ids to omit for this family. Empty until a family needs one. */
35+
sectionOmit: readonly string[];
36+
/** Optional per-model-id refinements, keyed by lowercase model id. */
37+
overrides?: Record<string, PromptVarianceRowOverride>;
38+
}
39+
40+
export const FAMILY_IDS: readonly PromptVarianceFamily[] = [
41+
"default",
42+
"muse",
43+
"grok",
44+
];
45+
46+
export const defaultRow: PromptVarianceRow = {
47+
id: "default",
48+
render: "tail",
49+
residual: "",
50+
advertisedToolDeny: [],
51+
sectionOmit: [],
52+
};
53+
54+
// Muse Spark does not reliably stop a tool loop at medium reasoning effort:
55+
// the same run with these three rules appended finished in 3 turns on 4.3x
56+
// fewer input tokens (CL-7869). Byte-identical to the shipped text so the
57+
// size table and the constructor append cannot drift apart.
58+
export const museRow: PromptVarianceRow = {
59+
id: "muse",
60+
render: "tail",
61+
residual:
62+
"Tool discipline:\n" +
63+
"- Batch independent tool calls into a single turn.\n" +
64+
"- Never re-read a file you have already read this session.\n" +
65+
"- Do not narrate; act.",
66+
advertisedToolDeny: [],
67+
sectionOmit: [],
68+
};
69+
70+
// Tiny residual for Grok/xAI workers: mining showed higher tools-only thrash
71+
// than Codex on the same harness. Shared thrash harness + spawn contracts do
72+
// the structural work; this is only a finish-bias nudge, not a full rewrite.
73+
// Built from the current grok finish-bias text on origin/main; the CL-8297
74+
// residual folds in here at merge time.
75+
export const grokRow: PromptVarianceRow = {
76+
id: "grok",
77+
render: "tail",
78+
residual: [
79+
"Finish bias (xAI / Grok worker):",
80+
"- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.",
81+
"- If the next call would only re-open paths you already read, write the report instead.",
82+
"- When the dispatch brief's done-definition is met, write the report envelope instead of making one more search or micro-edit.",
83+
"- 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.",
84+
].join("\n"),
85+
// Leaf value; resolvePromptVariance clears it for orchestrators.
86+
advertisedToolDeny: ["skill_search"],
87+
sectionOmit: [],
88+
};
89+
90+
export const FAMILY_ROWS: Record<PromptVarianceFamily, PromptVarianceRow> = {
91+
default: defaultRow,
92+
muse: museRow,
93+
grok: grokRow,
94+
};

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
detectModelFamily,
33
type ModelFamily,
44
} from "../subagent/provider-family.js";
5+
import { museRow } from "../../packages/prompt-variance/src/index.js";
56

67
/**
78
* Per-model-family tuning for the shared directors (main chat director and
@@ -98,11 +99,8 @@ const KIMI_POLICY: Omit<ModelFamilyPolicy, "family"> = { ...DEFAULT_POLICY };
9899
// three rules appended finished in 3 turns on 4.3x fewer input tokens. At
99100
// minimal effort it terminates either way, so the rules earn their keep exactly
100101
// at the rungs where each wasted turn is most expensive. See CL-7869.
101-
const MUSE_TOOL_DISCIPLINE_RULES =
102-
"Tool discipline:\n" +
103-
"- Batch independent tool calls into a single turn.\n" +
104-
"- Never re-read a file you have already read this session.\n" +
105-
"- Do not narrate; act.";
102+
// Single-sourced from the versioned prompt-variance package (CL-8269).
103+
const MUSE_TOOL_DISCIPLINE_RULES = museRow.residual;
106104

107105
const MUSE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
108106
...DEFAULT_POLICY,

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { resolveExecDirectorOverlay } from "../exec/runner.js";
2727
/**
2828
* Prompt size budget (CL-7664). Numeric asserts only — copy edits must not
2929
* fail this test. Baselines are a checked-in snapshot of the max measured
30-
* sizes across both families from the canonical fixture in
30+
* sizes across all three families from the canonical fixture in
3131
* src/agent/prompt-sizes.ts; budgets add a +2000 char / +3000 byte allowance
3232
* (ceiling to 100) in code below. Bytes get the larger headroom because
3333
* multibyte copy can shift them faster. Adding a director is a type error
@@ -196,15 +196,15 @@ describe("director prompt size budget", () => {
196196

197197
test("tool names match the production mount: no dupes, no phantoms", () => {
198198
for (const directorId of DIRECTOR_IDS) {
199-
for (const family of ["default", "grok"] as const) {
199+
for (const family of ["default", "muse", "grok"] as const) {
200200
const names = canonicalToolNamesForDirector(
201201
DIRECTOR_REGISTRY[directorId],
202202
family,
203203
);
204204
expect(new Set(names).size, `${directorId} [${family}]`).toBe(
205205
names.length,
206206
);
207-
// Neither fixture family is Codex, so the Codex proxies
207+
// No fixture family is Codex, so the Codex proxies
208208
// (createCodexToolProxies returns [] when !isCodex) must be absent,
209209
// as must list_dir, which no subagent mount installs.
210210
for (const phantom of [
@@ -230,17 +230,20 @@ describe("director prompt size budget", () => {
230230
test("formatPromptSizeTable renders one row per director", () => {
231231
const table = formatPromptSizeTable(rows);
232232
expect(table).toContain(
233-
"| director | default chars (bytes) | grok chars (bytes) |",
233+
"| director | default chars (bytes) | muse chars (bytes) | grok chars (bytes) |",
234234
);
235235
for (const directorId of DIRECTOR_IDS) {
236236
const base = rows.find(
237237
(r) => r.directorId === directorId && r.family === "default",
238238
);
239+
const muse = rows.find(
240+
(r) => r.directorId === directorId && r.family === "muse",
241+
);
239242
const grok = rows.find(
240243
(r) => r.directorId === directorId && r.family === "grok",
241244
);
242245
expect(table).toContain(
243-
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
246+
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${muse?.chars} (${muse?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
244247
);
245248
}
246249
});

‎src/agent/prompt-sizes.ts‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
formatAgentsMdExtension,
1717
MAX_AGENTS_MD_BYTES,
1818
} from "./context-extensions.js";
19-
import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js";
19+
import { resolvePromptVariance } from "../../packages/prompt-variance/src/index.js";
2020
import { isCodexProviderName } from "../config/codex-providers.js";
2121
import { shellCollectDefinition } from "./background-shell-tool.js";
2222
import {
@@ -34,8 +34,10 @@ import { webSearchDefinition } from "../tools/web-search.js";
3434
*
3535
* Assembles each director prompt exactly as src/subagent/run.ts does:
3636
* extensions=[director systemPromptRole] + environment + tools +
37-
* appendix, with the Grok finish-bias note gated by
38-
* shouldApplyGrokAntiThrash (leaves on Grok-family providers only).
37+
* appendix, with the family variance row resolved through the versioned
38+
* prompt-variance package (CL-8269). The muse family measures the
39+
* variance-applied shape: production muse workers carry the same tail
40+
* text via the director constructors (family policy), so the bytes match.
3941
*
4042
* The env and provider inputs are pinned here so sizes never drift with the
4143
* machine, date, or checkout — only real prompt changes move the numbers.
@@ -53,13 +55,17 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = {
5355
};
5456

5557
const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" };
58+
const MUSE_PROVIDER = {
59+
providerName: "opencode-go",
60+
model: "muse-spark-1.3-contributor",
61+
};
5662
const DEFAULT_PROVIDER = {
5763
providerName: "anthropic",
5864
model: "claude-sonnet-4",
5965
};
6066

61-
/** Families in the size table: default assembly vs Grok (+finish-bias note). */
62-
export type PromptSizeFamily = "default" | "grok";
67+
/** Families in the size table: default, muse (+tool-discipline rules), grok (+finish-bias note). */
68+
export type PromptSizeFamily = "default" | "muse" | "grok";
6369

6470
/**
6571
* Pinned AGENTS.md body for prefix measurement. Production reads the live
@@ -155,15 +161,24 @@ export function assembleDirectorPrompt(
155161
): string {
156162
const pkg = DIRECTOR_REGISTRY[directorId];
157163
const orchestrator = pkg.spawn.maySpawn;
158-
const provider = family === "grok" ? GROK_PROVIDER : DEFAULT_PROVIDER;
164+
const provider =
165+
family === "grok"
166+
? GROK_PROVIDER
167+
: family === "muse"
168+
? MUSE_PROVIDER
169+
: DEFAULT_PROVIDER;
159170
return buildSubAgentSystemPrompt(
160171
[formatDirectorSystemPrompt(pkg)],
161172
CANONICAL_PROMPT_ENV,
162173
undefined,
163174
{
164175
orchestrator,
165176
toolNames: canonicalToolNamesForDirector(pkg, family),
166-
grokAntiThrash: shouldApplyGrokAntiThrash({ ...provider, orchestrator }),
177+
variance: resolvePromptVariance({
178+
family,
179+
orchestrator,
180+
model: provider.model,
181+
}),
167182
},
168183
);
169184
}
@@ -225,7 +240,7 @@ export function measureSkywalkerPrefix(): SkywalkerPrefixSize {
225240
export function directorPromptSizeTable(): DirectorPromptSize[] {
226241
const rows: DirectorPromptSize[] = [];
227242
for (const directorId of DIRECTOR_IDS) {
228-
for (const family of ["default", "grok"] as const) {
243+
for (const family of ["default", "muse", "grok"] as const) {
229244
rows.push(measureDirectorPrompt(directorId, family));
230245
}
231246
}
@@ -235,18 +250,21 @@ export function directorPromptSizeTable(): DirectorPromptSize[] {
235250
/** Render the size table as markdown (for PR bodies and budget updates). */
236251
export function formatPromptSizeTable(rows: DirectorPromptSize[]): string {
237252
const lines = [
238-
"| director | default chars (bytes) | grok chars (bytes) |",
239-
"| --- | --- | --- |",
253+
"| director | default chars (bytes) | muse chars (bytes) | grok chars (bytes) |",
254+
"| --- | --- | --- | --- |",
240255
];
241256
for (const directorId of DIRECTOR_IDS) {
242257
const base = rows.find(
243258
(r) => r.directorId === directorId && r.family === "default",
244259
);
260+
const muse = rows.find(
261+
(r) => r.directorId === directorId && r.family === "muse",
262+
);
245263
const grok = rows.find(
246264
(r) => r.directorId === directorId && r.family === "grok",
247265
);
248266
lines.push(
249-
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
267+
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${muse?.chars} (${muse?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
250268
);
251269
}
252270
return lines.join("\n");

0 commit comments

Comments
 (0)