Skip to content

Commit 9311342

Browse files
test(prompts): pin grok primary to the session chat prompt (#1094)
1 parent 445886d commit 9311342

6 files changed

Lines changed: 200 additions & 15 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ The primary session identity is **Skywalker** (`buildChatRole` → `createSkywal
348348

349349
**Overrides.** `loadSystemPromptOverrides` (`src/agent/context-extensions.ts`) resolves a project `SYSTEM.md` (repo root, then `.corbits/`) that **replaces** the static base block, and an `APPEND_SYSTEM.md` that is **appended** as an extension. These compose with `config.systemPromptExtensions` (profile config) and the auto-discovered `AGENTS.md`, all of which attach as appended sections after the base.
350350

351+
**Grok prefix.** The provider cache prefix is the advertised tools array plus the system prompt. Grok does **not** get a trimmed fork of that prefix. `AGENTS.md` (capped at 32,000 bytes, framed as reference) and the full CORE+CATALOG schemas stay on every Grok primary prefix, same as every other family. Family residuals are additive lines only (`buildGrokLeafAntiThrashNote` on leaves); stripping project guidance or core schemas for Grok would be a prompt fork and would force `tool_search` round-trips — the thrash Grok is already sensitive to. The cheaper split already exists: workers use `buildSubAgentSystemPrompt` (trimmed director prompt, no `AGENTS.md`, mounted-tool schemas only). Skywalker's infer envelope is the primary chat prompt, not a spawned skywalker package. Prefix bytes are measured in `src/agent/prompt-sizes.ts` (`assembleSkywalkerInferEnvelope` vs `assembleDirectorPrompt("skywalker", "grok")`). `present` stays off the advertised prefix on every family.
352+
351353
### State Persistence (`src/session/state.ts`)
352354

353355
- `RunState` — `running` | `done` | `failed`, turns used, task, timestamps, error

‎docs/IMPLEMENTATION.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ Twenty packages under `src/agent/directors/<id>/` register in `DIRECTOR_REGISTRY
163163
4. `directorProfiles()` is the spawn catalog (`default-agents.ts`) — closed set minus skywalker. Plugin and local `.agents/agents/` profiles still load, but closed `DIRECTOR_IDS` cannot be overridden or aliased.
164164
5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. Product mutation tools (`write_file` / `edit_file` / `delete_file`) live in CORE (and `SKYWALKER_TOOLS`) so they are advertised on the primary without a `tool_search` round-trip. DIY tiny/bounded edits on the parent; spawn builder/docs directors for substantial work — a prompt judgment call, not a toolset strip. `PRIMARY_DENIED_PRODUCT_TOOLS` is gone. Shell file-writes stay denied; MCP tools are not re-filtered by a product-write deny list. There is no static per-profile write-path lock (CL-6952).
165165

166+
**Grok infer envelope.** `loadSessionChatPrompt` always appends `loadAgentContextExtensions` (`AGENTS.md`, capped at `MAX_AGENTS_MD_BYTES`) and advertises CORE+CATALOG full schemas via `advertisedToolNamesForSessionMode`. That assembly is family-agnostic — Grok does not substitute the trimmed director prompt (`buildSubAgentSystemPrompt` + `formatDirectorSystemPrompt`). Workers already use that trimmed path (no `AGENTS.md`, mounted-tool schemas only). Keep the infer envelope on Grok; do not strip `AGENTS.md` or core schemas. Measure in `src/agent/prompt-sizes.ts` (`assembleSkywalkerInferEnvelope` vs `assembleDirectorPrompt("skywalker", "grok")`).
167+
166168
**Codex tool proxies.** When the active provider is Codex (`isCodexProviderName`), `createAgentToolset` and `runSubAgent` mount `apply_patch`, `shell`, and `update_plan` stringTools from `createCodexToolProxies`, all forwarding through the same posix `ToolRunner` seam (`runTool`) so permission plugins still apply. `apply_patch` parses the Codex envelope and forwards each op (`write_file` / `delete_file` / `read_file`). `shell` — the native Codex name is `shell`, not `exec_command` — normalizes Codex's `command` (string or `["bash","-lc",script]`-style argv array), `workdir`, and `timeout_ms` onto `run_shell`'s `{command, cwd?, timeout?}` and is gated by `allowShellFromCapabilities` (mirrors `allowDeleteFromCapabilities` against `run_shell`). `update_plan` maps Codex's `plan: [{step, status}]` onto `manage_tasks(action: "create")`; `pending`/`in_progress`/`completed` map to `todo`/`doing`/`done` — `manage_tasks`'s `cancelled` status has no Codex equivalent and is never produced by this proxy. Primary strips `apply_patch` after mount (Corbits DIY stays on `write_file` / `edit_file` / `delete_file`); `shell` and `update_plan` stay on primary (same classification as `run_shell` / `manage_tasks`). Build and docs worker allowlists (`BUILD_TOOLS` / `DOCS_TOOLS`) include `apply_patch` so Codex workers keep the proxy after the capability filter. `CORE_TOOL_NAMES` does not list it.
167169

168170
6. There is no static write-path declaration on packages or profiles (CL-6952 removed it — no shipped director ever set one). Instead, `agent-fleet.ts` tracks each running dispatch by cwd; a new mutating dispatch that lands on the same cwd as a live mutating peer (`pending_init`/`running`, and not a declared read-only `modelRole` of `explore`/`plan`/`review`/`test`) records at most one `concurrent-lane-overlap` entry per cwd wave in `intervention-log.ts` (class `conflict`). The wave flag clears when no live mutating writer remains for that cwd. Terminal-but-unsettled lanes (for example cancelled with `finishedAt` set while the run promise has not reached `finally`) are pruned from the map and do not warn. This is advisory only — it never blocks the spawn, since cwd overlap does not prove the two lanes touch the same files.

‎src/agent/context-extensions.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
import { join } from "node:path";
22
import { SETTINGS_DIR_NAME } from "../branding.js";
33

4+
// Cap so a huge AGENTS.md cannot blow the provider cache prefix.
5+
export const MAX_AGENTS_MD_BYTES = 32_000;
6+
7+
export function formatAgentsMdExtension(content: string): string {
8+
return (
9+
`## Project guidance (AGENTS.md, reference)\n\n` +
10+
`The following is the repository's AGENTS.md, provided as background about the project. ` +
11+
`Do not execute its agent-onboarding or session-initialization steps (loading skills, reading skill files) — ` +
12+
`they target other tools and those files may not exist in this repo. Use it as reference when it helps the task.\n\n` +
13+
content.slice(0, MAX_AGENTS_MD_BYTES)
14+
);
15+
}
16+
417
export async function loadAgentContextExtensions(
518
cwd: string,
619
): Promise<string[]> {
@@ -9,19 +22,12 @@ export async function loadAgentContextExtensions(
922
try {
1023
const content = await Bun.file(agentsMdPath).text();
1124
if (content.trim().length > 0) {
12-
const MAX_AGENTS_MD_BYTES = 32_000;
1325
if (content.length > MAX_AGENTS_MD_BYTES) {
1426
process.stderr.write(
1527
`[interchange] Warning: AGENTS.md exceeds ${MAX_AGENTS_MD_BYTES} bytes and will be truncated.\n`,
1628
);
1729
}
18-
extensions.push(
19-
`## Project guidance (AGENTS.md, reference)\n\n` +
20-
`The following is the repository's AGENTS.md, provided as background about the project. ` +
21-
`Do not execute its agent-onboarding or session-initialization steps (loading skills, reading skill files) — ` +
22-
`they target other tools and those files may not exist in this repo. Use it as reference when it helps the task.\n\n` +
23-
content.slice(0, MAX_AGENTS_MD_BYTES),
24-
);
30+
extensions.push(formatAgentsMdExtension(content));
2531
}
2632
} catch (err: unknown) {
2733
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import { describe, expect, test } from "bun:test";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import { DIRECTOR_REGISTRY } from "./directors/registry.js";
36
import { DIRECTOR_IDS, type DirectorId } from "./directors/types.js";
47
import {
8+
assembleDirectorPrompt,
59
canonicalToolNamesForDirector,
610
directorPromptSizeTable,
711
formatPromptSizeTable,
12+
formatSkywalkerPrefixTable,
13+
assembleSkywalkerInferEnvelope,
14+
measureSkywalkerPrefix,
815
type PromptSizeFamily,
916
} from "./prompt-sizes.js";
17+
import {
18+
advertisedToolNamesForSessionMode,
19+
CATALOG_TOOL_NAMES,
20+
CORE_TOOL_NAMES,
21+
} from "./tool-search.js";
22+
import { MAX_AGENTS_MD_BYTES } from "./context-extensions.js";
23+
import { loadSessionChatPrompt } from "../session/runtime-assembly.js";
24+
import { createAdvertisedToolset } from "../session/assemble-runtime.js";
25+
import { resolveExecDirectorOverlay } from "../exec/runner.js";
1026

1127
/**
1228
* Prompt size budget (CL-7664). Numeric asserts only — copy edits must not
@@ -208,3 +224,105 @@ describe("director prompt size budget", () => {
208224
}
209225
});
210226
});
227+
228+
describe("skywalker grok prefix (infer envelope vs trimmed director)", () => {
229+
test("keeps AGENTS.md and core tools on the infer envelope", () => {
230+
const prompt = assembleSkywalkerInferEnvelope();
231+
expect(prompt).toContain("## Project guidance (AGENTS.md, reference)");
232+
expect(prompt).toContain("Follow the repository conventions.");
233+
for (const name of CORE_TOOL_NAMES) {
234+
if (name === "wait_agents") continue;
235+
expect(prompt, name).toContain(`- ${name}:`);
236+
}
237+
});
238+
239+
test("does not substitute the trimmed director prompt on grok", () => {
240+
const size = measureSkywalkerPrefix();
241+
expect(size.agentsMdCap).toBe(MAX_AGENTS_MD_BYTES);
242+
expect(size.inferEnvelopeChars).toBeGreaterThan(5000);
243+
expect(size.trimmedDirectorChars).toBeGreaterThan(5000);
244+
expect(size.inferEnvelopeBytes).toBeGreaterThanOrEqual(
245+
size.inferEnvelopeChars,
246+
);
247+
expect(size.inferEnvelopeChars).not.toBe(size.trimmedDirectorChars);
248+
const infer = assembleSkywalkerInferEnvelope();
249+
expect(infer).not.toContain("Finish bias (xAI / Grok worker):");
250+
});
251+
252+
test("formatSkywalkerPrefixTable reports both prefixes and the AGENTS.md cap", () => {
253+
const size = measureSkywalkerPrefix();
254+
const table = formatSkywalkerPrefixTable(size);
255+
expect(table).toContain(
256+
`| skywalker infer envelope (canonical AGENTS.md) | ${size.inferEnvelopeChars} (${size.inferEnvelopeBytes}) |`,
257+
);
258+
expect(table).toContain(
259+
`| skywalker trimmed director (grok) | ${size.trimmedDirectorChars} (${size.trimmedDirectorBytes}) |`,
260+
);
261+
expect(table).toContain(`| live AGENTS.md cap | ${size.agentsMdCap} |`);
262+
});
263+
264+
// Production pin: a Grok fork at the runner that swapped loadSessionChatPrompt
265+
// or advertisedToolNamesForSessionMode for the trimmed director would fail here,
266+
// not only the fixture size inequality above.
267+
test("grok primary uses loadSessionChatPrompt and CORE+CATALOG, not the trimmed director", async () => {
268+
const cwd = await mkdtemp(join(tmpdir(), "grok-primary-prefix-"));
269+
try {
270+
const agentsBody = "GROK_PRIMARY_KEEPS_AGENTS_MD\n";
271+
await writeFile(join(cwd, "AGENTS.md"), agentsBody);
272+
const availability = {
273+
languageServerAvailable: true,
274+
operatorAvailable: true,
275+
waitAgentsMounted: true,
276+
} as const;
277+
278+
const { systemPrompt } = await loadSessionChatPrompt({
279+
cwd,
280+
skillDirs: [],
281+
sessionMode: "orchestrator",
282+
toolAvailability: availability,
283+
skills: [],
284+
});
285+
expect(systemPrompt).toContain(
286+
"## Project guidance (AGENTS.md, reference)",
287+
);
288+
expect(systemPrompt).toContain(agentsBody.trim());
289+
for (const name of CORE_TOOL_NAMES) {
290+
expect(systemPrompt, name).toContain(`- ${name}:`);
291+
}
292+
293+
const trimmed = assembleDirectorPrompt("skywalker", "grok");
294+
expect(trimmed).not.toContain(
295+
"## Project guidance (AGENTS.md, reference)",
296+
);
297+
expect(trimmed).not.toContain(agentsBody.trim());
298+
299+
const advertised = advertisedToolNamesForSessionMode(
300+
"orchestrator",
301+
availability,
302+
);
303+
expect(advertised).toEqual([...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES]);
304+
const trimmedTools = canonicalToolNamesForDirector(
305+
DIRECTOR_REGISTRY.skywalker,
306+
"grok",
307+
);
308+
expect(trimmedTools).not.toContain("list_dir");
309+
expect(trimmedTools).not.toContain("tool_search");
310+
expect(trimmedTools).not.toContain("skill_search");
311+
312+
const overlay = resolveExecDirectorOverlay("skywalker");
313+
expect(overlay.systemPrompt).toBeUndefined();
314+
expect(overlay.advertisedAllow).toBeUndefined();
315+
const { isAdvertised } = createAdvertisedToolset({
316+
sessionMode: "orchestrator",
317+
toolAvailability: availability,
318+
getProvider: () => ({ providerName: "xai/default", model: "grok-4.6" }),
319+
builtInPrefix: overlay.advertisedAllow,
320+
});
321+
for (const name of advertised) {
322+
expect(isAdvertised(name), name).toBe(true);
323+
}
324+
} finally {
325+
await rm(cwd, { recursive: true, force: true });
326+
}
327+
});
328+
});

‎src/agent/prompt-sizes.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
type DirectorId,
1212
type DirectorPackage,
1313
} from "./directors/types.js";
14-
import { buildSubAgentSystemPrompt } from "./prompts.js";
14+
import { buildChatSystemPrompt, buildSubAgentSystemPrompt } from "./prompts.js";
15+
import {
16+
formatAgentsMdExtension,
17+
MAX_AGENTS_MD_BYTES,
18+
} from "./context-extensions.js";
1519
import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js";
1620
import { isCodexProviderName } from "../config/codex-providers.js";
1721
import { shellCollectDefinition } from "./background-shell-tool.js";
@@ -57,6 +61,14 @@ const DEFAULT_PROVIDER = {
5761
/** Families in the size table: default assembly vs Grok (+finish-bias note). */
5862
export type PromptSizeFamily = "default" | "grok";
5963

64+
/**
65+
* Pinned AGENTS.md body for prefix measurement. Production reads the live
66+
* file (capped at MAX_AGENTS_MD_BYTES); the fixture pins a short body so
67+
* sizes move only when framing or assembly changes, not when the checkout's
68+
* AGENTS.md is edited.
69+
*/
70+
export const CANONICAL_AGENTS_MD = "Follow the repository conventions.\n";
71+
6072
/**
6173
* Pre-filter mount names in run.ts install order: posix base (TOOL_NAMES,
6274
* shared with createPosixTools) + delete_file / lsp plugin tools
@@ -156,6 +168,19 @@ export function assembleDirectorPrompt(
156168
);
157169
}
158170

171+
/**
172+
* Skywalker primary infer envelope: the chat system prompt plus the
173+
* AGENTS.md extension. Family-agnostic — Grok does not substitute the
174+
* trimmed director prompt. Live AGENTS.md is capped at MAX_AGENTS_MD_BYTES;
175+
* the fixture uses CANONICAL_AGENTS_MD so the number is checkout-stable.
176+
*/
177+
export function assembleSkywalkerInferEnvelope(): string {
178+
return buildChatSystemPrompt(
179+
[formatAgentsMdExtension(CANONICAL_AGENTS_MD)],
180+
CANONICAL_PROMPT_ENV,
181+
);
182+
}
183+
159184
export interface DirectorPromptSize {
160185
directorId: DirectorId;
161186
family: PromptSizeFamily;
@@ -176,6 +201,26 @@ export function measureDirectorPrompt(
176201
};
177202
}
178203

204+
export interface SkywalkerPrefixSize {
205+
inferEnvelopeChars: number;
206+
inferEnvelopeBytes: number;
207+
trimmedDirectorChars: number;
208+
trimmedDirectorBytes: number;
209+
agentsMdCap: number;
210+
}
211+
212+
export function measureSkywalkerPrefix(): SkywalkerPrefixSize {
213+
const infer = assembleSkywalkerInferEnvelope();
214+
const trimmed = assembleDirectorPrompt("skywalker", "grok");
215+
return {
216+
inferEnvelopeChars: infer.length,
217+
inferEnvelopeBytes: Buffer.byteLength(infer, "utf8"),
218+
trimmedDirectorChars: trimmed.length,
219+
trimmedDirectorBytes: Buffer.byteLength(trimmed, "utf8"),
220+
agentsMdCap: MAX_AGENTS_MD_BYTES,
221+
};
222+
}
223+
179224
/** Full per-director x per-family size table. */
180225
export function directorPromptSizeTable(): DirectorPromptSize[] {
181226
const rows: DirectorPromptSize[] = [];
@@ -206,3 +251,13 @@ export function formatPromptSizeTable(rows: DirectorPromptSize[]): string {
206251
}
207252
return lines.join("\n");
208253
}
254+
255+
export function formatSkywalkerPrefixTable(size: SkywalkerPrefixSize): string {
256+
return [
257+
"| prefix | chars (bytes) |",
258+
"| --- | --- |",
259+
`| skywalker infer envelope (canonical AGENTS.md) | ${size.inferEnvelopeChars} (${size.inferEnvelopeBytes}) |`,
260+
`| skywalker trimmed director (grok) | ${size.trimmedDirectorChars} (${size.trimmedDirectorBytes}) |`,
261+
`| live AGENTS.md cap | ${size.agentsMdCap} |`,
262+
].join("\n");
263+
}

‎tests/unit/agent-context-extensions.test.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { test, expect, beforeEach, afterEach } from "bun:test";
22
import { mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
5-
import { loadAgentContextExtensions } from "../../src/agent/context-extensions.js";
5+
import {
6+
loadAgentContextExtensions,
7+
MAX_AGENTS_MD_BYTES,
8+
} from "../../src/agent/context-extensions.js";
69
import { defined } from "../helpers/defined.js";
710

811
let dir: string;
@@ -31,14 +34,13 @@ test("AGENTS.md absent returns empty array without throwing", async () => {
3134
expect(result).toHaveLength(0);
3235
});
3336

34-
test("AGENTS.md exceeds 32000 bytes is truncated to 32000 bytes", async () => {
35-
const MAX = 32_000;
36-
const content = "x".repeat(MAX + 500);
37+
test("AGENTS.md exceeds MAX_AGENTS_MD_BYTES is truncated", async () => {
38+
const content = "x".repeat(MAX_AGENTS_MD_BYTES + 500);
3739
await writeFile(join(dir, "AGENTS.md"), content);
3840
const result = await loadAgentContextExtensions(dir);
3941
expect(result).toHaveLength(1);
40-
expect(result[0]).toContain("x".repeat(MAX));
41-
expect(result[0]).not.toContain("x".repeat(MAX + 1));
42+
expect(result[0]).toContain("x".repeat(MAX_AGENTS_MD_BYTES));
43+
expect(result[0]).not.toContain("x".repeat(MAX_AGENTS_MD_BYTES + 1));
4244
});
4345

4446
test("AGENTS.md empty (whitespace only) returns empty array", async () => {

0 commit comments

Comments
 (0)