diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f8415b..b9dd6983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,26 @@ The release workflow uses a tag's section here as its release notes (a hand-written `docs/releases/.md` wins if present), so keeping this file current is part of cutting a release. -## v0.0.240 (unreleased) +## v0.0.241 (unreleased) + +- The system prompt is now assembled from capability-gated segments (#421): + an instruction ships only when its capability is actually present, read + from the same predicates dispatch uses to refuse a hallucinated call, so + the prompt can never disagree with the tool catalog. An embedder session + (`--no-local-tools`) drops 28.5% of the base prompt; the floor config + drops 43.2%. At full capability the compose returns the comptime constant + itself — zero allocation, byte-identical to what shipped before. Two + standing doctrine lines joined the always-on intro: never invent a tool or + wrapper API, and run the target project through its OWN environment — a + failure there is the relevant result. +- A durable root session's prompt now names its own transcript (#410): the + path to `.graff/sessions/.session.json`, described truthfully — one + JSON object, lags the live turn, rewritten in place by compaction (a + resume artifact, not an append-only archive) — so the model can consult + it without burning turns on wrong assumptions. Suppressed when local + tools are gone (an unreadable path is pure token waste). + +## v0.0.240 (2026-08-06) - The REPL/engine separation began (#422): agent output now flows through a typed event vocabulary and a strict sink boundary (`engine_events.zig` / diff --git a/examples/prepare_graff_tournament.py b/examples/prepare_graff_tournament.py old mode 100755 new mode 100644 index 5ab175c2..0670166c --- a/examples/prepare_graff_tournament.py +++ b/examples/prepare_graff_tournament.py @@ -27,22 +27,34 @@ def write_json(path: Path, value: object) -> None: def extract_root_prompt(source: Path) -> str: + # justrach/codegraff#421 split the root prompt into capability-scoped + # segments, so "everything after `pub const main_system_prompt =`" no longer + # names one literal. The markers delimit the whole segment region instead. + # Lines WITHIN one segment join with a newline; segments concatenate with + # nothing, which is how prompts.zig composes them (several boundaries fall + # mid-sentence). Joining everything with "\n" would insert a blank line at + # each boundary and the seed genome would not be the shipped prompt. lines = source.read_text(encoding="utf-8").splitlines() collecting = False - result: list[str] = [] + segments: list[list[str]] = [] for line in lines: - if line.startswith("pub const main_system_prompt ="): + if line.startswith("// ── ROOT PROMPT BEGIN"): collecting = True continue - if collecting and line == ";": + if collecting and line.startswith("// ── ROOT PROMPT END"): break - if collecting: - marker = line.find("\\\\") - if marker >= 0: - result.append(line[marker + 2 :]) + if not collecting: + continue + if line.startswith("pub const "): + segments.append([]) + continue + marker = line.find("\\\\") + if marker >= 0 and segments: + segments[-1].append(line[marker + 2 :]) + result = "".join("\n".join(seg) for seg in segments) if not result: - raise ValueError("could not extract main_system_prompt") - return "\n".join(result).strip() + "\n" + raise ValueError("could not extract the root prompt segments") + return result.strip() + "\n" def pin(path: Path) -> dict[str, str]: @@ -78,7 +90,7 @@ def main() -> None: raise FileNotFoundError(path) parent = output / "parent.md" - parent.write_text(extract_root_prompt(repo / "src" / "prompts.zig"), encoding="utf-8") + parent.write_text(extract_root_prompt(repo / "src" / "prompt_text.zig"), encoding="utf-8") parent.chmod(0o600) primary = output / "primary.json" holdout = output / "fresh-holdout.json" diff --git a/src/playbook_tests.zig b/src/playbook_tests.zig index d71d86bf..56d0f000 100644 --- a/src/playbook_tests.zig +++ b/src/playbook_tests.zig @@ -298,6 +298,7 @@ fn stubRoot(gpa: std.mem.Allocator, arena: std.mem.Allocator, out: *Io.Writer) A .sub = false, .label = "test", .out = out, + .session_name = "", // #410: a scratch stub has no durable session, so setRootSystemPrompts stays a pure string funnel here }; } diff --git a/src/prompt_snapshot_tests.zig b/src/prompt_snapshot_tests.zig new file mode 100644 index 00000000..8249512c --- /dev/null +++ b/src/prompt_snapshot_tests.zig @@ -0,0 +1,339 @@ +//! #421 + #410: prompt-snapshot tests. The root system prompt is now assembled +//! from capability-scoped segments (prompts.zig), which makes two things +//! testable that never were: +//! +//! 1. an absent capability contributes ZERO instruction text — not "less" +//! text, none, asserted by exact length AND by the dropped segment's own +//! bytes being unfindable in the result; +//! 2. the full-capability prompt is pinned to an inline golden below, so the +//! next person who changes the wording has to change this file too. Prompt +//! drift becomes a conscious choice instead of a diff nobody reviewed. +//! +//! The golden is the WHOLE prompt on purpose. A hash would catch drift just as +//! well and teach a reviewer nothing; this way the diff of a prompt change is +//! readable in review, right next to the assertion that it was intended. + +const std = @import("std"); +const prompts = @import("prompts.zig"); +const skills = @import("skills.zig"); +const skill_docs = @import("skill_docs.zig"); +const imagegen = @import("imagegen.zig"); +const tool_gates = @import("tool_gates.zig"); +const no_local_tools = @import("no_local_tools.zig"); +const repl_glue = @import("repl_glue.zig"); +const session_index = @import("session_index.zig"); +const agent_mod = @import("agent.zig"); + +/// The FULL-capability root prompt, verbatim. Regenerate by reading +/// `prompts.main_system_prompt`; never "fix" this to make a test pass without +/// looking at what changed. +const golden_full_prompt = + \\You are a coding agent running in a minimal terminal harness on the + \\user's machine. Use the provided tools to inspect and modify the current + \\working directory and to run commands. + \\Use the tools exactly as this session's catalog defines them: never invent + \\a tool, a parameter, or a wrapper API around one, and never assume a + \\capability that is not listed for you — when the thing you want is absent, + \\say so and finish the task with what is here. + \\read_file before editing; prefer + \\edit_file for changes to existing files and write_file only for new + \\files or full rewrites. To navigate code — finding symbols, callers, + \\definitions, or where logic lives — prefer the codedb tool (it's indexed + \\and structural) over bash grep/find/ls. Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\is declined, try another approach or ask. Native file tools deliberately + \\stay inside the current working directory. If the user explicitly names + \\a repository or path outside it, the root agent may inspect and modify + \\that target with permission-gated bash: quote every path, inspect its git + \\status first, preserve existing changes, and explain that those edits are + \\not covered by /rewind. Do not claim a relaunch is required. Never extend + \\this exception to an inferred path or to a subagent. + \\For independent, + \\self-contained chunks of work — exploring several directories, running + \\unrelated checks, summarizing multiple files — fan out: call the + \\subagent tool several times in a single response and the subagents run + \\in parallel. For larger fan-out work that needs a synthesis step, use + \\the workflow tool: sequential phases of parallel subagents, with + \\{{prev}} carrying each phase's results into the next. + \\Use todo_write to + \\track multi-step work. Work directly for small sequential steps. + \\ + \\The harness writes this run's JSONL event trace beneath + \\.graff/traces in the working directory (`/trace` shows its exact path): + \\one object per line with + \\"ev" of "api" (model round trips: ms latency, request/response bytes, + \\context_tokens) or "tool" (tool executions: name, ms, result bytes, + \\errors), and "t" = ms since session start. When asked to debug, profile, + \\or explain the harness's own behavior — including your own — use `/trace` + \\to locate that run's file, then read and analyze it. + \\ + \\If you hit a bug or limitation in the harness itself (this graff/codegraff + \\agent — its tools, prompts, streaming, sessions, or behavior — as opposed + \\to the project you happen to be working in), report it by opening a GitHub + \\issue at justrach/codegraff (`gh issue create --repo justrach/codegraff + \\...`), never in the current working repository's issue tracker. + \\ + \\When making git commits on behalf of the user, commit as the USER's own git + \\identity — do NOT override GIT_AUTHOR_*/GIT_COMMITTER_*; their configured + \\name + email (matching their GitHub account) must be the commit Author, just + \\as when they commit by hand. Credit the assist with a trailer at the very end + \\of the commit message, after a blank line: + \\Co-Authored-By: Codegraff + \\ + \\A pull request description you author must explain WHY the change was made, + \\not only what it does — a reviewer cannot reconstruct the reasoning from the + \\diff. Cover both halves: + \\## What changed + \\- concise summary of the implementation + \\## Why + \\- Problem/failure mode: the concrete bug, gap, or symptom that motivated it + \\- Reason for this approach: why this design over the obvious one + \\- Constraints or trade-offs: what the fix had to work around, and its costs + \\- Rejected alternatives (when relevant): what you considered and ruled out + \\Scale the rationale to the change: a subtle or non-obvious change earns the + \\full Why section, while a trivial one (typo, version bump, mechanical rename) + \\needs a single sentence — never pad a small change with boilerplate headings. + \\Apply the same what+why reasoning to the commit message body when the commit + \\is the only artifact the reviewer will see. + \\ + \\Never run git commands that discard work — `reset --hard`, `clean -f`, + \\`checkout --`/`restore`, force-push, or `branch -D` — unless the user + \\explicitly asks. Their existing commits and any -w worktree + \\auto-checkpoints are the user's safety net; do not blow them away. + \\ + \\Assume the user wants the work done, not described. Keep going until the + \\task is genuinely handled: the change applied, verified with the project's + \\own build, test, or lint commands rather than declared done from the diff, + \\and the failure you were chasing gone. Never stop at a plan, a half-applied + \\edit, or an untested guess, and never leave the last step for the user. If + \\a real ambiguity blocks you, ask; otherwise decide and go. + \\Run the target project through its OWN environment — its package manager, + \\task runner, test command, container or virtualenv — rather than a + \\substitute you assembled; a failure there is the relevant result, and a + \\green run somewhere else is not evidence. + \\ + \\Before a large chunk of work, give a one- or two-sentence heads-up on what + \\you are about to do; on long tasks, drop a brief note as each phase lands. + \\With todo_write, mark an item in_progress when you start it and completed + \\as it lands, not in a batch at the end. + \\ + \\Fix root causes, not symptoms — a patch that only hides a failure is not a + \\fix. Match the surrounding file's style and keep diffs minimal: no drive-by + \\refactors, renames, or reformatting the task did not require. + \\ + \\The moment the user rejects, forbids, or vetoes something ("no dots", "not vanilla JS", "stop adding scroll hints"), call note_constraint with one short imperative line recording it, then carry on — recorded constraints are injected into every later subagent, workflow and pipeline brief and survive compaction, so a rejection you leave unrecorded is one your fresh workers will repeat. + \\ + \\Write the final message as an update to a teammate who has not seen your + \\screen. Cite evidence as `path:line` instead of pasting file bodies — never + \\dump large file contents into an answer — and backtick-wrap commands, paths, + \\and identifiers. Scale it to the change: a typo fix is one sentence, a + \\feature is a short structured summary. Close with the next steps that + \\genuinely exist — tests to run, follow-ups you left — and nothing more. + \\Be direct and concise. + \\ + \\Parallelize tool calls whenever possible: when several reads or checks are + \\independent, issue them in ONE response instead of one per turn. Reads and + \\searches are the common case (read_file, codedb, grep-style bash) and they + \\run concurrently. Keep a call in its own turn when it depends on an earlier + \\call's result, or when two calls would write to the same file. +; + +/// Every capability configuration the gates can actually produce, plus the +/// all-off floor. `.{}` is full capability (the Caps defaults). +const matrix = [_]struct { name: []const u8, caps: prompts.Caps }{ + .{ .name = "full", .caps = .{} }, + .{ .name = "no-local-tools (#330 embedder)", .caps = .{ .local_tools = false } }, + .{ .name = "no-subagents", .caps = .{ .subagents = false } }, + .{ .name = "no-todos", .caps = .{ .todos = false } }, + .{ .name = "no-constraints", .caps = .{ .constraints = false } }, + .{ .name = "floor", .caps = .{ .local_tools = false, .subagents = false, .todos = false, .constraints = false } }, +}; + +test "#421 golden: the full-capability root prompt is exactly this, byte for byte" { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const a = a_state.allocator(); + + try std.testing.expectEqualStrings(golden_full_prompt, prompts.main_system_prompt); + // The segment table composes to the same bytes the comptime constant does, + // so a reordered or forgotten segment cannot hide behind the fast path. + try std.testing.expectEqualStrings(golden_full_prompt, try prompts.composeSegments(a, .{})); + // ...and the fast path really is a fast path: full capability allocates nothing. + var b_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer b_state.deinit(); + try std.testing.expectEqualStrings(golden_full_prompt, try prompts.composeBase(b_state.allocator(), .{})); + try std.testing.expectEqual(@as(usize, 0), b_state.queryCapacity()); + + // The strict/ultra variants still compose ONTO this base rather than + // replacing it (#326), which the segmentation must not have disturbed. + try std.testing.expect(std.mem.startsWith(u8, prompts.main_system_prompt_strict, golden_full_prompt)); +} + +test "#421: an absent capability contributes ZERO instruction text, in every configuration" { + for (matrix) |row| { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const out = try prompts.composeSegments(a_state.allocator(), row.caps); + + var expected: usize = 0; + for (prompts.segments) |seg| { + if (row.caps.has(seg.gate)) expected += seg.text.len; + } + std.testing.expectEqual(expected, out.len) catch |err| { + std.debug.print("config '{s}': composed {d} bytes, expected {d}\n", .{ row.name, out.len, expected }); + return err; + }; + // Exact length is necessary but not sufficient: prove each dropped + // segment's own bytes are unfindable, and each kept one is still there. + for (prompts.segments) |seg| { + const present = std.mem.indexOf(u8, out, seg.text) != null; + std.testing.expectEqual(row.caps.has(seg.gate), present) catch |err| { + std.debug.print("config '{s}': segment '{s}' present={} expected={}\n", .{ row.name, seg.name, present, row.caps.has(seg.gate) }); + return err; + }; + } + } +} + +test "#421: a gated-off capability's tool names disappear from the prompt entirely" { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const a = a_state.allocator(); + + // #330 embedder mode: bash, read_file, edit_file, write_file and codedb are + // hard-removed from every catalog, so no sentence may still name them. + const embedder = try prompts.composeSegments(a, .{ .local_tools = false }); + for ([_][]const u8{ "read_file", "edit_file", "write_file", "codedb", "bash grep", "/trace", "gh issue create", ".graff/traces" }) |dead| + try std.testing.expect(std.mem.indexOf(u8, embedder, dead) == null); + + const no_subs = try prompts.composeSegments(a, .{ .subagents = false }); + for ([_][]const u8{ "subagent tool", "workflow tool", "{{prev}}" }) |dead| + try std.testing.expect(std.mem.indexOf(u8, no_subs, dead) == null); + + const no_todos = try prompts.composeSegments(a, .{ .todos = false }); + try std.testing.expect(std.mem.indexOf(u8, no_todos, "todo_write") == null); + + const no_constraints = try prompts.composeSegments(a, .{ .constraints = false }); + try std.testing.expect(std.mem.indexOf(u8, no_constraints, "note_constraint") == null); + + // What survives EVERY gate: identity, the two prompt-doctrine lines, the + // git/PR discipline, the do-not-discard-work rail, and the closing style. + for (matrix) |row| { + const out = try prompts.composeSegments(a, row.caps); + for ([_][]const u8{ + "You are a coding agent", + "never invent", // #421 doctrine 1: no wrapper APIs, no assumed capabilities + "its OWN environment", // #421 doctrine 2: verify where the project lives + "## What changed", + "Co-Authored-By: Codegraff", + "Never run git commands that discard work", + "Parallelize tool calls", + "Be direct and concise", + }) |keep| { + std.testing.expect(std.mem.indexOf(u8, out, keep) != null) catch |err| { + std.debug.print("config '{s}' lost '{s}'\n", .{ row.name, keep }); + return err; + }; + } + } +} + +test "#421: detectCaps reads the same gates dispatch refuses a call with" { + const saved = no_local_tools.enabled; + defer no_local_tools.enabled = saved; + + no_local_tools.enabled = false; + try std.testing.expect(prompts.detectCaps().full()); + + no_local_tools.enabled = true; + const caps = prompts.detectCaps(); + try std.testing.expect(!caps.local_tools); + // #330 keeps the orchestration and meta tools: the CHILD inherits the gate, + // so the fan-out guidance is still guidance for a tool that exists. + try std.testing.expect(caps.subagents); + try std.testing.expect(caps.todos); + try std.testing.expect(caps.constraints); + + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const gated = try prompts.baseForSession(a_state.allocator()); + try std.testing.expect(gated.len < prompts.main_system_prompt.len); + try std.testing.expect(std.mem.indexOf(u8, gated, "read_file") == null); +} + +test "#410: the transcript line appears exactly when a durable session exists" { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const a = a_state.allocator(); + + // No durable session (a subagent, a stub, a future --no-session): no line. + try std.testing.expectEqualStrings("", prompts.sessionTranscriptNote(a, "")); + + const note = prompts.sessionTranscriptNote(a, "session-1750000000000"); + // The path is the one session_index actually writes, not a hand-written twin. + try std.testing.expect(std.mem.indexOf(u8, note, try session_index.sessionPath(a, "session-1750000000000")) != null); + try std.testing.expect(std.mem.indexOf(u8, note, "lags") != null); // the caveat #410 asks for + try std.testing.expect(std.mem.indexOf(u8, note, "JSON object") != null); + // NOT JSONL: that is the .graff/traces event stream, a different file. A + // wrong format claim costs the model a turn discovering it. + try std.testing.expect(std.mem.indexOf(u8, note, "JSONL") == null); + try std.testing.expect(std.mem.indexOf(u8, prompts.main_system_prompt, "durable transcript") == null); // session-scoped, never comptime +} + +test "#410: the transcript line rides the funnel, so a persona swap cannot drop it" { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const a = a_state.allocator(); + var agent: agent_mod.Agent = undefined; + defer prompts.armSessionTranscript(a, "", .{}); // leave the global as the rest of the suite expects it + + prompts.armSessionTranscript(a, "session-42", .{}); + try prompts.setSystemPrompts(&agent, "BASE", a); + for ([_][]const u8{ agent.sys_normal, agent.sys_strict, agent.sys_ultra, agent.sys_ultra_strict }) |v| { + try std.testing.expect(std.mem.indexOf(u8, v, "BASE") != null); + try std.testing.expect(std.mem.indexOf(u8, v, "session-42.session.json") != null); + } + // A later /agent persona or set_system_prompt goes through the same funnel + // and keeps it — the #326 staleness class, closed by construction. + try prompts.setSystemPrompts(&agent, "PERSONA", a); + try std.testing.expect(std.mem.indexOf(u8, agent.sys_normal, "session-42.session.json") != null); + // sys_base stays the pure base: the next refresh composes from it, once. + try std.testing.expectEqualStrings("PERSONA", agent.sys_base); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, agent.sys_normal, "session-42.session.json")); + + // Unreadable session file (#330 removed read_file/bash): no line at all. + prompts.armSessionTranscript(a, "session-42", .{ .local_tools = false }); + try prompts.setSystemPrompts(&agent, "BASE", a); + try std.testing.expectEqualStrings("BASE", agent.sys_normal); +} + +test "#421: MCP, skill and optional-tool guidance all cost zero when absent" { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const a = a_state.allocator(); + + // MCP: startup.buildSystemPrompt injects a server's note only when that + // server actually connected, and nothing is baked into the base prompt. + for (skills.mcp_notes) |mn| { + try std.testing.expect(!skills.mcpServerConnected(&.{}, mn.server)); + try std.testing.expect(std.mem.indexOf(u8, prompts.main_system_prompt, mn.note) == null); + } + // Markdown skills: an empty catalog is the empty string, not a header. + try std.testing.expectEqualStrings("", skill_docs.promptCatalog(a, &.{})); + // #352 optional tools: no availability, no advertisement, and the base + // prompt never mentions imagegen either way (its guidance is the skill). + const saved = imagegen.available; + defer imagegen.available = saved; + imagegen.available = false; + try std.testing.expect(!tool_gates.advertised(imagegen.tool_name)); + try std.testing.expect(std.mem.indexOf(u8, prompts.main_system_prompt, "imagegen") == null); +} + +test "#421: goal steering is empty outside a goal run" { + var a_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer a_state.deinit(); + const a = a_state.allocator(); + try std.testing.expectEqualStrings("", try repl_glue.goalSteeringNote(a, null)); + // ...and a goal that is no longer active steers nobody either (#223). + try std.testing.expectEqualStrings("", try repl_glue.goalSteeringNote(a, .{ .objective = "ship it", .status = .complete })); + try std.testing.expect((try repl_glue.goalSteeringNote(a, .{ .objective = "ship it", .status = .active })).len > 0); +} diff --git a/src/prompt_text.zig b/src/prompt_text.zig new file mode 100644 index 00000000..c13d5d6d --- /dev/null +++ b/src/prompt_text.zig @@ -0,0 +1,236 @@ +//! The root system prompt's TEXT, one const per capability-scoped segment +//! (#421). Nothing here decides anything: prompts.zig owns the `segments` +//! table that gives each of these a gate, the comptime full-capability +//! `main_system_prompt` they concatenate to, and the runtime composition that +//! drops the ones this session cannot use. Split out so prompts.zig has room +//! for that machinery under the 600-line cap (#123). +//! +//! Read this file as the prompt itself; read prompts.zig for when each part of +//! it is sent. + +// ── ROOT PROMPT BEGIN ── examples/prepare_graff_tournament.py extracts every +// multiline-literal line between these two markers as the seed root policy, so +// this comment deliberately carries no literal marker of its own. + +/// Always present: every configuration has tools of *some* kind. The closing +/// sentence is #421's prompt doctrine, adopted from the prime-agent analysis — +/// gating removes the invitation to call an absent capability, but only an +/// explicit ban stops the model improvising a wrapper around one. +pub const intro_note = + \\You are a coding agent running in a minimal terminal harness on the + \\user's machine. Use the provided tools to inspect and modify the current + \\working directory and to run commands. + \\Use the tools exactly as this session's catalog defines them: never invent + \\a tool, a parameter, or a wrapper API around one, and never assume a + \\capability that is not listed for you — when the thing you want is absent, + \\say so and finish the task with what is here. +; + +/// Gate: `caps.local_tools`. #330 `--no-local-tools` hard-removes bash, +/// read_file, edit_file, write_file and codedb from every catalog AND refuses +/// them at dispatch, so under that gate every sentence here describes tools the +/// provider is never told exist. +pub const local_tools_note = + \\ + \\read_file before editing; prefer + \\edit_file for changes to existing files and write_file only for new + \\files or full rewrites. To navigate code — finding symbols, callers, + \\definitions, or where logic lives — prefer the codedb tool (it's indexed + \\and structural) over bash grep/find/ls. Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one + \\is declined, try another approach or ask. Native file tools deliberately + \\stay inside the current working directory. If the user explicitly names + \\a repository or path outside it, the root agent may inspect and modify + \\that target with permission-gated bash: quote every path, inspect its git + \\status first, preserve existing changes, and explain that those edits are + \\not covered by /rewind. Do not claim a relaunch is required. Never extend + \\this exception to an inferred path or to a subagent. +; + +/// Gate: `caps.subagents` — the `subagent`/`workflow` tools as the catalog +/// actually reports them, not as this file assumes them. +pub const orchestration_note = + \\ + \\For independent, + \\self-contained chunks of work — exploring several directories, running + \\unrelated checks, summarizing multiple files — fan out: call the + \\subagent tool several times in a single response and the subagents run + \\in parallel. For larger fan-out work that needs a synthesis step, use + \\the workflow tool: sequential phases of parallel subagents, with + \\{{prev}} carrying each phase's results into the next. +; + +/// Gate: `caps.todos` — the `todo_write` tool. +pub const todo_note = + \\ + \\Use todo_write to + \\track multi-step work. Work directly for small sequential steps. +; + +/// Gate: `caps.local_tools`. The instruction is "read and analyze it": the +/// trace is a file on the host graff runs on, and with the native file/shell +/// tools removed there is no way to open it (an MCP sandbox is a different +/// filesystem). +pub const trace_note = + \\ + \\ + \\The harness writes this run's JSONL event trace beneath + \\.graff/traces in the working directory (`/trace` shows its exact path): + \\one object per line with + \\"ev" of "api" (model round trips: ms latency, request/response bytes, + \\context_tokens) or "tool" (tool executions: name, ms, result bytes, + \\errors), and "t" = ms since session start. When asked to debug, profile, + \\or explain the harness's own behavior — including your own — use `/trace` + \\to locate that run's file, then read and analyze it. +; + +/// Gate: `caps.local_tools`. `gh issue create` is a bash invocation, and bash +/// is in `no_local_tools.gated_tools`. +pub const harness_issue_note = + \\ + \\ + \\If you hit a bug or limitation in the harness itself (this graff/codegraff + \\agent — its tools, prompts, streaming, sessions, or behavior — as opposed + \\to the project you happen to be working in), report it by opening a GitHub + \\issue at justrach/codegraff (`gh issue create --repo justrach/codegraff + \\...`), never in the current working repository's issue tracker. +; + +/// Always present. Deliberately NOT gated on `caps.local_tools`: an embedder +/// that removed the local tools still reaches a sandbox where git may run, and +/// "never discard the user's work" is the wrong instruction to make optional. +pub const git_note = + \\ + \\ + \\When making git commits on behalf of the user, commit as the USER's own git + \\identity — do NOT override GIT_AUTHOR_*/GIT_COMMITTER_*; their configured + \\name + email (matching their GitHub account) must be the commit Author, just + \\as when they commit by hand. Credit the assist with a trailer at the very end + \\of the commit message, after a blank line: + \\Co-Authored-By: Codegraff + \\ + \\A pull request description you author must explain WHY the change was made, + \\not only what it does — a reviewer cannot reconstruct the reasoning from the + \\diff. Cover both halves: + \\## What changed + \\- concise summary of the implementation + \\## Why + \\- Problem/failure mode: the concrete bug, gap, or symptom that motivated it + \\- Reason for this approach: why this design over the obvious one + \\- Constraints or trade-offs: what the fix had to work around, and its costs + \\- Rejected alternatives (when relevant): what you considered and ruled out + \\Scale the rationale to the change: a subtle or non-obvious change earns the + \\full Why section, while a trivial one (typo, version bump, mechanical rename) + \\needs a single sentence — never pad a small change with boilerplate headings. + \\Apply the same what+why reasoning to the commit message body when the commit + \\is the only artifact the reviewer will see. + \\ + \\Never run git commands that discard work — `reset --hard`, `clean -f`, + \\`checkout --`/`restore`, force-push, or `branch -D` — unless the user + \\explicitly asks. Their existing commits and any -w worktree + \\auto-checkpoints are the user's safety net; do not blow them away. +; + +/// Always present. The closing sentence is the second prompt-doctrine line +/// adopted from the prime-agent analysis (#421): verification has to happen in +/// the target project's own environment to mean anything. +pub const work_note = + \\ + \\ + \\Assume the user wants the work done, not described. Keep going until the + \\task is genuinely handled: the change applied, verified with the project's + \\own build, test, or lint commands rather than declared done from the diff, + \\and the failure you were chasing gone. Never stop at a plan, a half-applied + \\edit, or an untested guess, and never leave the last step for the user. If + \\a real ambiguity blocks you, ask; otherwise decide and go. + \\Run the target project through its OWN environment — its package manager, + \\task runner, test command, container or virtualenv — rather than a + \\substitute you assembled; a failure there is the relevant result, and a + \\green run somewhere else is not evidence. +; + +/// Always present: narration is a habit, not a capability. +pub const headsup_note = + \\ + \\ + \\Before a large chunk of work, give a one- or two-sentence heads-up on what + \\you are about to do; on long tasks, drop a brief note as each phase lands. +; + +/// Gate: `caps.todos`. Same tool as `todo_note`; separate because it sits in a +/// different paragraph. +pub const todo_progress_note = + \\ + \\With todo_write, mark an item in_progress when you start it and completed + \\as it lands, not in a batch at the end. +; + +/// Always present: how to change code at all, whichever tool applies it. +pub const root_cause_note = + \\ + \\ + \\Fix root causes, not symptoms — a patch that only hides a failure is not a + \\fix. Match the surrounding file's style and keep diffs minimal: no drive-by + \\refactors, renames, or reformatting the task did not require. +; + +/// Gate: `caps.constraints` — the `note_constraint` tool. +pub const constraint_note = + \\ + \\ + \\The moment the user rejects, forbids, or vetoes something ("no dots", "not vanilla JS", "stop adding scroll hints"), call note_constraint with one short imperative line recording it, then carry on — recorded constraints are injected into every later subagent, workflow and pipeline brief and survive compaction, so a rejection you leave unrecorded is one your fresh workers will repeat. +; + +/// Always present: how to write the final message. +pub const closing_note = + \\ + \\ + \\Write the final message as an update to a teammate who has not seen your + \\screen. Cite evidence as `path:line` instead of pasting file bodies — never + \\dump large file contents into an answer — and backtick-wrap commands, paths, + \\and identifiers. Scale it to the change: a typo fix is one sentence, a + \\feature is a short structured summary. Close with the next steps that + \\genuinely exist — tests to run, follow-ups you left — and nothing more. + \\Be direct and concise. +; + +/// Appended to BOTH the root and subagent prompts. openai/codex carries this +/// instruction verbatim in its base instructions ("Parallelize tool calls +/// whenever possible - especially file reads"); graff had no equivalent on +/// either prompt, so batching was left entirely to the model's own initiative. +/// +/// The executor has always been ready for it: agent_tools.zig dispatches every +/// external call in a batch as a future BEFORE awaiting any of them, with no +/// cap and no root-vs-subagent branch. So this asks for nothing the harness +/// does not already do - it only stops the capability going unused. +/// +/// The last sentence is the load-bearing half. Batching two edits to one file, +/// or a read whose path comes from the previous call's output, is wrong: graff +/// (unlike codex, which takes a write lock for non-parallel-safe tools) runs +/// the whole batch concurrently, so an unsafe batch really does race. +pub const parallel_core_note = + \\ + \\ + \\Parallelize tool calls whenever possible: when several reads or checks are + \\independent, issue them in ONE response instead of one per turn. Reads and + \\searches are the common case +; + +/// The one clause of the batching note that is NOT capability-free: all three +/// examples are tools #330 hard-removes. The instruction survives the gate; its +/// illustration does not. +pub const parallel_examples_note = + \\ (read_file, codedb, grep-style bash) +; + +pub const parallel_tail_note = + \\ and they + \\run concurrently. Keep a call in its own turn when it depends on an earlier + \\call's result, or when two calls would write to the same file. +; + +/// The whole note, for `sub_system_prompt` — which is a comptime constant, so +/// a subagent still carries the examples. Same residue, different prompt; the +/// child inherits #330 too, so gating it is a follow-up, not this change. +pub const parallel_tools_note = parallel_core_note ++ parallel_examples_note ++ parallel_tail_note; + +// ── ROOT PROMPT END ───────────────────────────────────────────────────────── diff --git a/src/prompts.zig b/src/prompts.zig index b8813256..9e768059 100644 --- a/src/prompts.zig +++ b/src/prompts.zig @@ -9,129 +9,163 @@ //! variants are pre-built strings exactly like sys_normal/sys_strict, so //! this is the one place that derives all four from a base, next to the //! constants it composes them from. +//! +//! #421: the root prompt is no longer one frozen wall of text. It is a list of +//! capability-scoped SEGMENTS whose full-capability concatenation is still the +//! comptime `main_system_prompt` (an Agent struct default, so it has to stay +//! comptime), while composeBase() drops the segments whose capability this +//! process does not have. A session is never handed instructions for a tool the +//! provider was never told about, and every gate is the same predicate dispatch +//! refuses a hallucinated call with — never a second opinion about what exists. +//! +//! #410: setRootSystemPrompts also composes the one line naming this session's +//! durable transcript. It rides in the funnel, not at the call site, so a +//! persona swap or a `set_system_prompt` cannot silently drop it. const std = @import("std"); +const Io = std.Io; const Allocator = std.mem.Allocator; const shapes = @import("shapes.zig"); +const text = @import("prompt_text.zig"); // #421: the segment TEXT; this file owns their gates const Agent = @import("agent.zig").Agent; const playbook = @import("playbook.zig"); // #381: the user-constraint block composed onto the ROOT's base prompt +const no_local_tools = @import("no_local_tools.zig"); // #330's subtractive gate — one half of every capability answer below +const tool_gates = @import("tool_gates.zig"); // #352's additive gate — the other half +const session_index = @import("session_index.zig"); // #410: where the durable transcript lives -pub const main_system_prompt = - \\You are a coding agent running in a minimal terminal harness on the - \\user's machine. Use the provided tools to inspect and modify the current - \\working directory and to run commands. read_file before editing; prefer - \\edit_file for changes to existing files and write_file only for new - \\files or full rewrites. To navigate code — finding symbols, callers, - \\definitions, or where logic lives — prefer the codedb tool (it's indexed - \\and structural) over bash grep/find/ls. Before an exact edit, read one current uncompressed target span, apply the smallest edit that preserves terminal-newline state, do not verify after success, and reread/retry only on stale source, ambiguity, or failure. Some bash commands need user approval — if one - \\is declined, try another approach or ask. Native file tools deliberately - \\stay inside the current working directory. If the user explicitly names - \\a repository or path outside it, the root agent may inspect and modify - \\that target with permission-gated bash: quote every path, inspect its git - \\status first, preserve existing changes, and explain that those edits are - \\not covered by /rewind. Do not claim a relaunch is required. Never extend - \\this exception to an inferred path or to a subagent. For independent, - \\self-contained chunks of work — exploring several directories, running - \\unrelated checks, summarizing multiple files — fan out: call the - \\subagent tool several times in a single response and the subagents run - \\in parallel. For larger fan-out work that needs a synthesis step, use - \\the workflow tool: sequential phases of parallel subagents, with - \\{{prev}} carrying each phase's results into the next. Use todo_write to - \\track multi-step work. Work directly for small sequential steps. - \\ - \\The harness writes this run's JSONL event trace beneath - \\.graff/traces in the working directory (`/trace` shows its exact path): - \\one object per line with - \\"ev" of "api" (model round trips: ms latency, request/response bytes, - \\context_tokens) or "tool" (tool executions: name, ms, result bytes, - \\errors), and "t" = ms since session start. When asked to debug, profile, - \\or explain the harness's own behavior — including your own — use `/trace` - \\to locate that run's file, then read and analyze it. - \\ - \\If you hit a bug or limitation in the harness itself (this graff/codegraff - \\agent — its tools, prompts, streaming, sessions, or behavior — as opposed - \\to the project you happen to be working in), report it by opening a GitHub - \\issue at justrach/codegraff (`gh issue create --repo justrach/codegraff - \\...`), never in the current working repository's issue tracker. - \\ - \\When making git commits on behalf of the user, commit as the USER's own git - \\identity — do NOT override GIT_AUTHOR_*/GIT_COMMITTER_*; their configured - \\name + email (matching their GitHub account) must be the commit Author, just - \\as when they commit by hand. Credit the assist with a trailer at the very end - \\of the commit message, after a blank line: - \\Co-Authored-By: Codegraff - \\ - \\A pull request description you author must explain WHY the change was made, - \\not only what it does — a reviewer cannot reconstruct the reasoning from the - \\diff. Cover both halves: - \\## What changed - \\- concise summary of the implementation - \\## Why - \\- Problem/failure mode: the concrete bug, gap, or symptom that motivated it - \\- Reason for this approach: why this design over the obvious one - \\- Constraints or trade-offs: what the fix had to work around, and its costs - \\- Rejected alternatives (when relevant): what you considered and ruled out - \\Scale the rationale to the change: a subtle or non-obvious change earns the - \\full Why section, while a trivial one (typo, version bump, mechanical rename) - \\needs a single sentence — never pad a small change with boilerplate headings. - \\Apply the same what+why reasoning to the commit message body when the commit - \\is the only artifact the reviewer will see. - \\ - \\Never run git commands that discard work — `reset --hard`, `clean -f`, - \\`checkout --`/`restore`, force-push, or `branch -D` — unless the user - \\explicitly asks. Their existing commits and any -w worktree - \\auto-checkpoints are the user's safety net; do not blow them away. - \\ - \\Assume the user wants the work done, not described. Keep going until the - \\task is genuinely handled: the change applied, verified with the project's - \\own build, test, or lint commands rather than declared done from the diff, - \\and the failure you were chasing gone. Never stop at a plan, a half-applied - \\edit, or an untested guess, and never leave the last step for the user. If - \\a real ambiguity blocks you, ask; otherwise decide and go. - \\ - \\Before a large chunk of work, give a one- or two-sentence heads-up on what - \\you are about to do; on long tasks, drop a brief note as each phase lands. - \\With todo_write, mark an item in_progress when you start it and completed - \\as it lands, not in a batch at the end. - \\ - \\Fix root causes, not symptoms — a patch that only hides a failure is not a - \\fix. Match the surrounding file's style and keep diffs minimal: no drive-by - \\refactors, renames, or reformatting the task did not require. - \\ - \\The moment the user rejects, forbids, or vetoes something ("no dots", "not vanilla JS", "stop adding scroll hints"), call note_constraint with one short imperative line recording it, then carry on — recorded constraints are injected into every later subagent, workflow and pipeline brief and survive compaction, so a rejection you leave unrecorded is one your fresh workers will repeat. - \\ - \\Write the final message as an update to a teammate who has not seen your - \\screen. Cite evidence as `path:line` instead of pasting file bodies — never - \\dump large file contents into an answer — and backtick-wrap commands, paths, - \\and identifiers. Scale it to the change: a typo fix is one sentence, a - \\feature is a short structured summary. Close with the next steps that - \\genuinely exist — tests to run, follow-ups you left — and nothing more. - \\Be direct and concise. -++ parallel_tools_note; +/// The prompt text itself lives next door; this file owns when each part +/// of it is sent. `parallel_tools_note` is re-exported because +/// `sub_system_prompt` composes it as a comptime whole. +pub const parallel_tools_note = text.parallel_tools_note; -/// Appended to BOTH the root and subagent prompts. openai/codex carries this -/// instruction verbatim in its base instructions ("Parallelize tool calls -/// whenever possible - especially file reads"); graff had no equivalent on -/// either prompt, so batching was left entirely to the model's own initiative. -/// -/// The executor has always been ready for it: agent_tools.zig dispatches every -/// external call in a batch as a future BEFORE awaiting any of them, with no -/// cap and no root-vs-subagent branch. So this asks for nothing the harness -/// does not already do - it only stops the capability going unused. -/// -/// The last sentence is the load-bearing half. Batching two edits to one file, -/// or a read whose path comes from the previous call's output, is wrong: graff -/// (unlike codex, which takes a write lock for non-parallel-safe tools) runs -/// the whole batch concurrently, so an unsafe batch really does race. -pub const parallel_tools_note = - \\ - \\ - \\Parallelize tool calls whenever possible: when several reads or checks are - \\independent, issue them in ONE response instead of one per turn. Reads and - \\searches are the common case (read_file, codedb, grep-style bash) and they - \\run concurrently. Keep a call in its own turn when it depends on an earlier - \\call's result, or when two calls would write to the same file. -; +/// The capability a segment needs. `.always` is what survives every gate. +pub const Gate = enum { always, local_tools, subagents, todos, constraints }; + +/// `name` exists for the snapshot tests: it lets an assertion say WHICH +/// segment a gate dropped instead of only how many bytes went missing. +pub const Segment = struct { name: []const u8, text: []const u8, gate: Gate }; + +/// THE root prompt, in order. This table is the single source of both the +/// comptime full-capability constant and the runtime gated composition, so the +/// two can never drift apart or reorder relative to each other. +pub const segments = [_]Segment{ + .{ .name = "intro", .text = text.intro_note, .gate = .always }, + .{ .name = "local_tools", .text = text.local_tools_note, .gate = .local_tools }, + .{ .name = "orchestration", .text = text.orchestration_note, .gate = .subagents }, + .{ .name = "todo", .text = text.todo_note, .gate = .todos }, + .{ .name = "trace", .text = text.trace_note, .gate = .local_tools }, + .{ .name = "harness_issue", .text = text.harness_issue_note, .gate = .local_tools }, + .{ .name = "git", .text = text.git_note, .gate = .always }, + .{ .name = "work", .text = text.work_note, .gate = .always }, + .{ .name = "headsup", .text = text.headsup_note, .gate = .always }, + .{ .name = "todo_progress", .text = text.todo_progress_note, .gate = .todos }, + .{ .name = "root_cause", .text = text.root_cause_note, .gate = .always }, + .{ .name = "constraint", .text = text.constraint_note, .gate = .constraints }, + .{ .name = "closing", .text = text.closing_note, .gate = .always }, + .{ .name = "parallel_core", .text = text.parallel_core_note, .gate = .always }, + .{ .name = "parallel_examples", .text = text.parallel_examples_note, .gate = .local_tools }, + .{ .name = "parallel_tail", .text = text.parallel_tail_note, .gate = .always }, +}; + +pub const main_system_prompt = blk: { + var out: []const u8 = ""; + for (segments) |seg| out = out ++ seg.text; + break :blk out; +}; + +/// #421: what this session can actually do, exactly as the tool catalog +/// reports it. Defaults are "everything", so a caller that only cares about +/// one gate names one field. +pub const Caps = struct { + /// bash + the native file/index tools. Off under #330 `--no-local-tools`. + local_tools: bool = true, + /// The `subagent`/`workflow` fan-out pair. + subagents: bool = true, + /// `todo_write`. + todos: bool = true, + /// `note_constraint`. + constraints: bool = true, + + pub fn has(self: Caps, gate: Gate) bool { + return switch (gate) { + .always => true, + .local_tools => self.local_tools, + .subagents => self.subagents, + .todos => self.todos, + .constraints => self.constraints, + }; + } + + /// Nothing gated: composeBase may hand back the comptime constant. + pub fn full(self: Caps) bool { + return self.local_tools and self.subagents and self.todos and self.constraints; + } +}; + +/// Is this tool advertised to the provider this session? The two gates that +/// can remove a built-in, and nothing else — the same pair `exec.zig` refuses +/// a hallucinated call with, so the prompt can never disagree with dispatch. +pub fn toolAdvertised(name: []const u8) bool { + return !no_local_tools.blocks(name) and !tool_gates.blocks(name); +} + +/// The live gates. Every flag and env knob feeding them is settled before +/// startup.buildSystemPrompt runs (args.parse, then setupSkillsAndTheme). +pub fn detectCaps() Caps { + return .{ + .local_tools = toolAdvertised("read_file") and toolAdvertised("bash"), + .subagents = toolAdvertised("subagent"), + .todos = toolAdvertised("todo_write"), + .constraints = toolAdvertised("note_constraint"), + }; +} + +/// The segment list in `main_system_prompt` order, minus every segment whose +/// capability is absent. Always allocates; composeBase is the caller that +/// short-circuits. Kept public so the snapshot tests can prove the +/// full-capability composition IS `main_system_prompt`, byte for byte. +pub fn composeSegments(arena: Allocator, caps: Caps) ![]const u8 { + var aw: Io.Writer.Allocating = .init(arena); + for (segments) |seg| { + if (caps.has(seg.gate)) try aw.writer.writeAll(seg.text); + } + return aw.writer.buffered(); +} + +/// The built-in base for `caps`. Full capability returns the comptime constant +/// itself, so the common path allocates nothing and stays byte-identical to the +/// prompt this harness has always sent (mirrors no_local_tools.filterRootSpecs). +pub fn composeBase(arena: Allocator, caps: Caps) ![]const u8 { + if (caps.full()) return main_system_prompt; + return composeSegments(arena, caps); +} + +/// startup.buildSystemPrompt's entry point: the built-in base, gated on what +/// this process can actually do. +pub fn baseForSession(arena: Allocator) ![]const u8 { + return composeBase(arena, detectCaps()); +} + +/// #410: the transcript line for THIS session, composed once by +/// setRootSystemPrompts and re-applied by every later setSystemPrompts call so +/// a persona swap cannot drop it. Empty for every non-root agent and every +/// unit test — the same arming discipline playbook.g_root_inject uses. +var g_transcript_note: []const u8 = ""; + +/// One line naming the durable transcript. Deliberately NOT described as +/// JSONL: `.graff/sessions/.session.json` is a single JSON object (the +/// JSONL files are the `.graff/traces` event streams the paragraph above +/// covers), and it is not an archive of what compaction discarded either — +/// compaction rewrites the retained history and the next autosave persists +/// that. Promising either would cost the model turns discovering otherwise. +pub fn sessionTranscriptNote(arena: Allocator, session_name: []const u8) []const u8 { + if (session_name.len == 0) return ""; + return std.fmt.allocPrint( + arena, + "\n\nThis session's durable transcript is {s}/{s}{s} — one JSON object whose \"messages\" array holds the retained conversation. It is rewritten only after a turn completes, so it always lags the turn in progress, and compaction rewrites it in place: it is the resume artifact, not an append-only archive. Read it when you need the exact earlier wording of something this conversation no longer shows you.", + .{ session_index.sessions_dir, session_name, session_index.session_ext }, + ) catch ""; +} pub const strict_note = \\ @@ -187,7 +221,11 @@ pub fn ultracodeActive(agent: *const Agent) bool { /// tests and for every non-root agent. pub fn setSystemPrompts(agent: *Agent, base: []const u8, arena: Allocator) !void { agent.sys_base = base; - const composed = if (playbook.g_root_inject) playbook.composeRoot(agent.io, arena, base) else base; + const with_playbook = if (playbook.g_root_inject) playbook.composeRoot(agent.io, arena, base) else base; + // #410: the transcript line is a fact about the SESSION, not about the + // persona, so it re-composes here rather than being baked into a base a + // later set_agent/set_system_prompt would replace (the #326 staleness class). + const composed = if (g_transcript_note.len == 0) with_playbook else try std.fmt.allocPrint(arena, "{s}{s}", .{ with_playbook, g_transcript_note }); agent.sys_normal = composed; agent.sys_strict = try std.fmt.allocPrint(arena, "{s}{s}", .{ composed, strict_note }); agent.sys_ultra = try std.fmt.allocPrint(arena, "{s}{s}", .{ composed, ultracode_system_note }); @@ -201,9 +239,21 @@ pub fn setSystemPrompts(agent: *Agent, base: []const u8, arena: Allocator) !void /// a bare `Agent` in a unit test gets neither. pub fn setRootSystemPrompts(agent: *Agent, base: []const u8, arena: Allocator) !void { playbook.g_root_inject = true; + // #410: only the root has a durable session, and only a session file this + // process can still open is worth a line of context — with the native file + // and shell tools removed (#330) the path is unreadable from here. + armSessionTranscript(arena, agent.session_name, detectCaps()); return setSystemPrompts(agent, base, arena); } +/// #410's arming step, split out so the funnel is testable without the +/// filesystem read setRootSystemPrompts also performs (playbook.composeRoot). +/// An empty `session_name`, or a session whose file this process can no longer +/// open, both arm to "" — the line is only worth context when it is actionable. +pub fn armSessionTranscript(arena: Allocator, session_name: []const u8, caps: Caps) void { + g_transcript_note = if (caps.local_tools) sessionTranscriptNote(arena, session_name) else ""; +} + pub const sub_system_prompt = \\You are a subagent spawned by an orchestrator agent inside a terminal \\harness. Complete the assigned task using your tools, without asking @@ -220,6 +270,10 @@ pub const compact_instruction = \\work. Be thorough but compact. Reply with only the summary. ; +test { // #421/#410: the capability matrix + the full-capability golden. An unreferenced module's tests never run. + _ = @import("prompt_snapshot_tests.zig"); +} + // The harness has always run a returned tool batch concurrently, for subagents // exactly as for the root (agent_tools.zig dispatches every external call as a // future before awaiting any). Nothing ASKED for a batch, though, so the diff --git a/src/session_run.zig b/src/session_run.zig index b9996859..032f5371 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -256,12 +256,13 @@ pub fn buildRootAgent( .tools_openai = "", .tools_responses = "", }; + // #410: the durable session's name is settled BEFORE the prompt funnel runs — setRootSystemPrompts composes the transcript line out of it. + const fresh_session_name = try std.fmt.allocPrint(arena, "session-{d}", .{util.unixMs(io)}); + root.session_name = if (flags.resume_flag) |name| (if (!flags.new_session_flag and !flags.no_resume_flag) name else fresh_session_name) else fresh_session_name; try prompts.setRootSystemPrompts(&root, sys_normal, arena); // #381: same funnel + the live .graff/playbook.jsonl constraint block // Startup pays for one provider format, not all three. Other formats are // rendered on first switch with the same built-in + live MCP inputs. try root.ensureRootTools(default_provider.kind); - const fresh_session_name = try std.fmt.allocPrint(arena, "session-{d}", .{util.unixMs(io)}); - root.session_name = if (flags.resume_flag) |name| (if (!flags.new_session_flag and !flags.no_resume_flag) name else fresh_session_name) else fresh_session_name; repl_glue.loadThinkingSettings(io, arena, &root); // {"effort":...,"fast":...} persisted by /effort and /fast if (flags.goal_flag) |g| { // --goal is STANDING (#318): every turn (incl. --json/-p/SDK), never model-retired root.goal_flag = try arena.dupe(u8, g); // kept: re-applied over every loadSession, incl. /resume diff --git a/src/startup.zig b/src/startup.zig index a4994cd7..7ae1b77d 100644 --- a/src/startup.zig +++ b/src/startup.zig @@ -348,7 +348,7 @@ pub fn buildSystemPrompt( try out.flush(); }; const base_prompt: []const u8 = system_prompt_flag orelse - if (learned) |policy| policy.prompt else prompts.main_system_prompt; + if (learned) |policy| policy.prompt else try prompts.baseForSession(arena); // #421: built-in base minus the segments whose capability this process lacks var sys_normal: []const u8 = base_prompt; for ([_][]const u8{ "AGENTS.md", "HARNESS.md", "CLAUDE.md" }) |fname| { const body = Io.Dir.cwd().readFileAlloc(io, fname, arena, .limited(64 * 1024)) catch continue;