diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 5db241f3..0180c62b 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -33,9 +33,11 @@ const Credentials = zenai.provider.Credentials; const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); +const Conversation = @import("Conversation.zig"); const Terminal = @import("Terminal.zig"); const SlashCommand = @import("SlashCommand.zig"); const settings = @import("settings.zig"); +const welcome = @import("welcome.zig"); const string = @import("../string.zig"); const Agent = @This(); @@ -111,12 +113,10 @@ save_buffer: Recorder, save_path: ?[]u8, script_runtime_mutex: std.Thread.Mutex = .{}, active_script_runtime: ?*ScriptRuntime = null, -messages: std.ArrayList(zenai.provider.Message), -message_arena: std.heap.ArenaAllocator, +conversation: Conversation, model: []u8, /// Per-turn reasoning budget for LLM turns. Mutable at runtime via `/effort`. effort: Config.Effort, -system_prompt: []const u8, script_file: ?[]const u8, one_shot_task: ?[]const u8, one_shot_attachments: ?[]const []const u8, @@ -134,81 +134,6 @@ total_usage: zenai.provider.Usage = .{}, last_turn_refused: bool = false, available_providers: []const []const u8, -fn resolveModelName(opts: Config.Agent, resolved: ?settings.ResolvedProvider, remembered: ?settings.Remembered) []const u8 { - if (opts.model) |m| return m; - if (resolved) |r| { - // Use the remembered model whenever it matches the chosen provider, - // not only when the provider itself came from the remembered file. - if (remembered) |rem| { - if (rem.provider) |p| if (p == r.credentials.provider) return rem.model; - } - return zenai.provider.defaultModel(r.credentials.provider); - } - return ""; -} - -/// Precedence: explicit `--effort` flag > remembered `.lp-agent.zon` value > -/// mode default. The interactive REPL defaults to `.low` so turns stay snappy; -/// one-shot `--task` and script runs default to `.medium`, where answer -/// quality matters more than per-turn latency. -fn resolveEffort(opts: Config.Agent, remembered: ?settings.Remembered, will_repl: bool) Config.Effort { - if (opts.effort) |e| return e; - if (remembered) |r| if (r.effort) |e| return e; - return if (will_repl) .low else .medium; -} - -/// Precedence: explicit `--verbosity` flag > remembered `.lp-agent.zon` value > -/// mode default (see `Config.agentVerbosity`). -fn resolveVerbosity(opts: Config.Agent, remembered: ?settings.Remembered) Config.AgentVerbosity { - if (opts.verbosity) |v| return v; - if (remembered) |r| if (r.verbosity) |v| return v; - return Config.agentVerbosity(opts); -} - -const ReconciledModel = union(enum) { - /// Owned by the allocator passed to reconcileModel. - use: []u8, - abort, -}; - -/// Validate `desired` against the provider's catalog, mirroring the interactive -/// `/model` command. An unreachable server (empty list) leaves it unchecked. -/// An explicit model that isn't listed is fatal. Ollama's local catalog is -/// authoritative, so its default is substituted when not pulled; cloud defaults -/// are hardcoded real models and trusted as-is. -fn reconcileModel( - allocator: std.mem.Allocator, - llm: Credentials, - desired: []const u8, - base_url: ?[:0]const u8, - explicit: bool, -) !ReconciledModel { - var arena: std.heap.ArenaAllocator = .init(allocator); - defer arena.deinit(); - const ids: []const []const u8 = zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, base_url) catch &.{}; - if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; - - if (!explicit) { - if (llm.provider != .ollama) return .{ .use = try allocator.dupe(u8, desired) }; - std.debug.print("Default Ollama model '{s}' is not installed; using '{s}'.\n", .{ desired, ids[0] }); - return .{ .use = try allocator.dupe(u8, ids[0]) }; - } - - if (llm.provider == .ollama) { - const installed = std.mem.join(arena.allocator(), ", ", ids) catch ""; - std.debug.print( - "Model '{s}' is not installed in Ollama.\nInstalled: {s}\nRun `ollama pull {s}` to install it, or choose one of the above.\n", - .{ desired, installed, desired }, - ); - } else { - std.debug.print( - "Model '{s}' is not available for {s}.\nRun with --list-models to see options.\n", - .{ desired, @tagName(llm.provider) }, - ); - } - return .abort; -} - pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent { var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Credentials = undefined; const found_providers = settings.availableProviders(&providers_buf); @@ -265,7 +190,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent // no key detected) resolveCredentials prints its own message and the // banner is skipped. if (will_repl and (!resolve or settings.wouldResolve(allocator, opts, remembered))) { - printWelcome(resolve); + welcome.print(resolve); } const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; @@ -278,7 +203,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent return error.MissingProvider; } - var model = try allocator.dupe(u8, resolveModelName(opts, resolved, remembered)); + var model = try allocator.dupe(u8, settings.resolveModelName(opts, resolved, remembered)); errdefer allocator.free(model); // The REPL skips this network round trip to keep startup snappy; an invalid @@ -286,7 +211,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent if (llm) |l| if (!will_repl) { const remembered_matches = remembered != null and remembered.?.provider == l.provider; const explicit = opts.model != null or remembered_matches; - switch (try reconcileModel(allocator, l, model, opts.base_url, explicit)) { + switch (try settings.reconcileModel(allocator, l, model, opts.base_url, explicit)) { .use => |m| { allocator.free(model); model = m; @@ -295,8 +220,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent } }; - const effort = resolveEffort(opts, remembered, will_repl); - const verbosity = resolveVerbosity(opts, remembered); + const effort = settings.resolveEffort(opts, remembered, will_repl); + const verbosity = settings.resolveVerbosity(opts, remembered); if (resolved) |r| { if (r.source == .picked) { @@ -332,11 +257,9 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .terminal = .init(allocator, history_paths, verbosity, will_repl), .save_buffer = .init(allocator), .save_path = null, - .messages = .empty, - .message_arena = .init(allocator), + .conversation = .init(allocator, opts.system_prompt orelse default_system_prompt), .model = model, .effort = effort, - .system_prompt = opts.system_prompt orelse default_system_prompt, .script_file = opts.script_file, .one_shot_task = opts.task, .one_shot_attachments = if (opts.attach.items.len == 0) null else opts.attach.items, @@ -344,7 +267,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent }; errdefer self.node_registry.deinit(); errdefer self.terminal.deinit(); - errdefer self.message_arena.deinit(); + errdefer self.conversation.deinit(); self.terminal.installLogSink(); errdefer self.terminal.uninstallLogSink(); @@ -376,9 +299,8 @@ pub fn deinit(self: *Agent) void { self.save_buffer.deinit(); if (self.save_path) |p| self.allocator.free(p); self.terminal.deinit(); - self.message_arena.deinit(); + self.conversation.deinit(); self.model_completion_arena.deinit(); - self.messages.deinit(self.allocator); self.node_registry.deinit(); self.browser.deinit(); self.notification.deinit(); @@ -467,7 +389,7 @@ fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} { /// The side effects of `drainCancellation` without surfacing the error, for /// void callers (e.g. `/save` synthesis) that just need to clean up. fn resetAfterCancel(self: *Agent, baseline: usize) void { - self.rollbackMessages(baseline); + self.conversation.rollback(baseline); self.browser.env.cancelTerminate(); self.cancel_requested.store(false, .release); } @@ -524,7 +446,7 @@ fn runTurn(self: *Agent, input: TurnInput) bool { error.UnsupportedAttachment, error.AttachmentReadFailed => return false, error.UserCancelled => { self.terminal.printInfo("Interrupted.", .{}); - self.pruneMessages(); + self.conversation.prune(); return false; }, else => { @@ -538,7 +460,7 @@ fn runTurn(self: *Agent, input: TurnInput) bool { self.terminal.printInfo("(model declined to respond — safety refusal)", .{}) else self.terminal.printInfo("(no response from model)", .{}); - self.pruneMessages(); + self.conversation.prune(); return true; } @@ -723,7 +645,7 @@ fn handleUsage(self: *Agent) void { /// re-seeds lazily on the next turn), cumulative usage, the recorded action /// buffer, and DOM node IDs. Shared by `/clear` and `/reset`. fn clearConversation(self: *Agent) void { - self.rollbackMessages(0); + self.conversation.rollback(0); self.save_buffer.reset(); self.total_usage = .{}; self.node_registry.reset(); @@ -759,111 +681,6 @@ fn handleLoad(self: *Agent, rest: []const u8) void { const api_keys_hint = settings.api_keys_hint; const llm_setup_hint = "set an API key (" ++ api_keys_hint ++ ") and run /provider "; -// A pre-colored (truecolor braille) panda. Each line carries its own ANSI and -// resets at the end, so it prints as-is; non-empty lines are the visible rows. -const logo = - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" ++ - "⠀⠀⠀⠀⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⢀⣠⣤⣶⣶⣶⣶⣶⣶⣶⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀\x1b[0m\n" ++ - "⠀⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀\x1b[0m\n" ++ - "⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⣴⣿⣿⣿⣿⣿⡿⠿⢿⣿⡿⠿⠟⠛⠛⣿⣿⣿⣿⣷⣄⠀⠀⠀\x1b[0m\n" ++ - "⠀⠀⠀⠀\x1b[38;2;247;247;239m⣼⣿⣿⣿⣿⣿⡇⠀⢀⣤⣶⣾⣿⣿⣶⣄⢸⣿⣿⣿⣿⣿⣆⠀⠀\x1b[0m\n" ++ - "⠀⠀⠀\x1b[38;2;247;247;239m⣼⠿⠟⠛⠋⠉⠉⠁⣼⣿⣿⡟⠛⠛⣿⣿⠋⢳⢹⣿⣿⣿⣿⣿⡆⠀\x1b[0m\n" ++ - "⠀⠀⠀\x1b[38;2;247;247;239m⣇⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠀⠀⢤⣿⣿⣿⡃⡇⣿⣿⣿⣿⣿⣷⠀\x1b[0m\n" ++ - "⠀⠀⠀\x1b[38;2;247;247;239m⣿⣿⣿⣶⣦⣤⡀⠀⢿⣿⣿⣷⣶⣟⠿⡷⠠⣾⠃⣿⣿⣿⣿⣿⣿⠀\x1b[0m\n" ++ - "⠀⠀⠀\x1b[38;2;247;247;239m⣿⣿⣿⣿⣿⣿⣿⡄⠈⠻⢿⣿⣿⣿⣿⡿⠟⠁⠀⠈⢿⣿⣿⣿⡿⠀\x1b[0m\n" ++ - "⠀⠀⠀\x1b[38;2;247;247;239m⢹⣿⣿⣿⣿⣏\x1b[38;2;9;126;179m⣁⠀\x1b[38;2;247;247;239m⣤⢀⡤⠀\x1b[38;2;100;112;112m⠈⠁⠀⠀⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⢻⣿⣿⠇⠀\x1b[0m\n" ++ - "⠀⠀\x1b[38;2;9;126;179m⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣄⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⠐⢄⠀⠀⠈⣿⠏⠀⠀\x1b[0m\n" ++ - "\x1b[38;2;106;197;230m⢀⣰⣿⠿⠿⠿⠿⠿⠿⣿⣿⣿\x1b[38;2;9;126;179m⣿⣿⣿⣿⣶⣤⣄⣀⠀⠀\x1b[38;2;247;247;239m⠑⢶⣶\x1b[38;2;106;197;230m⣃\x1b[38;2;9;126;179m⣀⣤⠄\x1b[0m\n" ++ - "\x1b[38;2;106;197;230m⠋⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠻⠿⣝\x1b[38;2;9;126;179m⠛⠿⣿⣿⣿⣿⣿⣿⠿⠛⠉⠀⠀\x1b[0m\n" ++ - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" ++ - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"; -const logo_cols = 29; // braille cells per row -const logo_rows = blk: { - @setEvalBranchQuota(20000); - var n: usize = 0; - var it = std.mem.splitScalar(u8, logo, '\n'); - while (it.next()) |line| { - if (line.len != 0) n += 1; - } - break :blk n; -}; -const welcome_gap = " "; - -/// Banner text. Kept narrow enough that the logo + gap + the widest line fits in -/// 80 columns (asserted at comptime below), so the banner always shows in full -/// and never needs to measure the terminal or shed the logo. -const banner_tagline_llm = "Control the browser with natural language"; -const banner_tagline_basic = "Basic REPL (--no-llm) — commands only"; -const banner_setup = "Set an API key, then run /provider "; -const banner_hints = [_][]const u8{ - "/goto to navigate", - "/save to generate a reproducible script", - "/help to list commands /quit to exit", - "! to run JavaScript on the current page", -}; - -comptime { - // Excludes the version line: it's build-environment-controlled (nightly tags - // add a commit count + hash), so asserting it would break the build over an - // input this file doesn't own. - const fixed = [_][]const u8{ - "Lightpanda Agent", - banner_tagline_llm, - banner_tagline_basic, - banner_setup, - } ++ banner_hints; - var maxw: usize = 0; - for (fixed) |s| maxw = @max(maxw, std.unicode.utf8CountCodepoints(s) catch s.len); - if (logo_cols + welcome_gap.len + maxw > 79) @compileError("welcome banner exceeds 79 columns"); -} - -/// Prints the welcome banner: the logo on the left with the title and command -/// hints beside it, vertically centered. `llm_active` picks the tagline. -fn printWelcome(llm_active: bool) void { - const a = Terminal.ansi; - - var version_buf: [192]u8 = undefined; - const version: []const u8 = std.fmt.bufPrint(&version_buf, a.dim ++ "{s}" ++ a.reset, .{lp.build_config.version}) catch ""; - - var lines: [9][]const u8 = undefined; - var n: usize = 0; - lines[n] = a.bold ++ "Lightpanda Agent" ++ a.reset; - n += 1; - lines[n] = version; - n += 1; - lines[n] = ""; - n += 1; - if (llm_active) { - lines[n] = a.italic ++ banner_tagline_llm ++ a.reset; - n += 1; - } else { - lines[n] = a.italic ++ banner_tagline_basic ++ a.reset; - n += 1; - lines[n] = a.dim ++ banner_setup ++ a.reset; - n += 1; - } - inline for (banner_hints) |t| { - lines[n] = a.dim ++ t ++ a.reset; - n += 1; - } - const text = lines[0..n]; - - const start = (logo_rows - text.len) / 2 -| 1; - std.debug.print("\n", .{}); - var row: usize = 0; - var it = std.mem.splitScalar(u8, logo, '\n'); - while (it.next()) |logo_line| { - if (logo_line.len == 0) continue; - std.debug.print("{s}", .{logo_line}); - if (row >= start and row - start < text.len) { - const line = text[row - start]; - if (line.len != 0) std.debug.print("{s}{s}", .{ welcome_gap, line }); - } - std.debug.print("\n", .{}); - row += 1; - } -} - /// `/provider ` disables the LLM and persists it; shared by the command /// parser, autocomplete, and the save report so they can't drift apart. const provider_off_keyword = "null"; @@ -1161,18 +978,18 @@ fn synthesizeSave(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8, const resolved = self.resolveSavePathAndMode(arena, filename) orelse return; const path = resolved.path; - self.ensureSystemPrompt() catch return self.failSave("out of memory"); + self.conversation.ensureSystemPrompt() catch return self.failSave("out of memory"); - const ma = self.message_arena.allocator(); - const baseline = self.messages.items.len; + const ma = self.conversation.arena.allocator(); + const baseline = self.conversation.messages.items.len; const user_msg = self.buildSaveSynthesisMessage(ma, prompt) catch return self.failSave("out of memory"); - self.messages.append(self.allocator, .{ .role = .user, .content = user_msg }) catch return self.failSave("out of memory"); + self.conversation.messages.append(self.allocator, .{ .role = .user, .content = user_msg }) catch return self.failSave("out of memory"); self.terminal.spinner.start(); var result = provider_client.runTools( self.model, - &self.messages, + &self.conversation.messages, self.allocator, ma, .{ .context = @ptrCast(self), .callFn = handleToolCall }, @@ -1191,7 +1008,7 @@ fn synthesizeSave(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8, return; } log.err(.app, "AI save synthesis error", .{ .err = err }); - self.rollbackMessages(baseline); + self.conversation.rollback(baseline); return self.failSave(@errorName(err)); }; self.terminal.spinner.stop(); @@ -1204,23 +1021,23 @@ fn synthesizeSave(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8, } const raw = result.text orelse { - self.rollbackMessages(baseline); + self.conversation.rollback(baseline); return self.failSave("the model returned no script"); }; // `result.text` lives in `message_arena`, which the rollback below frees; // copy into the command arena first (scrubbing may return its input as-is). const owned = arena.dupe(u8, stripCodeFence(raw)) catch { - self.rollbackMessages(baseline); + self.conversation.rollback(baseline); return self.failSave("out of memory"); }; const script = browser_tools.reverseSubstituteEnvVars(arena, owned) catch { - self.rollbackMessages(baseline); + self.conversation.rollback(baseline); return self.failSave("out of memory"); }; // The save turn is a meta-action; keep it out of the ongoing conversation. - self.rollbackMessages(baseline); + self.conversation.rollback(baseline); writeContentFile(path, script, resolved.mode) catch |err| { self.terminal.printError("failed to save {s}: {s}", .{ path, @errorName(err) }); @@ -1489,16 +1306,7 @@ fn runScript(self: *Agent, path: []const u8) bool { return true; } -fn ensureSystemPrompt(self: *Agent) !void { - if (self.messages.items.len == 0) { - try self.messages.append(self.allocator, .{ - .role = .system, - .content = self.system_prompt, - }); - } -} - -/// Mirror a user-typed slash command into `self.messages` as if the LLM +/// Mirror a user-typed slash command into `self.conversation.messages` as if the LLM /// had called the tool itself, so the next natural-language turn sees /// the same conversation shape either way. fn recordSlashToolCall( @@ -1509,9 +1317,9 @@ fn recordSlashToolCall( result: browser_tools.ToolResult, ) !void { if (self.ai_client == null) return; - try self.ensureSystemPrompt(); + try self.conversation.ensureSystemPrompt(); - const ma = self.message_arena.allocator(); + const ma = self.conversation.arena.allocator(); self.synthetic_tool_call_id += 1; const user_content = try ma.dupe(u8, user_input); @@ -1536,106 +1344,51 @@ fn recordSlashToolCall( .is_error = result.is_error, }; - const baseline = self.messages.items.len; - errdefer self.messages.shrinkRetainingCapacity(baseline); + const baseline = self.conversation.messages.items.len; + errdefer self.conversation.messages.shrinkRetainingCapacity(baseline); // User turn before the assistant tool_call satisfies Gemini's rule // that a function call must follow a user or function-response turn. - try self.messages.append(self.allocator, .{ + try self.conversation.messages.append(self.allocator, .{ .role = .user, .content = user_content, }); - try self.messages.append(self.allocator, .{ + try self.conversation.messages.append(self.allocator, .{ .role = .assistant, .tool_calls = tool_calls, }); - try self.messages.append(self.allocator, .{ + try self.conversation.messages.append(self.allocator, .{ .role = .tool, .tool_results = tool_results, }); } -const prune_high = 30; -const prune_keep = 20; - -fn pruneMessages(self: *Agent) void { - const msgs = self.messages.items; - if (msgs.len <= prune_high) return; - - const tail_start = zenai.provider.safeTruncationStart(msgs, msgs.len - prune_keep) orelse return; - - // Dupe into the new arena before mutating self.messages — a partial - // failure would otherwise leave items pointing into a freed arena. - var new_arena: std.heap.ArenaAllocator = .init(self.allocator); - const duped = zenai.provider.dupeMessages(new_arena.allocator(), msgs[tail_start..]) catch { - new_arena.deinit(); - return; - }; - - // System prompt at index 0 lives outside the arena and is preserved. - @memcpy(self.messages.items[1..][0..duped.len], duped); - self.messages.shrinkRetainingCapacity(1 + duped.len); - self.message_arena.deinit(); - self.message_arena = new_arena; -} - -/// Shrink `self.messages` back to `baseline` and rebuild the arena. Used -/// after a failed turn (API error, synthesis) so the next turn doesn't -/// replay the dropped messages and the arena doesn't accumulate their bytes. -fn rollbackMessages(self: *Agent, baseline: usize) void { - self.messages.shrinkRetainingCapacity(baseline); - self.rebuildMessageArena(); -} - -/// Rebuild `message_arena` keeping only the messages currently in -/// `self.messages`. Used after a rolled-back turn so the arena doesn't -/// accumulate prompt/tool-output bytes from doomed turns. -fn rebuildMessageArena(self: *Agent) void { - const msgs = self.messages.items; - if (msgs.len <= 1) { - // Only the system prompt (or nothing) remains — the system prompt - // lives outside the arena, so we can reset freely. - _ = self.message_arena.reset(.retain_capacity); - return; - } - - var new_arena: std.heap.ArenaAllocator = .init(self.allocator); - // System prompt at index 0 lives outside the arena and is preserved. - const duped = zenai.provider.dupeMessages(new_arena.allocator(), msgs[1..]) catch { - new_arena.deinit(); - return; - }; - @memcpy(self.messages.items[1..][0..duped.len], duped); - self.message_arena.deinit(); - self.message_arena = new_arena; -} - -/// Returned text lives in `message_arena`, so it's only valid until the -/// next prune. The caller is responsible for calling `pruneMessages()` +/// Returned text lives in `conversation.arena`, so it's only valid until the +/// next prune. The caller is responsible for calling `conversation.prune()` /// after consuming the returned text — pruning earlier would free the /// arena the slice points into. `null` means the model emitted nothing /// even after the synthesis turn. fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { - const ma = self.message_arena.allocator(); + const ma = self.conversation.arena.allocator(); - try self.ensureSystemPrompt(); + try self.conversation.ensureSystemPrompt(); // Attachments only ride on the very first user turn (just after the // system prompt) — wired into the message's rich `parts`. const turn_attachments: ?[]const []const u8 = - if (self.messages.items.len == 1) input.attachments else null; + if (self.conversation.messages.items.len == 1) input.attachments else null; // Save message count so we can roll back on API failure — otherwise the // failed user turn stays in history and replays on the next attempt. - const msg_baseline = self.messages.items.len; + const msg_baseline = self.conversation.messages.items.len; if (turn_attachments) |paths| { const parts = try self.buildUserMessageParts(ma, input.prompt, paths); - try self.messages.append(self.allocator, .{ + try self.conversation.messages.append(self.allocator, .{ .role = .user, .parts = parts, }); } else { - try self.messages.append(self.allocator, .{ + try self.conversation.messages.append(self.allocator, .{ .role = .user, .content = try ma.dupe(u8, input.prompt), }); @@ -1646,7 +1399,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { self.terminal.spinner.start(); var result = provider_client.runTools( self.model, - &self.messages, + &self.conversation.messages, self.allocator, ma, .{ .context = @ptrCast(self), .callFn = handleToolCall }, @@ -1669,7 +1422,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { // outcome they asked for. if (self.cancel_requested.load(.acquire)) return self.drainCancellation(msg_baseline); log.err(.app, "AI API error", .{ .err = err }); - self.rollbackMessages(msg_baseline); + self.conversation.rollback(msg_baseline); return error.ApiError; }; self.terminal.spinner.stop(); @@ -1694,7 +1447,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { if (last_extract_idx) |idx| { if (tool == .extract and idx != i) continue; } - const args = browser_tools.normalizeArgKeys(self.message_arena.allocator(), tool, tc.arguments) catch tc.arguments; + const args = browser_tools.normalizeArgKeys(self.conversation.arena.allocator(), tool, tc.arguments) catch tc.arguments; const cmd = Command.fromToolCall(tool, args); if (!cmd.isRecorded()) continue; if (!recorded_any) { @@ -1718,15 +1471,15 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { // Without a synthesis turn forbidding tools+pretraining, models // confabulate when the page was blocked or empty. log.info(.app, "synthesizing final answer", .{}); - const synth_baseline = self.messages.items.len; - try self.messages.append(self.allocator, .{ + const synth_baseline = self.conversation.messages.items.len; + try self.conversation.messages.append(self.allocator, .{ .role = .user, .content = try ma.dupe(u8, synthesis_prompt), }); var synth = provider_client.runTools( self.model, - &self.messages, + &self.conversation.messages, self.allocator, ma, .{ .context = @ptrCast(self), .callFn = handleToolCall }, @@ -1743,7 +1496,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { ) catch |err| { if (self.cancel_requested.load(.acquire)) return self.drainCancellation(msg_baseline); log.err(.app, "AI synthesis error", .{ .err = err }); - self.rollbackMessages(synth_baseline); + self.conversation.rollback(synth_baseline); break :blk null; }; defer synth.deinit(); diff --git a/src/agent/Conversation.zig b/src/agent/Conversation.zig new file mode 100644 index 00000000..3b5110e4 --- /dev/null +++ b/src/agent/Conversation.zig @@ -0,0 +1,111 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The agent's chat history and the arena backing every message's bytes. The +//! pairing is the point: pruning and rollback re-home the surviving messages +//! into a fresh arena so dropped turns' bytes are freed in one shot, and the +//! system prompt at index 0 is the one message kept outside the arena so those +//! rebuilds never disturb it. `Agent` appends turns and reads `messages` / +//! `arena` directly; it leaves the lifecycle (seed, prune, rollback) here. + +const std = @import("std"); +const zenai = @import("zenai"); + +const Conversation = @This(); +const Message = zenai.provider.Message; + +// Once history exceeds `prune_high` messages, drop the middle and keep the +// system prompt plus the most recent `prune_keep`. +const prune_high = 30; +const prune_keep = 20; + +allocator: std.mem.Allocator, +/// Seeded as `messages[0]` on the first turn. Lives outside `arena` (static or +/// caller-owned), so the arena rebuilds below never touch it. +system_prompt: []const u8, +messages: std.ArrayList(Message), +/// Backs every message's content/parts. Rebuilt — not just reset — on prune and +/// rollback so dropped turns' bytes don't accumulate. +arena: std.heap.ArenaAllocator, + +pub fn init(allocator: std.mem.Allocator, system_prompt: []const u8) Conversation { + return .{ + .allocator = allocator, + .system_prompt = system_prompt, + .messages = .empty, + .arena = .init(allocator), + }; +} + +pub fn deinit(self: *Conversation) void { + self.arena.deinit(); + self.messages.deinit(self.allocator); +} + +/// Seed the system prompt as `messages[0]` when the history is empty. Idempotent +/// and called every turn, so a cleared conversation re-seeds lazily. +pub fn ensureSystemPrompt(self: *Conversation) !void { + if (self.messages.items.len == 0) { + try self.messages.append(self.allocator, .{ + .role = .system, + .content = self.system_prompt, + }); + } +} + +/// Cap history growth: once it exceeds `prune_high`, keep the system prompt plus +/// the most recent `prune_keep` messages, snapped to a safe boundary so a +/// tool_call isn't split from its result. +pub fn prune(self: *Conversation) void { + const msgs = self.messages.items; + if (msgs.len <= prune_high) return; + const tail_start = zenai.provider.safeTruncationStart(msgs, msgs.len - prune_keep) orelse return; + self.repackTail(msgs[tail_start..]); +} + +/// Shrink history back to `baseline` and rebuild the arena. Used after a failed +/// turn (API error, synthesis) so the next turn doesn't replay the dropped +/// messages and the arena doesn't accumulate their bytes. +pub fn rollback(self: *Conversation, baseline: usize) void { + self.messages.shrinkRetainingCapacity(baseline); + const msgs = self.messages.items; + if (msgs.len <= 1) { + // Only the system prompt (or nothing) remains — it lives outside the + // arena, so a plain reset suffices. + _ = self.arena.reset(.retain_capacity); + return; + } + self.repackTail(msgs[1..]); +} + +/// Re-home `tail` (a suffix of `messages`) into a fresh arena, placing it right +/// after the preserved system prompt at index 0, then swap arenas so the old +/// turns' bytes are freed at once. A dupe failure leaves the conversation as-is. +fn repackTail(self: *Conversation, tail: []const Message) void { + var new_arena: std.heap.ArenaAllocator = .init(self.allocator); + // Dupe into the new arena before mutating `messages` — a partial failure + // would otherwise leave items pointing into a freed arena. + const duped = zenai.provider.dupeMessages(new_arena.allocator(), tail) catch { + new_arena.deinit(); + return; + }; + @memcpy(self.messages.items[1..][0..duped.len], duped); + self.messages.shrinkRetainingCapacity(1 + duped.len); + self.arena.deinit(); + self.arena = new_arena; +} diff --git a/src/agent/settings.zig b/src/agent/settings.zig index b6d3e865..25335c8c 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -27,6 +27,7 @@ const zenai = @import("zenai"); const lp = @import("lightpanda"); const Config = lp.Config; const Terminal = @import("Terminal.zig"); +const string = @import("../string.zig"); const Credentials = zenai.provider.Credentials; pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY"; @@ -145,3 +146,78 @@ pub fn saveRemembered(remembered: Remembered) !void { pub fn availableProviders(buf: []Credentials) []Credentials { return zenai.provider.detectKeys(buf, std.enums.values(Config.AiProvider)); } + +pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 { + if (opts.model) |m| return m; + if (resolved) |r| { + // Use the remembered model whenever it matches the chosen provider, + // not only when the provider itself came from the remembered file. + if (remembered) |rem| { + if (rem.provider) |p| if (p == r.credentials.provider) return rem.model; + } + return zenai.provider.defaultModel(r.credentials.provider); + } + return ""; +} + +/// Precedence: explicit `--effort` flag > remembered `.lp-agent.zon` value > +/// mode default. The interactive REPL defaults to `.low` so turns stay snappy; +/// one-shot `--task` and script runs default to `.medium`, where answer +/// quality matters more than per-turn latency. +pub fn resolveEffort(opts: Config.Agent, remembered: ?Remembered, will_repl: bool) Config.Effort { + if (opts.effort) |e| return e; + if (remembered) |r| if (r.effort) |e| return e; + return if (will_repl) .low else .medium; +} + +/// Precedence: explicit `--verbosity` flag > remembered `.lp-agent.zon` value > +/// mode default (see `Config.agentVerbosity`). +pub fn resolveVerbosity(opts: Config.Agent, remembered: ?Remembered) Config.AgentVerbosity { + if (opts.verbosity) |v| return v; + if (remembered) |r| if (r.verbosity) |v| return v; + return Config.agentVerbosity(opts); +} + +pub const ReconciledModel = union(enum) { + /// Owned by the allocator passed to reconcileModel. + use: []u8, + abort, +}; + +/// Validate `desired` against the provider's catalog, mirroring the interactive +/// `/model` command. An unreachable server (empty list) leaves it unchecked. +/// An explicit model that isn't listed is fatal. Ollama's local catalog is +/// authoritative, so its default is substituted when not pulled; cloud defaults +/// are hardcoded real models and trusted as-is. +pub fn reconcileModel( + allocator: std.mem.Allocator, + llm: Credentials, + desired: []const u8, + base_url: ?[:0]const u8, + explicit: bool, +) !ReconciledModel { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const ids: []const []const u8 = zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, base_url) catch &.{}; + if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; + + if (!explicit) { + if (llm.provider != .ollama) return .{ .use = try allocator.dupe(u8, desired) }; + std.debug.print("Default Ollama model '{s}' is not installed; using '{s}'.\n", .{ desired, ids[0] }); + return .{ .use = try allocator.dupe(u8, ids[0]) }; + } + + if (llm.provider == .ollama) { + const installed = std.mem.join(arena.allocator(), ", ", ids) catch ""; + std.debug.print( + "Model '{s}' is not installed in Ollama.\nInstalled: {s}\nRun `ollama pull {s}` to install it, or choose one of the above.\n", + .{ desired, installed, desired }, + ); + } else { + std.debug.print( + "Model '{s}' is not available for {s}.\nRun with --list-models to see options.\n", + .{ desired, @tagName(llm.provider) }, + ); + } + return .abort; +} diff --git a/src/agent/welcome.zig b/src/agent/welcome.zig new file mode 100644 index 00000000..388b937d --- /dev/null +++ b/src/agent/welcome.zig @@ -0,0 +1,130 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The agent REPL's startup banner: a pre-colored braille panda logo with the +//! title and command hints laid out beside it. Sized at comptime to fit 80 +//! columns so it never has to measure the terminal. + +const std = @import("std"); +const lp = @import("lightpanda"); +const Terminal = @import("Terminal.zig"); + +// A pre-colored (truecolor braille) panda. Each line carries its own ANSI and +// resets at the end, so it prints as-is; non-empty lines are the visible rows. +const logo = + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" ++ + "⠀⠀⠀⠀⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⢀⣠⣤⣶⣶⣶⣶⣶⣶⣶⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀\x1b[0m\n" ++ + "⠀⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀\x1b[0m\n" ++ + "⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⣴⣿⣿⣿⣿⣿⡿⠿⢿⣿⡿⠿⠟⠛⠛⣿⣿⣿⣿⣷⣄⠀⠀⠀\x1b[0m\n" ++ + "⠀⠀⠀⠀\x1b[38;2;247;247;239m⣼⣿⣿⣿⣿⣿⡇⠀⢀⣤⣶⣾⣿⣿⣶⣄⢸⣿⣿⣿⣿⣿⣆⠀⠀\x1b[0m\n" ++ + "⠀⠀⠀\x1b[38;2;247;247;239m⣼⠿⠟⠛⠋⠉⠉⠁⣼⣿⣿⡟⠛⠛⣿⣿⠋⢳⢹⣿⣿⣿⣿⣿⡆⠀\x1b[0m\n" ++ + "⠀⠀⠀\x1b[38;2;247;247;239m⣇⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠀⠀⢤⣿⣿⣿⡃⡇⣿⣿⣿⣿⣿⣷⠀\x1b[0m\n" ++ + "⠀⠀⠀\x1b[38;2;247;247;239m⣿⣿⣿⣶⣦⣤⡀⠀⢿⣿⣿⣷⣶⣟⠿⡷⠠⣾⠃⣿⣿⣿⣿⣿⣿⠀\x1b[0m\n" ++ + "⠀⠀⠀\x1b[38;2;247;247;239m⣿⣿⣿⣿⣿⣿⣿⡄⠈⠻⢿⣿⣿⣿⣿⡿⠟⠁⠀⠈⢿⣿⣿⣿⡿⠀\x1b[0m\n" ++ + "⠀⠀⠀\x1b[38;2;247;247;239m⢹⣿⣿⣿⣿⣏\x1b[38;2;9;126;179m⣁⠀\x1b[38;2;247;247;239m⣤⢀⡤⠀\x1b[38;2;100;112;112m⠈⠁⠀⠀⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⢻⣿⣿⠇⠀\x1b[0m\n" ++ + "⠀⠀\x1b[38;2;9;126;179m⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣄⠀⠀⠀⠀⠀\x1b[38;2;247;247;239m⠐⢄⠀⠀⠈⣿⠏⠀⠀\x1b[0m\n" ++ + "\x1b[38;2;106;197;230m⢀⣰⣿⠿⠿⠿⠿⠿⠿⣿⣿⣿\x1b[38;2;9;126;179m⣿⣿⣿⣿⣶⣤⣄⣀⠀⠀\x1b[38;2;247;247;239m⠑⢶⣶\x1b[38;2;106;197;230m⣃\x1b[38;2;9;126;179m⣀⣤⠄\x1b[0m\n" ++ + "\x1b[38;2;106;197;230m⠋⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠻⠿⣝\x1b[38;2;9;126;179m⠛⠿⣿⣿⣿⣿⣿⣿⠿⠛⠉⠀⠀\x1b[0m\n" ++ + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" ++ + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"; +const logo_cols = 29; // braille cells per row +const logo_rows = blk: { + @setEvalBranchQuota(20000); + var n: usize = 0; + var it = std.mem.splitScalar(u8, logo, '\n'); + while (it.next()) |line| { + if (line.len != 0) n += 1; + } + break :blk n; +}; +const welcome_gap = " "; + +/// Banner text. Kept narrow enough that the logo + gap + the widest line fits in +/// 80 columns (asserted at comptime below), so the banner always shows in full +/// and never needs to measure the terminal or shed the logo. +const banner_tagline_llm = "Control the browser with natural language"; +const banner_tagline_basic = "Basic REPL (--no-llm) — commands only"; +const banner_setup = "Set an API key, then run /provider "; +const banner_hints = [_][]const u8{ + "/goto to navigate", + "/save to generate a reproducible script", + "/help to list commands /quit to exit", + "! to run JavaScript on the current page", +}; + +comptime { + // Excludes the version line: it's build-environment-controlled (nightly tags + // add a commit count + hash), so asserting it would break the build over an + // input this file doesn't own. + const fixed = [_][]const u8{ + "Lightpanda Agent", + banner_tagline_llm, + banner_tagline_basic, + banner_setup, + } ++ banner_hints; + var maxw: usize = 0; + for (fixed) |s| maxw = @max(maxw, std.unicode.utf8CountCodepoints(s) catch s.len); + if (logo_cols + welcome_gap.len + maxw > 79) @compileError("welcome banner exceeds 79 columns"); +} + +/// Prints the welcome banner: the logo on the left with the title and command +/// hints beside it, vertically centered. `llm_active` picks the tagline. +pub fn print(llm_active: bool) void { + const a = Terminal.ansi; + + var version_buf: [192]u8 = undefined; + const version: []const u8 = std.fmt.bufPrint(&version_buf, a.dim ++ "{s}" ++ a.reset, .{lp.build_config.version}) catch ""; + + var lines: [9][]const u8 = undefined; + var n: usize = 0; + lines[n] = a.bold ++ "Lightpanda Agent" ++ a.reset; + n += 1; + lines[n] = version; + n += 1; + lines[n] = ""; + n += 1; + if (llm_active) { + lines[n] = a.italic ++ banner_tagline_llm ++ a.reset; + n += 1; + } else { + lines[n] = a.italic ++ banner_tagline_basic ++ a.reset; + n += 1; + lines[n] = a.dim ++ banner_setup ++ a.reset; + n += 1; + } + inline for (banner_hints) |t| { + lines[n] = a.dim ++ t ++ a.reset; + n += 1; + } + const text = lines[0..n]; + + const start = (logo_rows - text.len) / 2 -| 1; + std.debug.print("\n", .{}); + var row: usize = 0; + var it = std.mem.splitScalar(u8, logo, '\n'); + while (it.next()) |logo_line| { + if (logo_line.len == 0) continue; + std.debug.print("{s}", .{logo_line}); + if (row >= start and row - start < text.len) { + const line = text[row - start]; + if (line.len != 0) std.debug.print("{s}{s}", .{ welcome_gap, line }); + } + std.debug.print("\n", .{}); + row += 1; + } +}