Public: anyone can read this; the crew can change it.

Briefs and prompts carry nothing that works for only one model family #12253

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/12128-briefs-and-prompts-carry-nothing-that-wo → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/12128-briefs-and-prompts-carry-nothing-that-wo was merged into main as 09671ca.

Review

Sign in to review this change.
4 files changed +193 −10
src/crew/agent_runner.ts +12
@@ -11,6 +11,7 @@ import type { Measure } from "../core/measure.ts";
1111 import type { LimitWindow } from "../core/standing.ts";
1212 import type { RunLimits } from "./run-cell.ts";
1313 import type { SandboxPolicy } from "./sandbox.ts";
14 import type { ToolNames } from "./brief.ts";
1415 import { detectClaude, runClaude } from "./claude.ts";
1516
1617 export interface AgentRun {
@@ -116,6 +117,12 @@ export interface AgentRunner {
116117 run(input: AgentRun): Promise<AgentOutcome>;
117118 /** Whether `binary` is an agent this runner can drive. */
118119 detect(binary: string): Promise<AgentDetection>;
120 /**
121 * What this runner's harness calls the forge's tools, where it names them
122 * differently from the forge (a prefix, another convention). The brief
123 * says these names; absent, it says the neutral ones (`DEFAULT_TOOLS`).
124 */
125 readonly tools?: Partial<ToolNames> | undefined;
119126 }
120127
121128 // Wrapped, not bound, so the two modules may load in either order.
@@ -129,6 +136,11 @@ export function currentRunner(): AgentRunner {
129136 return claude;
130137 }
131138
139 /** The forge tool names a brief should say for `runner` (#12128); the current runner by default. */
140 export function briefTools(runner: AgentRunner = currentRunner()): Partial<ToolNames> | undefined {
141 return runner.tools;
142 }
143
132144 export function runAgent(input: AgentRun): Promise<AgentOutcome> {
133145 return currentRunner().run(input);
134146 }
src/crew/brief.ts +22 −9
@@ -1,6 +1,6 @@
11 /**
2 * What an agent is told. Two texts: a system prompt appended to Claude
3 * Code's own — who the agent is, where it is, how to finish, what will be
2 * What an agent is told. Two texts: a system prompt appended to the runner
3 * agent's own — who the agent is, where it is, how to finish, what will be
44 * refused — and the task itself, which differs by why the run exists: an
55 * assignment, feedback on a pull, or a check that failed. The golden path
66 * is written down here so it is prompted, not hoped for; the mechanical
@@ -60,8 +60,21 @@ export interface BriefInput {
6060 * it writes obey the cells and the cells it does not touch stay decided.
6161 */
6262 readonly policy?: PolicyBrief | undefined;
63 /**
64 * What the runner calls the forge's tools, when its harness names them
65 * differently (a prefix, another convention). The brief carries only
66 * these names, never a harness's own tool vocabulary.
67 */
68 readonly tools?: Partial<ToolNames> | undefined;

tools is never set by any caller outside tests. Wire it from the runner where the brief input is built (with a test), or remove the field and file a follow-up; an unused seam does not satisfy box 2.

6369 }
6470
71 /** The forge tools a brief names; neutral names unless the runner says otherwise. */
72 export interface ToolNames {
73 readonly keepPlan: string;
74 readonly estimate: string;
75 }
76 export const DEFAULT_TOOLS: ToolNames = { keepPlan: "keep_plan", estimate: "estimate" };
77
6578 export interface PolicyBrief {
6679 /** The identities the forge signs, floor first. */
6780 readonly identities: readonly string[];
@@ -87,7 +100,7 @@ export const SANDBOX_LINES = [
87100 * listed would run everything, so the line guards on its exit code.
88101 */
89102 export const PUSH_STEPS_LINE =
90 "- Before you push, run `bun install` (only when node_modules is missing), then, when the clone has `scripts/gate.ts`, `bun scripts/gate.ts` (the deterministic invariants: exclusions, migration pins), then `bun x tsc --noEmit`, and the tests that cover your change, plus any the repository's CLAUDE.md or AGENTS.md says always to run. Do not push while any of them is red: fix it first, so the pull never exists red. Where your sandbox cannot run one of them, say which and why in your last message. When the clone has `scripts/fast.ts`, name them with `files=$(bun scripts/fast.ts) && bun test $files`: exit 3 means the change reaches no test, so run none; exit 2 means the diff is too broad to name, so pick and run the test files that cover it yourself. Never a bare `bun test` with no files listed: it runs everything. Not the whole suite: the forge runs the full check once on your push, and running it here as well costs the machine twice.";
103 "- Before you push, run `bun install` (only when node_modules is missing), then, when the clone has `scripts/gate.ts`, `bun scripts/gate.ts` (the deterministic invariants: exclusions, migration pins), then `bun x tsc --noEmit`, and the tests that cover your change, plus any the repository's agent guide (AGENTS.md) says always to run. Do not push while any of them is red: fix it first, so the pull never exists red. Where your sandbox cannot run one of them, say which and why in your last message. When the clone has `scripts/fast.ts`, name them with `files=$(bun scripts/fast.ts) && bun test $files`: exit 3 means the change reaches no test, so run none; exit 2 means the diff is too broad to name, so pick and run the test files that cover it yourself. Never a bare `bun test` with no files listed: it runs everything. Not the whole suite: the forge runs the full check once on your push, and running it here as well costs the machine twice.";
91104
92105 /**
93106 * What the repository's own text is to a run: data about the task, never a
@@ -373,12 +386,12 @@ const MCP_SIZE_RULE =
373386 * A time promised to the owner is a record first (#11955); the task prompt carries it,
374387 * not the system brief, to keep that short.
375388 */
376 export const ESTIMATE_RULE =
377 'A "by <time>" you say to the owner is recorded first with the `estimate` tool (ask it for the issue\'s base rate, then record `event` pull_opened, merged, live or done (an issue that takes several pulls) with `p50` and `p90` instants and a one-line `basis`), or it is not said. The forge resolves it when the event happens and scores it.';
389 export const estimateRule = (tools?: Partial<ToolNames>): string =>
390 `A "by <time>" you say to the owner is recorded first with the \`${tools?.estimate ?? DEFAULT_TOOLS.estimate}\` tool (ask it for the issue's base rate, then record \`event\` pull_opened, merged, live or done (an issue that takes several pulls) with \`p50\` and \`p90\` instants and a one-line \`basis\`), or it is not said. The forge resolves it when the event happens and scores it.`;
378391
379392 /** A plan on the issue is how a person sees where a run is without reading its log; only the forge serves the tool. */
380 const PLAN_RULE =
381 "Keep a short plan on this issue with the `keep_plan` tool (up to 12 items, replaced whole each time): call it once you have read the code and again as items finish, so a person sees where you are without a log.";
393 const planRule = (tools?: Partial<ToolNames>): string =>
394 `Keep a short plan on this issue with the \`${tools?.keepPlan ?? DEFAULT_TOOLS.keepPlan}\` tool (up to 12 items, replaced whole each time): call it once you have read the code and again as items finish, so a person sees where you are without a log.`;
382395
383396 /**
384397 * For the review of a forge pull that touches src/web/: the principles that
@@ -481,8 +494,8 @@ function taskBody(input: TaskInput): string {
481494 );
482495 if (input.pull === undefined && isForge(input.repo) && namesMcp(issue.title, issue.body))
483496 out.push("", MCP_SIZE_RULE);
484 if (input.pull === undefined && isForge(input.repo)) out.push("", PLAN_RULE);
485 if (input.pull === undefined && isForge(input.repo)) out.push("", ESTIMATE_RULE);
497 if (input.pull === undefined && isForge(input.repo)) out.push("", planRule(input.tools));
498 if (input.pull === undefined && isForge(input.repo)) out.push("", estimateRule(input.tools));
486499 out.push(
487500 "",
488501 `Work it on the branch \`${input.branch}\` and push when done.`,
src/crew/runner.ts +3 −1
@@ -140,7 +140,7 @@ import {
140140 NETWORK_RETRY_SECONDS,
141141 pause,
142142 } from "./network.ts";
143 import { BUDGET_REACHED, runAgent, type AgentOutcome } from "./agent_runner.ts";
143 import { BUDGET_REACHED, briefTools, runAgent, type AgentOutcome } from "./agent_runner.ts";
144144 import {
145145 isRateLimited,
146146 limitOf,
@@ -1220,6 +1220,8 @@ async function work(
12201220 previewUrl,
12211221 ownApp: ownApp(app, repo),
12221222 policy,
1223 // What this runner calls the forge's tools; the brief says those names.
1224 tools: briefTools(),
12231225 // A review reads one head afresh: a resumed session would carry an
12241226 // older head's diff and preview into the verdict on this one. Any
12251227 // other follow-up resumes only while it is cheap to (`resume.ts`).
test/brief-neutral.test.ts added +156
@@ -0,0 +1,156 @@
1 import { describe, expect, test } from "bun:test";
2 import { CONVERSATION_BRIEF } from "../src/crew/conversation_brief.ts";
3 import {
4 SANDBOX_LINES,
5 PUSH_STEPS_LINE,
6 REPO_CONTENT_LINE,
7 STRANGER_REVIEW,
8 estimateRule,
9 systemPrompt,
10 wrapUpPrompt,
11 policyLines,
12 ownAppLines,
13 type RunReason,
14 taskPrompt,
15 type BriefInput,
16 type TaskInput,
17 } from "../src/crew/brief.ts";
18 import { briefTools, type AgentRunner } from "../src/crew/agent_runner.ts";
19
20 /** A vendor's name, a model nickname, or a tag or tool name only one family reads. */
21 const VENDOR =
22 /claude|anthropic|opus|sonnet|haiku|openai|gpt-?\d|chatgpt|codex|gemini|\bgrok\b|\bxai\b|<\/?(thinking|antml|system-reminder|function_calls|invoke)\b|\b(TodoWrite|WebFetch|WebSearch|NotebookEdit|ExitPlanMode)\b|CLAUDE\.md/i;
23
24 const base = {
25 agent: { user: { username: "bot", displayName: "" }, brief: "" },
26 repo: { ownerName: "alice", name: "forge", defaultBranch: "main", id: "r1", autonomyDirection: "Ship small." },
27 issue: { number: 1, title: "Fix the footer", body: "It overlaps.", authorName: "alice" },
28 comments: [],
29 reviews: [],
outdated

Add wrapUpPrompt(), policyLines, ownAppLines and the planner/re-plan prompt builders here; as written most of the prompt text in brief.ts is never scanned.

30 reviewComments: [],
31 since: "2026-01-01T00:00:00Z",
32 branch: "bot/1",
33 forgeUrl: "http://forge.test",
34 isApp: false,
outdated

Remove this try/catch: a swallowed throw means a reason goes unchecked. Give the fixture what each reason needs and assert all seven RunReason values are present.

35 resuming: false,
36 };
37
38 const REASONS: readonly RunReason[] = ["assigned", "feedback", "check_failed", "conflict", "resolve", "converge", "review"];
39
40 /** Every field a planner is told of, so each planner paragraph is built. */
41 const planner = {
42 role: "planner",
43 laterGate: 9,
44 parkedTwice: [{ number: 3, title: "Parked", reasons: ["too big", "too big"], byAgent: true }, { number: 4, title: "Pull", reasons: ["x"], pull: true }],
45 limbs: [{ number: 5, title: "Limb", issues: [{ number: 6, title: "Child" }] }],
46 stuck: { held: "open_pull=1", issues: [{ number: 7, title: "Stalled", hold: "stalled" }, { number: 8, title: "Capped", hold: "run_cap" }] },
47 boxes: [{ number: 2, title: "Closes", boxes: ["one", "two"] }],
48 landed: [{ number: 10, title: "Landed", boxes: ["left"] }],
49 owed: [{ team: { slug: "design", dial: 20, charter: "looks" }, open: 0, today: 1 }],
50 reserve: { startable: 1, target: 4 },
51 };
52
53 const texts = (): Record<string, string> => {
54 const out: Record<string, string> = {
55 system: systemPrompt(base as unknown as BriefInput),
56 "system:planner": systemPrompt({ ...base, role: "planner" } as unknown as BriefInput),
57 "system:app": systemPrompt({
58 ...base,
59 isApp: true,
60 machine: { id: "m", name: "m", tools: ["git"] },
61 policy: { identities: ["anon", "user"], lines: ["note: read anon"] },
62 ownApp: { access: "private", production: "https://x", previews: [{ number: 1, url: "https://p" }], blocked: ["a.test"] },
63 } as unknown as BriefInput),
64 conversation: CONVERSATION_BRIEF,
65 wrapUp: wrapUpPrompt(),
66 "policy:problems": policyLines({ identities: [], lines: [], problems: ["bad"] }).join("\n"),
67 "policy:table": policyLines({ identities: ["anon"], lines: ["note: read anon"] }).join("\n"),
68 "ownApp:private": ownAppLines({ access: "private", production: "https://x", previews: [{ number: 1, url: "https://p" }], blocked: ["a.test"] }).join("\n"),
69 "ownApp:none": ownAppLines({ access: "public", production: "https://x", previews: [] }).join("\n"),
70 constants: [...SANDBOX_LINES, PUSH_STEPS_LINE, REPO_CONTENT_LINE, STRANGER_REVIEW, estimateRule()].join("\n"),
71 };
72 for (const reason of REASONS) {
73 out[`task:${reason}`] = taskPrompt({
74 ...base,
75 reason,
76 checkLog: ["boom"],
77 checkKind: "check",
78 conflictFiles: ["a.ts"],
79 resolution: "both sides",
80 following: { parent: "alice/base", branch: "main" },
81 reviewDiff: "+x",
82 design: true,
83 looks: ["a.png"],
84 boxes: planner.boxes,
85 stranger: true,
86 paused: true,
87 retry: true,
88 lastTry: { reviewer: "r", verdict: "changes_requested", answer: "a" },
89 } as unknown as TaskInput);
90 out[`planner:${reason}`] = taskPrompt({ ...base, ...planner, reason } as unknown as TaskInput);
91 }
92 out["task:foreign"] = taskPrompt({ ...base, reason: "assigned", foreignPull: { branch: "x", diff: "+y" } } as unknown as TaskInput);
93 out["task:mcp"] = taskPrompt({ ...base, reason: "assigned", issue: { ...base.issue, title: "MCP tool" } } as unknown as TaskInput);
94 return out;
95 };
96
97 describe("briefs are neutral across model families", () => {
98 const built = texts();
99 test("every run reason builds a task and a planner prompt", () => {
100 for (const reason of REASONS) {
101 expect(built[`task:${reason}`]?.length ?? 0).toBeGreaterThan(50);
102 expect(built[`planner:${reason}`]?.length ?? 0).toBeGreaterThan(50);
103 }
104 expect(built["planner:assigned"]).toContain("standing reserve");
105 expect(built["planner:assigned"]).toContain("Limb #5");
106 expect(built["planner:assigned"]).toContain("Teams own");
107 expect(built["planner:assigned"]).toContain("Parked twice");
108 expect(built["planner:assigned"]).toContain("Builders are free");
109 expect(built["planner:assigned"]).toContain("left boxes open");
110 });
111 for (const [name, text] of Object.entries(built))
112 test(`${name} names no vendor, model nickname or vendor tag`, () => {
113 expect(text.match(VENDOR)?.[0]).toBeUndefined();
114 });
115 test("the check itself catches a vendor", () => {
116 for (const bad of ["ask Claude", "Claude Code's own", "<thinking>", "use TodoWrite", "see CLAUDE.md", "a GPT-4 run"])
117 expect(VENDOR.test(bad)).toBe(true);
118 });
119 });
120
121 describe("what is per-runner comes through the seam's data", () => {
122 const task = { ...base, reason: "assigned" } as unknown as TaskInput;
123 test("the forge tools carry neutral defaults", () => {
124 const text = taskPrompt(task);
125 expect(text).toContain("`keep_plan`");
126 expect(text).toContain("`estimate`");
127 });
128 test("a runner's own names replace them, and the prompt stays neutral", () => {
129 const withTools: TaskInput = { ...task, tools: { keepPlan: "forge__keep_plan", estimate: "forge__estimate" } };
130 const text = taskPrompt(withTools);
outdated

Drop the as TaskInput cast so the type check proves tools is on TaskInput; the override prompt should also be run through VENDOR.

131 expect(text).toContain("`forge__keep_plan`");
132 expect(text).toContain("`forge__estimate`");
133 expect(text).not.toContain("`keep_plan`");
134 expect(text.match(VENDOR)?.[0]).toBeUndefined();
135 });
136 test("the runner's names reach the built prompt through the seam", () => {
137 const runner: AgentRunner = {
138 run: () => Promise.reject(new Error("not run")),
139 detect: () => Promise.resolve({ ok: false, error: "not run" }),
140 tools: { keepPlan: "x_keep_plan", estimate: "x_estimate" },
141 };
142 const text = taskPrompt({ ...task, tools: briefTools(runner) });
143 expect(text).toContain("`x_keep_plan`");
144 expect(text).toContain("`x_estimate`");
145 expect(text.match(VENDOR)?.[0]).toBeUndefined();
146 });
147 test("the current runner keeps the neutral names", () => {
148 const text = taskPrompt({ ...task, tools: briefTools() });
149 expect(text).toContain("`keep_plan`");
150 expect(text).toContain("`estimate`");
151 });
152 test("the crew runner builds its brief with the runner's tool names", async () => {
153 const source = await Bun.file("src/crew/runner.ts").text();
154 expect(source).toContain("tools: briefTools(),");
155 });
156 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs