//! System-prompt sourcing and resume reconciliation for the `panto` CLI. //! //! The system prompt is **conversation state**, not config state (see //! `docs/system-prompt.md`). It is sourced — by convention, with no TOML //! key — from two files discovered across the same three config layers the //! CLI already uses, in precedence order base → user → project (project //! highest): //! //! base = $PANTO_HOME/agent/ (= $XDG_DATA_HOME/panto/agent/) //! user = ${XDG_CONFIG_HOME:-$HOME/.config}/panto/ //! project = ./.panto/ //! //! In each layer we look for: //! - `SYSTEM.md` — the base/seed prompt (highest layer present wins). //! - `APPEND_SYSTEM.md` — an additional appended block (every layer kept). //! //! Resolution: //! 1. Seed = the highest-layer `SYSTEM.md`, else a built-in default. //! 2. Appends = each layer's `APPEND_SYSTEM.md`, collected base → user → //! project, then emitted reversed: project → user → base. The seed //! leads; the most-specific (project) append follows it. const std = @import("std"); const panto = @import("panto"); const Allocator = std.mem.Allocator; const Io = std.Io; /// Last-resort fallback seed when no `SYSTEM.md` exists at any layer. /// /// In normal operation this is never used: the base layer ships a bundled /// `agent/SYSTEM.md` (embedded in the binary and staged to /// `$PANTO_HOME/agent/SYSTEM.md` at bootstrap), so a seed is always found. /// This constant only matters if that staged file is missing or /// unreadable. pub const default_seed = "You are a helpful assistant."; const seed_filename = "SYSTEM.md"; const append_filename = "APPEND_SYSTEM.md"; const compaction_filename = "COMPACTION.md"; /// Last-resort fallback compaction prompt when no `COMPACTION.md` exists at /// any layer. In normal operation the base layer ships a bundled /// `agent/COMPACTION.md`, so this is only used if that staged file is /// missing or unreadable. pub const default_compaction_prompt = \\You are compacting a long conversation to reduce its context size. \\ \\You will be given a transcript of an earlier portion of a \\conversation between a user and an AI coding assistant, and possibly a \\previous summary of even older history. Produce a single, dense \\summary that preserves everything needed to continue the work \\coherently: \\ \\- the user's goals, decisions, and stated preferences \\- the current state of the task and what remains to be done \\- key facts discovered about the codebase or problem \\- important file paths, identifiers, and code structures \\- unresolved questions, bugs, and their understanding \\ \\Write the summary as factual notes, not as a chat reply. Do not \\address the user. Do not include pleasantries. Be comprehensive about \\specifics but omit redundant back-and-forth. ; /// A resolved set of system blocks for a fresh session: the seed followed /// by zero or more appends, already in emission order. All slices /// are owned by `arena`. pub const Resolved = struct { /// Seed text (always present — a built-in default if no file existed). seed: []const u8, /// Append blocks in emission order (project → user → base). appends: []const []const u8, /// The full ordered block list: `[seed, appends...]`. Owned by `arena`. pub fn blocks(self: Resolved, arena: Allocator) ![]const []const u8 { var list = try arena.alloc([]const u8, 1 + self.appends.len); list[0] = self.seed; @memcpy(list[1..], self.appends); return list; } }; /// Resolve the system-prompt blocks from the three config layers. /// /// `base_dir` is the base layer directory (`$PANTO_HOME/agent`). The user /// layer is derived from `XDG_CONFIG_HOME`/`HOME`; the project layer is /// `cwd()/.panto`. Allocations are made in `arena` (caller owns it). pub fn resolve( arena: Allocator, io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, ) !Resolved { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); return resolveLayers(arena, io, base_dir, user_dir, project_dir); } /// Core resolution against explicit layer directories (base → user → /// project, project highest). Any layer dir may be null (absent). Exposed /// for testing; `resolve` derives the user/project dirs from the /// environment and cwd. pub fn resolveLayers( arena: Allocator, io: Io, base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, ) !Resolved { // Seed: highest layer present wins (project > user > base). var seed: []const u8 = default_seed; for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| { const dir = maybe_dir orelse continue; if (try readLayerFile(arena, io, dir, seed_filename)) |text| { seed = text; } } // Appends: collect base → user → project, then reverse for emission. var collected: std.ArrayList([]const u8) = .empty; for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| { const dir = maybe_dir orelse continue; if (try readLayerFile(arena, io, dir, append_filename)) |text| { try collected.append(arena, text); } } std.mem.reverse([]const u8, collected.items); return .{ .seed = seed, .appends = try collected.toOwnedSlice(arena) }; } /// Seed a fresh conversation with the resolved blocks and record matching /// `append`-mode entries in the session log. pub fn seedFresh( arena: Allocator, io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, agent: *panto.Agent, ) !void { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); return seedFreshLayers(arena, io, base_dir, user_dir, project_dir, agent); } /// Layer-explicit variant of `seedFresh` (see `resolveLayers`). pub fn seedFreshLayers( arena: Allocator, io: Io, base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, agent: *panto.Agent, ) !void { const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); const blocks = try resolved.blocks(arena); for (blocks) |text| { // The agent adds to its conversation and persists the entry. try agent.addSystemMessage(text); } } /// Reconcile a resumed conversation against the current on-disk config. /// /// Compares the freshly-resolved config blocks, by position and exact /// text, against the conversation's **current effective config window**: /// the last `replace`-mode system block plus every system block after it. /// If they match, this is a no-op. Otherwise it appends a fresh /// `replace + N×append` sequence to both the conversation and the log, /// re-seeding the prompt wholesale without rewriting history. pub fn reconcileResume( arena: Allocator, io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, agent: *panto.Agent, ) !void { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); return reconcileResumeLayers(arena, io, base_dir, user_dir, project_dir, agent); } /// Layer-explicit variant of `reconcileResume` (see `resolveLayers`). pub fn reconcileResumeLayers( arena: Allocator, io: Io, base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, agent: *panto.Agent, ) !void { const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); const config_blocks = try resolved.blocks(arena); const window = try effectiveConfigWindow(arena, agent.conversation.messages.items); if (blocksEqual(config_blocks, window)) return; // Re-seed: one `setSystemPrompt` (replace) for the seed, then one // `addSystemMessage` (append) per append. The agent adds to its // conversation and persists each entry (with the correct system mode). try agent.setSystemPrompt(config_blocks[0]); for (config_blocks[1..]) |text| { try agent.addSystemMessage(text); } } /// Resolve the compaction system prompt across the three config layers. /// Like `SYSTEM.md` (not `APPEND_SYSTEM.md`): the highest layer present /// wins (project > user > base); falls back to a built-in default if no /// `COMPACTION.md` exists anywhere. The result is owned by `arena`. pub fn resolveCompaction( arena: Allocator, io: Io, environ_map: *const std.process.Environ.Map, base_dir: []const u8, ) ![]const u8 { const user_dir = try userLayerDir(arena, environ_map); const project_dir = try projectLayerDir(arena, io); return resolveCompactionLayers(arena, io, base_dir, user_dir, project_dir); } /// Layer-explicit variant of `resolveCompaction` (see `resolveLayers`). pub fn resolveCompactionLayers( arena: Allocator, io: Io, base_dir: ?[]const u8, user_dir: ?[]const u8, project_dir: ?[]const u8, ) ![]const u8 { var prompt: []const u8 = default_compaction_prompt; for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| { const dir = maybe_dir orelse continue; if (try readLayerFile(arena, io, dir, compaction_filename)) |text| { prompt = text; } } return prompt; } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- /// The current effective config window: the text of the last `replace`-mode /// system block plus every system block after it, in order. If there is no /// `replace` block, the window is every leading system block (the original /// seed sequence). Returned slices borrow from `messages`. fn effectiveConfigWindow( arena: Allocator, messages: []const panto.Message, ) ![]const []const u8 { // Find the index of the last message carrying a `replace`-mode block. var anchor: ?usize = null; for (messages, 0..) |msg, i| { if (msg.role != .system) continue; for (msg.content.items) |block| { switch (block) { .System => |sb| if (sb.mode == .replace) { anchor = i; }, else => {}, } } } var out: std.ArrayList([]const u8) = .empty; const start = anchor orelse 0; for (messages[start..]) |msg| { if (msg.role != .system) continue; for (msg.content.items) |block| { switch (block) { .System => |sb| try out.append(arena, sb.text.items), .Text => |tb| try out.append(arena, tb.items), else => {}, } } } return out.toOwnedSlice(arena); } fn blocksEqual(a: []const []const u8, b: []const []const u8) bool { if (a.len != b.len) return false; for (a, b) |x, y| { if (!std.mem.eql(u8, x, y)) return false; } return true; } /// Read `dir/name`, returning its contents (owned by `arena`) or null if the /// file does not exist. Other I/O errors propagate. fn readLayerFile( arena: Allocator, io: Io, dir: []const u8, name: []const u8, ) !?[]u8 { const path = try std.fs.path.join(arena, &.{ dir, name }); const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { error.FileNotFound => return null, else => return err, }; defer file.close(io); const len = file.length(io) catch return error.SystemPromptReadFailed; const bytes = try arena.alloc(u8, @intCast(len)); errdefer arena.free(bytes); _ = file.readPositionalAll(io, bytes, 0) catch return error.SystemPromptReadFailed; return bytes; } fn userLayerDir(arena: Allocator, environ_map: *const std.process.Environ.Map) !?[]u8 { if (environ_map.get("XDG_CONFIG_HOME")) |xdg| { return try std.fs.path.join(arena, &.{ xdg, "panto" }); } if (environ_map.get("HOME")) |home| { return try std.fs.path.join(arena, &.{ home, ".config", "panto" }); } return null; } fn projectLayerDir(arena: Allocator, io: Io) ![]u8 { const cwd = try std.process.currentPathAlloc(io, arena); return try std.fs.path.join(arena, &.{ cwd, ".panto" }); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; test "blocksEqual - length and content" { try testing.expect(blocksEqual(&.{ "a", "b" }, &.{ "a", "b" })); try testing.expect(!blocksEqual(&.{ "a", "b" }, &.{ "a", "c" })); try testing.expect(!blocksEqual(&.{"a"}, &.{ "a", "b" })); try testing.expect(blocksEqual(&.{}, &.{})); } test "effectiveConfigWindow - no replace anchors to leading system blocks" { const alloc = testing.allocator; var conv = panto.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage("seed"); try conv.addSystemMessage("append-1"); try conv.addUserMessage("hi"); var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const window = try effectiveConfigWindow(arena_state.allocator(), conv.messages.items); try testing.expectEqual(@as(usize, 2), window.len); try testing.expectEqualStrings("seed", window[0]); try testing.expectEqualStrings("append-1", window[1]); } test "effectiveConfigWindow - anchors to last replace block" { const alloc = testing.allocator; var conv = panto.Conversation.init(alloc); defer conv.deinit(); try conv.addSystemMessage("old seed"); try conv.addUserMessage("hi"); try conv.replaceSystemMessage("new seed"); try conv.addSystemMessage("new append"); try conv.addUserMessage("again"); var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const window = try effectiveConfigWindow(arena_state.allocator(), conv.messages.items); try testing.expectEqual(@as(usize, 2), window.len); try testing.expectEqualStrings("new seed", window[0]); try testing.expectEqualStrings("new append", window[1]); } fn writeTmpFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void { try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content }); } test "resolveLayers - highest SYSTEM.md wins, appends reversed project-first" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); // base: SYSTEM.md + APPEND_SYSTEM.md; user: APPEND only; project: // SYSTEM.md + APPEND_SYSTEM.md (matches the design's worked example). try tmp.dir.createDirPath(testing.io, "base"); try tmp.dir.createDirPath(testing.io, "user"); try tmp.dir.createDirPath(testing.io, "project"); try writeTmpFile(tmp.dir, "base/SYSTEM.md", "base seed"); try writeTmpFile(tmp.dir, "base/APPEND_SYSTEM.md", "base append"); try writeTmpFile(tmp.dir, "user/APPEND_SYSTEM.md", "user append"); try writeTmpFile(tmp.dir, "project/SYSTEM.md", "project seed"); try writeTmpFile(tmp.dir, "project/APPEND_SYSTEM.md", "project append"); var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var buf: [std.fs.max_path_bytes]u8 = undefined; const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf); const root = buf[0..root_len]; const base_dir = try std.fs.path.join(arena, &.{ root, "base" }); const user_dir = try std.fs.path.join(arena, &.{ root, "user" }); const project_dir = try std.fs.path.join(arena, &.{ root, "project" }); const resolved = try resolveLayers(arena, testing.io, base_dir, user_dir, project_dir); try testing.expectEqualStrings("project seed", resolved.seed); const blocks = try resolved.blocks(arena); try testing.expectEqual(@as(usize, 4), blocks.len); try testing.expectEqualStrings("project seed", blocks[0]); try testing.expectEqualStrings("project append", blocks[1]); try testing.expectEqualStrings("user append", blocks[2]); try testing.expectEqualStrings("base append", blocks[3]); } test "resolveLayers - no SYSTEM.md anywhere falls back to built-in default" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var buf: [std.fs.max_path_bytes]u8 = undefined; const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf); const root = buf[0..root_len]; const resolved = try resolveLayers(arena, testing.io, root, null, null); try testing.expectEqualStrings(default_seed, resolved.seed); try testing.expectEqual(@as(usize, 0), resolved.appends.len); } const TmpLayers = struct { tmp: std.testing.TmpDir, root: []u8, fn init(arena: Allocator) !TmpLayers { var tmp = std.testing.tmpDir(.{ .iterate = true }); errdefer tmp.cleanup(); var buf: [std.fs.max_path_bytes]u8 = undefined; const n = try tmp.dir.realPathFile(testing.io, ".", &buf); const root = try arena.dupe(u8, buf[0..n]); try tmp.dir.createDirPath(testing.io, "project"); return .{ .tmp = tmp, .root = root }; } fn projectDir(self: TmpLayers, arena: Allocator) ![]u8 { return std.fs.path.join(arena, &.{ self.root, "project" }); } fn writeProject(self: *TmpLayers, name: []const u8, content: []const u8) !void { const sub = try std.fmt.allocPrint(testing.allocator, "project/{s}", .{name}); defer testing.allocator.free(sub); try self.tmp.dir.writeFile(testing.io, .{ .sub_path = sub, .data = content }); } fn deinit(self: *TmpLayers) void { self.tmp.cleanup(); } }; fn openTmpStore(arena: Allocator, root: []const u8) !panto.FileSystemJSONLStore { const sessions = try std.fs.path.join(arena, &.{ root, "sessions" }); return panto.FileSystemJSONLStore.init(testing.allocator, testing.io, sessions, "/cwd"); } /// Minimal agent harness for system-prompt tests: a throwaway provider /// config plus a session (minted from / resolved against the catalog /// store). Post-R1 the agent owns its own (empty) tool registry, so the /// harness only holds the config to keep the agent's borrowed pointer valid. const SPAgentHarness = struct { config: panto.Config, agent: *panto.Agent, /// A second handle onto the same session (a copyable value proxying to /// the same store + id) so the test can `forceFlush` without reaching /// agent internals. `session_id` mirrors the agent's session id. session: panto.Session, session_id: []const u8, fn init( self: *SPAgentHarness, session: panto.Session, adopted: ?panto.Conversation, ) !void { self.config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, }; self.session = session; self.session_id = session.info.id; self.agent = try panto.Agent.init(testing.allocator, testing.io, &self.config, session, adopted); } fn deinit(self: *SPAgentHarness) void { self.agent.deinit(); } /// Force the session to flush to disk by appending an assistant message /// through the (shared) session handle. (System/user messages buffer in /// memory until the first assistant message; a realistic resume only /// sees what a completed turn persisted.) fn forceFlush(self: *SPAgentHarness) !void { var conv = panto.Conversation.init(testing.allocator); defer conv.deinit(); try conv.addAssistantMessage(&.{}, null); const id: panto.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" }; var batch = [_]panto.PersistentMessage{ .{ .message = conv.messages.items[0], .identity = id }, }; try self.session.append(&batch); } }; test "seedFresh then no-config-change resume is a no-op" { const alloc = testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var layers = try TmpLayers.init(arena); defer layers.deinit(); try layers.writeProject("SYSTEM.md", "project seed"); try layers.writeProject("APPEND_SYSTEM.md", "project append"); const project_dir = try layers.projectDir(arena); // Fresh session: seed + persist (through the agent). var store_impl = try openTmpStore(arena, layers.root); defer store_impl.deinit(); const store = store_impl.store(); var session_id_buf: [64]u8 = undefined; var session_id_len: usize = 0; { var h: SPAgentHarness = undefined; try h.init(store.create(), null); defer h.deinit(); try seedFreshLayers(arena, testing.io, null, null, project_dir, h.agent); try h.forceFlush(); // persist seed (+ assistant) to disk @memcpy(session_id_buf[0..h.session_id.len], h.session_id); session_id_len = h.session_id.len; } const session_id = session_id_buf[0..session_id_len]; // The persisted conversation reflects the seeded system prompt. var conv_disk = (try store.load(session_id)).?; const eff_disk = try panto.effectiveSystemBlocks(arena, conv_disk.messages.items); const seeded_count = eff_disk.items.len; conv_disk.deinit(); try testing.expect(seeded_count > 0); // Resume: adopt the rebuilt conversation + reconcile against unchanged // config → the effective system prompt is unchanged. var sess2 = (try store.resolve(session_id)).?; const conv2 = try sess2.load(); var h2: SPAgentHarness = undefined; try h2.init(sess2, conv2); defer h2.deinit(); try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); const eff_after = try panto.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); try testing.expectEqual(seeded_count, eff_after.items.len); } test "resume after config change appends replace + append sequence" { const alloc = testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var layers = try TmpLayers.init(arena); defer layers.deinit(); try layers.writeProject("SYSTEM.md", "old seed"); const project_dir = try layers.projectDir(arena); var store_impl = try openTmpStore(arena, layers.root); defer store_impl.deinit(); const store = store_impl.store(); var session_id_buf: [64]u8 = undefined; var session_id_len: usize = 0; { var h: SPAgentHarness = undefined; try h.init(store.create(), null); defer h.deinit(); try seedFreshLayers(arena, testing.io, null, null, project_dir, h.agent); try h.forceFlush(); // persist old seed (+ assistant) to disk @memcpy(session_id_buf[0..h.session_id.len], h.session_id); session_id_len = h.session_id.len; } const session_id = session_id_buf[0..session_id_len]; // Change the config: new seed + a new append. try layers.writeProject("SYSTEM.md", "new seed"); try layers.writeProject("APPEND_SYSTEM.md", "new append"); var sess2 = (try store.resolve(session_id)).?; const conv2 = try sess2.load(); var h2: SPAgentHarness = undefined; try h2.init(sess2, conv2); defer h2.deinit(); try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); // The effective prompt now reflects only the new config blocks. const eff = try panto.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); try testing.expectEqual(@as(usize, 2), eff.items.len); try testing.expectEqualStrings("new seed", eff.items[0]); try testing.expectEqualStrings("new append", eff.items[1]); // A second no-op resume must not change the effective prompt (anchors // to the new `replace` window, not the stale original seed). try reconcileResumeLayers(arena, testing.io, null, null, project_dir, h2.agent); const eff2 = try panto.effectiveSystemBlocks(arena, h2.agent.conversation.messages.items); try testing.expectEqual(@as(usize, 2), eff2.items.len); } test "Resolved.blocks - seed leads appends" { const alloc = testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); const resolved: Resolved = .{ .seed = "S", .appends = &.{ "P", "U", "B" } }; const blocks = try resolved.blocks(arena); try testing.expectEqual(@as(usize, 4), blocks.len); try testing.expectEqualStrings("S", blocks[0]); try testing.expectEqualStrings("P", blocks[1]); try testing.expectEqualStrings("U", blocks[2]); try testing.expectEqualStrings("B", blocks[3]); } test "resolveCompactionLayers - highest COMPACTION.md wins, default otherwise" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var buf: [std.fs.max_path_bytes]u8 = undefined; const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf); const root = buf[0..root_len]; // No files anywhere -> default. { const p = try resolveCompactionLayers(arena, testing.io, root, null, null); try testing.expectEqualStrings(default_compaction_prompt, p); } // base + project; project wins. try tmp.dir.createDirPath(testing.io, "base"); try tmp.dir.createDirPath(testing.io, "project"); try writeTmpFile(tmp.dir, "base/COMPACTION.md", "base compaction"); try writeTmpFile(tmp.dir, "project/COMPACTION.md", "project compaction"); const base_dir = try std.fs.path.join(arena, &.{ root, "base" }); const project_dir = try std.fs.path.join(arena, &.{ root, "project" }); { const p = try resolveCompactionLayers(arena, testing.io, base_dir, null, project_dir); try testing.expectEqualStrings("project compaction", p); } }