Skip to content

Commit bba2951

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 e3a769d commit bba2951

8 files changed

Lines changed: 232 additions & 19 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
@@ -120,11 +121,8 @@ const KIMI_POLICY: Omit<ModelFamilyPolicy, "family"> = { ...DEFAULT_POLICY };
120121
// three rules appended finished in 3 turns on 4.3x fewer input tokens. At
121122
// minimal effort it terminates either way, so the rules earn their keep exactly
122123
// at the rungs where each wasted turn is most expensive. See CL-7869.
123-
const MUSE_TOOL_DISCIPLINE_RULES =
124-
"Tool discipline:\n" +
125-
"- Batch independent tool calls into a single turn.\n" +
126-
"- Never re-read a file you have already read this session.\n" +
127-
"- Do not narrate; act.";
124+
// Single-sourced from the versioned prompt-variance package (CL-8269).
125+
const MUSE_TOOL_DISCIPLINE_RULES = museRow.residual;
128126

129127
const MUSE_POLICY: Omit<ModelFamilyPolicy, "family"> = {
130128
...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
@@ -200,15 +200,15 @@ describe("director prompt size budget", () => {
200200

201201
test("tool names match the production mount: no dupes, no phantoms", () => {
202202
for (const directorId of DIRECTOR_IDS) {
203-
for (const family of ["default", "grok"] as const) {
203+
for (const family of ["default", "muse", "grok"] as const) {
204204
const names = canonicalToolNamesForDirector(
205205
DIRECTOR_REGISTRY[directorId],
206206
family,
207207
);
208208
expect(new Set(names).size, `${directorId} [${family}]`).toBe(
209209
names.length,
210210
);
211-
// Neither fixture family is Codex, so the Codex proxies
211+
// No fixture family is Codex, so the Codex proxies
212212
// (createCodexToolProxies returns [] when !isCodex) must be absent,
213213
// as must list_dir, which no subagent mount installs.
214214
for (const phantom of [
@@ -234,17 +234,20 @@ describe("director prompt size budget", () => {
234234
test("formatPromptSizeTable renders one row per director", () => {
235235
const table = formatPromptSizeTable(rows);
236236
expect(table).toContain(
237-
"| director | default chars (bytes) | grok chars (bytes) |",
237+
"| director | default chars (bytes) | muse chars (bytes) | grok chars (bytes) |",
238238
);
239239
for (const directorId of DIRECTOR_IDS) {
240240
const base = rows.find(
241241
(r) => r.directorId === directorId && r.family === "default",
242242
);
243+
const muse = rows.find(
244+
(r) => r.directorId === directorId && r.family === "muse",
245+
);
243246
const grok = rows.find(
244247
(r) => r.directorId === directorId && r.family === "grok",
245248
);
246249
expect(table).toContain(
247-
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
250+
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${muse?.chars} (${muse?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
248251
);
249252
}
250253
});

‎src/agent/prompt-sizes.ts‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ import { webSearchDefinition } from "../tools/web-search.js";
3838
* appendix, with the Grok finish-bias note gated by
3939
* shouldApplyGrokAntiThrash (leaves on Grok-family providers only) and the
4040
* family promptResidual (CL-8297 tool budget for grok leaves, XML
41-
* task_guidance block for claude leaves) resolved from the model family
42-
* policy.
41+
* task_guidance block for claude leaves, narrate-before-tools nudge for
42+
* gpt leaves) resolved from the model family policy. Residual texts are
43+
* single-sourced from the versioned prompt-variance package (CL-8269).
4344
*
4445
* The env and provider inputs are pinned here so sizes never drift with the
4546
* machine, date, or checkout — only real prompt changes move the numbers.
@@ -60,13 +61,17 @@ const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" };
6061
// Default-family probe: an unrecognized provider stays on the default
6162
// family no matter how many family rows land (claude/gpt already ship),
6263
// so the default column carries no residual.
64+
const MUSE_PROVIDER = {
65+
providerName: "opencode-go",
66+
model: "muse-spark-1.3-contributor",
67+
};
6368
const DEFAULT_PROVIDER = {
6469
providerName: "unknown-provider",
6570
model: "unknown-model",
6671
};
6772

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

7176
/**
7277
* Pinned AGENTS.md body for prefix measurement. Production reads the live
@@ -162,7 +167,12 @@ export function assembleDirectorPrompt(
162167
): string {
163168
const pkg = DIRECTOR_REGISTRY[directorId];
164169
const orchestrator = pkg.spawn.maySpawn;
165-
const provider = family === "grok" ? GROK_PROVIDER : DEFAULT_PROVIDER;
170+
const provider =
171+
family === "grok"
172+
? GROK_PROVIDER
173+
: family === "muse"
174+
? MUSE_PROVIDER
175+
: DEFAULT_PROVIDER;
166176
const policy = resolveModelFamilyPolicy({ ...provider, orchestrator });
167177
return buildSubAgentSystemPrompt(
168178
[formatDirectorSystemPrompt(pkg)],
@@ -234,7 +244,7 @@ export function measureSkywalkerPrefix(): SkywalkerPrefixSize {
234244
export function directorPromptSizeTable(): DirectorPromptSize[] {
235245
const rows: DirectorPromptSize[] = [];
236246
for (const directorId of DIRECTOR_IDS) {
237-
for (const family of ["default", "grok"] as const) {
247+
for (const family of ["default", "muse", "grok"] as const) {
238248
rows.push(measureDirectorPrompt(directorId, family));
239249
}
240250
}
@@ -244,18 +254,21 @@ export function directorPromptSizeTable(): DirectorPromptSize[] {
244254
/** Render the size table as markdown (for PR bodies and budget updates). */
245255
export function formatPromptSizeTable(rows: DirectorPromptSize[]): string {
246256
const lines = [
247-
"| director | default chars (bytes) | grok chars (bytes) |",
248-
"| --- | --- | --- |",
257+
"| director | default chars (bytes) | muse chars (bytes) | grok chars (bytes) |",
258+
"| --- | --- | --- | --- |",
249259
];
250260
for (const directorId of DIRECTOR_IDS) {
251261
const base = rows.find(
252262
(r) => r.directorId === directorId && r.family === "default",
253263
);
264+
const muse = rows.find(
265+
(r) => r.directorId === directorId && r.family === "muse",
266+
);
254267
const grok = rows.find(
255268
(r) => r.directorId === directorId && r.family === "grok",
256269
);
257270
lines.push(
258-
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
271+
`| ${directorId} | ${base?.chars} (${base?.bytes}) | ${muse?.chars} (${muse?.bytes}) | ${grok?.chars} (${grok?.bytes}) |`,
259272
);
260273
}
261274
return lines.join("\n");

0 commit comments

Comments
 (0)