// 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 . const std = @import("std"); const zenai = @import("zenai"); const lp = @import("lightpanda"); const browser_tools = lp.tools; const BrowserTool = browser_tools.Tool; const ProviderTool = zenai.provider.Tool; const log = lp.log; const Config = lp.Config; const Command = lp.Command; const Schema = lp.Schema; const Recorder = lp.Recorder; const ScriptRuntime = lp.Runtime; 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(); /// Raised by init/listModels after they've printed a user-facing message to /// stderr; callers should exit non-zero without logging more. pub const UserError = error{ MissingApiKey, MissingProvider, ConflictingFlags, ModelNotAvailable, }; pub fn isUserError(err: anyerror) bool { inline for (@typeInfo(UserError).error_set.?) |e| { if (err == @field(anyerror, e.name)) return true; } return false; } const default_system_prompt = browser_tools.driver_guidance ++ \\ \\Agent-specific behavior: \\- Call a tool for every browser action. NEVER claim you performed an \\ action, visited a page, or saw content without the corresponding tool \\ call. If a task needs a capability Lightpanda lacks (images, PDFs, \\ audio), say so rather than improvising. \\- Verify before answering: when a task asks for a specific value, ranked \\ list, or comparison, and your first source is ambiguous, incomplete, \\ or the answer is non-obvious, cross-check on ONE more authoritative \\ source before committing. For multi-candidate questions (yes/no, \\ A/B/C, pick-N), commit to a choice — don't abstain when you have data \\ to reason from. \\- If the user asks for account-scoped data (karma, profile, inbox, …) \\ and the page shows you're not signed in, log in proactively (per \\ the Credentials section above) before reporting unavailable. ; const synthesis_prompt = \\You have used your tool budget or cannot finish the exploration. \\Give your best final answer NOW based ONLY on what you actually observed \\via tool calls in this conversation. Do NOT fall back to prior knowledge — \\if your snapshots show only cookie banners, 403/access-denied pages, \\blocked search results, or empty bodies, say that explicitly \\(e.g. "the page was blocked by a cookie wall and I could not extract X"). \\Do not invent details that are not visible in the tool outputs above. \\Do not call any more tools. \\Respond with ONLY the answer — one word, one number, one short phrase, \\or a brief honest explanation of why the page could not be read. \\No prefix, no markdown. ; allocator: std.mem.Allocator, ai_client: ?zenai.provider.Client, model_credentials: ?Credentials, /// True when the no-LLM state is a persisted preference (remembered null /// provider or runtime `/provider null`), so `reportSaved` writes /// `provider = null`. A transient `--no-llm` run leaves it false so saving /// other settings doesn't clobber the remembered provider. no_llm_persisted: bool, model_base_url: ?[:0]const u8, /// Cached chat-model ids for the current provider, backed by /// `model_completion_arena`; invalidated on `/provider` switch. model_completions: ?ModelCompletions, model_completion_arena: std.heap.ArenaAllocator, notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, node_registry: CDPNode.Registry, terminal: Terminal, save_buffer: Recorder, save_path: ?[]u8, /// Backs `last_extract_json`; reset alongside `save_buffer`. last_extract_arena: std.heap.ArenaAllocator, /// The JSON the most recent successful `extract` returned this session — the /// real data `/save` grounds and verifies its synthesized script against. last_extract_json: ?[]const u8 = null, /// Set for the duration of an LLM `/save` so the `run_script` tool can reach /// the dry-run runtime it executes candidates on. active_verify: ?*Verify = null, script_runtime_mutex: std.Thread.Mutex = .{}, active_script_runtime: ?*ScriptRuntime = null, conversation: Conversation, model: []u8, /// Per-turn reasoning budget for LLM turns. Mutable at runtime via `/effort`. effort: Config.Effort, script_file: ?[]const u8, one_shot_task: ?[]const u8, one_shot_attachments: ?[]const []const u8, cancel_requested: std.atomic.Value(bool) = .init(false), /// Shuts down the in-flight LLM socket on Ctrl-C so an agent turn aborts /// mid-request instead of blocking until the model's full response arrives. http_interrupt: zenai.http.Interrupt = .{}, synthetic_tool_call_id: u32 = 0, /// Aggregate Anthropic/OpenAI/Gemini token usage across every model call. /// Printed as a structured `$usage ...` line on stderr at the end of `--task` /// (one-shot) mode so wrappers can capture per-task cost. total_usage: zenai.provider.Usage = .{}, /// Set when the last turn ended in a model refusal (safety stop). last_turn_refused: bool = false, available_providers: []const []const u8, 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); const available_providers = try allocator.alloc([]const u8, found_providers.len); var provider_count: usize = 0; errdefer { for (available_providers[0..provider_count]) |p| allocator.free(p); allocator.free(available_providers); } for (found_providers, 0..) |f, i| { available_providers[i] = try allocator.dupe(u8, @tagName(f.provider)); provider_count = i + 1; } if (opts.task != null and opts.script_file != null) { log.fatal(.app, "conflicting flags", .{ .hint = "--task runs a one-shot turn; drop the positional script or drop --task", }); return error.ConflictingFlags; } if (opts.no_llm and opts.provider != null) { log.warn(.app, "ignoring --provider", .{ .reason = "--no-llm takes precedence" }); } if (opts.task == null and opts.attach.items.len > 0) { log.warn(.app, "ignoring --attach", .{ .reason = "no --task; attachments are only consumed in one-shot mode" }); } const is_one_shot = opts.task != null; const will_repl = !is_one_shot and opts.script_file == null; // Load remembered selection up front so a saved null provider can flip the // REPL into basic mode before resolution. Pure script runs need nothing. const remembered: ?settings.Remembered = if (will_repl or is_one_shot) settings.loadRemembered(allocator) else null; defer if (remembered) |r| std.zon.parse.free(allocator, r); // A remembered null provider means the user disabled the LLM via // `/provider null`; honor it for the REPL only (one-shot --task and script // runs always need a model). An explicit --provider overrides it. const remembered_no_llm = will_repl and opts.provider == null and remembered != null and remembered.?.provider == null; // Basic-mode REPL (no LLM) must be opted into via --no-llm or a remembered // null provider. Without it the REPL accepts natural language, so an absent // API key would only surface at the first non-slash-command line — too late. // Pure JavaScript script runs stay allowed: no REPL, no LLM. const requires_llm = is_one_shot or (will_repl and !opts.no_llm and !remembered_no_llm); // Skip resolve when no client is wanted — else resolveCredentials prints // "No API key detected" for a run that does not need one. const resolve = !opts.no_llm and requires_llm; // Print the banner before provider resolution so it precedes any // interactive "Select a provider" prompt. On error paths (missing key / no // key detected) resolveCredentials prints its own message; banner skipped. if (will_repl and (!resolve or settings.wouldResolve(allocator, opts, remembered))) { welcome.print(resolve); } const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; const llm: ?Credentials = if (resolved) |r| r.credentials else null; if (llm == null and requires_llm) { if (opts.no_llm) { std.debug.print("--no-llm forbids LLM use; drop it to run this mode.\n", .{}); } return error.MissingProvider; } var model = try allocator.dupe(u8, settings.resolveModelName(opts, resolved, remembered)); errdefer allocator.free(model); // The REPL skips this network round trip for snappy startup; an invalid // model surfaces on the first turn instead. 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 settings.reconcileModel(allocator, l, model, opts.base_url, explicit)) { .use => |m| { allocator.free(model); model = m; }, .abort => return error.ModelNotAvailable, } }; const effort = settings.resolveEffort(opts, remembered, will_repl); const verbosity = settings.resolveVerbosity(opts, remembered); if (resolved) |r| { if (r.source == .picked) { settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); } const notification: *lp.Notification = try .init(allocator); errdefer notification.deinit(); const self = try allocator.create(Agent); errdefer allocator.destroy(self); const history_paths: ?Terminal.HistoryPaths = if (will_repl) .{ .normal = ".lp-history", .js = ".lp-history.js" } else null; self.* = .{ .allocator = allocator, .ai_client = null, .model_credentials = llm, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, .model_completions = null, .model_completion_arena = .init(allocator), .notification = notification, .browser = undefined, .session = undefined, .node_registry = .init(allocator), .terminal = .init(allocator, history_paths, verbosity, will_repl), .save_buffer = .init(allocator), .save_path = null, .last_extract_arena = .init(allocator), .conversation = .init(allocator, opts.system_prompt orelse default_system_prompt), .model = model, .effort = effort, .script_file = opts.script_file, .one_shot_task = opts.task, .one_shot_attachments = if (opts.attach.items.len == 0) null else opts.attach.items, .available_providers = available_providers, }; errdefer self.node_registry.deinit(); errdefer self.terminal.deinit(); errdefer self.conversation.deinit(); self.terminal.installLogSink(); errdefer self.terminal.uninstallLogSink(); try self.browser.init(app, .{}, null); errdefer self.browser.deinit(); try self.startSession(); self.ai_client = if (llm) |l| try zenai.provider.Client.init(allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); if (will_repl) { self.terminal.attachCompleter(); self.terminal.completion_source = .{ .context = @ptrCast(self), .providers = completionProviders, .models = completionModels, }; // The model-list cache fills lazily on the first `/model` completion, // so startup never blocks on the network. } return self; } pub fn deinit(self: *Agent) void { self.terminal.uninstallLogSink(); self.save_buffer.deinit(); self.last_extract_arena.deinit(); if (self.save_path) |p| self.allocator.free(p); self.terminal.deinit(); self.conversation.deinit(); self.model_completion_arena.deinit(); self.node_registry.deinit(); self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); self.allocator.free(self.available_providers); self.allocator.destroy(self); } /// Create a fresh browser session and wire its cancel hook back to this agent /// so Ctrl-C aborts in-flight page work. Startup and `/reset`. fn startSession(self: *Agent) !void { self.session = try self.browser.newSession(self.notification); self.session.cancel_hook = .{ .context = @ptrCast(self), .check = checkCancel }; } // Compile-time constant; projected once per process to avoid rebuilding per call. var global_tools_storage: [browser_tools.tool_defs.len]ProviderTool = undefined; var global_tools_once = std.once(initGlobalTools); fn initGlobalTools() void { for (Schema.all(), 0..) |s, i| { global_tools_storage[i] = .{ .name = s.tool_name, .description = s.description, .parameters = s.parameters }; } } fn globalTools() []const ProviderTool { global_tools_once.call(); return global_tools_storage[0..browser_tools.tool_defs.len]; } /// Called from the sighandler thread. Flips `cancel_requested` for the LLM /// streaming/HTTP probe and any code polling `Session.isCancelled`, then asks /// V8 to bail out of whatever JS is running. Both hooks are thread-safe /// (`Env.terminate` takes a mutex); no terminal touches from this context. pub fn requestCancel(self: *Agent) void { self.cancel_requested.store(true, .release); self.http_interrupt.fire(); { self.script_runtime_mutex.lock(); defer self.script_runtime_mutex.unlock(); if (self.active_script_runtime) |runtime| { runtime.terminate(); } } self.browser.env.terminate(); } /// Lives in main's stack so it can be registered with the sighandler before the /// agent thread exists. The agent attaches once constructed and detaches before /// deinit, so the sighandler-thread listener can fire safely whether or not an /// agent is currently up. pub const SigBridge = struct { agent: std.atomic.Value(?*Agent) = .init(null), pub fn attach(self: *SigBridge, agent: *Agent) void { self.agent.store(agent, .release); } pub fn detach(self: *SigBridge) void { self.agent.store(null, .release); } pub fn onSignal(self: *SigBridge) void { const a = self.agent.load(.acquire) orelse return; a.requestCancel(); } }; fn checkCancel(ctx: *anyopaque) bool { const self: *Agent = @ptrCast(@alignCast(ctx)); return self.cancel_requested.load(.acquire); } /// Roll the agent back to `baseline` messages, clear the V8 termination flag, /// drop the cancel signal, and surface `error.UserCancelled`. Caller handles /// any spinner cleanup not already done on its path. fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} { self.resetAfterCancel(baseline); return 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.conversation.rollback(baseline); self.browser.env.cancelTerminate(); self.cancel_requested.store(false, .release); } /// One agent turn: the prompt sent to the model, plus optional context — a /// recorder comment to write before the turn, file attachments to bundle into /// the first user message, and a display label used in error output. pub const TurnInput = struct { prompt: []const u8, record_comment: ?[]const u8 = null, capture_for_save: bool = false, attachments: ?[]const []const u8 = null, label: []const u8 = "Request", }; /// Returns true on success. pub fn run(self: *Agent) bool { if (self.one_shot_task) |task| { const ok = self.runTurn(.{ .prompt = task, .attachments = self.one_shot_attachments, }); self.printUsageSummary(); return ok; } if (self.script_file) |path| { return self.runScript(path); } self.runRepl(); return true; } /// Print single-line cumulative token usage to stderr, so wrappers driving /// `lightpanda agent --task ...` can capture per-task cost by `grep`-ing the /// `$usage` prefix. Stable key=value format: /// $usage prompt=N completion=N total=N cached=N cache_creation=N /// Fields emit 0 when the provider didn't report them. fn printUsageSummary(self: *Agent) void { const u = self.total_usage; std.debug.print( "$usage prompt={d} completion={d} total={d} cached={d} cache_creation={d}\n", .{ u.prompt_tokens orelse 0, u.completion_tokens orelse 0, u.total_tokens orelse 0, u.cached_tokens orelse 0, u.cache_creation_tokens orelse 0, }, ); } fn runTurn(self: *Agent, input: TurnInput) bool { const text = self.processUserMessage(input) catch |err| switch (err) { error.UnsupportedAttachment, error.AttachmentReadFailed => return false, error.UserCancelled => { self.terminal.printInfo("Interrupted.", .{}); self.conversation.prune(); return false; }, else => { self.terminal.printError("{s} failed: {s}", .{ input.label, @errorName(err) }); return false; }, }; if (text) |t| self.terminal.printAssistant(t) else if (self.last_turn_refused) self.terminal.printInfo("(model declined to respond — safety refusal)", .{}) else self.terminal.printInfo("(no response from model)", .{}); self.conversation.prune(); return true; } fn runRepl(self: *Agent) void { log.debug(.app, "tools loaded", .{ .count = globalTools().len }); if (self.ai_client != null) { const a = Terminal.ansi; std.debug.print(" model: {s}{s} {s}effort: {s}{s}{s}\n", .{ a.dim, self.model, a.reset, a.dim, @tagName(self.effort), a.reset }); } repl: while (true) { std.debug.print("\n", .{}); const line = Terminal.readLine("") orelse break; defer Terminal.freeLine(line); // Slash commands and idle Ctrl-C set the cancel flag without clearing // V8's terminate state; drain both before the next turn. if (self.cancel_requested.swap(false, .acq_rel)) { self.browser.env.cancelTerminate(); } const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); if (trimmed.len == 0) { self.terminal.clearPromptFrame(); continue; } std.debug.print("\n", .{}); var arena: std.heap.ArenaAllocator = .init(self.allocator); defer arena.deinit(); const aa = arena.allocator(); if (self.terminal.jsMode()) { // `line` keeps the `$LP_*` placeholder so the secret never reaches // the recorder; only the evaluated copy is expanded. const script = browser_tools.substituteEnvVars(aa, line) catch line; const result = browser_tools.evalScript(aa, self.session, &self.node_registry, script) catch |err| { self.terminal.printError("{s}", .{switch (err) { error.OutOfMemory => "out of memory", error.FrameNotLoaded => "no page loaded — run /goto first (Esc exits JS mode)", else => std.fmt.allocPrint(aa, "evaluate failed: {s}", .{@errorName(err)}) catch "evaluate failed", }}); continue :repl; }; // Surface console output: slash commands (and thus /consoleLogs) // are unreachable in JS mode, so a console must echo logs itself. const logs = std.mem.trimRight(u8, self.session.drainConsoleMessages(), "\n"); if (logs.len > 0) self.printData(logs); if (result.is_error) { self.terminal.printError("{s}", .{result.text}); } else { self.printData(result.text); self.recordSaveRaw(line); } continue :repl; } const slash_split: ?Schema.Split = Schema.parseSlashCommand(trimmed); if (slash_split) |split| { if (SlashCommand.findMeta(split.name)) |meta| { if (self.handleMeta(aa, meta, split.rest)) break :repl; continue :repl; } } var diag: Schema.Diag = .{}; const cmd = Command.parseDiag(aa, line, &diag) catch |err| switch (err) { error.NotASlashCommand => { if (self.ai_client == null) { self.terminal.printError("Basic REPL (LLM disabled) accepts only commands. Try /help, or " ++ llm_setup_hint ++ " to enable natural-language prompts.", .{}); continue :repl; } _ = self.runTurn(.{ .prompt = line, .record_comment = line, .capture_for_save = true }); continue :repl; }, else => |e| { const name = if (slash_split) |sp| sp.name else line; self.terminal.printSlashParseError(e, name, &diag); continue :repl; }, }; if (cmd == .llm) { var name_buf: [32]u8 = undefined; const name = std.fmt.bufPrint(&name_buf, "/{s}", .{@tagName(cmd.llm)}) catch "/?"; if (!self.requireLlm(name)) continue :repl; } switch (cmd) { .comment => continue :repl, .llm => |lc| { var label_buf: [32]u8 = undefined; const label = std.fmt.bufPrint(&label_buf, "/{s}", .{@tagName(lc)}) catch "/?"; _ = self.runTurn(.{ .prompt = lc.prompt(), .record_comment = line, .capture_for_save = true, .label = label }); }, .tool_call => |tc| { self.terminal.beginTool(tc.name(), slash_split.?.rest); const result = self.runCommand(aa, cmd); self.terminal.endTool(); self.printCommandResult(cmd, result); if (!result.is_error) { self.recordSaveCommand(cmd); } self.recordSlashToolCall(trimmed, tc.name(), tc.args, result) catch |err| { self.terminal.printWarning("LLM conversation out of sync (/{s}: {s}); next prompt may not see this action", .{ tc.name(), @errorName(err) }); }; }, } } } /// Handle a REPL-only meta slash command — not a tool slash command, never /// reaches the browser tool dispatcher. Returns `true` if the user asked to quit. fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand.MetaCommand, rest: []const u8) bool { switch (meta.tag) { .quit => return true, .help => self.printSlashHelp(arena, rest), .verbosity => self.setEnumOption("verbosity", &self.terminal.verbosity, rest), .effort => self.setEnumOption("effort", &self.effort, rest), .usage => self.handleUsage(), .clear => self.handleClear(), .reset => self.handleReset(), .save => self.handleSave(arena, rest), .load => self.handleLoad(rest), .model => self.handleModel(arena, rest), .provider => self.handleProvider(arena, rest), } return false; } /// Shared body of `/verbosity` and `/effort`: bare prints the current level; an /// argument is parsed against the enum, stored in `target`, and persisted. /// `name` drives the slash name, usage hint, and report label. fn setEnumOption(self: *Agent, comptime name: []const u8, target: anytype, rest: []const u8) void { const T = @typeInfo(@TypeOf(target)).pointer.child; if (rest.len == 0) { self.terminal.printInfo(name ++ ": {s}", .{@tagName(target.*)}); return; } const level = std.meta.stringToEnum(T, rest) orelse { self.terminal.printError("usage: /" ++ name ++ " " ++ Config.tagHint(T) ++ " (got {s})", .{rest}); return; }; target.* = level; self.reportSaved(name, @tagName(level)); } /// Print cumulative session token usage, broken down so the cache's effect is /// visible — the REPL otherwise never surfaces the `$usage` line `--task` /// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`); /// fresh/cache split semantics live on `Usage`. fn handleUsage(self: *Agent) void { const u = self.total_usage; const input = u.inputTokens(); const output = u.completion_tokens orelse 0; if (input == 0 and output == 0) { self.terminal.printInfo("usage: no model turns yet this session", .{}); return; } self.terminal.printInfo( "usage: input={d} (fresh={d} · cache read={d} · cache write={d}), output={d}", .{ input, u.prompt_tokens orelse 0, u.cached_tokens orelse 0, u.cache_creation_tokens orelse 0, output }, ); if (input > 0) { self.terminal.printInfo("cache: {d}% of input served from cache", .{u.cacheHitPercent()}); } } /// Drop everything tied to the conversation: history (system prompt re-seeds /// lazily next turn), cumulative usage, the recorded action buffer, and DOM /// node IDs. Shared by `/clear` and `/reset`. fn clearConversation(self: *Agent) void { self.conversation.rollback(0); self.resetSaveBuffers(); self.total_usage = .{}; self.node_registry.reset(); } /// Drop everything `/save` accumulates: the recorded action buffer and the /// captured extract data that grounds synthesis. fn resetSaveBuffers(self: *Agent) void { self.save_buffer.reset(); _ = self.last_extract_arena.reset(.retain_capacity); self.last_extract_json = null; } /// Forget the conversation while leaving the browser session live — loaded page /// stays put, cookies/logins preserved. fn handleClear(self: *Agent) void { self.clearConversation(); self.terminal.printInfo("Cleared conversation, usage, and node IDs. Page and cookies kept.", .{}); } /// Full clean slate: everything `/clear` drops, plus a fresh browser session, /// so the loaded page, cookies, storage, and history are gone too. fn handleReset(self: *Agent) void { self.startSession() catch |err| { self.terminal.printError("reset failed: {s}", .{@errorName(err)}); return; }; self.clearConversation(); self.terminal.printInfo("Reset conversation and browser session. Page, cookies, and storage cleared.", .{}); } fn handleLoad(self: *Agent, rest: []const u8) void { const path = std.mem.trim(u8, rest, &std.ascii.whitespace); if (path.len == 0) { self.terminal.printError("usage: /load ", .{}); return; } _ = self.runScript(path); } const api_keys_hint = settings.api_keys_hint; const llm_setup_hint = "set an API key (" ++ api_keys_hint ++ ") and run /provider "; /// `/provider ` disables the LLM and persists it; shared by command /// parser, autocomplete, and save report so they can't drift apart. const provider_off_keyword = "null"; fn requireLlm(self: *Agent, name: []const u8) bool { if (self.model_credentials == null) { self.terminal.printError("{s} requires an LLM — " ++ llm_setup_hint ++ ".", .{name}); return false; } return true; } fn handleModel(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { if (!self.requireLlm("/model")) return; const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace); if (trimmed.len == 0) { self.terminal.printInfo("Current model: {s} (Tab to list)", .{self.model}); return; } const ids = completionModels(self, self.allocator); // Empty list = fetch failed or unlisted local models; can't confirm, allow. if (ids.len != 0 and !string.isOneOf(trimmed, ids)) { self.terminal.printError("unknown model: {s} (Tab to list)", .{trimmed}); return; } self.setModel(trimmed) catch |err| { self.terminal.printError("failed to set model: {s}", .{@errorName(err)}); }; } /// Persist provider/model/effort/verbosity to `.lp-agent.zon` and report it as /// "