mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
1761 lines
72 KiB
Zig
1761 lines
72 KiB
Zig
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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,
|
|
};
|
|
|
|
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.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 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 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,
|
|
model_base_url: ?[:0]const u8,
|
|
/// Cached chat-model ids for the current provider, backed by
|
|
/// `model_completion_arena` and 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,
|
|
verifier: Verifier,
|
|
recorder: ?Recorder,
|
|
save_buffer: Recorder.Memory,
|
|
save_path: ?[]u8,
|
|
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),
|
|
synthetic_tool_call_id: u32 = 0,
|
|
/// Aggregate Anthropic/OpenAI/Gemini token usage across every model call
|
|
/// this Agent has made. 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 = .{},
|
|
|
|
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 <script>.lp`) stays allowed: no REPL, no LLM needed.
|
|
const requires_llm = is_one_shot or opts.self_heal or (will_repl and !opts.no_llm);
|
|
|
|
// Skip resolve when --no-llm forces no client, or no mode could use one
|
|
// (pure replay) — otherwise resolve prints "No API key detected" for a
|
|
// run that does not need one.
|
|
const resolve = !opts.no_llm and requires_llm;
|
|
const remembered: ?Remembered = if (resolve) loadRemembered(allocator) else null;
|
|
defer if (remembered) |r| std.zon.parse.free(allocator, r);
|
|
|
|
const resolved: ?ResolvedProvider = if (resolve) try resolveCredentials(opts, remembered) 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", .{});
|
|
} else if (opts.self_heal) {
|
|
std.debug.print("--self-heal needs an LLM — set an API key.\n", .{});
|
|
}
|
|
return error.MissingProvider;
|
|
}
|
|
|
|
const model: []u8 = if (opts.model) |m|
|
|
try allocator.dupe(u8, m)
|
|
else if (resolved) |r|
|
|
if (r.source == .remembered)
|
|
try allocator.dupe(u8, remembered.?.model)
|
|
else
|
|
try allocator.dupe(u8, defaultModel(r.credentials.provider))
|
|
else
|
|
try allocator.dupe(u8, "");
|
|
errdefer allocator.free(model);
|
|
|
|
if (resolved) |r| switch (r.source) {
|
|
.flag => {},
|
|
.remembered => std.debug.print(
|
|
"Resuming provider {s}, model {s} (remembered in ./.lp-agent.zon). Change with --provider/--model or /provider, /model.\n",
|
|
.{ @tagName(r.credentials.provider), model },
|
|
),
|
|
.detected => std.debug.print(
|
|
"Auto-selected provider {s}, model {s}. Set --provider/--model or use /provider, /model to change.\n",
|
|
.{ @tagName(r.credentials.provider), model },
|
|
),
|
|
};
|
|
|
|
const notification: *lp.Notification = try .init(allocator);
|
|
errdefer notification.deinit();
|
|
|
|
const self = try allocator.create(Agent);
|
|
errdefer allocator.destroy(self);
|
|
|
|
const history_path: ?[:0]const u8 = if (will_repl) ".lp-history" else null;
|
|
|
|
// `-i <file>` means "replay then grow this file"; a script path alone is
|
|
// pure replay and must not be mutated.
|
|
const recorder_path: ?[]const u8 = if (opts.interactive) opts.script_file else null;
|
|
|
|
self.* = .{
|
|
.allocator = allocator,
|
|
.ai_client = null,
|
|
.model_credentials = llm,
|
|
.model_base_url = opts.base_url,
|
|
.model_completions = null,
|
|
.model_completion_arena = .init(allocator),
|
|
.notification = notification,
|
|
.browser = undefined,
|
|
.session = undefined,
|
|
.node_registry = CDPNode.Registry.init(allocator),
|
|
.terminal = .init(allocator, history_path, Config.agentVerbosity(opts), will_repl),
|
|
.verifier = undefined,
|
|
.recorder = null,
|
|
.save_buffer = .init(allocator),
|
|
.save_path = null,
|
|
.messages = .empty,
|
|
.message_arena = .init(allocator),
|
|
.model = model,
|
|
.system_prompt = opts.system_prompt orelse default_system_prompt,
|
|
.script_file = opts.script_file,
|
|
.self_heal = opts.self_heal,
|
|
.interactive = opts.interactive,
|
|
.one_shot_task = opts.task,
|
|
.one_shot_attachments = if (opts.attach.items.len == 0) null else opts.attach.items,
|
|
};
|
|
errdefer self.node_registry.deinit();
|
|
errdefer self.terminal.deinit();
|
|
errdefer self.message_arena.deinit();
|
|
self.terminal.installLogSink();
|
|
errdefer self.terminal.uninstallLogSink();
|
|
|
|
try self.browser.init(app, .{}, null);
|
|
errdefer self.browser.deinit();
|
|
|
|
self.session = try self.browser.newSession(notification);
|
|
self.session.cancel_hook = .{ .context = @ptrCast(self), .check = checkCancel };
|
|
self.verifier = .{ .session = self.session, .node_registry = &self.node_registry };
|
|
|
|
self.ai_client = if (llm) |l| try initAiClient(allocator, l, opts.base_url) else null;
|
|
errdefer if (self.ai_client) |c| deinitAiClient(allocator, c);
|
|
|
|
if (will_repl) {
|
|
self.terminal.attachCompleter();
|
|
self.terminal.completion_source = .{
|
|
.context = @ptrCast(self),
|
|
.providers = completionProviders,
|
|
.models = completionModels,
|
|
};
|
|
}
|
|
|
|
if (recorder_path) |p| {
|
|
if (Recorder.init(allocator, std.fs.cwd(), p)) |r| {
|
|
self.recorder = r;
|
|
self.terminal.printInfo("recording to {s}", .{r.path});
|
|
} else |err| {
|
|
self.terminal.printError("recording disabled: {s}", .{@errorName(err)});
|
|
}
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
pub fn deinit(self: *Agent) void {
|
|
self.terminal.uninstallLogSink();
|
|
if (self.recorder) |*r| r.deinit();
|
|
self.save_buffer.deinit();
|
|
if (self.save_path) |p| self.allocator.free(p);
|
|
self.terminal.deinit();
|
|
self.message_arena.deinit();
|
|
self.model_completion_arena.deinit();
|
|
self.messages.deinit(self.allocator);
|
|
self.node_registry.deinit();
|
|
self.browser.deinit();
|
|
self.notification.deinit();
|
|
if (self.ai_client) |ai_client| {
|
|
switch (ai_client) {
|
|
inline else => |c| {
|
|
c.deinit();
|
|
self.allocator.destroy(c);
|
|
},
|
|
}
|
|
}
|
|
self.allocator.free(self.model);
|
|
self.allocator.destroy(self);
|
|
}
|
|
|
|
// Tool definitions are compile-time constant; project them once per process.
|
|
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 currently 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.browser.env.terminate();
|
|
}
|
|
|
|
/// Lives in main's stack so it can be registered with the sighandler
|
|
/// before the agent thread exists. The agent attaches itself once it's
|
|
/// 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` to the
|
|
/// caller. Caller is responsible for any spinner cleanup that hasn't
|
|
/// already happened on its path.
|
|
fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} {
|
|
self.rollbackMessages(baseline);
|
|
self.browser.env.cancelTerminate();
|
|
self.cancel_requested.store(false, .release);
|
|
return error.UserCancelled;
|
|
}
|
|
|
|
/// 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| {
|
|
const script_ok = self.runScript(path);
|
|
if (!self.interactive) return script_ok;
|
|
}
|
|
self.runRepl();
|
|
return true;
|
|
}
|
|
|
|
/// Final answer goes to stdout; errors go to stderr, so a caller can
|
|
/// pipe stdout to capture a clean answer.
|
|
/// Print a single-line summary of cumulative token usage to stderr, so
|
|
/// wrappers driving `lightpanda agent --task ...` can capture per-task cost
|
|
/// by `grep`-ing for the `$usage` prefix. Format is stable and key=value:
|
|
/// $usage prompt=N completion=N total=N cached=N cache_creation=N
|
|
/// Fields are emitted with value 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.pruneMessages();
|
|
return false;
|
|
},
|
|
else => {
|
|
self.terminal.printError("{s} failed: {s}", .{ input.label, @errorName(err) });
|
|
return false;
|
|
},
|
|
};
|
|
if (text) |t| self.terminal.printAssistant(t) else self.terminal.printInfo("(no response from model)", .{});
|
|
self.pruneMessages();
|
|
return true;
|
|
}
|
|
|
|
fn runRepl(self: *Agent) void {
|
|
self.terminal.printDimmed("Lightpanda Agent (type '/quit' to exit)", .{});
|
|
self.terminal.printDimmed("Tab completes/cycles through commands; the dim grey ghost shows the first match.", .{});
|
|
self.terminal.printDimmed("Shift-Tab (or Ctrl-J) inserts a newline — use it inside '''…''' or \"\"\"…\"\"\" blocks.", .{});
|
|
log.debug(.app, "tools loaded", .{ .count = globalTools().len });
|
|
if (self.ai_client) |ai_client| {
|
|
self.terminal.printDimmed("Provider: {s}, Model: {s}", .{ @tagName(std.meta.activeTag(ai_client)), self.model });
|
|
} else {
|
|
self.terminal.printDimmed("Basic REPL (--no-llm) — PandaScript only.", .{});
|
|
self.terminal.printDimmed("Drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY) to enable natural-language, /login, and /acceptCookies.", .{});
|
|
}
|
|
|
|
repl: while (true) {
|
|
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) continue;
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
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 (--no-llm) accepts only commands. Try /help, or drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY) 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.printSlashParseError(e, name, &diag);
|
|
continue :repl;
|
|
},
|
|
};
|
|
|
|
if (cmd == .llm and self.ai_client == null) {
|
|
self.terminal.printError("/{s} requires an LLM. Drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY).", .{@tagName(cmd.llm)});
|
|
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 (self.recorder) |*r| r.record(cmd);
|
|
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) });
|
|
};
|
|
},
|
|
}
|
|
}
|
|
|
|
self.terminal.printInfo("Goodbye!", .{});
|
|
}
|
|
|
|
/// Handle a REPL-only meta slash command. These aren't part of PandaScript
|
|
/// and never reach 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.handleVerbosity(rest),
|
|
.save => self.handleSave(arena, rest),
|
|
.model => self.handleModel(arena, rest),
|
|
.provider => self.handleProvider(arena, rest),
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn handleVerbosity(self: *Agent, rest: []const u8) void {
|
|
if (rest.len == 0) {
|
|
self.terminal.printInfo("verbosity: {s}", .{@tagName(self.terminal.verbosity)});
|
|
return;
|
|
}
|
|
const level = std.meta.stringToEnum(Config.AgentVerbosity, rest) orelse {
|
|
self.terminal.printError("usage: /verbosity <low|medium|high> (got {s})", .{rest});
|
|
return;
|
|
};
|
|
self.terminal.verbosity = level;
|
|
self.terminal.printInfo("verbosity: {s}", .{@tagName(level)});
|
|
}
|
|
|
|
fn requireLlm(self: *Agent, name: []const u8) bool {
|
|
if (self.model_credentials == null) {
|
|
self.terminal.printError("{s} requires an LLM. Drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY).", .{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;
|
|
}
|
|
self.setModel(trimmed) catch |err| {
|
|
self.terminal.printError("failed to set model: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
|
|
fn setModel(self: *Agent, model: []const u8) !void {
|
|
const new_model = try self.allocator.dupe(u8, model);
|
|
self.allocator.free(self.model);
|
|
self.model = new_model;
|
|
if (self.model_credentials) |c| saveRemembered(c.provider, self.model);
|
|
self.terminal.printInfo("model: {s}", .{self.model});
|
|
}
|
|
|
|
fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
|
|
if (!self.requireLlm("/provider")) return;
|
|
const current = self.model_credentials.?;
|
|
|
|
const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace);
|
|
if (trimmed.len == 0) {
|
|
self.terminal.printInfo("Current provider: {s} (Tab to list)", .{@tagName(current.provider)});
|
|
return;
|
|
}
|
|
|
|
const provider = std.meta.stringToEnum(Config.AiProvider, trimmed) orelse {
|
|
self.terminal.printError("unknown provider: {s}", .{trimmed});
|
|
return;
|
|
};
|
|
if (provider == current.provider) {
|
|
self.terminal.printInfo("provider: {s}", .{@tagName(provider)});
|
|
return;
|
|
}
|
|
const key = zenai.provider.envApiKey(provider) orelse {
|
|
self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) });
|
|
return;
|
|
};
|
|
self.setProvider(.{ .provider = provider, .key = key }) catch |err| {
|
|
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
|
|
fn setProvider(self: *Agent, credentials: Credentials) !void {
|
|
const new_client = try initAiClient(self.allocator, credentials, self.model_base_url);
|
|
errdefer deinitAiClient(self.allocator, new_client);
|
|
|
|
const new_model = try self.allocator.dupe(u8, defaultModel(credentials.provider));
|
|
if (self.ai_client) |client| deinitAiClient(self.allocator, client);
|
|
self.ai_client = new_client;
|
|
self.model_credentials = credentials;
|
|
self.model_completions = null;
|
|
self.allocator.free(self.model);
|
|
self.model = new_model;
|
|
saveRemembered(credentials.provider, self.model);
|
|
self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)});
|
|
self.terminal.printInfo("model: {s}", .{self.model});
|
|
}
|
|
|
|
const SaveMode = enum { replace, append };
|
|
|
|
fn handleSave(self: *Agent, arena: std.mem.Allocator, rest: []const u8) void {
|
|
const filename = parseSaveFilename(rest) catch |err| {
|
|
const msg: []const u8 = switch (err) {
|
|
error.TooManyArguments => "usage: /save [filename.lp]",
|
|
error.UnterminatedQuote => "unterminated filename quote",
|
|
error.EmptyFilename => "filename cannot be empty",
|
|
error.InvalidFilename => "filename must be a local file name, not a path",
|
|
};
|
|
self.terminal.printError("{s}", .{msg});
|
|
return;
|
|
};
|
|
|
|
const path: []const u8, const mode: SaveMode = if (self.save_path) |saved| blk: {
|
|
if (filename) |name| {
|
|
if (!std.mem.eql(u8, saved, name)) {
|
|
self.terminal.printError("already saving to {s}; use /save without a filename to append to it", .{saved});
|
|
return;
|
|
}
|
|
}
|
|
break :blk .{ saved, .append };
|
|
} else blk: {
|
|
const path = filename orelse randomSaveFilename(arena) catch |err| {
|
|
self.terminal.printError("failed to choose save filename: {s}", .{@errorName(err)});
|
|
return;
|
|
};
|
|
const exists = fileExists(path) catch |err| {
|
|
self.terminal.printError("failed to inspect {s}: {s}", .{ path, @errorName(err) });
|
|
return;
|
|
};
|
|
const mode: SaveMode = if (exists)
|
|
self.promptSaveMode(path) orelse return
|
|
else
|
|
.replace;
|
|
break :blk .{ path, mode };
|
|
};
|
|
|
|
// `path` aliases either an arena-owned string (first save) or
|
|
// `self.save_path` (subsequent saves to the same destination); only
|
|
// the former needs to be persisted into agent-owned memory.
|
|
var new_save_path: ?[]u8 = if (self.save_path == null)
|
|
self.allocator.dupe(u8, path) catch |err| {
|
|
self.terminal.printError("failed to remember save destination {s}: {s}", .{ path, @errorName(err) });
|
|
return;
|
|
}
|
|
else
|
|
null;
|
|
defer if (new_save_path) |p| self.allocator.free(p);
|
|
|
|
self.writeSaveFile(path, mode) catch |err| {
|
|
self.terminal.printError("failed to save {s}: {s}", .{ path, @errorName(err) });
|
|
return;
|
|
};
|
|
|
|
if (new_save_path) |p| {
|
|
self.save_path = p;
|
|
new_save_path = null;
|
|
}
|
|
const saved_lines = self.save_buffer.lines;
|
|
self.save_buffer.reset();
|
|
self.terminal.printInfo("Saved {d} line(s) to {s}", .{ saved_lines, self.save_path.? });
|
|
}
|
|
|
|
fn parseSaveFilename(rest: []const u8) !?[]const u8 {
|
|
const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace);
|
|
if (trimmed.len == 0) return null;
|
|
|
|
const name = if (trimmed[0] == '\'' or trimmed[0] == '"') blk: {
|
|
const quote = trimmed[0];
|
|
const end = std.mem.indexOfScalarPos(u8, trimmed, 1, quote) orelse return error.UnterminatedQuote;
|
|
if (std.mem.trim(u8, trimmed[end + 1 ..], &std.ascii.whitespace).len != 0) return error.TooManyArguments;
|
|
break :blk trimmed[1..end];
|
|
} else blk: {
|
|
if (std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) != null) return error.TooManyArguments;
|
|
break :blk trimmed;
|
|
};
|
|
|
|
if (name.len == 0) return error.EmptyFilename;
|
|
if (std.fs.path.isAbsolute(name)) return error.InvalidFilename;
|
|
if (std.mem.indexOfScalar(u8, name, '/') != null) return error.InvalidFilename;
|
|
if (std.mem.indexOfScalar(u8, name, '\\') != null) return error.InvalidFilename;
|
|
if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidFilename;
|
|
return name;
|
|
}
|
|
|
|
fn randomSaveFilename(arena: std.mem.Allocator) ![]const u8 {
|
|
for (0..100) |_| {
|
|
const n = std.crypto.random.int(u64);
|
|
const path = try std.fmt.allocPrint(arena, "session-{x}.lp", .{n});
|
|
if (!(try fileExists(path))) return path;
|
|
}
|
|
return error.NameCollision;
|
|
}
|
|
|
|
fn fileExists(path: []const u8) !bool {
|
|
std.fs.cwd().access(path, .{}) catch |err| switch (err) {
|
|
error.FileNotFound => return false,
|
|
else => return err,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
fn promptSaveMode(self: *Agent, path: []const u8) ?SaveMode {
|
|
var header_buf: [256]u8 = undefined;
|
|
const header = std.fmt.bufPrint(&header_buf, "{s} already exists. Pick save mode:", .{path}) catch
|
|
"File already exists. Pick save mode:";
|
|
const labels: []const []const u8 = &.{ "replace", "append" };
|
|
const idx = Terminal.promptNumberedChoice(header, labels, null) catch {
|
|
self.terminal.printInfo("Save cancelled.", .{});
|
|
return null;
|
|
};
|
|
return if (idx == 0) .replace else .append;
|
|
}
|
|
|
|
fn writeSaveFile(self: *Agent, path: []const u8, mode: SaveMode) !void {
|
|
const content = self.save_buffer.bytes();
|
|
const file = try std.fs.cwd().createFile(path, .{ .truncate = mode == .replace });
|
|
defer file.close();
|
|
if (mode == .append) {
|
|
try file.seekFromEnd(0);
|
|
const pos = try file.getPos();
|
|
if (pos > 0 and content.len > 0) try file.writeAll("\n");
|
|
}
|
|
try file.writeAll(content);
|
|
}
|
|
|
|
fn recordSaveCommand(self: *Agent, cmd: Command) void {
|
|
self.save_buffer.record(cmd) catch |err| {
|
|
self.terminal.printError("save buffer disabled: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
|
|
fn recordSaveComment(self: *Agent, comment: []const u8) void {
|
|
self.save_buffer.recordComment(comment) catch |err| {
|
|
self.terminal.printError("save buffer disabled: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
|
|
fn helpLessThan(_: void, a: SlashCommand.Help, b: SlashCommand.Help) bool {
|
|
return std.mem.lessThan(u8, a.name, b.name);
|
|
}
|
|
|
|
fn printHelpSection(term: *Terminal, header: []const u8, rows: []SlashCommand.Help) void {
|
|
if (rows.len == 0) return;
|
|
std.sort.pdq(SlashCommand.Help, rows, {}, helpLessThan);
|
|
term.printInfo("{s}{s}{s}", .{ Terminal.ansi.bold, header, Terminal.ansi.reset });
|
|
for (rows) |r| term.printInfo(" {s}{s}/{s}{s} — {s}", .{
|
|
Terminal.ansi.bold, Terminal.ansi.cyan, r.name, Terminal.ansi.reset, r.description,
|
|
});
|
|
}
|
|
|
|
fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) void {
|
|
if (target.len == 0) {
|
|
const all = Schema.all();
|
|
const browser = arena.alloc(SlashCommand.Help, all.len) catch return;
|
|
for (all, browser) |*s, *e| e.* = .{ .name = s.tool_name, .description = s.summary };
|
|
printHelpSection(&self.terminal, "Browser commands:", browser);
|
|
|
|
if (self.ai_client != null) {
|
|
const llm = arena.alloc(SlashCommand.Help, SlashCommand.llm_commands.len) catch return;
|
|
@memcpy(llm, &SlashCommand.llm_commands);
|
|
printHelpSection(&self.terminal, "\nLLM commands:", llm);
|
|
}
|
|
|
|
const meta = arena.alloc(SlashCommand.Help, SlashCommand.meta_commands.len) catch return;
|
|
for (SlashCommand.meta_commands, meta) |m, *e| e.* = .{ .name = m.name, .description = m.description };
|
|
printHelpSection(&self.terminal, "\nMeta commands:", meta);
|
|
return;
|
|
}
|
|
const lookup = if (target[0] == '/') target[1..] else target;
|
|
if (SlashCommand.findMeta(lookup)) |meta| {
|
|
switch (meta.tag) {
|
|
.help => self.terminal.printInfo("/help [name] — show help for a command, or list all when [name] is omitted", .{}),
|
|
.quit => self.terminal.printInfo("/quit — exit the REPL", .{}),
|
|
.verbosity => self.terminal.printInfo(
|
|
"/verbosity <low|medium|high> — set REPL agent verbosity (currently: {s}). Bare /verbosity prints the level.",
|
|
.{@tagName(self.terminal.verbosity)},
|
|
),
|
|
.save => self.terminal.printInfo(
|
|
"/save [filename.lp] — save recorded REPL actions to [filename.lp]. Without a filename, creates a random session-*.lp file in the current directory.",
|
|
.{},
|
|
),
|
|
.model => self.terminal.printInfo(
|
|
"/model [name] — change the model; Tab completes the provider's models, bare /model shows the current one",
|
|
.{},
|
|
),
|
|
.provider => self.terminal.printInfo(
|
|
"/provider [name] — change the provider; Tab completes detected providers, bare /provider shows the current one",
|
|
.{},
|
|
),
|
|
}
|
|
return;
|
|
}
|
|
const tool_schema = Schema.findByName(lookup) orelse {
|
|
self.terminal.printError("unknown command: {s}", .{lookup});
|
|
return;
|
|
};
|
|
self.terminal.printInfo("/{s} — {s}", .{ tool_schema.tool_name, tool_schema.description });
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
std.json.Stringify.value(tool_schema.parameters, .{ .whitespace = .indent_2 }, &aw.writer) catch return;
|
|
self.terminal.printInfo("schema:\n{s}", .{aw.written()});
|
|
}
|
|
|
|
fn printSlashParseError(self: *Agent, err: Schema.ParseError, name: []const u8, diag: ?*const Schema.Diag) void {
|
|
if (err == error.InvalidValue) {
|
|
if (diag) |d| if (d.bad_field.len > 0) {
|
|
self.terminal.printError("{s}: {s}: expected {s}, got '{s}'. Try /help {s}.", .{ name, d.bad_field, @tagName(d.expected_type), d.bad_value, name });
|
|
return;
|
|
};
|
|
}
|
|
const reason: []const u8 = switch (err) {
|
|
error.UnknownTool => "unknown command",
|
|
error.MissingName => return self.terminal.printError("missing command name. Try /help.", .{}),
|
|
error.MissingRequired => "missing required argument",
|
|
error.MalformedKv => "malformed key=value. Use key=value or {json}",
|
|
error.UnknownField => "unknown field (typo?)",
|
|
error.DuplicateField => "the same field was supplied twice (check for case-variants like Selector vs selector)",
|
|
error.PositionalNotAllowed => "positional only works for commands with one required field. Use key=value",
|
|
error.UnterminatedQuote => "unterminated quote",
|
|
error.UnsupportedEscape => "backslash escapes aren't supported in quoted values; use the other quote style or `'''…'''`",
|
|
error.InvalidValue => "invalid value (check argument type)",
|
|
error.OutOfMemory => return self.terminal.printError("out of memory", .{}),
|
|
};
|
|
self.terminal.printError("{s}: {s}. Try /help {s}.", .{ name, reason, name });
|
|
}
|
|
|
|
const Replacement = script.Replacement;
|
|
|
|
/// Caller contract: `cmd` must be `.tool_call` — `.comment` and `.llm` are
|
|
/// filtered upstream because they have no tool mapping.
|
|
fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tools.ToolResult {
|
|
const tc = switch (cmd) {
|
|
.tool_call => |t| t,
|
|
else => return .{ .text = "internal: command has no tool mapping", .is_error = true },
|
|
};
|
|
return browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args) catch |err| .{
|
|
.text = if (err == error.OutOfMemory)
|
|
"out of memory"
|
|
else
|
|
std.fmt.allocPrint(arena, "{s} failed: {s}", .{ tc.name(), @errorName(err) }) catch "tool failed",
|
|
.is_error = true,
|
|
};
|
|
}
|
|
|
|
/// Data output (/extract, /eval, /markdown, /tree, …) → plain stdout on
|
|
/// success so a caller can pipe it. Everything else routes through
|
|
/// `printToolOutcome`, which lays down the green ● / red ● dot shared
|
|
/// with the LLM tool-call path. Callers only invoke this for `.tool_call`
|
|
/// commands (the comment/login/acceptCookies branches take other paths).
|
|
fn printCommandResult(self: *Agent, cmd: Command, result: browser_tools.ToolResult) void {
|
|
const tc = switch (cmd) {
|
|
.tool_call => |t| t,
|
|
else => return,
|
|
};
|
|
if (cmd.producesData() and !result.is_error) {
|
|
self.terminal.printAssistant(result.text);
|
|
return;
|
|
}
|
|
self.terminal.printToolOutcome(tc.name(), result.text, result.is_error);
|
|
}
|
|
|
|
fn runScript(self: *Agent, path: []const u8) bool {
|
|
self.terminal.printInfo("Running script: {s}", .{path});
|
|
|
|
var script_arena: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer script_arena.deinit();
|
|
const sa = script_arena.allocator();
|
|
|
|
const content = std.fs.cwd().readFileAlloc(sa, path, 10 * 1024 * 1024) catch |err| {
|
|
self.terminal.printError("Failed to read script '{s}': {s}", .{ path, @errorName(err) });
|
|
return false;
|
|
};
|
|
|
|
var iter: script.Iterator = .init(sa, content);
|
|
var last_comment: ?[]const u8 = null;
|
|
var replacements: std.ArrayList(Replacement) = .empty;
|
|
|
|
while (true) {
|
|
const entry = (iter.next() catch |err| {
|
|
self.terminal.printError("line {d}: {s} parsing script", .{ iter.line_num, @errorName(err) });
|
|
self.flushReplacements(path, content, replacements.items);
|
|
return false;
|
|
}) orelse break;
|
|
switch (entry.command) {
|
|
.comment => {
|
|
// `#` prefix lines preceding a recorded action are the
|
|
// natural-language prompt that produced it — kept for
|
|
// self-heal context.
|
|
if (entry.opener_line.len > 2 and entry.opener_line[0] == '#') {
|
|
last_comment = std.mem.trim(u8, entry.opener_line[1..], &std.ascii.whitespace);
|
|
}
|
|
continue;
|
|
},
|
|
.llm => |lc| {
|
|
if (self.ai_client == null) {
|
|
self.terminal.printError("line {d}: {s} requires --provider", .{
|
|
entry.line_num,
|
|
entry.opener_line,
|
|
});
|
|
self.flushReplacements(path, content, replacements.items);
|
|
return false;
|
|
}
|
|
const prompt = lc.prompt();
|
|
const text = self.processUserMessage(.{ .prompt = prompt }) catch |err| {
|
|
self.terminal.printError("line {d}: {s} failed: {s}", .{
|
|
entry.line_num,
|
|
entry.opener_line,
|
|
@errorName(err),
|
|
});
|
|
self.flushReplacements(path, content, replacements.items);
|
|
return false;
|
|
};
|
|
if (text) |t| self.terminal.printAssistant(t);
|
|
self.pruneMessages();
|
|
},
|
|
.tool_call => {
|
|
self.terminal.printInfo("[{d}] {s}", .{ entry.line_num, entry.opener_line });
|
|
switch (self.runActionEntry(sa, entry, last_comment)) {
|
|
.ok => {},
|
|
.healed => |r| replacements.append(sa, r) catch |err| {
|
|
self.flushReplacements(path, content, replacements.items);
|
|
self.terminal.printError(
|
|
"line {d}: out of memory recording heal: {s}",
|
|
.{ entry.line_num, @errorName(err) },
|
|
);
|
|
return false;
|
|
},
|
|
.fail => {
|
|
self.flushReplacements(path, content, replacements.items);
|
|
return false;
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
self.flushReplacements(path, content, replacements.items);
|
|
self.terminal.printInfo("Script completed.", .{});
|
|
return true;
|
|
}
|
|
|
|
const ActionOutcome = union(enum) {
|
|
ok,
|
|
healed: Replacement,
|
|
/// The per-line error has already been printed; caller must not re-report.
|
|
fail,
|
|
};
|
|
|
|
/// Execute one action-style script entry, including post-execution
|
|
/// verification, transient-failure retry, and LLM self-heal escalation.
|
|
fn runActionEntry(self: *Agent, sa: std.mem.Allocator, entry: script.Iterator.Entry, last_comment: ?[]const u8) ActionOutcome {
|
|
var cmd_arena: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer cmd_arena.deinit();
|
|
const ca = cmd_arena.allocator();
|
|
|
|
const result = self.runCommand(ca, entry.command);
|
|
self.printCommandResult(entry.command, result);
|
|
|
|
const verification: Verifier.VerifyResult = if (!result.is_error and self.self_heal)
|
|
self.verifier.verify(ca, entry.command)
|
|
else
|
|
.inconclusive;
|
|
|
|
if (!result.is_error and verification != .failed) return .ok;
|
|
|
|
if (self.self_heal and self.ai_client != null) {
|
|
// Verification-only failures often resolve with a brief wait
|
|
// (animations, lazy-load); skip the LLM round-trip when they do.
|
|
if (!result.is_error and entry.command.isRetryable() and self.retryCommand(ca, entry.command)) {
|
|
return .ok;
|
|
}
|
|
|
|
const msg = if (result.is_error)
|
|
"Command failed, attempting self-healing..."
|
|
else
|
|
"Command succeeded but verification failed, attempting self-healing...";
|
|
self.terminal.printInfo("{s}", .{msg});
|
|
|
|
const reason: ?[]const u8 = switch (verification) {
|
|
.failed => |r| r,
|
|
.passed, .inconclusive => null,
|
|
};
|
|
// For multi-line blocks (`/eval '''…'''`, `/extract '''…'''`) the
|
|
// opener alone is useless to the LLM — feed it the full block body.
|
|
const failed_text = std.mem.trimRight(u8, entry.raw_span, &std.ascii.whitespace);
|
|
if (self.attemptSelfHeal(sa, failed_text, reason, last_comment)) |healed_cmds| {
|
|
const replacement = script.formatHealReplacement(sa, entry.raw_span, entry.opener_line, .{ .cmds = healed_cmds }) catch |err| {
|
|
self.terminal.printError(
|
|
"line {d}: failed to record heal: {s} (script left unchanged)",
|
|
.{ entry.line_num, @errorName(err) },
|
|
);
|
|
return .fail;
|
|
};
|
|
return .{ .healed = replacement };
|
|
}
|
|
}
|
|
self.terminal.printError("line {d}: command failed: {s}", .{
|
|
entry.line_num,
|
|
entry.opener_line,
|
|
});
|
|
return .fail;
|
|
}
|
|
|
|
/// Re-run a verification-failed command with bounded backoff. Returns true
|
|
/// once both execution and verification pass, false after 3 attempts.
|
|
fn retryCommand(self: *Agent, ca: std.mem.Allocator, cmd: Command) bool {
|
|
for (0..3) |i| {
|
|
std.Thread.sleep((500 + i * 250) * std.time.ns_per_ms);
|
|
self.terminal.printInfo("Retrying command...", .{});
|
|
const retry_result = self.runCommand(ca, cmd);
|
|
if (retry_result.is_error) continue;
|
|
if (self.verifier.verify(ca, cmd) == .failed) continue;
|
|
self.printCommandResult(cmd, retry_result);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn flushReplacements(self: *Agent, path: []const u8, content: []const u8, replacements: []const Replacement) void {
|
|
if (replacements.len == 0) return;
|
|
script.writeAtomic(self.allocator, std.fs.cwd(), path, content, replacements) catch |err| {
|
|
self.terminal.printError(
|
|
"Failed to update script {s}: {s} {s}",
|
|
.{ path, @errorName(err), script.writeAtomicErrorTail(err) },
|
|
);
|
|
return;
|
|
};
|
|
self.terminal.printInfo(
|
|
"Script updated with {d} healed command(s); backup at {s}.bak",
|
|
.{ replacements.len, path },
|
|
);
|
|
}
|
|
|
|
const self_heal_max_attempts = 3;
|
|
|
|
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
|
|
/// had called the tool itself, so the next natural-language turn sees
|
|
/// the same conversation shape either way.
|
|
fn recordSlashToolCall(
|
|
self: *Agent,
|
|
user_input: []const u8,
|
|
tool_name: []const u8,
|
|
args: ?std.json.Value,
|
|
result: browser_tools.ToolResult,
|
|
) !void {
|
|
if (self.ai_client == null) return;
|
|
try self.ensureSystemPrompt();
|
|
|
|
const ma = self.message_arena.allocator();
|
|
self.synthetic_tool_call_id += 1;
|
|
|
|
const user_content = try ma.dupe(u8, user_input);
|
|
|
|
const tool_calls = try ma.alloc(zenai.provider.ToolCall, 1);
|
|
tool_calls[0] = .{
|
|
.id = try std.fmt.allocPrint(ma, "lp-slash-{d}", .{self.synthetic_tool_call_id}),
|
|
.name = try ma.dupe(u8, tool_name),
|
|
.arguments = if (args) |v| try zenai.json.dupeValue(ma, v) else null,
|
|
};
|
|
|
|
// capToolOutput returns its input unchanged under the cap; dupe so
|
|
// content doesn't alias the caller's per-iteration arena.
|
|
const capped = capToolOutput(ma, result.text);
|
|
const content = if (capped.ptr == result.text.ptr) try ma.dupe(u8, capped) else capped;
|
|
|
|
const tool_results = try ma.alloc(zenai.provider.ToolResult, 1);
|
|
tool_results[0] = .{
|
|
.id = try ma.dupe(u8, tool_calls[0].id),
|
|
.name = try ma.dupe(u8, tool_calls[0].name),
|
|
.content = content,
|
|
.is_error = result.is_error,
|
|
};
|
|
|
|
const baseline = self.messages.items.len;
|
|
errdefer self.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, .{
|
|
.role = .user,
|
|
.content = user_content,
|
|
});
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .assistant,
|
|
.tool_calls = tool_calls,
|
|
});
|
|
try self.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;
|
|
}
|
|
|
|
/// Runs a single LLM turn, captures the commands it called without recording
|
|
/// them — so the caller can splice healed commands into the script directly.
|
|
fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Command {
|
|
const provider_client = self.ai_client orelse return error.NoAiClient;
|
|
const ma = self.message_arena.allocator();
|
|
|
|
try self.ensureSystemPrompt();
|
|
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.content = try ma.dupe(u8, prompt),
|
|
});
|
|
|
|
self.terminal.spinner.start();
|
|
var result = provider_client.runTools(
|
|
self.model,
|
|
&self.messages,
|
|
self.allocator,
|
|
ma,
|
|
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
|
.{
|
|
.tools = globalTools(),
|
|
.max_tool_calls = 4,
|
|
.max_tokens = 4096,
|
|
.tool_choice = .auto,
|
|
},
|
|
) catch |err| {
|
|
self.terminal.spinner.cancel();
|
|
log.err(.app, "AI API error", .{ .err = err });
|
|
return error.ApiError;
|
|
};
|
|
self.terminal.spinner.stop();
|
|
defer result.deinit();
|
|
self.total_usage.add(result.usage);
|
|
|
|
var cmds: std.ArrayList(Command) = .empty;
|
|
for (result.tool_calls_made) |tc| {
|
|
if (tc.is_error) continue;
|
|
const tool = std.meta.stringToEnum(BrowserTool, tc.name) orelse continue;
|
|
// `result.deinit()` (deferred above) frees the args arena before the
|
|
// caller formats `cmds`; deep-copy into `arena` to outlive it.
|
|
const owned_args = if (tc.arguments) |v| try zenai.json.dupeValue(arena, v) else null;
|
|
const cmd = Command.fromToolCall(tool, owned_args);
|
|
if (!cmd.canHeal()) {
|
|
self.terminal.printInfo(
|
|
"self-heal: ignoring {s} (navigation and eval are not allowed during heal)",
|
|
.{tc.name},
|
|
);
|
|
continue;
|
|
}
|
|
try cmds.append(arena, cmd);
|
|
}
|
|
|
|
if (result.text) |text| {
|
|
self.terminal.printAssistant(text);
|
|
}
|
|
|
|
return cmds.toOwnedSlice(arena);
|
|
}
|
|
|
|
fn attemptSelfHeal(self: *Agent, arena: std.mem.Allocator, failed_command: []const u8, verify_context: ?[]const u8, context_comment: ?[]const u8) ?[]Command {
|
|
// Build the prompt in `arena` (the caller's per-replay arena), not in
|
|
// `message_arena`. The prompt is re-used across attempts, so it must
|
|
// survive arena rebuilds done between failed attempts.
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
aw.writer.print("{s}{s}{s}{s}", .{
|
|
self_heal_prompt_prefix,
|
|
failed_command,
|
|
self_heal_prompt_page_state,
|
|
browser_tools.currentUrlOrPlaceholder(self.session),
|
|
}) catch return null;
|
|
if (context_comment) |c|
|
|
aw.writer.print("\n\nThe original user request that generated this command was:\n{s}", .{c}) catch return null;
|
|
if (verify_context) |ctx|
|
|
aw.writer.print("\n\nVerification detected a problem:\n{s}", .{ctx}) catch return null;
|
|
aw.writer.writeAll(self_heal_prompt_instructions) catch return null;
|
|
const prompt = aw.written();
|
|
|
|
// Save message count so we can roll back between attempts — each failed
|
|
// heal turn would otherwise accumulate in context, confusing the next try.
|
|
const msg_baseline = self.messages.items.len;
|
|
|
|
var attempt: u8 = 0;
|
|
while (attempt < self_heal_max_attempts) : (attempt += 1) {
|
|
const cmds = self.runHealTurn(arena, prompt) catch |err| {
|
|
self.terminal.printError("self-heal attempt {d}/{d} failed: {s}", .{
|
|
attempt + 1,
|
|
self_heal_max_attempts,
|
|
@errorName(err),
|
|
});
|
|
self.rollbackMessages(msg_baseline);
|
|
continue;
|
|
};
|
|
if (cmds.len > 0) {
|
|
self.pruneMessages();
|
|
return cmds;
|
|
}
|
|
self.rollbackMessages(msg_baseline);
|
|
break;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Shrink `self.messages` back to `baseline` and rebuild the arena. Used
|
|
/// after a failed turn (API error, self-heal attempt, 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 between failed self-heal attempts 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()`
|
|
/// 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();
|
|
|
|
try self.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;
|
|
|
|
// 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;
|
|
|
|
if (turn_attachments) |paths| {
|
|
const parts = try self.buildUserMessageParts(ma, input.prompt, paths);
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.parts = parts,
|
|
});
|
|
} else {
|
|
try self.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.content = try ma.dupe(u8, input.prompt),
|
|
});
|
|
}
|
|
|
|
const provider_client = self.ai_client orelse return error.NoAiClient;
|
|
|
|
self.terminal.spinner.start();
|
|
var result = provider_client.runTools(
|
|
self.model,
|
|
&self.messages,
|
|
self.allocator,
|
|
ma,
|
|
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
|
.{
|
|
.tools = globalTools(),
|
|
.max_turns = 100,
|
|
.max_tool_calls = 200,
|
|
.max_tokens = 4096,
|
|
.tool_choice = .auto,
|
|
// Cap per-turn reasoning so thinking models don't burn
|
|
// minutes per turn. Ignored by non-thinking models.
|
|
.thinking_level = .medium,
|
|
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
|
},
|
|
) catch |err| {
|
|
self.terminal.spinner.cancel();
|
|
// Ctrl-C can land while runTools is unwinding an HTTP error —
|
|
// surface UserCancelled, not ApiError, so the user sees the
|
|
// 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);
|
|
return error.ApiError;
|
|
};
|
|
self.terminal.spinner.stop();
|
|
defer result.deinit();
|
|
self.total_usage.add(result.usage);
|
|
|
|
if (result.cancelled) return self.drainCancellation(msg_baseline);
|
|
|
|
const file_recorder: ?*Recorder = if (self.recorder) |*r| (if (r.isActive()) r else null) else null;
|
|
const record_to_memory = input.capture_for_save;
|
|
if (file_recorder != null or record_to_memory) {
|
|
// When the LLM tries multiple `extract` schemas in one turn, only the
|
|
// last successful one is the answer — earlier probes are noise.
|
|
var last_extract_idx: ?usize = null;
|
|
for (result.tool_calls_made, 0..) |tc, i| {
|
|
const t = std.meta.stringToEnum(BrowserTool, tc.name) orelse continue;
|
|
if (!tc.is_error and t == .extract) last_extract_idx = i;
|
|
}
|
|
|
|
var recorded_any = false;
|
|
for (result.tool_calls_made, 0..) |tc, i| {
|
|
if (tc.is_error) continue;
|
|
const tool = std.meta.stringToEnum(BrowserTool, tc.name) orelse continue;
|
|
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 cmd = Command.fromToolCall(tool, args);
|
|
if (!cmd.isRecorded()) continue;
|
|
if (!recorded_any) {
|
|
if (input.record_comment) |c| {
|
|
if (file_recorder) |r| r.recordComment(c);
|
|
if (record_to_memory) self.recordSaveComment(c);
|
|
}
|
|
recorded_any = true;
|
|
}
|
|
if (file_recorder) |r| r.record(cmd);
|
|
if (record_to_memory) self.recordSaveCommand(cmd);
|
|
}
|
|
if (file_recorder) |r| if (!r.isActive()) {
|
|
self.terminal.printError("recording disabled (write failed); see logs", .{});
|
|
};
|
|
}
|
|
|
|
// Dupe into `message_arena` — RunToolsResult arenas are deinited below.
|
|
const final_text: ?[]const u8 = blk: {
|
|
if (result.text) |text| break :blk try ma.dupe(u8, text);
|
|
|
|
// 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, .{
|
|
.role = .user,
|
|
.content = try ma.dupe(u8, synthesis_prompt),
|
|
});
|
|
|
|
var synth = provider_client.runTools(
|
|
self.model,
|
|
&self.messages,
|
|
self.allocator,
|
|
ma,
|
|
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
|
.{
|
|
.tools = &.{},
|
|
.max_turns = 1,
|
|
.max_tokens = 4096,
|
|
.tool_choice = .none,
|
|
// .low (≈512 tokens) so reasoning models still pick an answer
|
|
// but can't burn the whole turn on thinking and emit nothing.
|
|
.thinking_level = .low,
|
|
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
|
},
|
|
) 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);
|
|
break :blk null;
|
|
};
|
|
defer synth.deinit();
|
|
self.total_usage.add(synth.usage);
|
|
|
|
if (synth.cancelled) return self.drainCancellation(msg_baseline);
|
|
|
|
break :blk if (synth.text) |text| try ma.dupe(u8, text) else null;
|
|
};
|
|
|
|
// NB: pruning is deferred to the caller. `final_text` is allocated in
|
|
// `message_arena`, and `pruneMessages` may rebuild that arena — running
|
|
// it here would hand the caller a dangling slice.
|
|
return final_text;
|
|
}
|
|
|
|
/// Build a `parts`-based user message when `--attach` was given.
|
|
/// Text-ish files are inlined into the text prefix (surrounded by clear
|
|
/// markers); binary files (image/audio/pdf) are base64-encoded and sent as
|
|
/// provider inline-data parts. Unknown extensions error out so the caller
|
|
/// fails loudly instead of silently dropping the attachment.
|
|
fn buildUserMessageParts(
|
|
self: *Agent,
|
|
ma: std.mem.Allocator,
|
|
user_input: []const u8,
|
|
paths: []const []const u8,
|
|
) ![]const zenai.provider.ContentPart {
|
|
var text_prefix: std.ArrayList(u8) = .empty;
|
|
var inline_parts: std.ArrayList(zenai.provider.ContentPart) = .empty;
|
|
|
|
for (paths) |path| {
|
|
const mime = zenai.provider.inferInlineMimeType(path) orelse {
|
|
log.err(.app, "unsupported attachment", .{ .path = path });
|
|
self.terminal.printError("unsupported attachment type: {s}", .{path});
|
|
return error.UnsupportedAttachment;
|
|
};
|
|
|
|
if (std.mem.startsWith(u8, mime, "text/")) {
|
|
const bytes = std.fs.cwd().readFileAlloc(ma, path, 512 * 1024) catch |err| {
|
|
log.err(.app, "read attachment failed", .{ .path = path, .err = err });
|
|
self.terminal.printError("could not read attachment: {s}", .{path});
|
|
return error.AttachmentReadFailed;
|
|
};
|
|
try text_prefix.writer(ma).print(
|
|
"[Attached file: {s}]\n{s}\n[End of attachment]\n\n",
|
|
.{ path, bytes },
|
|
);
|
|
} else {
|
|
const raw = std.fs.cwd().readFileAlloc(ma, path, 20 * 1024 * 1024) catch |err| {
|
|
log.err(.app, "read attachment failed", .{ .path = path, .err = err });
|
|
self.terminal.printError("could not read attachment: {s}", .{path});
|
|
return error.AttachmentReadFailed;
|
|
};
|
|
const b64_len = std.base64.standard.Encoder.calcSize(raw.len);
|
|
const b64 = try ma.alloc(u8, b64_len);
|
|
_ = std.base64.standard.Encoder.encode(b64, raw);
|
|
try inline_parts.append(ma, .{ .image = .{
|
|
.data = b64,
|
|
.mime_type = try ma.dupe(u8, mime),
|
|
} });
|
|
}
|
|
}
|
|
|
|
var parts: std.ArrayList(zenai.provider.ContentPart) = .empty;
|
|
try text_prefix.appendSlice(ma, user_input);
|
|
try parts.append(ma, .{ .text = try text_prefix.toOwnedSlice(ma) });
|
|
for (inline_parts.items) |p| try parts.append(ma, p);
|
|
return parts.toOwnedSlice(ma);
|
|
}
|
|
|
|
// Cap per-call tool output so heavy pages don't balloon the message arena
|
|
// (and the next request body) without bound.
|
|
const tool_output_max_bytes: usize = 1 * 1024 * 1024;
|
|
|
|
fn capToolOutput(allocator: std.mem.Allocator, output: []const u8) []const u8 {
|
|
if (output.len <= tool_output_max_bytes) return output;
|
|
// Walk back at most 3 bytes (max UTF-8 sequence is 4); on malformed input
|
|
// fall back to the raw cap so we don't drop everything.
|
|
var end: usize = tool_output_max_bytes;
|
|
const floor = end -| 3;
|
|
while (end > floor and (output[end] & 0b1100_0000) == 0b1000_0000) : (end -= 1) {}
|
|
if ((output[end] & 0b1100_0000) == 0b1000_0000) end = tool_output_max_bytes;
|
|
const prefix = output[0..end];
|
|
var suffix_buf: [64]u8 = undefined;
|
|
const suffix = std.fmt.bufPrint(&suffix_buf, "\n...[truncated, original {d} bytes]", .{output.len}) catch return prefix;
|
|
return std.mem.concat(allocator, u8, &.{ prefix, suffix }) catch prefix;
|
|
}
|
|
|
|
fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) zenai.provider.Client.ToolHandler.Result {
|
|
const self: *Agent = @ptrCast(@alignCast(ctx));
|
|
// The spinner doesn't render args, and `agentToolDone` skips the body
|
|
// line at low verbosity — don't pay for the stringify when nobody reads it.
|
|
const needs_args = self.terminal.spinner.isEnabled() or self.terminal.verbosity != .low;
|
|
// Stringify the pre-substitution args so $LP_* placeholders the model
|
|
// emitted stay redacted in the UI.
|
|
const args_str: []const u8 = if (needs_args) (if (arguments) |v|
|
|
std.json.Stringify.valueAlloc(allocator, v, .{}) catch ""
|
|
else
|
|
"") else "";
|
|
self.terminal.spinner.setTool(tool_name, args_str);
|
|
defer self.terminal.spinner.setThinking();
|
|
|
|
if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments)) |result| {
|
|
const capped = capToolOutput(allocator, result.text);
|
|
self.terminal.agentToolDone(tool_name, args_str, !result.is_error);
|
|
if (self.terminal.verbosity == .high) self.terminal.printToolOutcome(tool_name, capped, result.is_error);
|
|
return .{ .content = capped, .is_error = result.is_error };
|
|
} else |err| {
|
|
const msg = std.fmt.allocPrint(allocator, "Error: {s}", .{@errorName(err)}) catch "Error: tool execution failed";
|
|
self.terminal.agentToolDone(tool_name, args_str, false);
|
|
if (self.terminal.verbosity == .high) self.terminal.printToolOutcome(tool_name, msg, true);
|
|
return .{ .content = msg, .is_error = true };
|
|
}
|
|
}
|
|
|
|
/// Determine which provider to use and read its env key. Returns null
|
|
/// only when no `--provider` was given AND no env key exists (the caller
|
|
/// decides whether that's fatal — basic REPL tolerates it).
|
|
const ResolvedProvider = struct {
|
|
credentials: Credentials,
|
|
source: enum { flag, remembered, detected },
|
|
};
|
|
|
|
/// Precedence: `--provider` > remembered (if its key is still set) > first
|
|
/// detected. Null means no key at all (the reason is already printed).
|
|
fn resolveCredentials(opts: Config.Agent, remembered: ?Remembered) !?ResolvedProvider {
|
|
if (opts.provider) |p| {
|
|
const key = zenai.provider.envApiKey(p) orelse {
|
|
std.debug.print(
|
|
"Missing API key for --provider {s}: set {s} — or pass --no-llm for the basic REPL.\n",
|
|
.{ @tagName(p), zenai.provider.envVarName(p) },
|
|
);
|
|
return error.MissingApiKey;
|
|
};
|
|
return .{ .credentials = .{ .provider = p, .key = key }, .source = .flag };
|
|
}
|
|
|
|
if (remembered) |r| if (zenai.provider.envApiKey(r.provider)) |key| {
|
|
return .{ .credentials = .{ .provider = r.provider, .key = key }, .source = .remembered };
|
|
};
|
|
|
|
var buf: [zenai.provider.default_candidates.len]Credentials = undefined;
|
|
const found = zenai.provider.detectKeys(&buf, zenai.provider.default_candidates);
|
|
if (found.len == 0) {
|
|
std.debug.print(
|
|
\\No API key detected. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY.
|
|
\\If you want to use the REPL in basic mode (without LLM integration) you can pass the --no-llm option.
|
|
\\
|
|
, .{});
|
|
return null;
|
|
}
|
|
return .{ .credentials = found[0], .source = .detected };
|
|
}
|
|
|
|
const remembered_path = ".lp-agent.zon";
|
|
|
|
/// Last user-selected provider/model, persisted per-directory in `.lp-agent.zon`.
|
|
/// `model` is owned by the caller.
|
|
const Remembered = struct {
|
|
provider: Config.AiProvider,
|
|
model: []const u8,
|
|
};
|
|
|
|
fn loadRemembered(allocator: std.mem.Allocator) ?Remembered {
|
|
const data = std.fs.cwd().readFileAllocOptions(allocator, remembered_path, 1024, null, .of(u8), 0) catch return null;
|
|
defer allocator.free(data);
|
|
const remembered = std.zon.parse.fromSlice(Remembered, allocator, data, null, .{}) catch return null;
|
|
if (remembered.model.len == 0) {
|
|
std.zon.parse.free(allocator, remembered);
|
|
return null;
|
|
}
|
|
return remembered;
|
|
}
|
|
|
|
/// Best-effort persist of the current selection; failures are ignored.
|
|
fn saveRemembered(provider: Config.AiProvider, model: []const u8) void {
|
|
var buf: [512]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
std.zon.stringify.serialize(Remembered{ .provider = provider, .model = model }, .{}, &w) catch return;
|
|
std.fs.cwd().writeFile(.{ .sub_path = remembered_path, .data = w.buffered() }) catch {};
|
|
}
|
|
|
|
/// One-shot for `--list-models`: resolve provider+key, fetch chat-capable
|
|
/// model IDs, print to stdout (one per line).
|
|
pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
|
|
if (opts.no_llm) {
|
|
log.fatal(.app, "list-models needs LLM", .{
|
|
.hint = "--no-llm and --list-models conflict; drop --no-llm",
|
|
});
|
|
return error.ConflictingFlags;
|
|
}
|
|
if (opts.task != null or opts.self_heal or opts.interactive or
|
|
opts.script_file != null)
|
|
{
|
|
log.fatal(.app, "list-models is exclusive", .{
|
|
.hint = "--list-models only takes --provider/--model/--base-url",
|
|
});
|
|
return error.ConflictingFlags;
|
|
}
|
|
const resolved = (try resolveCredentials(opts, null)) orelse return error.MissingProvider;
|
|
const llm = resolved.credentials;
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(allocator);
|
|
defer arena.deinit();
|
|
const ids = try zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, opts.base_url);
|
|
|
|
var stdout_file = std.fs.File.stdout().writer(&.{});
|
|
const w = &stdout_file.interface;
|
|
for (ids) |id| try w.print("{s}\n", .{id});
|
|
try w.flush();
|
|
}
|
|
|
|
fn defaultModel(p: Config.AiProvider) []const u8 {
|
|
return switch (p) {
|
|
.anthropic => "claude-sonnet-4-6",
|
|
.openai => "gpt-5.5",
|
|
.gemini => "gemini-3.5-flash",
|
|
.ollama => "gemma4",
|
|
};
|
|
}
|
|
|
|
fn initAiClient(allocator: std.mem.Allocator, credentials: Credentials, base_url: ?[:0]const u8) !zenai.provider.Client {
|
|
return switch (credentials.provider) {
|
|
inline else => |tag| blk: {
|
|
const ProviderClient = zenai.provider.Client;
|
|
const ClientPtr = @FieldType(ProviderClient, @tagName(tag));
|
|
const Client = @typeInfo(ClientPtr).pointer.child;
|
|
const client = try allocator.create(Client);
|
|
errdefer allocator.destroy(client);
|
|
const url: ?[]const u8 = base_url orelse if (tag == .ollama) "http://localhost:11434/v1" else null;
|
|
client.* = .init(allocator, credentials.key, if (url) |u|
|
|
.{ .base_url = u, .retry_policy = .long_running }
|
|
else
|
|
.{ .retry_policy = .long_running });
|
|
break :blk @unionInit(ProviderClient, @tagName(tag), client);
|
|
},
|
|
};
|
|
}
|
|
|
|
fn deinitAiClient(allocator: std.mem.Allocator, ai_client: zenai.provider.Client) void {
|
|
switch (ai_client) {
|
|
inline else => |client| {
|
|
client.deinit();
|
|
allocator.destroy(client);
|
|
},
|
|
}
|
|
}
|
|
|
|
fn availableProviders(buf: []Credentials) []Credentials {
|
|
return zenai.provider.detectKeys(buf, std.enums.values(Config.AiProvider));
|
|
}
|
|
|
|
const ModelCompletions = struct {
|
|
provider: Config.AiProvider,
|
|
/// Empty when the fetch failed — cached so the per-keystroke hinter doesn't
|
|
/// re-hit the network on every press.
|
|
ids: []const []const u8,
|
|
};
|
|
|
|
/// `CompletionSource.providers`. Stateless — `context` (the `*Agent`) is unused,
|
|
/// present only for the shared callback shape.
|
|
fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const []const u8 {
|
|
_ = context;
|
|
var buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Credentials = undefined;
|
|
const found = availableProviders(&buf);
|
|
const names = arena.alloc([]const u8, found.len) catch return &.{};
|
|
for (found, 0..) |f, i| names[i] = @tagName(f.provider);
|
|
return names;
|
|
}
|
|
|
|
/// `CompletionSource.models`. Blocks on a one-time fetch per provider, caching
|
|
/// success or empty so the per-keystroke hinter pays the round-trip only once.
|
|
fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u8 {
|
|
const self: *Agent = @ptrCast(@alignCast(context));
|
|
const llm = self.model_credentials orelse return &.{};
|
|
if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids;
|
|
|
|
_ = self.model_completion_arena.reset(.retain_capacity);
|
|
const ids = zenai.provider.listChatModelIds(
|
|
self.allocator,
|
|
self.model_completion_arena.allocator(),
|
|
llm.provider,
|
|
llm.key,
|
|
self.model_base_url,
|
|
) catch &.{};
|
|
self.model_completions = .{ .provider = llm.provider, .ids = ids };
|
|
return ids;
|
|
}
|
|
|
|
test "capToolOutput: truncates at UTF-8 codepoint boundary" {
|
|
const ta = std.testing.allocator;
|
|
|
|
// 3-byte Hangul codepoint (U+D55C '한' = 0xED 0x95 0x9C) straddling the cap.
|
|
// A naive byte-slice would leave the truncated body invalid UTF-8.
|
|
const cap = tool_output_max_bytes;
|
|
var buf = try ta.alloc(u8, cap + 8);
|
|
defer ta.free(buf);
|
|
@memset(buf[0 .. cap - 1], 'a');
|
|
buf[cap - 1] = 0xED;
|
|
buf[cap + 0] = 0x95;
|
|
buf[cap + 1] = 0x9C;
|
|
@memset(buf[cap + 2 ..], 'b');
|
|
|
|
const out = capToolOutput(ta, buf);
|
|
defer if (out.ptr != buf.ptr) ta.free(out);
|
|
|
|
try std.testing.expect(std.unicode.utf8ValidateSlice(out));
|
|
}
|
|
|
|
test "capToolOutput: passes through when under cap" {
|
|
const ta = std.testing.allocator;
|
|
const out = capToolOutput(ta, "short");
|
|
try std.testing.expectEqualStrings("short", out);
|
|
}
|
|
|
|
test "capToolOutput: malformed UTF-8 around cap falls back to raw boundary" {
|
|
const ta = std.testing.allocator;
|
|
const cap = tool_output_max_bytes;
|
|
const buf = try ta.alloc(u8, cap + 8);
|
|
defer ta.free(buf);
|
|
@memset(buf, 0x80);
|
|
|
|
const out = capToolOutput(ta, buf);
|
|
defer if (out.ptr != buf.ptr) ta.free(out);
|
|
|
|
try std.testing.expect(out.len > cap);
|
|
}
|