// 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 script = lp.script; const Command = lp.script.Command; const Schema = lp.script.Schema; const Recorder = lp.script.Recorder; const Verifier = lp.script.Verifier; const Credentials = zenai.provider.Credentials; const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); const Terminal = @import("Terminal.zig"); const SlashCommand = @import("SlashCommand.zig"); const Agent = @This(); /// Errors raised by Agent.init / listModels where the function has already /// printed a human-readable message to stderr. Callers should exit non-zero /// without further logging. pub const UserError = error{ MissingApiKey, MissingProvider, ConflictingFlags, AmbiguousProvider, NotInteractive, }; 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 = script.mcp_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. \\- Be decisive: prefer few well-chosen tool calls over probing. If \\ extraction repeatedly fails or the site errors, commit to a best- \\ effort answer instead of thrashing. An honest "the site blocked \\ access" beats a fabricated answer. \\- If the user asks for account-scoped data (karma, profile, inbox, …) \\ and the page shows you're not signed in, log in proactively (dismiss \\ cookie banner first, follow the Credentials section above) before \\ reporting unavailable. Only fall back to "I couldn't access X" if no \\ credentials are set, the form is missing, or login was rejected — \\ and say which. ; const self_heal_prompt_prefix = \\A PandaScript command failed during replay. The command that failed was: \\ ; const self_heal_prompt_page_state = \\ \\The current page URL is: \\ ; const self_heal_prompt_instructions = \\ \\IMPORTANT: \\- Do NOT navigate away from the current page. The page is already loaded and \\ contains the element you need — the selector just needs to be fixed. \\- Use the tree or interactiveElements tools WITHOUT a url parameter to inspect \\ the current page, find the correct selector, and execute the equivalent action. \\- If the action is blocked by a popup, cookie banner, or surprise modal, \\ handle it first (e.g., click "Accept") before executing the fixed command. \\- ONLY fix the failed command and handle immediate blockers. STOP immediately \\ once the intent of the original command is achieved. \\ The script will continue executing the remaining commands after the heal. ; const login_prompt = \\Find the login form on the current page. Fill in the credentials using \\$LP_* placeholders — the substitution happens inside the Lightpanda \\subprocess so the secret never enters your context. Do NOT call getEnv \\with a credential name (it would return the value). \\ \\Call getEnv with NO `name` argument first to see which LP_* variables \\are set (names only, values never included). Then pick: \\- Site-prefixed form (LP__) when the list shows one for \\ the current site — e.g. $LP_HN_USERNAME for news.ycombinator.com, \\ $LP_GH_TOKEN for github.com. \\- Otherwise fall back to the unprefixed $LP_USERNAME / $LP_PASSWORD \\ (or $LP_EMAIL) form. \\ \\Handle any cookie banners or popups first, then submit the form by \\clicking its submit button or pressing Enter in a filled field — there \\is no dedicated submit tool. ; const accept_cookies_prompt = \\Find and dismiss the cookie consent banner on the current page. \\Look for "Accept", "Accept All", "I agree", or similar buttons and click them. ; 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, notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, node_registry: CDPNode.Registry, terminal: Terminal, verifier: Verifier, recorder: ?Recorder, messages: std.ArrayList(zenai.provider.Message), message_arena: std.heap.ArenaAllocator, model: []u8, system_prompt: []const u8, script_file: ?[]const u8, self_heal: bool, interactive: bool, one_shot_task: ?[]const u8, one_shot_attachments: ?[]const []const u8, cancel_requested: std.atomic.Value(bool) = .init(false), pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent { 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.task != null and opts.interactive) { log.fatal(.app, "conflicting flags", .{ .hint = "--task is one-shot and exits; drop --interactive or drop --task", }); return error.ConflictingFlags; } if (opts.self_heal and opts.script_file == null) { log.fatal(.app, "self-heal needs a script", .{ .hint = "--self-heal rewrites a recorded .lp on drift; pass a script path", }); 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.interactive or opts.script_file == null); // Basic-mode REPL (no LLM) must be opted into via --no-llm. Without it, // the REPL accepts natural language and an absent API key would only // surface at the first non-PandaScript line — too late to be useful. // Pure replay (`agent