From 8b88b886346460b1ab29edb03ad85bc3bb565a45 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 15:00:44 -0600 Subject: compaction --- src/main.zig | 241 +++++++++++++++++++++++++---------------------------------- 1 file changed, 100 insertions(+), 141 deletions(-) (limited to 'src/main.zig') 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; -} -- cgit v1.3