summaryrefslogtreecommitdiff
path: root/src/main.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 15:00:44 -0600
committert <t@tjp.lol>2026-06-02 16:37:32 -0600
commit8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch)
tree13b9ab56061a722641389b5fc8ac1113d09b66e5 /src/main.zig
parent7268c0b8e8bcf4b325581fabe7476a394f167738 (diff)
compaction
Diffstat (limited to 'src/main.zig')
-rw-r--r--src/main.zig241
1 files changed, 100 insertions, 141 deletions
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 = &registry,
+ .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;
-}