// 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 Candidate = zenai.provider.Candidate; 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 auth = @import("auth/auth.zig"); const models_dev = @import("auth/models_dev.zig"); const picker = @import("picker.zig"); const save = @import("save.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. \\ ++ lp.skill.semantics_note; // System prompt of the `/save` command: the save instructions plus the // script skill (`lp.skill`), whose primitives reference is rendered from // the tool schemas at first use — hence lazy rather than comptime. var save_prompts_once = lp.once(initSavePrompts); var save_system_prompt: []const u8 = undefined; var save_revision_system_prompt: []const u8 = undefined; // Swapped in instead of `save_system_prompt` when the user message carries a // previously saved script: the "this session" framing above would otherwise // make the model discard it as out-of-scope. const save_revision_note = \\This save REVISES an existing script, included in the user message. Its \\goal spans every session that contributed to it, not just this one. Use \\it as the base: keep its steps, structure, and output shape unchanged \\except where this session's commands or the user's instruction require \\a change, and add what this session contributes. Output the complete \\updated script — never a fragment, diff, or continuation. ; /// Panics on OOM like `Schema.all()` — the inputs are static, process-lifetime /// strings, so failure is a build bug, not a runtime condition. fn initSavePrompts() void { const a = std.heap.page_allocator; save_system_prompt = std.mem.concat(a, u8, &.{ browser_tools.save_synthesis_prompt, "\n\n", lp.skill.text(), }) catch @panic("OOM building save prompt"); save_revision_system_prompt = std.mem.concat(a, u8, &.{ browser_tools.save_synthesis_prompt, "\n", save_revision_note, "\n", lp.skill.text(), }) catch @panic("OOM building save prompt"); } fn savePrompt(revision: bool) []const u8 { save_prompts_once.call(); return if (revision) save_revision_system_prompt else save_system_prompt; } 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, /// The active LLM credential (null = LLM disabled). The AI client borrows /// its key, so deinit only after the client is gone. credential: ?settings.Credential, /// App data dir (owned by `App`, which outlives the agent), used to cache the /// models.dev catalog. Null when no data dir is available. app_dir: ?[]const u8, /// 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, script_runtime_mutex: std.Io.Mutex = .init, 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_save: ?[]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, /// Whether assistant text streams to the terminal as the model produces it. /// Toggled via `/stream`; persisted in `.lp-agent.zon`. stream_enabled: bool, /// True while assistant text is streaming to stdout (spinner paused). Cleared /// by `endStreamedText`, which also emits the closing newline. stream_active: bool = false, /// True when any assistant text streamed during the current turn, so `runTurn` /// skips the buffered `printAssistant` that would double-print it. streamed_text: bool = false, available_providers: []const []const u8, /// Cached reachability of each `local_providers` entry, so the per-keystroke /// `/provider` hinter probes each local server at most once. local_completable: [local_providers.len]?bool = @splat(null), api_error_buf: [512]u8 = undefined, /// Last failure's status+message, surfaced by `runTurn` past `error.ApiError`. api_error_detail: ?[]const u8 = null, pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent { var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Candidate = 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.save) |save_path| { if (opts.task == null) { log.fatal(.app, "conflicting flags", .{ .hint = "--save synthesizes a script from the --task run; pass --task too", }); return error.ConflictingFlags; } if (save_path.len == 0) { log.fatal(.app, "invalid --save filename", .{ .hint = "--save needs a non-empty file name", }); return error.InvalidFilename; } } 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 resolution so it precedes the interactive picker. // The Ollama-only path never prompts, so its banner is deferred (below) to // avoid probing the local server a second time just to decide ordering. const banner_before = will_repl and (!resolve or settings.hasDetectableKey(opts, remembered)); if (banner_before) welcome.print(resolve); var resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; // Before the ai_client errdefer, so on unwind the client goes first. errdefer if (resolved) |*r| r.credential.deinit(allocator); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); if (resolved == 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 (resolved) |*r| if (!will_repl) { const remembered_matches = remembered != null and remembered.?.provider == r.credential.provider; const explicit = opts.model != null or remembered_matches; switch (try settings.reconcileModel(allocator, &r.credential, model, opts.base_url, explicit)) { .use => |m| { allocator.free(model); model = m; }, .abort => return error.ModelNotAvailable, } }; const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credential.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); const stream_enabled = settings.resolveStream(remembered); browser_tools.search_engine = settings.resolveSearchEngine(remembered); if (resolved) |r| { if (r.source == .picked) { settings.saveRemembered(.{ .provider = r.credential.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) 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, .credential = if (resolved) |r| r.credential else null, .app_dir = app.app_dir_path, .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, .conversation = .init(allocator, opts.system_prompt orelse default_system_prompt), .model = model, .effort = effort, .stream_enabled = stream_enabled, .script_file = opts.script_file, .one_shot_task = opts.task, .one_shot_save = opts.save, .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 (self.credential) |*c| try zenai.provider.Client.init(lp.io, allocator, c.provider, c.keySlice(), .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(c.provider), .environ = lp.environ(), .account_id = c.accountId() }) 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(.{ .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. Terminal.setIdleCallback(&idlePump, @ptrCast(self)); } return self; } pub fn deinit(self: *Agent) void { self.terminal.uninstallLogSink(); self.save_buffer.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); if (self.credential) |*c| c.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); } /// isocline idle hook; returns the delay in ms before the next invocation. fn idlePump(arg: ?*anyopaque) callconv(.c) c_long { const self: *Agent = @ptrCast(@alignCast(arg.?)); return self.session.idleSlice(); } /// 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 }; try self.session.enableConsoleCapture(); } // 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 = lp.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.lockUncancelable(lp.io); defer self.script_runtime_mutex.unlock(lp.io); 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.endStreamedText(); self.conversation.rollback(baseline); self.browser.env.cancelTerminate(); self.cancel_requested.store(false, .release); self.http_interrupt.reset(); } /// On the first delta it pauses the spinner, whose stderr frames would /// otherwise interleave with this stdout text. fn streamAssistantDelta(ctx: *anyopaque, delta: []const u8) void { const self: *Agent = @ptrCast(@alignCast(ctx)); if (delta.len == 0) return; if (!self.stream_active) { self.terminal.spinner.pause(); self.stream_active = true; } self.streamed_text = true; self.terminal.printAssistantDelta(delta); } /// Close an in-progress streamed line with a newline. Idempotent, so every /// `runTools` exit path can call it unconditionally. fn endStreamedText(self: *Agent) void { if (!self.stream_active) return; self.terminal.endAssistantStream(); self.stream_active = false; } /// The text-delta hook, or null when `/stream off` — a null hook makes /// `runTools` fall back to a buffered response. Streaming is an interactive /// REPL affordance; one-shot (`--task`) and script modes keep stdout to the /// buffered final answer so wrappers can parse it cleanly. fn streamHook(self: *Agent) ?zenai.provider.Client.TextDeltaHook { if (!self.stream_enabled or !self.terminal.isRepl()) return null; return .{ .context = @ptrCast(self), .onText = streamAssistantDelta }; } /// 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, suppress_answer: 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 saving = self.one_shot_save != null; const ok = self.runTurn(.{ .prompt = task, .attachments = self.one_shot_attachments, .capture_for_save = saving, .record_comment = if (saving) task else null, .suppress_answer = saving, }); // Synthesis is a second LLM call that adds to total_usage, so save // before printing the cumulative summary. if (ok and saving) self.saveOneShot(); 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, self.api_error_detail orelse @errorName(err) }); return false; }, }; if (!input.suppress_answer) { if (text) |t| { // Streaming already emitted the text incrementally (plus a closing // newline); only the buffered path needs to print it here. Safe as a // turn-wide flag: the stream accumulator reconstructs `t` from the // same deltas it emitted, and the synthesis fallback also streams, so // `streamed_text` implies `t` was already shown in full. if (!self.streamed_text) 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) { self.terminal.printSessionBanner(self.model, @tagName(self.effort), self.stream_enabled); } 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.trimEnd(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; } // A slash command whose `'''…'''` body is still open continues on the // following lines until the block closes (the multi-line /extract // form). Ctrl-D on the continuation prompt abandons the command. const command_text: []const u8 = if (trimmed[0] == '/' and Schema.hasUnclosedTripleQuote(trimmed)) Terminal.readContinuation(aa, trimmed) orelse continue :repl else trimmed; const slash_split: ?Schema.Split = Schema.parseSlashCommand(command_text); 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, command_text, &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; }, }; switch (cmd) { .comment => continue :repl, .llm => |lc| { var label_buf: [32]u8 = undefined; const label = std.fmt.bufPrint(&label_buf, "/{s}", .{@tagName(lc)}) catch "/?"; if (!self.requireLlm(label)) continue :repl; _ = 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(navigationGoto(aa, tc.tool, tc.args) orelse cmd); } self.recordSlashToolCall(command_text, 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), .stream => self.handleStream(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), .searchEngine => self.handleSearchEngine(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)); } /// `/stream`: bare prints the current state; `on`/`off` sets and persists it. fn handleStream(self: *Agent, rest: []const u8) void { if (rest.len == 0) { self.terminal.printInfo("stream: {s}", .{if (self.stream_enabled) "on" else "off"}); return; } const on = if (std.ascii.eqlIgnoreCase(rest, "on") or std.ascii.eqlIgnoreCase(rest, "true")) true else if (std.ascii.eqlIgnoreCase(rest, "off") or std.ascii.eqlIgnoreCase(rest, "false")) false else { self.terminal.printError("usage: /stream [on|off] (got {s})", .{rest}); return; }; self.stream_enabled = on; self.reportSaved("stream", if (on) "on" else "off"); } /// `/searchEngine`: bare prints the current engine; an argument sets and /// persists it, warning when the chosen engine's API key is unset. fn handleSearchEngine(self: *Agent, rest: []const u8) void { self.setEnumOption("searchEngine", &browser_tools.search_engine, rest); const selected = std.meta.stringToEnum(browser_tools.SearchEngine, rest) orelse return; const key = browser_tools.searchKeyStatus(selected) orelse return; switch (key.state) { .set => {}, .keyless => self.terminal.printInfo("{s} is not set; using the keyless endpoint (rate-limited per client IP)", .{key.env_var}), .missing => self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{key.env_var}), } } /// 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 its save /// destination, and DOM node IDs. Shared by `/clear` and `/reset`. fn clearConversation(self: *Agent) void { self.conversation.rollback(0); self.save_buffer.reset(); if (self.save_path) |p| self.allocator.free(p); self.save_path = null; self.total_usage = .{}; self.node_registry.reset(); } /// 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"; /// Keyless local providers (placeholder key), so reachability needs a live probe. /// Parallel to `local_completable`. const local_providers = [_]Config.AiProvider{ .ollama, .llama_cpp }; fn requireLlm(self: *Agent, name: []const u8) bool { if (self.credential == 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}", .{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}", .{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 /// "