From 8b88b886346460b1ab29edb03ad85bc3bb565a45 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 15:00:44 -0600 Subject: compaction --- src/command.zig | 176 +++++++++++++++++++++++++++++++++++ src/compaction.zig | 48 ++++++++++ src/config_file.zig | 112 ++++++++++++++++++++++ src/main.zig | 241 ++++++++++++++++++++---------------------------- src/session_persist.zig | 153 ++++++++++++++++++++++++++++++ src/system_prompt.zig | 89 ++++++++++++++++++ 6 files changed, 678 insertions(+), 141 deletions(-) create mode 100644 src/command.zig create mode 100644 src/compaction.zig create mode 100644 src/session_persist.zig (limited to 'src') diff --git a/src/command.zig b/src/command.zig new file mode 100644 index 0000000..f10592f --- /dev/null +++ b/src/command.zig @@ -0,0 +1,176 @@ +//! Slash-command framework for the panto CLI. +//! +//! The REPL treats any input line beginning with `/` as a *slash +//! command* rather than a prompt for the model. This module provides the +//! structure panto uses to understand those commands: a `Registry` of +//! `Command`s, lookup by name, and a `list()` accessor (useful later for +//! tab-completion). Command handlers are plain functions that *do stuff* +//! directly — they print to the REPL's writer, mutate the conversation, +//! persist to the session, etc. — and return `!void`. +//! +//! Dispatch contract (see `Registry.dispatch`): +//! - The caller only invokes `dispatch` for lines starting with `/`. +//! - `dispatch` splits the leading word (sans `/`) as the command name +//! and passes the remainder (trimmed) as `args`. +//! - An unknown command yields `error.CommandNotFound`; the REPL turns +//! that into a user-facing error message. Slash lines never reach the +//! model regardless. +//! +//! Builtin commands (e.g. `/compact`) register themselves from their own +//! modules; future Lua extensions will append commands here too via a +//! `panto.register_command` bridge. + +const std = @import("std"); +const panto = @import("panto"); + +/// The mutable REPL state a command handler may touch. Bundles pointers +/// so handler signatures stay stable as the REPL grows. All pointers are +/// borrowed for the duration of the call; handlers must not retain them. +pub const Context = struct { + allocator: std.mem.Allocator, + + /// In-memory conversation the agent is driving. + conv: *panto.conversation.Conversation, + /// The active agent (model/provider/tooling snapshot). + agent: *panto.agent.Agent, + /// Session log to persist any changes a command makes. + session_mgr: *panto.session_manager.SessionManager, + + /// REPL output writer and its backing file writer (for flushing). + stdout: *std.Io.Writer, + stdout_file: *std.Io.File.Writer, + + /// Resolved compaction system prompt (owned elsewhere; borrowed here). + compaction_prompt: []const u8, + + /// Banner identity strings used when stamping persisted entries. + provider_name: []const u8, + model_name: []const u8, +}; + +/// A single slash command. +pub const Command = struct { + /// Command name *without* the leading `/` (e.g. `"compact"`). + name: []const u8, + /// One-line description, shown in listings/completion. + description: []const u8, + /// Handler. `args` is the remainder of the line after the command + /// name, trimmed of surrounding whitespace (empty string if none). + run: *const fn (args: []const u8, ctx: *Context) anyerror!void, +}; + +pub const Error = error{ + /// The line's command word matched no registered command. + CommandNotFound, +}; + +/// A registry of slash commands. Owns its backing list; call `deinit`. +pub const Registry = struct { + allocator: std.mem.Allocator, + commands: std.ArrayList(Command) = .empty, + + pub fn init(allocator: std.mem.Allocator) Registry { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Registry) void { + self.commands.deinit(self.allocator); + } + + /// Register a command. Returns `error.DuplicateCommand` if a command + /// with the same name is already present. + pub fn register(self: *Registry, command: Command) !void { + if (self.find(command.name) != null) return error.DuplicateCommand; + try self.commands.append(self.allocator, command); + } + + /// Find a command by name (without leading `/`). Null if absent. + pub fn find(self: *const Registry, name: []const u8) ?*const Command { + for (self.commands.items) |*cmd| { + if (std.mem.eql(u8, cmd.name, name)) return cmd; + } + return null; + } + + /// All registered commands, in registration order. Useful for + /// listing and (later) completion. + pub fn list(self: *const Registry) []const Command { + return self.commands.items; + } + + /// Parse and run a slash-command line. `line` must begin with `/`. + /// + /// Splits the first whitespace-delimited word (after the `/`) as the + /// command name and passes the trimmed remainder as `args`. Returns + /// `error.CommandNotFound` if no command matches; otherwise returns + /// whatever the handler returns. + pub fn dispatch(self: *const Registry, line: []const u8, ctx: *Context) !void { + std.debug.assert(line.len > 0 and line[0] == '/'); + const body = line[1..]; + + // Split into name + rest on the first run of whitespace. + const name_end = std.mem.indexOfAny(u8, body, " \t") orelse body.len; + const name = body[0..name_end]; + const args = std.mem.trim(u8, body[name_end..], " \t"); + + const cmd = self.find(name) orelse return Error.CommandNotFound; + try cmd.run(args, ctx); + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +var test_calls: usize = 0; +var test_last_args: []const u8 = ""; + +fn testHandler(args: []const u8, ctx: *Context) anyerror!void { + _ = ctx; + test_calls += 1; + test_last_args = args; +} + +test "register, find, list" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + + try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); + try reg.register(.{ .name = "help", .description = "d", .run = testHandler }); + + try testing.expect(reg.find("compact") != null); + try testing.expect(reg.find("nope") == null); + try testing.expectEqual(@as(usize, 2), reg.list().len); + + try testing.expectError(error.DuplicateCommand, reg.register(.{ + .name = "compact", + .description = "d", + .run = testHandler, + })); +} + +test "dispatch splits name and args" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); + + var ctx: Context = undefined; + + test_calls = 0; + try reg.dispatch("/compact", &ctx); + try testing.expectEqual(@as(usize, 1), test_calls); + try testing.expectEqualStrings("", test_last_args); + + try reg.dispatch("/compact extra words ", &ctx); + try testing.expectEqual(@as(usize, 2), test_calls); + try testing.expectEqualStrings("extra words", test_last_args); +} + +test "dispatch on unknown command yields CommandNotFound" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + var ctx: Context = undefined; + try testing.expectError(Error.CommandNotFound, reg.dispatch("/nope", &ctx)); +} diff --git a/src/compaction.zig b/src/compaction.zig new file mode 100644 index 0000000..a1f6e89 --- /dev/null +++ b/src/compaction.zig @@ -0,0 +1,48 @@ +//! The builtin `/compact` slash command. +//! +//! Folds the conversation's older turns into a summary (via +//! `Agent.compact`), persists the result to the session log, and reports +//! the outcome to the user. Registered into the CLI's command registry by +//! `register`. + +const std = @import("std"); +const panto = @import("panto"); +const command = @import("command.zig"); +const session_persist = @import("session_persist.zig"); + +/// Install the `/compact` command into `registry`. +pub fn register(registry: *command.Registry) !void { + try registry.register(.{ + .name = "compact", + .description = "Summarize older turns to free up context. Optional args become extra compaction instructions.", + .run = run, + }); +} + +/// `/compact [extra instructions]` — compact the conversation now. +fn run(args: []const u8, ctx: *command.Context) anyerror!void { + const extra: ?[]const u8 = if (args.len > 0) args else null; + + const res = ctx.agent.compact(ctx.conv, ctx.compaction_prompt, extra) catch |err| { + try ctx.stdout.print("\n[compaction failed: {s}]\n> ", .{@errorName(err)}); + try ctx.stdout_file.flush(); + return; + }; + + if (res.compacted) { + try session_persist.persistCompaction( + ctx.allocator, + ctx.session_mgr, + ctx.conv, + ctx.provider_name, + ctx.model_name, + ); + try ctx.stdout.print( + "\n[compacted: summarized {d} message(s), kept {d} recent turn(s)]\n> ", + .{ res.summarized_messages, res.kept_turns }, + ); + } else { + try ctx.stdout.writeAll("\n[nothing to compact: conversation already fits]\n> "); + } + try ctx.stdout_file.flush(); +} diff --git a/src/config_file.zig b/src/config_file.zig index f7589fe..f1ae65f 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -158,11 +158,19 @@ pub const Config = struct { default_model_ref: ?ModelRef, tools: Policy, extensions: Policy, + /// `[compaction]` settings. `compaction_keep_verbatim` is the kept- + /// suffix token budget (null = use libpanto's default). `compaction_model` + /// is an owned `:` reference string for the optional + /// compaction-model override; `compaction_model_ref` slices into it. + compaction_keep_verbatim: ?u32 = null, + compaction_model: ?[]const u8 = null, + compaction_model_ref: ?ModelRef = null, pub fn deinit(self: *Config) void { for (self.providers) |p| p.deinit(self.allocator); self.allocator.free(self.providers); if (self.default_model) |m| self.allocator.free(m); + if (self.compaction_model) |m| self.allocator.free(m); self.tools.deinit(self.allocator); self.extensions.deinit(self.allocator); } @@ -440,6 +448,25 @@ fn resolve( } } + // `[compaction]` settings. + var compaction_keep_verbatim: ?u32 = null; + var compaction_model: ?[]const u8 = null; + errdefer if (compaction_model) |m| allocator.free(m); + var compaction_model_ref: ?ModelRef = null; + if (root.get("compaction")) |compaction_tbl| { + if (compaction_tbl.get("keep_verbatim")) |kv| { + if (kv.* == .integer and kv.integer > 0) { + compaction_keep_verbatim = @intCast(kv.integer); + } + } + if (compaction_tbl.get("model")) |model_v| { + if (model_v.* == .string) { + compaction_model = try allocator.dupe(u8, model_v.string); + compaction_model_ref = try parseModelRef(compaction_model.?); + } + } + } + const providers_slice = try providers.toOwnedSlice(allocator); errdefer { for (providers_slice) |p| p.deinit(allocator); @@ -458,6 +485,18 @@ fn resolve( if (!found) return error.UnknownDefaultProvider; } + // Validate the compaction-model override names a provider that resolved. + if (compaction_model_ref) |ref| { + var found = false; + for (providers_slice) |p| { + if (std.mem.eql(u8, p.name, ref.provider)) { + found = true; + break; + } + } + if (!found) return error.UnknownDefaultProvider; + } + return .{ .allocator = allocator, .providers = providers_slice, @@ -465,6 +504,9 @@ fn resolve( .default_model_ref = default_model_ref, .tools = tools, .extensions = extensions, + .compaction_keep_verbatim = compaction_keep_verbatim, + .compaction_model = compaction_model, + .compaction_model_ref = compaction_model_ref, }; } @@ -977,3 +1019,73 @@ test "loadFromPaths: missing files are skipped" { try testing.expectEqual(@as(usize, 0), cfg.providers.len); try testing.expect(cfg.default_model == null); } + +test "resolve: [compaction] keep_verbatim and model parse" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + \\ + \\[compaction] + \\keep_verbatim = 12345 + \\model = "anthropic:haiku" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + try testing.expectEqual(@as(?u32, 12345), cfg.compaction_keep_verbatim); + try testing.expectEqualStrings("anthropic:haiku", cfg.compaction_model.?); + try testing.expectEqualStrings("anthropic", cfg.compaction_model_ref.?.provider); + try testing.expectEqualStrings("haiku", cfg.compaction_model_ref.?.model); +} + +test "resolve: [compaction] absent leaves defaults null" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + try testing.expect(cfg.compaction_keep_verbatim == null); + try testing.expect(cfg.compaction_model == null); + try testing.expect(cfg.compaction_model_ref == null); +} + +test "resolve: [compaction] model naming an unresolved provider errors" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + \\ + \\[compaction] + \\model = "openai:gpt-4o" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root)); +} diff --git a/src/main.zig b/src/main.zig index 28f1144..14a14b6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,6 +12,9 @@ const models_toml = @import("models_toml.zig"); const config_file = @import("config_file.zig"); const glob = @import("glob.zig"); const system_prompt = @import("system_prompt.zig"); +const command = @import("command.zig"); +const command_compaction = @import("compaction.zig"); +const session_persist = @import("session_persist.zig"); // Shorthand alias for the Lua C API. The bridge module owns the actual // `@cImport`; we re-use it here so the smoke check uses identical types. @@ -39,6 +42,9 @@ test { _ = config_file; _ = glob; _ = system_prompt; + _ = command; + _ = command_compaction; + _ = session_persist; } const Receiver = panto.provider.Receiver; @@ -335,6 +341,21 @@ pub fn main(init: std.process.Init) !void { std.process.exit(1); }; + // Resolve the optional compaction-model override into a ProviderConfig. + // On any resolution failure we log and fall back to no override (the + // active chat model is used for compaction). + const compaction_provider_config: ?panto.config.ProviderConfig = blk: { + const ref = app_config.compaction_model_ref orelse break :blk null; + break :blk config_file.buildProviderConfig(&app_config, &models.defs, ref) catch |err| { + std.log.warn("compaction_model resolution failed ({t}); using active model", .{err}); + break :blk null; + }; + }; + const compaction_cfg: panto.config.CompactionConfig = .{ + .keep_verbatim = app_config.compaction_keep_verbatim orelse 20_000, + .model = compaction_provider_config, + }; + // Process-global HTTP client: one connection pool for every provider / // base_url the agent may switch to. Torn down at shutdown. panto.config.initHttp(alloc, io); @@ -471,10 +492,22 @@ pub fn main(init: std.process.Init) !void { const active_config: panto.config.Config = .{ .provider = provider_config, .registry = ®istry, + .compaction = compaction_cfg, }; var agent = panto.agent.Agent.init(alloc, io, &active_config); defer agent.deinit(); + // Resolve the compaction system prompt (COMPACTION.md across layers, + // last wins; built-in default otherwise) and arm automatic compaction. + // Owned by `sp_arena`, which lives for the whole REPL. + const compaction_prompt = try system_prompt.resolveCompaction( + sp_arena.allocator(), + io, + init.environ_map, + luarocks_rt.layout.agent_dir, + ); + agent.compaction_system_prompt = compaction_prompt; + const banner_base: []const u8 = switch (provider_config) { inline else => |c| c.base_url, }; @@ -493,6 +526,24 @@ pub fn main(init: std.process.Init) !void { defer cli_recv.deinit(); var recv = cli_recv.receiver(); + // Build the slash-command registry and register builtins. Future Lua + // extensions will append commands here too. + var cmd_registry = command.Registry.init(alloc); + defer cmd_registry.deinit(); + try command_compaction.register(&cmd_registry); + + var cmd_ctx: command.Context = .{ + .allocator = alloc, + .conv = &conv, + .agent = &agent, + .session_mgr = &session_mgr, + .stdout = stdout, + .stdout_file = &stdout_file, + .compaction_prompt = compaction_prompt, + .provider_name = banner_provider_initial, + .model_name = banner_model_initial, + }; + while (true) { const maybe_line = stdin.takeDelimiter('\n') catch |err| { std.debug.print("read error: {}\n", .{err}); @@ -510,8 +561,25 @@ pub fn main(init: std.process.Init) !void { continue; } + // Slash commands are handled by the CLI, not sent to the model. + // Any line starting with `/` is a command; an unrecognized one + // prints an error rather than reaching the model. + if (std.mem.startsWith(u8, line, "/")) { + cmd_registry.dispatch(line, &cmd_ctx) catch |err| switch (err) { + command.Error.CommandNotFound => { + try stdout.print("\n[unknown command: {s}]\n> ", .{line}); + try stdout_file.flush(); + }, + else => { + try stdout.print("\n[command error: {s}]\n> ", .{@errorName(err)}); + try stdout_file.flush(); + }, + }; + continue; + } + try conv.addUserMessage(line); - try appendUserPromptToSession( + try session_persist.appendUserPromptToSession( alloc, &session_mgr, line, @@ -526,22 +594,37 @@ pub fn main(init: std.process.Init) !void { try stdout.print("\n[error: {s}]\n", .{@errorName(err)}); }; - // Persist whatever new entries the agent produced this turn. The - // agent loop may have appended: - // - assistant message(s) (one per provider response) - // - user messages containing ToolResult blocks (one per tool round) - // Each assistant message gets paired with the Usage that the - // receiver captured at its onMessageComplete time (or null if - // the provider didn't emit usage that round). - try persistTurn( - alloc, - &session_mgr, - &conv, - entries_before_step, - banner_provider_initial, - banner_model_initial, - cli_recv.per_message_usage.items, - ); + if (agent.auto_compacted) { + // Automatic compaction rewrote the in-memory conversation, so + // message indices no longer align with `entries_before_step`. + // Persist the whole post-compaction window (summary + duplicated + // kept suffix + the retried turn) as fresh entries; the + // superseded prefix stays on disk and is ignored on replay. + try session_persist.persistCompaction( + alloc, + &session_mgr, + &conv, + banner_provider_initial, + banner_model_initial, + ); + } else { + // Persist whatever new entries the agent produced this turn. The + // agent loop may have appended: + // - assistant message(s) (one per provider response) + // - user messages containing ToolResult blocks (one per tool round) + // Each assistant message gets paired with the Usage that the + // receiver captured at its onMessageComplete time (or null if + // the provider didn't emit usage that round). + try session_persist.persistTurn( + alloc, + &session_mgr, + &conv, + entries_before_step, + banner_provider_initial, + banner_model_initial, + cli_recv.per_message_usage.items, + ); + } try stdout.writeAll("\n> "); try stdout_file.flush(); @@ -663,127 +746,3 @@ fn openSession( } } -// ----------------------------------------------------------------------------- -// Session append helpers — bridge in-memory ContentBlocks to on-disk entries. -// ----------------------------------------------------------------------------- - -fn appendUserPromptToSession( - alloc: std.mem.Allocator, - mgr: *panto.session_manager.SessionManager, - text: []const u8, - provider: []const u8, - model: []const u8, -) !void { - const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1); - blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } }; - _ = try mgr.appendMessage( - .{ .role = .user, .content = blocks }, - provider, - model, - ); -} - -/// After the agent loop has driven a turn to completion, persist every -/// new in-memory message at index `>= start_index` to the session log. -/// -/// Each in-memory message is mapped to disk: -/// - assistant → assistant entry with provider/model/stop_reason metadata -/// and Usage (from `per_message_usage`, one slot per assistant message in -/// completion order). -/// - user (with ToolResult blocks) → user entry stamped with provider/model. -/// -/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire -/// value requires plumbing it through the Receiver vtable (future work, -/// separate from token plumbing). -fn persistTurn( - alloc: std.mem.Allocator, - mgr: *panto.session_manager.SessionManager, - conv: *const panto.conversation.Conversation, - start_index: usize, - provider: []const u8, - model: []const u8, - per_message_usage: []const ?panto.session_manager.Usage, -) !void { - var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty; - defer batch_messages.deinit(alloc); - var batch_providers: std.ArrayList(?[]const u8) = .empty; - defer batch_providers.deinit(alloc); - var batch_models: std.ArrayList(?[]const u8) = .empty; - defer batch_models.deinit(alloc); - - var i = start_index; - var assistant_seen: usize = 0; - while (i < conv.messages.items.len) : (i += 1) { - const msg = conv.messages.items[i]; - const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len); - var allocated: usize = 0; - errdefer { - for (blocks[0..allocated]) |b| b.deinit(alloc); - alloc.free(blocks); - } - for (msg.content.items) |block| { - blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block); - allocated += 1; - } - if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) { - for (blocks[0..allocated]) |b| b.deinit(alloc); - alloc.free(blocks); - continue; - } - switch (msg.role) { - .system => { - // Mid-turn system messages aren't a thing in pantograph today. - // Treat as harmless and persist verbatim, sans stamps. - try batch_messages.append(alloc, .{ .role = .system, .content = blocks }); - try batch_providers.append(alloc, null); - try batch_models.append(alloc, null); - }, - .user => { - try batch_messages.append(alloc, .{ .role = .user, .content = blocks }); - try batch_providers.append(alloc, provider); - try batch_models.append(alloc, model); - }, - .assistant => { - // Pair this assistant message with its usage, if the - // receiver captured one for this position. (A turn - // ending in a stream error can have fewer usage entries - // than assistant messages; treat as null and move on.) - const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len) - per_message_usage[assistant_seen] - else - null; - assistant_seen += 1; - try batch_messages.append(alloc, .{ - .role = .assistant, - .content = blocks, - .provider = try alloc.dupe(u8, provider), - .model = try alloc.dupe(u8, model), - .stop_reason = try alloc.dupe(u8, "stop"), - .usage = usage, - }); - try batch_providers.append(alloc, null); - try batch_models.append(alloc, null); - }, - } - } - try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items); -} - -fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool { - const msg = conv.messages.items[index]; - for (msg.content.items) |block| { - if (block != .ToolUse) continue; - if (index + 1 >= conv.messages.items.len) return true; - const next = conv.messages.items[index + 1]; - if (next.role != .user) return true; - var found = false; - for (next.content.items) |next_block| { - if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) { - found = true; - break; - } - } - if (!found) return true; - } - return false; -} diff --git a/src/session_persist.zig b/src/session_persist.zig new file mode 100644 index 0000000..d5349bc --- /dev/null +++ b/src/session_persist.zig @@ -0,0 +1,153 @@ +//! Session-log persistence helpers shared between the REPL loop +//! (`main.zig`) and slash-command handlers (e.g. `compaction.zig`). +//! +//! These were previously private to `main.zig`. They were lifted into +//! their own module so that command handlers living outside the root +//! source file can reuse them without depending on `main.zig` (which, +//! being the executable root, can't be imported as a normal module). + +const std = @import("std"); +const panto = @import("panto"); + +/// Append a single user-prompt message to the session log. +pub fn appendUserPromptToSession( + alloc: std.mem.Allocator, + mgr: *panto.session_manager.SessionManager, + text: []const u8, + provider: []const u8, + model: []const u8, +) !void { + const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1); + blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } }; + _ = try mgr.appendMessage( + .{ .role = .user, .content = blocks }, + provider, + model, + ); +} + +/// After the agent loop has driven a turn to completion, persist every +/// new in-memory message at index `>= start_index` to the session log. +/// +/// Each in-memory message is mapped to disk: +/// - assistant → assistant entry with provider/model/stop_reason metadata +/// and Usage (from `per_message_usage`, one slot per assistant message in +/// completion order). +/// - user (with ToolResult blocks) → user entry stamped with provider/model. +/// +/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire +/// value requires plumbing it through the Receiver vtable (future work, +/// separate from token plumbing). +pub fn persistTurn( + alloc: std.mem.Allocator, + mgr: *panto.session_manager.SessionManager, + conv: *const panto.conversation.Conversation, + start_index: usize, + provider: []const u8, + model: []const u8, + per_message_usage: []const ?panto.session_manager.Usage, +) !void { + var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty; + defer batch_messages.deinit(alloc); + var batch_providers: std.ArrayList(?[]const u8) = .empty; + defer batch_providers.deinit(alloc); + var batch_models: std.ArrayList(?[]const u8) = .empty; + defer batch_models.deinit(alloc); + + var i = start_index; + var assistant_seen: usize = 0; + while (i < conv.messages.items.len) : (i += 1) { + const msg = conv.messages.items[i]; + const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len); + var allocated: usize = 0; + errdefer { + for (blocks[0..allocated]) |b| b.deinit(alloc); + alloc.free(blocks); + } + for (msg.content.items) |block| { + blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block); + allocated += 1; + } + if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) { + for (blocks[0..allocated]) |b| b.deinit(alloc); + alloc.free(blocks); + continue; + } + switch (msg.role) { + .system => { + // Mid-turn system messages aren't a thing in pantograph today. + // Treat as harmless and persist verbatim, sans stamps. + try batch_messages.append(alloc, .{ .role = .system, .content = blocks }); + try batch_providers.append(alloc, null); + try batch_models.append(alloc, null); + }, + .user => { + try batch_messages.append(alloc, .{ .role = .user, .content = blocks }); + try batch_providers.append(alloc, provider); + try batch_models.append(alloc, model); + }, + .assistant => { + // Pair this assistant message with its usage, if the + // receiver captured one for this position. (A turn + // ending in a stream error can have fewer usage entries + // than assistant messages; treat as null and move on.) + const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len) + per_message_usage[assistant_seen] + else + null; + assistant_seen += 1; + try batch_messages.append(alloc, .{ + .role = .assistant, + .content = blocks, + .provider = try alloc.dupe(u8, provider), + .model = try alloc.dupe(u8, model), + .stop_reason = try alloc.dupe(u8, "stop"), + .usage = usage, + }); + try batch_providers.append(alloc, null); + try batch_models.append(alloc, null); + }, + } + } + try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items); +} + +/// Persist a compaction to the session log. The agent rewrote the +/// in-memory conversation to `[system..., summary, kept-suffix...]`; the +/// superseded prefix stays on disk untouched. We append everything from +/// the compaction summary onward as fresh entries: the summary message +/// (carrying the `compactionSummary` block) followed by the duplicated +/// kept-verbatim suffix. On replay, the latest summary resets effective +/// context, so the duplicated suffix is what survives. +/// +/// Usage is not threaded through here: the duplicated entries are replays +/// of already-sized turns, and the summary itself isn't an assistant turn. +pub fn persistCompaction( + alloc: std.mem.Allocator, + mgr: *panto.session_manager.SessionManager, + conv: *const panto.conversation.Conversation, + provider: []const u8, + model: []const u8, +) !void { + const start = panto.conversation.latestCompactionIndex(conv.messages.items) orelse return; + try persistTurn(alloc, mgr, conv, start, provider, model, &.{}); +} + +pub fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool { + const msg = conv.messages.items[index]; + for (msg.content.items) |block| { + if (block != .ToolUse) continue; + if (index + 1 >= conv.messages.items.len) return true; + const next = conv.messages.items[index + 1]; + if (next.role != .user) return true; + var found = false; + for (next.content.items) |next_block| { + if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) { + found = true; + break; + } + } + if (!found) return true; + } + return false; +} diff --git a/src/system_prompt.zig b/src/system_prompt.zig index b796394..0b449dc 100644 --- a/src/system_prompt.zig +++ b/src/system_prompt.zig @@ -37,6 +37,31 @@ 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 @@ -185,6 +210,39 @@ pub fn reconcileResumeLayers( } } +/// 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 // --------------------------------------------------------------------------- @@ -539,3 +597,34 @@ test "Resolved.blocks - seed leads appends" { 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); + } +} -- cgit v1.3