Public: anyone can read this; the crew can change it.
Briefs and prompts carry nothing that works for only one model family #12253
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12128-briefs-and-prompts-carry-nothing-that-wo → main 5 commits fork 4 threads · 4 unresolved
4 files
changed +193
−10
src/crew/agent_runner.ts +12
| @@ -11,6 +11,7 @@ import type { Measure } from "../core/measure.ts"; | ||
| 11 | 11 | import type { LimitWindow } from "../core/standing.ts"; |
| 12 | 12 | import type { RunLimits } from "./run-cell.ts"; |
| 13 | 13 | import type { SandboxPolicy } from "./sandbox.ts"; |
| 14 | import type { ToolNames } from "./brief.ts"; | |
| 14 | 15 | import { detectClaude, runClaude } from "./claude.ts"; |
| 15 | 16 | |
| 16 | 17 | export interface AgentRun { |
| @@ -116,6 +117,12 @@ export interface AgentRunner { | ||
| 116 | 117 | run(input: AgentRun): Promise<AgentOutcome>; |
| 117 | 118 | /** Whether `binary` is an agent this runner can drive. */ |
| 118 | 119 | 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; | |
| 119 | 126 | } |
| 120 | 127 | |
| 121 | 128 | // Wrapped, not bound, so the two modules may load in either order. |
| @@ -129,6 +136,11 @@ export function currentRunner(): AgentRunner { | ||
| 129 | 136 | return claude; |
| 130 | 137 | } |
| 131 | 138 | |
| 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 | ||
| 132 | 144 | export function runAgent(input: AgentRun): Promise<AgentOutcome> { |
| 133 | 145 | return currentRunner().run(input); |
| 134 | 146 | } |
src/crew/brief.ts +22 −9
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 4 | 4 | * refused — and the task itself, which differs by why the run exists: an |
| 5 | 5 | * assignment, feedback on a pull, or a check that failed. The golden path |
| 6 | 6 | * is written down here so it is prompted, not hoped for; the mechanical |
| @@ -60,8 +60,21 @@ export interface BriefInput { | ||
| 60 | 60 | * it writes obey the cells and the cells it does not touch stay decided. |
| 61 | 61 | */ |
| 62 | 62 | 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; | |
| 63 | 69 | } |
| 64 | 70 | |
| 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 | ||
| 65 | 78 | export interface PolicyBrief { |
| 66 | 79 | /** The identities the forge signs, floor first. */ |
| 67 | 80 | readonly identities: readonly string[]; |
| @@ -87,7 +100,7 @@ export const SANDBOX_LINES = [ | ||
| 87 | 100 | * listed would run everything, so the line guards on its exit code. |
| 88 | 101 | */ |
| 89 | 102 | 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."; | |
| 91 | 104 | |
| 92 | 105 | /** |
| 93 | 106 | * What the repository's own text is to a run: data about the task, never a |
| @@ -373,12 +386,12 @@ const MCP_SIZE_RULE = | ||
| 373 | 386 | * A time promised to the owner is a record first (#11955); the task prompt carries it, |
| 374 | 387 | * not the system brief, to keep that short. |
| 375 | 388 | */ |
| 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.`; | |
| 378 | 391 | |
| 379 | 392 | /** 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.`; | |
| 382 | 395 | |
| 383 | 396 | /** |
| 384 | 397 | * For the review of a forge pull that touches src/web/: the principles that |
| @@ -481,8 +494,8 @@ function taskBody(input: TaskInput): string { | ||
| 481 | 494 | ); |
| 482 | 495 | if (input.pull === undefined && isForge(input.repo) && namesMcp(issue.title, issue.body)) |
| 483 | 496 | 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)); | |
| 486 | 499 | out.push( |
| 487 | 500 | "", |
| 488 | 501 | `Work it on the branch \`${input.branch}\` and push when done.`, |
src/crew/runner.ts +3 −1
| @@ -140,7 +140,7 @@ import { | ||
| 140 | 140 | NETWORK_RETRY_SECONDS, |
| 141 | 141 | pause, |
| 142 | 142 | } 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"; | |
| 144 | 144 | import { |
| 145 | 145 | isRateLimited, |
| 146 | 146 | limitOf, |
| @@ -1220,6 +1220,8 @@ async function work( | ||
| 1220 | 1220 | previewUrl, |
| 1221 | 1221 | ownApp: ownApp(app, repo), |
| 1222 | 1222 | policy, |
| 1223 | // What this runner calls the forge's tools; the brief says those names. | |
| 1224 | tools: briefTools(), | |
| 1223 | 1225 | // A review reads one head afresh: a resumed session would carry an |
| 1224 | 1226 | // older head's diff and preview into the verdict on this one. Any |
| 1225 | 1227 | // 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 |
||
| 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 | }); |
new above
offline
toolsis 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.