mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 09:46:05 -04:00
`stream_active` relied on every runTools exit path balancing pause with `endStreamedText`. If one is ever missed, a stale-true flag skips the spinner pause on the next turn, interleaving frames with streamed text. Reset it alongside `streamed_text` so the invariant is local.
2041 lines
92 KiB
Zig
2041 lines
92 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 Command = lp.Command;
|
|
const Schema = lp.Schema;
|
|
const Recorder = lp.Recorder;
|
|
const ScriptRuntime = lp.Runtime;
|
|
const Credentials = zenai.provider.Credentials;
|
|
|
|
const App = @import("../App.zig");
|
|
const CDPNode = @import("../cdp/Node.zig");
|
|
const Conversation = @import("Conversation.zig");
|
|
const Terminal = @import("Terminal.zig");
|
|
const SlashCommand = @import("SlashCommand.zig");
|
|
const settings = @import("settings.zig");
|
|
const 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.
|
|
;
|
|
|
|
/// Skill-like documentation for writing Lightpanda agent script
|
|
/// Used in the system prompt of the `/save` command.
|
|
const script_skill =
|
|
\\# Writing Lightpanda agent scripts
|
|
\\
|
|
\\Run with:
|
|
\\
|
|
\\```console
|
|
\\./lightpanda agent script.js
|
|
\\```
|
|
\\
|
|
\\## Mental model (get this right first)
|
|
\\
|
|
\\The script runs in its **own V8 context** — neither the page nor Node.js:
|
|
\\
|
|
\\- `Page` is the only global. `new Page()` makes a page and `await page.goto(url)` navigates it; every other primitive is a **method on that page**: `const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);`.
|
|
\\- No `window`, `document`, DOM, `localStorage` — read pages with `page.extract(...)`, run page-side JS only via `page.evaluate("...")`.
|
|
\\- No `require`, `process`, `fs`, npm. Standard ECMAScript built-ins only (`JSON`, `Map`, template literals, …).
|
|
\\- `page.goto(...)` is **async — always `await` it**. Page methods are **synchronous**: `const data = page.extract({...})`, never `await page.extract(...)`. The script body runs as an async function, so top-level `await` is allowed.
|
|
\\- **Re-navigating reuses the same page**: `await page.goto(url2)` keeps `page` valid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make a `new Page()` for each and load them in parallel (fan-out, best practice 2).
|
|
\\- Page `evaluate("...")` cannot see script variables — interpolate values into the string. Script code cannot see page variables.
|
|
\\- Variables persist across navigations within one run, so cross-page aggregation is plain JS.
|
|
\\- **`return <value>` is the script's output**, printed automatically (objects/arrays as JSON). End with `return page.extract({...});` or `return results;`. A bare trailing expression is NOT printed; neither is `console.log(JSON.stringify(...))`.
|
|
\\
|
|
\\## Primitives
|
|
\\
|
|
\\`Page` is the only global; `new Page()` makes a page and everything else is a method on it.
|
|
\\
|
|
\\| Call | Notes |
|
|
\\|------|-------|
|
|
\\| `new Page()` | Makes a page object. No navigation yet — call `page.goto(url)` before any other method. Make several to navigate in parallel (fan-out, best practice 2). |
|
|
\\| `await page.goto(url[, { timeout }])` | **Async — must be `await`ed.** Navigates the page (re-navigating reuses the same object). Waits for `load`. Default timeout 10000 ms. Rejects on navigation failure; a **timeout does NOT reject** (the page may still be usable). |
|
|
\\| `page.close()` | Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. |
|
|
\\| `page.extract(schema)` | The only primitive returning a real JS value (object/array). See schema below. |
|
|
\\| `page.evaluate(script[, { url, timeout, save }])` | Page-side JS escape hatch; returns text (JSON for objects/arrays). |
|
|
\\| `page.click(sel)` / `page.hover(sel)` | |
|
|
\\| `page.fill(sel, value)` / `page.selectOption(sel, value)` | |
|
|
\\| `page.setChecked(sel[, checked])` | `checked` defaults to `true`. |
|
|
\\| `page.press(sel, key)` / `page.press(null, key)` / `page.press({ key })` | Selector first! `page.press("Enter")` binds "Enter" to `selector` and fails. |
|
|
\\| `page.scroll()` / `page.scroll({ x, y })` | |
|
|
\\| `page.waitForSelector(sel[, { timeout }])` | `waitFor*` default timeout 5000 ms. |
|
|
\\| `page.waitForScript(js[, { timeout }])` | Re-evaluates page JS until truthy. |
|
|
\\| `page.waitForState(state[, { timeout }])` | `"load"`, `"domcontentloaded"`, `"networkalmostidle"`, `"networkidle"`, `"done"`. |
|
|
\\
|
|
\\Calling convention: leading positionals + optional trailing options object, or one object with everything (`page.waitForSelector("#row", { timeout: 2000 })` ≡ `page.waitForSelector({ selector: "#row", timeout: 2000 })`). A bare option positional (`page.waitForSelector("#row", 2000)`) and a field passed both ways are `invalid arguments`. `null` skips a positional. Arguments must be JSON-serializable.
|
|
\\
|
|
\\CSS selectors only — `backendNodeId`s don't exist here. Standard CSS only: no jQuery `:contains()` or Playwright `:has-text()`.
|
|
\\
|
|
\\## extract schema
|
|
\\
|
|
\\Keys = output field names; values pick what to lift (not a JSON Schema):
|
|
\\
|
|
\\```js
|
|
\\const { stories } = page.extract({
|
|
\\ stories: [{
|
|
\\ selector: "tr.athing", // one record per match
|
|
\\ limit: 5,
|
|
\\ fields: { // resolved relative to each match
|
|
\\ title: ".titleline > a", // first match's text (null if missing)
|
|
\\ url: { selector: ".titleline > a", attr: "href" },
|
|
\\ text: "" // "" = the matched element's own text
|
|
\\ }
|
|
\\ }]
|
|
\\});
|
|
\\```
|
|
\\
|
|
\\- `"sel"` → first match's text; `["sel"]` → all matches' text; `{ selector, attr }` / `[{ selector, attr }]` → attribute(s); `limit: N` caps any array form.
|
|
\\- Every value is a string or null — parse numbers in script logic.
|
|
\\- Empty arrays are valid results; if **every** field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty.
|
|
\\- An object schema always returns an object (destructure it); a bare array schema returns the array directly.
|
|
\\- No `save:` option in scripts — keep results in variables.
|
|
\\
|
|
\\## Best practices
|
|
\\
|
|
\\1. **Navigate, settle, read.** After `await page.goto` on a dynamic page (feeds, search results, comment threads), call `page.waitForState("networkidle")` or `page.waitForSelector(...)` before extracting. Most static pages are complete at `load` — don't wait blindly.
|
|
\\2. **List-to-detail — fan out independent pages.** Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another:
|
|
\\ ```js
|
|
\\ const list = new Page();
|
|
\\ await list.goto(listUrl);
|
|
\\ const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] });
|
|
\\
|
|
\\ const pages = items.map(() => new Page());
|
|
\\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once
|
|
\\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) }));
|
|
\\ ```
|
|
\\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed.
|
|
\\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine.
|
|
\\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state.
|
|
\\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist.
|
|
\\4. **Credentials via `$LP_*` placeholders** in any string argument (`page.fill("#pw", "$LP_HN_PASSWORD")`). Never inline a real secret; placeholders resolve inside the Lightpanda process.
|
|
\\5. **Unique selectors.** Disambiguate with attributes/position: `input[type="submit"][value="login"]`, not `input[type="submit"]`.
|
|
\\6. **Let failures fail.** Primitives throw on error and stop the script — only `try/catch` where you have a real fallback (e.g. optional cookie banner: `try { page.click("#accept") } catch {}`).
|
|
\\7. **End with `return <result>`.** `console.log` is for debug output only and doesn't JSON-format objects.
|
|
\\8. Modern, readable JS: `const`/`let`, `for (const x of xs)`, template literals, destructuring, 2-space indent.
|
|
\\9. **Comment the intent of each block.** Put a one-line `//` comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines:
|
|
\\ ```js
|
|
\\ // Load the Hacker News front page
|
|
\\ const page = new Page();
|
|
\\ await page.goto("https://news.ycombinator.com");
|
|
\\
|
|
\\ // Pull the top 5 stories (title + link)
|
|
\\ const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] });
|
|
\\
|
|
\\ // Open each story page in parallel and read its text
|
|
\\ const pages = stories.map(() => new Page());
|
|
\\ await Promise.all(pages.map((p, i) => p.goto(stories[i].url)));
|
|
\\ ```
|
|
\\
|
|
\\## Common errors
|
|
\\
|
|
\\| Error | Cause / fix |
|
|
\\|-------|-------------|
|
|
\\| `extract is not defined` (or click/fill/…) | These are methods on the page object, not globals → `const page = new Page(); await page.goto(url); page.extract(...)` |
|
|
\\| `Page must be called with new` | `Page(...)` called without `new` → `const page = new Page();` |
|
|
\\| `page is not navigated or has been closed` | A method on a fresh `new Page()` (or a closed page) → `await page.goto(url)` first |
|
|
\\| `page handle is no longer valid` | Used a page after a later `goto` on the **same** page replaced it → read it before navigating away. Sibling pages from other `new Page()` calls stay valid. |
|
|
\\| `document is not defined` | DOM API in script context → use `page.extract` or `page.evaluate` |
|
|
\\| `require is not defined` | Not Node.js |
|
|
\\| `no page loaded - run page.goto(url) first` | Page method before navigation |
|
|
\\| `invalid arguments` | Wrong arity/shape, non-JSON value, or a field set both positionally and in options |
|
|
\\| `extract: no schema selector matched any element` | All schema fields missed → fix selectors |
|
|
\\| `press` fails with one string arg | Selector-first: use `page.press(null, "Enter")` or `page.press({ key: "Enter" })` |
|
|
;
|
|
|
|
// Sytem prompt of the `/save` command
|
|
// With the save instructions and the skill-like agent script documentation.
|
|
const save_system_prompt = browser_tools.save_synthesis_prompt ++ "\n\n" ++ script_skill;
|
|
|
|
// 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.
|
|
;
|
|
const save_revision_system_prompt = browser_tools.save_synthesis_prompt ++ "\n" ++ save_revision_note ++ "\n" ++ script_skill;
|
|
|
|
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,
|
|
/// Allocated credentials key (Vertex gcloud token) — other keys are unowned
|
|
/// env pointers. The AI client references it: free only after client deinit.
|
|
owned_key: ?[:0]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.Thread.Mutex = .{},
|
|
active_script_runtime: ?*ScriptRuntime = null,
|
|
conversation: Conversation,
|
|
model: []u8,
|
|
/// Per-turn reasoning budget for LLM turns. Mutable at runtime via `/effort`.
|
|
effort: Config.Effort,
|
|
script_file: ?[]const u8,
|
|
one_shot_task: ?[]const u8,
|
|
one_shot_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]Credentials = undefined;
|
|
const found_providers = settings.availableProviders(&providers_buf);
|
|
const available_providers = try allocator.alloc([]const u8, found_providers.len);
|
|
var provider_count: usize = 0;
|
|
errdefer {
|
|
for (available_providers[0..provider_count]) |p| allocator.free(p);
|
|
allocator.free(available_providers);
|
|
}
|
|
for (found_providers, 0..) |f, i| {
|
|
available_providers[i] = try allocator.dupe(u8, @tagName(f.provider));
|
|
provider_count = i + 1;
|
|
}
|
|
|
|
if (opts.task != null and opts.script_file != null) {
|
|
log.fatal(.app, "conflicting flags", .{
|
|
.hint = "--task runs a one-shot turn; drop the positional script or drop --task",
|
|
});
|
|
return error.ConflictingFlags;
|
|
}
|
|
if (opts.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);
|
|
|
|
const 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| if (r.key_owned) allocator.free(r.credentials.key);
|
|
|
|
if (will_repl and !banner_before and resolved != null) welcome.print(resolve);
|
|
const llm: ?Credentials = if (resolved) |r| r.credentials else null;
|
|
|
|
if (llm == null and requires_llm) {
|
|
if (opts.no_llm) {
|
|
std.debug.print("--no-llm forbids LLM use; drop it to run this mode.\n", .{});
|
|
}
|
|
return error.MissingProvider;
|
|
}
|
|
|
|
var model = try allocator.dupe(u8, settings.resolveModelName(opts, resolved, remembered));
|
|
errdefer allocator.free(model);
|
|
|
|
// The REPL skips this network round trip for snappy startup; an invalid
|
|
// model surfaces on the first turn instead.
|
|
if (llm) |l| if (!will_repl) {
|
|
const remembered_matches = remembered != null and remembered.?.provider == l.provider;
|
|
const explicit = opts.model != null or remembered_matches;
|
|
switch (try settings.reconcileModel(allocator, l, model, opts.base_url, explicit)) {
|
|
.use => |m| {
|
|
allocator.free(model);
|
|
model = m;
|
|
},
|
|
.abort => return error.ModelNotAvailable,
|
|
}
|
|
};
|
|
|
|
const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null);
|
|
const verbosity = settings.resolveVerbosity(opts, remembered);
|
|
const stream_enabled = settings.resolveStream(remembered);
|
|
|
|
if (resolved) |r| {
|
|
if (r.source == .picked) {
|
|
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {};
|
|
}
|
|
// provider/model now live in the status bar; just space before the help
|
|
std.debug.print("\n", .{});
|
|
}
|
|
|
|
const notification: *lp.Notification = try .init(allocator);
|
|
errdefer notification.deinit();
|
|
|
|
const self = try allocator.create(Agent);
|
|
errdefer allocator.destroy(self);
|
|
|
|
const history_paths: ?Terminal.HistoryPaths = if (will_repl)
|
|
.{ .normal = ".lp-history", .js = ".lp-history.js" }
|
|
else
|
|
null;
|
|
|
|
self.* = .{
|
|
.allocator = allocator,
|
|
.ai_client = null,
|
|
.model_credentials = llm,
|
|
.owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null,
|
|
.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 (llm) |l| try zenai.provider.Client.init(allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider) }) else null;
|
|
errdefer if (self.ai_client) |c| c.deinit(allocator);
|
|
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
|
|
|
|
if (will_repl) {
|
|
self.terminal.attachCompleter();
|
|
self.terminal.completion_source = .{
|
|
.context = @ptrCast(self),
|
|
.providers = completionProviders,
|
|
.models = completionModels,
|
|
};
|
|
// The model-list cache fills lazily on the first `/model` completion,
|
|
// so startup never blocks on the network.
|
|
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.owned_key) |k| self.allocator.free(k);
|
|
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 = std.once(initGlobalTools);
|
|
|
|
fn initGlobalTools() void {
|
|
for (Schema.all(), 0..) |s, i| {
|
|
global_tools_storage[i] = .{ .name = s.tool_name, .description = s.description, .parameters = s.parameters };
|
|
}
|
|
}
|
|
|
|
fn globalTools() []const ProviderTool {
|
|
global_tools_once.call();
|
|
return global_tools_storage[0..browser_tools.tool_defs.len];
|
|
}
|
|
|
|
/// Called from the sighandler thread. Flips `cancel_requested` for the LLM
|
|
/// streaming/HTTP probe and any code polling `Session.isCancelled`, then asks
|
|
/// V8 to bail out of whatever JS is running. Both hooks are thread-safe
|
|
/// (`Env.terminate` takes a mutex); no terminal touches from this context.
|
|
pub fn requestCancel(self: *Agent) void {
|
|
self.cancel_requested.store(true, .release);
|
|
self.http_interrupt.fire();
|
|
{
|
|
self.script_runtime_mutex.lock();
|
|
defer self.script_runtime_mutex.unlock();
|
|
if (self.active_script_runtime) |runtime| {
|
|
runtime.terminate();
|
|
}
|
|
}
|
|
self.browser.env.terminate();
|
|
}
|
|
|
|
/// Lives in main's stack so it can be registered with the sighandler before the
|
|
/// agent thread exists. The agent attaches once constructed and detaches before
|
|
/// deinit, so the sighandler-thread listener can fire safely whether or not an
|
|
/// agent is currently up.
|
|
pub const SigBridge = struct {
|
|
agent: std.atomic.Value(?*Agent) = .init(null),
|
|
|
|
pub fn attach(self: *SigBridge, agent: *Agent) void {
|
|
self.agent.store(agent, .release);
|
|
}
|
|
|
|
pub fn detach(self: *SigBridge) void {
|
|
self.agent.store(null, .release);
|
|
}
|
|
|
|
pub fn onSignal(self: *SigBridge) void {
|
|
const a = self.agent.load(.acquire) orelse return;
|
|
a.requestCancel();
|
|
}
|
|
};
|
|
|
|
fn checkCancel(ctx: *anyopaque) bool {
|
|
const self: *Agent = @ptrCast(@alignCast(ctx));
|
|
return self.cancel_requested.load(.acquire);
|
|
}
|
|
|
|
/// Roll the agent back to `baseline` messages, clear the V8 termination flag,
|
|
/// drop the cancel signal, and surface `error.UserCancelled`. Caller handles
|
|
/// any spinner cleanup not already done on its path.
|
|
fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} {
|
|
self.resetAfterCancel(baseline);
|
|
return error.UserCancelled;
|
|
}
|
|
|
|
/// The side effects of `drainCancellation` without surfacing the error, for
|
|
/// void callers (e.g. `/save` synthesis) that just need to clean up.
|
|
fn resetAfterCancel(self: *Agent, baseline: usize) void {
|
|
self.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.printAssistantDelta("\n");
|
|
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) {
|
|
const a = Terminal.ansi;
|
|
std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ a.dim, self.model, a.reset, a.dim, @tagName(self.effort), a.reset, a.dim, if (self.stream_enabled) "on" else "off", a.reset });
|
|
}
|
|
|
|
repl: while (true) {
|
|
std.debug.print("\n", .{});
|
|
const line = Terminal.readLine("") orelse break;
|
|
defer Terminal.freeLine(line);
|
|
|
|
// Slash commands and idle Ctrl-C set the cancel flag without clearing
|
|
// V8's terminate state; drain both before the next turn.
|
|
if (self.cancel_requested.swap(false, .acq_rel)) {
|
|
self.browser.env.cancelTerminate();
|
|
}
|
|
|
|
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
|
if (trimmed.len == 0) {
|
|
self.terminal.clearPromptFrame();
|
|
continue;
|
|
}
|
|
std.debug.print("\n", .{});
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
if (self.terminal.jsMode()) {
|
|
// `line` keeps the `$LP_*` placeholder so the secret never reaches
|
|
// the recorder; only the evaluated copy is expanded.
|
|
const script = browser_tools.substituteEnvVars(aa, line) catch line;
|
|
const result = browser_tools.evalScript(aa, self.session, &self.node_registry, script) catch |err| {
|
|
self.terminal.printError("{s}", .{switch (err) {
|
|
error.OutOfMemory => "out of memory",
|
|
error.FrameNotLoaded => "no page loaded — run /goto <url> first (Esc exits JS mode)",
|
|
else => std.fmt.allocPrint(aa, "evaluate failed: {s}", .{@errorName(err)}) catch "evaluate failed",
|
|
}});
|
|
continue :repl;
|
|
};
|
|
// Surface console output: slash commands (and thus /consoleLogs)
|
|
// are unreachable in JS mode, so a console must echo logs itself.
|
|
const logs = std.mem.trimRight(u8, self.session.drainConsoleMessages(), "\n");
|
|
if (logs.len > 0) self.printData(logs);
|
|
if (result.is_error) {
|
|
self.terminal.printError("{s}", .{result.text});
|
|
} else {
|
|
self.printData(result.text);
|
|
self.recordSaveRaw(line);
|
|
}
|
|
continue :repl;
|
|
}
|
|
|
|
const slash_split: ?Schema.Split = Schema.parseSlashCommand(trimmed);
|
|
if (slash_split) |split| {
|
|
if (SlashCommand.findMeta(split.name)) |meta| {
|
|
if (self.handleMeta(aa, meta, split.rest)) break :repl;
|
|
continue :repl;
|
|
}
|
|
}
|
|
|
|
var diag: Schema.Diag = .{};
|
|
const cmd = Command.parseDiag(aa, line, &diag) catch |err| switch (err) {
|
|
error.NotASlashCommand => {
|
|
if (self.ai_client == null) {
|
|
self.terminal.printError("Basic REPL (LLM disabled) accepts only commands. Try /help, or " ++ llm_setup_hint ++ " to enable natural-language prompts.", .{});
|
|
continue :repl;
|
|
}
|
|
_ = self.runTurn(.{ .prompt = line, .record_comment = line, .capture_for_save = true });
|
|
continue :repl;
|
|
},
|
|
else => |e| {
|
|
const name = if (slash_split) |sp| sp.name else line;
|
|
self.terminal.printSlashParseError(e, name, &diag);
|
|
continue :repl;
|
|
},
|
|
};
|
|
|
|
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(trimmed, tc.name(), tc.args, result) catch |err| {
|
|
self.terminal.printWarning("LLM conversation out of sync (/{s}: {s}); next prompt may not see this action", .{ tc.name(), @errorName(err) });
|
|
};
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle a REPL-only meta slash command — not a tool slash command, never
|
|
/// reaches the browser tool dispatcher. Returns `true` if the user asked to quit.
|
|
fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand.MetaCommand, rest: []const u8) bool {
|
|
switch (meta.tag) {
|
|
.quit => return true,
|
|
.help => self.printSlashHelp(arena, rest),
|
|
.verbosity => self.setEnumOption("verbosity", &self.terminal.verbosity, rest),
|
|
.effort => self.setEnumOption("effort", &self.effort, rest),
|
|
.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),
|
|
}
|
|
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");
|
|
}
|
|
|
|
/// 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 <path>", .{});
|
|
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 <name>";
|
|
|
|
/// `/provider <keyword>` 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.model_credentials == null) {
|
|
self.terminal.printError("{s} requires an LLM — " ++ llm_setup_hint ++ ".", .{name});
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
fn handleModel(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
|
|
if (!self.requireLlm("/model")) return;
|
|
|
|
const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace);
|
|
if (trimmed.len == 0) {
|
|
self.terminal.printInfo("Current model: {s}", .{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
|
|
/// "<label>: <value>", appending "(saved to …)" on write success. With no model
|
|
/// credentials it persists `provider = null` only when that's an intentional
|
|
/// preference (`no_llm_persisted`); a transient --no-llm run reports without saving.
|
|
fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void {
|
|
const provider: ?Config.AiProvider = if (self.model_credentials) |c| c.provider else null;
|
|
// A transient --no-llm run has no provider and no intent to persist one;
|
|
// report without saving so we don't clobber the remembered selection.
|
|
if (provider == null and !self.no_llm_persisted) {
|
|
self.terminal.printInfo("{s}: {s}", .{ label, value });
|
|
return;
|
|
}
|
|
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) {
|
|
self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path });
|
|
} else |_| {
|
|
self.terminal.printInfo("{s}: {s}", .{ label, value });
|
|
}
|
|
}
|
|
|
|
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;
|
|
self.reportSaved("model", self.model);
|
|
}
|
|
|
|
fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
|
|
const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace);
|
|
|
|
if (trimmed.len == 0) {
|
|
if (self.model_credentials) |c| {
|
|
self.terminal.printInfo("Current provider: {s}", .{@tagName(c.provider)});
|
|
} else {
|
|
self.terminal.printInfo("Current provider: null — LLM disabled", .{});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (std.mem.eql(u8, trimmed, provider_off_keyword)) {
|
|
self.disableProvider();
|
|
return;
|
|
}
|
|
|
|
const provider = std.meta.stringToEnum(Config.AiProvider, trimmed) orelse {
|
|
self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed});
|
|
return;
|
|
};
|
|
// Re-selecting vertex falls through — that's the token-refresh path.
|
|
const vertex_project = provider == .vertex and settings.vertexProjectMode();
|
|
if (self.model_credentials) |current| if (provider == current.provider and !vertex_project) {
|
|
self.terminal.printInfo("provider: {s}", .{@tagName(provider)});
|
|
return;
|
|
};
|
|
if (vertex_project) {
|
|
const token = settings.gcloudAccessToken(self.allocator) catch |err| {
|
|
self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)});
|
|
return;
|
|
};
|
|
self.setProvider(.{ .provider = .vertex, .key = token }, token) catch |err| {
|
|
self.allocator.free(token);
|
|
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
|
|
};
|
|
return;
|
|
}
|
|
const key = zenai.provider.envApiKey(provider) orelse {
|
|
if (provider == .vertex) {
|
|
self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{});
|
|
return;
|
|
}
|
|
self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) });
|
|
return;
|
|
};
|
|
// Ollama's key is a placeholder, so probe the server instead of trusting it.
|
|
if (provider == .ollama and settings.detectLocalProvider(self.allocator, .ollama, self.model_base_url) == null) {
|
|
self.terminal.printError("no Ollama server with a pulled model at {s}", .{self.model_base_url orelse zenai.provider.ollama_default_base_url});
|
|
return;
|
|
}
|
|
if (provider == .llama_cpp and settings.detectLocalProvider(self.allocator, .llama_cpp, self.model_base_url) == null) {
|
|
self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url});
|
|
return;
|
|
}
|
|
self.setProvider(.{ .provider = provider, .key = key }, null) catch |err| {
|
|
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
|
|
/// Tear down the LLM client and persist a null provider so the next REPL launch
|
|
/// starts in basic mode without re-prompting. Inverse of `setProvider`.
|
|
fn disableProvider(self: *Agent) void {
|
|
if (self.ai_client) |client| client.deinit(self.allocator);
|
|
self.ai_client = null;
|
|
if (self.owned_key) |k| self.allocator.free(k);
|
|
self.owned_key = null;
|
|
self.model_credentials = null;
|
|
self.model_completions = null;
|
|
self.no_llm_persisted = true;
|
|
self.reportSaved("provider", provider_off_keyword);
|
|
}
|
|
|
|
/// `HF_BILL_TO` org for routed requests; null for non-HF providers. Without it,
|
|
/// requests bill the token owner's personal account instead of the org.
|
|
fn hfBillTo(provider: Config.AiProvider) ?[]const u8 {
|
|
if (provider != .huggingface) return null;
|
|
return std.posix.getenv("HF_BILL_TO");
|
|
}
|
|
|
|
/// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex
|
|
/// gcloud token) on success; on error the caller still owns it.
|
|
fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void {
|
|
const new_client = try zenai.provider.Client.init(self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider) });
|
|
errdefer new_client.deinit(self.allocator);
|
|
|
|
// A same-provider re-select (vertex token refresh) must not reset the model.
|
|
const same_provider = if (self.model_credentials) |c| c.provider == credentials.provider else false;
|
|
const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider));
|
|
if (self.ai_client) |client| client.deinit(self.allocator);
|
|
if (self.owned_key) |k| self.allocator.free(k);
|
|
self.owned_key = owned_key;
|
|
new_client.setInterrupt(&self.http_interrupt);
|
|
self.ai_client = new_client;
|
|
self.model_credentials = credentials;
|
|
self.no_llm_persisted = false;
|
|
self.model_completions = null;
|
|
self.allocator.free(self.model);
|
|
self.model = new_model;
|
|
self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)});
|
|
if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) {
|
|
self.effort = e;
|
|
self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) });
|
|
};
|
|
self.reportSaved("model", self.model);
|
|
_ = completionModels(self, self.allocator);
|
|
}
|
|
|
|
const PathAndMode = struct { path: []const u8, mode: save.Mode };
|
|
|
|
fn resolveSavePathAndMode(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8) ?PathAndMode {
|
|
if (self.save_path) |saved| {
|
|
if (filename) |name| {
|
|
if (!std.mem.eql(u8, saved, name)) {
|
|
self.terminal.printError("already saving to {s}; use /save without a filename to update it", .{saved});
|
|
return null;
|
|
}
|
|
}
|
|
// A repeat save reuses the destination without re-asking: revision
|
|
// when a model can merge, plain append for the verbatim dump.
|
|
return .{ .path = saved, .mode = if (self.ai_client != null) .update else .append };
|
|
} else if (filename) |name| {
|
|
const exists = save.fileExists(name) catch |err| {
|
|
self.terminal.printError("failed to inspect {s}: {s}", .{ name, @errorName(err) });
|
|
return null;
|
|
};
|
|
const mode = if (exists)
|
|
self.promptSaveMode(name) orelse return null
|
|
else
|
|
.replace;
|
|
return .{ .path = name, .mode = mode };
|
|
} else {
|
|
const path = save.randomFilename(arena) catch |err| {
|
|
self.terminal.printError("failed to choose save filename: {s}", .{@errorName(err)});
|
|
return null;
|
|
};
|
|
return .{ .path = path, .mode = .replace };
|
|
}
|
|
}
|
|
|
|
fn handleSave(self: *Agent, arena: std.mem.Allocator, rest: []const u8) void {
|
|
const parsed = save.parseCommand(arena, rest) catch |err| {
|
|
const msg: []const u8 = switch (err) {
|
|
error.UnterminatedQuote => "unterminated filename quote",
|
|
error.EmptyFilename => "filename cannot be empty",
|
|
error.OutOfMemory => "out of memory",
|
|
};
|
|
self.terminal.printError("{s}", .{msg});
|
|
return;
|
|
};
|
|
|
|
if (self.ai_client != null) {
|
|
self.synthesizeSave(arena, parsed.filename, parsed.prompt);
|
|
return;
|
|
}
|
|
|
|
if (parsed.prompt != null) {
|
|
self.terminal.printWarning("prompt ignored without an LLM; saving the recorded commands as-is", .{});
|
|
}
|
|
const resolved = self.resolveSavePathAndMode(arena, parsed.filename) orelse return;
|
|
const path = resolved.path;
|
|
const mode = resolved.mode;
|
|
|
|
// `path` aliases either an arena-owned string (first save) or
|
|
// `self.save_path` (subsequent saves to the same destination); only the
|
|
// former needs persisting 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);
|
|
|
|
save.writeContentFile(path, self.save_buffer.bytes(), 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 promptSaveMode(self: *Agent, path: []const u8) ?save.Mode {
|
|
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 with_llm = self.ai_client != null;
|
|
const modes: []const save.Mode = if (with_llm)
|
|
&.{ .update, .append, .replace }
|
|
else
|
|
&.{ .append, .replace };
|
|
const labels: []const [:0]const u8 = if (with_llm)
|
|
&.{
|
|
"update — revise the saved script with this session's actions and the prompt",
|
|
"append — keep the file as-is and add the new script at the end",
|
|
"replace — discard the file and write a fresh script",
|
|
}
|
|
else
|
|
&.{
|
|
"append — add the recorded commands at the end",
|
|
"replace — overwrite with the recorded commands",
|
|
};
|
|
const idx = Terminal.promptNumberedChoice(header, labels, 0) catch {
|
|
self.terminal.printInfo("Save cancelled.", .{});
|
|
return null;
|
|
};
|
|
return modes[idx];
|
|
}
|
|
|
|
fn failSave(self: *Agent, reason: []const u8) void {
|
|
self.terminal.printError("save failed: {s}", .{reason});
|
|
}
|
|
|
|
/// Roll the in-flight save turn back out of the conversation, then report the
|
|
/// failure — so a doomed `/save` synthesis never leaks its messages into history.
|
|
fn abortSave(self: *Agent, baseline: usize, reason: []const u8) void {
|
|
self.conversation.rollback(baseline);
|
|
self.failSave(reason);
|
|
}
|
|
|
|
/// Save synthesis warrants more reasoning than a normal turn. `.none` stays off
|
|
/// so users can opt out on models that reject `reasoning_effort` (e.g. Mistral).
|
|
fn bumpedEffort(effort: Config.Effort) Config.Effort {
|
|
return switch (effort) {
|
|
.none => .none,
|
|
.minimal => .low,
|
|
.low => .medium,
|
|
.medium => .high,
|
|
.high, .xhigh => .xhigh,
|
|
};
|
|
}
|
|
|
|
/// LLM-synthesized `/save`: hand the model the builtin catalog, the full
|
|
/// conversation, and the deterministic record of what ran, then write the
|
|
/// idiomatic script it returns.
|
|
fn synthesizeSave(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8, prompt: ?[]const u8) void {
|
|
// With nothing recorded and no prompt, a re-synthesis has nothing to act
|
|
// on — it would just re-roll the previous output.
|
|
if (self.save_buffer.bytes().len == 0 and prompt == null) {
|
|
if (self.save_path) |saved| {
|
|
self.terminal.printWarning("nothing ran since the last save; run more commands or give a prompt to revise {s}, e.g. /save <what to change>", .{saved});
|
|
} else {
|
|
self.terminal.printWarning("nothing to save yet; run some commands or give a prompt, e.g. /save <what the script should do>", .{});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const resolved = self.resolveSavePathAndMode(arena, filename) orelse return;
|
|
self.synthesizeSaveTo(arena, resolved.path, resolved.mode, prompt);
|
|
}
|
|
|
|
/// One-shot `--task ... --save`: overwrites the destination without the REPL's
|
|
/// interactive file-exists prompt. The task doubles as the synthesis prompt so
|
|
/// the empty-buffer guard in `synthesizeSaveTo` never trips.
|
|
fn saveOneShot(self: *Agent) void {
|
|
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
|
defer arena.deinit();
|
|
self.synthesizeSaveTo(arena.allocator(), self.one_shot_save.?, .replace, self.one_shot_task.?);
|
|
}
|
|
|
|
/// LLM synthesis + write for an already-resolved destination. Shared by the
|
|
/// interactive `/save` and one-shot `--save`.
|
|
fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mode: save.Mode, prompt: ?[]const u8) void {
|
|
const provider_client = self.ai_client.?;
|
|
|
|
// Only update feeds the saved script back to the model; append stays
|
|
// blind — the script is synthesized from this session alone and written
|
|
// after the existing content.
|
|
const previous_script: ?[]const u8 = if (mode == .update)
|
|
save.readScript(arena, path) catch |err| {
|
|
self.terminal.printError("failed to read {s}: {s}", .{ path, @errorName(err) });
|
|
return;
|
|
}
|
|
else
|
|
null;
|
|
|
|
self.conversation.ensureSystemPrompt() catch return self.failSave("out of memory");
|
|
|
|
// Swap the dedicated save_system_prompt in as the system prompt for this one turn;
|
|
// regular turns keep the driver prompt. (`messages[0]` is the system
|
|
// message — rollback and prune never touch it.)
|
|
const plain_system = self.conversation.messages.items[0].content;
|
|
self.conversation.messages.items[0].content = if (previous_script != null) save_revision_system_prompt else save_system_prompt;
|
|
defer self.conversation.messages.items[0].content = plain_system;
|
|
|
|
const ma = self.conversation.arena.allocator();
|
|
const baseline = self.conversation.messages.items.len;
|
|
|
|
const user_msg = self.buildSaveSynthesisMessage(ma, path, previous_script, prompt) catch return self.failSave("out of memory");
|
|
self.conversation.messages.append(self.allocator, .{ .role = .user, .content = user_msg }) catch return self.failSave("out of memory");
|
|
|
|
self.http_interrupt.reset();
|
|
self.terminal.spinner.start();
|
|
var result = provider_client.runTools(
|
|
self.model,
|
|
&self.conversation.messages,
|
|
self.allocator,
|
|
ma,
|
|
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
|
.{
|
|
.tools = &.{},
|
|
.max_turns = 1,
|
|
.max_tokens = 8192,
|
|
.tool_choice = .none,
|
|
.effort = bumpedEffort(self.effort),
|
|
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
|
},
|
|
) catch |err| {
|
|
self.terminal.spinner.cancel();
|
|
if (self.cancel_requested.load(.acquire)) {
|
|
self.resetAfterCancel(baseline);
|
|
return;
|
|
}
|
|
log.err(.app, "AI save synthesis error", .{ .err = err });
|
|
return self.abortSave(baseline, @errorName(err));
|
|
};
|
|
self.terminal.spinner.stop();
|
|
defer result.deinit();
|
|
self.total_usage.add(result.usage);
|
|
|
|
if (result.cancelled) {
|
|
self.resetAfterCancel(baseline);
|
|
return;
|
|
}
|
|
|
|
const raw = result.text orelse return self.abortSave(baseline, "the model returned no script");
|
|
|
|
// `result.text` lives in the conversation arena, freed by the rollback
|
|
// below; copy into the command arena first (scrubbing may return its input
|
|
// as-is).
|
|
const owned = arena.dupe(u8, save.stripCodeFence(raw)) catch return self.abortSave(baseline, "out of memory");
|
|
const script = browser_tools.reverseSubstituteEnvVars(arena, owned) catch return self.abortSave(baseline, "out of memory");
|
|
|
|
// The save turn is a meta-action; keep it out of the ongoing conversation.
|
|
self.conversation.rollback(baseline);
|
|
|
|
save.writeContentFile(path, script, mode) catch |err| {
|
|
self.terminal.printError("failed to save {s}: {s}", .{ path, @errorName(err) });
|
|
return;
|
|
};
|
|
|
|
self.rememberSavePath(path);
|
|
self.save_buffer.reset();
|
|
self.terminal.printInfo("Saved synthesized script to {s}", .{path});
|
|
}
|
|
|
|
/// Persist `path` as the destination reused by a subsequent bare `/save`.
|
|
fn rememberSavePath(self: *Agent, path: []const u8) void {
|
|
if (self.save_path) |old| {
|
|
if (std.mem.eql(u8, old, path)) return;
|
|
}
|
|
const dup = self.allocator.dupe(u8, path) catch return;
|
|
if (self.save_path) |old| self.allocator.free(old);
|
|
self.save_path = dup;
|
|
}
|
|
|
|
fn buildSaveSynthesisMessage(self: *Agent, arena: std.mem.Allocator, path: []const u8, previous_script: ?[]const u8, prompt: ?[]const u8) ![]const u8 {
|
|
var out: std.Io.Writer.Allocating = .init(arena);
|
|
const w = &out.writer;
|
|
if (previous_script) |script| {
|
|
try w.print("\nThe previously saved script in {s}, the base you are revising:\n", .{path});
|
|
try w.writeAll(script);
|
|
}
|
|
const recorded = self.save_buffer.bytes();
|
|
if (recorded.len > 0) {
|
|
const since: []const u8 = if (previous_script != null) " since the last save" else " this session";
|
|
try w.print("\nCommands and JS that actually ran{s}:\n", .{since});
|
|
try w.writeAll(recorded);
|
|
}
|
|
if (prompt) |p| {
|
|
try w.writeAll("\nThe user's instruction for this script:\n");
|
|
try w.writeAll(p);
|
|
}
|
|
return out.written();
|
|
}
|
|
|
|
fn logSaveBufferError(self: *Agent, err: anyerror) void {
|
|
self.terminal.printError("save buffer disabled: {s}", .{@errorName(err)});
|
|
}
|
|
|
|
fn recordSaveCommand(self: *Agent, cmd: Command) void {
|
|
self.save_buffer.record(cmd) catch |err| self.logSaveBufferError(err);
|
|
}
|
|
|
|
/// Synthesize the `goto` a navigating read tool performed (`markdown {url}`, …)
|
|
/// so `/save` can replay it; null when it didn't navigate. The result borrows
|
|
/// `args`/`arena` — record it before either is freed.
|
|
fn navigationGoto(arena: std.mem.Allocator, tool: BrowserTool, args: ?std.json.Value) ?Command {
|
|
if (!tool.navigatesToUrl()) return null;
|
|
const a = args orelse return null;
|
|
if (a != .object) return null;
|
|
const url = a.object.get("url") orelse return null;
|
|
if (url != .string or url.string.len == 0) return null;
|
|
var obj: std.json.ObjectMap = .init(arena);
|
|
obj.put("url", url) catch return null;
|
|
return Command.fromToolCall(.goto, .{ .object = obj });
|
|
}
|
|
|
|
fn recordSaveComment(self: *Agent, comment: []const u8) void {
|
|
self.save_buffer.recordComment(comment) catch |err| self.logSaveBufferError(err);
|
|
}
|
|
|
|
fn recordSaveRaw(self: *Agent, line: []const u8) void {
|
|
self.save_buffer.recordRaw(line) catch |err| self.logSaveBufferError(err);
|
|
}
|
|
|
|
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 };
|
|
self.terminal.printHelpSection("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);
|
|
self.terminal.printHelpSection("\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 };
|
|
self.terminal.printHelpSection("\nMeta commands:", meta);
|
|
return;
|
|
}
|
|
if (SlashCommand.findMeta(target)) |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 " ++ Config.tagHint(Config.AgentVerbosity) ++ " — set REPL agent verbosity (currently: {s}). Bare /verbosity prints the level.",
|
|
.{@tagName(self.terminal.verbosity)},
|
|
),
|
|
.effort => self.terminal.printInfo(
|
|
"/effort " ++ Config.tagHint(Config.Effort) ++ " — set per-turn reasoning effort (currently: {s}); saved to {s}. Bare /effort prints the level.",
|
|
.{ @tagName(self.effort), settings.remembered_path },
|
|
),
|
|
.stream => self.terminal.printInfo(
|
|
"/stream [on|off] — stream assistant text as it's generated (currently: {s}); saved to {s}. Bare /stream prints the state.",
|
|
.{ if (self.stream_enabled) "on" else "off", settings.remembered_path },
|
|
),
|
|
.usage => self.terminal.printInfo(
|
|
"/usage — show cumulative token usage and cache hit rate for this session",
|
|
.{},
|
|
),
|
|
.clear => self.terminal.printInfo(
|
|
"/clear — forget the conversation (history, usage, recorded actions, node IDs); keeps the loaded page and cookies",
|
|
.{},
|
|
),
|
|
.reset => self.terminal.printInfo(
|
|
"/reset — full reset: everything /clear does plus a new browser session, dropping the page, cookies, storage, and history",
|
|
.{},
|
|
),
|
|
.save => self.terminal.printInfo(
|
|
"/save [filename.js] [prompt] — save the session to [filename.js] (a random session-*.js if omitted). With an LLM, synthesizes an idiomatic script from the session and the optional prompt, and a repeat /save with a prompt revises the saved script; with --no-llm, dumps the recorded actions verbatim.",
|
|
.{},
|
|
),
|
|
.load => self.terminal.printInfo(
|
|
"/load <path> — read a script from disk and run it against the current session; Tab completes file paths",
|
|
.{},
|
|
),
|
|
.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|null] — change the provider, or 'null' to disable the LLM (persisted, so the next launch starts in basic mode); Tab completes detected providers, bare /provider shows the current one",
|
|
.{},
|
|
),
|
|
}
|
|
return;
|
|
}
|
|
if (self.ai_client != null) {
|
|
for (SlashCommand.llm_commands) |row| {
|
|
if (std.ascii.eqlIgnoreCase(row.name, target)) {
|
|
self.terminal.printInfo("/{s} — {s}", .{ row.name, row.description });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
const tool_schema = Schema.findByName(target) orelse {
|
|
if (Terminal.closestCommand(target)) |near| {
|
|
self.terminal.printError("unknown command: {s}. Did you mean " ++ Terminal.highlightCmd("/help {s}") ++ "?", .{ target, near });
|
|
} else {
|
|
self.terminal.printError("unknown command: {s}", .{target});
|
|
}
|
|
return;
|
|
};
|
|
self.terminal.printInfo("/{s} — {s}", .{ tool_schema.tool_name, tool_schema.description });
|
|
}
|
|
|
|
/// Caller contract: `cmd` must be `.tool_call` — `.comment` and `.llm` are
|
|
/// filtered upstream, having 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 = switch (err) {
|
|
error.OutOfMemory => "out of memory",
|
|
error.FrameNotLoaded => "no page loaded — run /goto <url> first",
|
|
else => std.fmt.allocPrint(arena, "{s} failed: {s}", .{ tc.name(), @errorName(err) }) catch "tool failed",
|
|
},
|
|
.is_error = true,
|
|
};
|
|
}
|
|
|
|
/// Data output (/extract, /evaluate, /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.printData(result.text);
|
|
return;
|
|
}
|
|
self.terminal.printToolOutcome(tc.name(), result.text, result.is_error);
|
|
}
|
|
|
|
/// Re-indent JSON for the terminal; MCP keeps renderJson's compact form.
|
|
fn printData(self: *Agent, text: []const u8) void {
|
|
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
|
defer arena.deinit();
|
|
self.terminal.printAssistant(Terminal.reindentJson(arena.allocator(), text) orelse text);
|
|
}
|
|
|
|
/// Tracks whether a `/load`-run script emitted any `console.*` output, deciding
|
|
/// how `runScript` ends: one that printed nothing freezes the spinner into a
|
|
/// `/goto`-style bullet; one that printed leaves its output as the result.
|
|
const ScriptOutput = struct {
|
|
terminal: *Terminal,
|
|
emitted: bool = false,
|
|
|
|
/// `Runtime.ConsoleObserver` callback: on the first line, clear the live
|
|
/// spinner so output starts clean instead of colliding with the indicator.
|
|
fn observe(context: *anyopaque) void {
|
|
const self: *ScriptOutput = @ptrCast(@alignCast(context));
|
|
if (self.emitted) return;
|
|
self.emitted = true;
|
|
self.terminal.endTool();
|
|
}
|
|
};
|
|
|
|
fn runScript(self: *Agent, path: []const u8) bool {
|
|
var script_arena: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer script_arena.deinit();
|
|
|
|
const content = std.fs.cwd().readFileAlloc(script_arena.allocator(), path, 10 * 1024 * 1024) catch |err| {
|
|
self.terminal.printError("Failed to read script '{s}': {s}", .{ path, @errorName(err) });
|
|
return false;
|
|
};
|
|
|
|
const runtime = ScriptRuntime.init(self.allocator, self.browser.app, self.session, &self.node_registry) catch |err| {
|
|
self.terminal.printError("Failed to initialize script runtime: {s}", .{@errorName(err)});
|
|
return false;
|
|
};
|
|
defer runtime.deinit();
|
|
self.script_runtime_mutex.lock();
|
|
self.active_script_runtime = runtime;
|
|
self.script_runtime_mutex.unlock();
|
|
defer {
|
|
self.script_runtime_mutex.lock();
|
|
self.active_script_runtime = null;
|
|
self.script_runtime_mutex.unlock();
|
|
runtime.cancelTerminate();
|
|
self.browser.env.cancelTerminate();
|
|
self.cancel_requested.store(false, .release);
|
|
}
|
|
|
|
var output: ScriptOutput = .{ .terminal = &self.terminal };
|
|
runtime.console_observer = .{ .context = @ptrCast(&output), .notify = ScriptOutput.observe };
|
|
self.terminal.beginTool("script", path);
|
|
const result = runtime.runSource(content, path);
|
|
self.terminal.endTool();
|
|
|
|
if (result catch |err| {
|
|
self.terminal.printError("Script failed: {s}", .{@errorName(err)});
|
|
return false;
|
|
}) |message| {
|
|
self.terminal.printError("{s}", .{message});
|
|
return false;
|
|
}
|
|
|
|
// A script that printed nothing leaves no trace, so freeze the spinner into
|
|
// a green bullet (like /goto); one that printed already showed its result.
|
|
if (!output.emitted) self.terminal.printScriptDone("script", path);
|
|
return true;
|
|
}
|
|
|
|
/// Mirror a user-typed slash command into `self.conversation.messages` as if the
|
|
/// LLM had called the tool itself, so the next natural-language turn sees the
|
|
/// same conversation shape either way.
|
|
fn recordSlashToolCall(
|
|
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.conversation.ensureSystemPrompt();
|
|
|
|
const ma = self.conversation.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.conversation.messages.items.len;
|
|
errdefer self.conversation.messages.shrinkRetainingCapacity(baseline);
|
|
// User turn before the assistant tool_call satisfies Gemini's rule
|
|
// that a function call must follow a user or function-response turn.
|
|
try self.conversation.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.content = user_content,
|
|
});
|
|
try self.conversation.messages.append(self.allocator, .{
|
|
.role = .assistant,
|
|
.tool_calls = tool_calls,
|
|
});
|
|
try self.conversation.messages.append(self.allocator, .{
|
|
.role = .tool,
|
|
.tool_results = tool_results,
|
|
});
|
|
}
|
|
|
|
/// Format the client's last failure into `api_error_buf`: HTTP status+message,
|
|
/// or the raw error name when there's no status (transport/parse failures).
|
|
fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []const u8 {
|
|
const e = client.lastError();
|
|
const status = e.status orelse return @errorName(err);
|
|
const hint = if (status == 401 and client == .vertex)
|
|
if (self.owned_key != null)
|
|
" (Vertex token may have expired; run /provider vertex to refresh)"
|
|
else
|
|
" (Vertex express mode needs an express API key — a Gemini Developer key won't work)"
|
|
else
|
|
"";
|
|
if (e.message) |m| {
|
|
if (std.fmt.bufPrint(&self.api_error_buf, "HTTP {d} — {s}{s}", .{ status, m, hint })) |s| return s else |_| {}
|
|
}
|
|
return std.fmt.bufPrint(&self.api_error_buf, "HTTP {d}{s}", .{ status, hint }) catch @errorName(err);
|
|
}
|
|
|
|
/// Returned text lives in `conversation.arena`, valid only until the next prune.
|
|
/// Caller must call `conversation.prune()` after consuming it — pruning earlier
|
|
/// frees 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.conversation.arena.allocator();
|
|
self.api_error_detail = null;
|
|
self.streamed_text = false;
|
|
// Defensive: if an exit path ever missed `endStreamedText`, a stale-true
|
|
// `stream_active` would skip the spinner pause on the next turn's first
|
|
// delta and let frames interleave with the text. Reset it at the source.
|
|
self.stream_active = false;
|
|
self.http_interrupt.reset();
|
|
|
|
try self.conversation.ensureSystemPrompt();
|
|
|
|
// Attachments only ride on the first user turn (just after the system prompt).
|
|
const turn_attachments: ?[]const []const u8 =
|
|
if (self.conversation.messages.items.len == 1) input.attachments else null;
|
|
|
|
// Roll-back baseline: on API failure the failed user turn would otherwise
|
|
// stay in history and replay on the next attempt.
|
|
const msg_baseline = self.conversation.messages.items.len;
|
|
|
|
if (turn_attachments) |paths| {
|
|
const parts = try self.buildUserMessageParts(ma, input.prompt, paths);
|
|
try self.conversation.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.parts = parts,
|
|
});
|
|
} else {
|
|
try self.conversation.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.conversation.messages,
|
|
self.allocator,
|
|
ma,
|
|
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
|
.{
|
|
.tools = globalTools(),
|
|
.max_turns = 100,
|
|
.max_tool_calls = 200,
|
|
.max_tokens = 4096,
|
|
.tool_choice = .auto,
|
|
// Per-turn reasoning budget; resolved from --effort / .lp-agent.zon
|
|
// / mode default, adjustable at runtime via /effort. Ignored by
|
|
// non-thinking models.
|
|
.effort = self.effort,
|
|
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
|
// Suppressed turns (e.g. `--save` capture) must keep stdout clean;
|
|
// streaming would bypass the `suppress_answer` guard in `runTurn`.
|
|
.stream = if (input.suppress_answer) null else self.streamHook(),
|
|
},
|
|
) catch |err| {
|
|
self.endStreamedText();
|
|
self.terminal.spinner.cancel();
|
|
// Ctrl-C can land while runTools unwinds 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.api_error_detail = self.formatApiError(provider_client, err);
|
|
self.conversation.rollback(msg_baseline);
|
|
return error.ApiError;
|
|
};
|
|
self.endStreamedText();
|
|
self.terminal.spinner.stop();
|
|
defer result.deinit();
|
|
self.total_usage.add(result.usage);
|
|
|
|
if (result.cancelled) return self.drainCancellation(msg_baseline);
|
|
|
|
if (input.capture_for_save) {
|
|
// 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 ca = self.conversation.arena.allocator();
|
|
const args = browser_tools.normalizeArgKeys(ca, tool, tc.arguments) catch tc.arguments;
|
|
// Fall back to the navigation a read tool performed, so a
|
|
// markdown/tree-driven turn isn't lost from `/save`.
|
|
const cmd = Command.fromToolCall(tool, args);
|
|
const to_record = if (cmd.isRecorded())
|
|
cmd
|
|
else
|
|
navigationGoto(ca, tool, args) orelse continue;
|
|
if (!recorded_any) {
|
|
if (input.record_comment) |c| self.recordSaveComment(c);
|
|
recorded_any = true;
|
|
}
|
|
self.recordSaveCommand(to_record);
|
|
}
|
|
}
|
|
|
|
// Dupe into the conversation arena — RunToolsResult arenas deinit below.
|
|
self.last_turn_refused = result.finish_reason == .safety;
|
|
const final_text: ?[]const u8 = blk: {
|
|
if (result.text) |text| {
|
|
if (std.mem.trim(u8, text, " \t\r\n").len > 0) break :blk try ma.dupe(u8, text);
|
|
}
|
|
|
|
// A refusal is deterministic; re-prompting just refuses again.
|
|
if (self.last_turn_refused) break :blk null;
|
|
|
|
// 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.conversation.messages.items.len;
|
|
try self.conversation.messages.append(self.allocator, .{
|
|
.role = .user,
|
|
.content = try ma.dupe(u8, synthesis_prompt),
|
|
});
|
|
|
|
var synth = provider_client.runTools(
|
|
self.model,
|
|
&self.conversation.messages,
|
|
self.allocator,
|
|
ma,
|
|
.{ .context = @ptrCast(self), .callFn = handleToolCall },
|
|
.{
|
|
.tools = &.{},
|
|
.max_turns = 1,
|
|
.max_tokens = 4096,
|
|
.tool_choice = .none,
|
|
// .low caps thinking so reasoning models still emit an answer;
|
|
// `.none` stays off to opt out on models that reject it.
|
|
.effort = if (self.effort == .none) .none else .low,
|
|
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
|
.stream = if (input.suppress_answer) null else self.streamHook(),
|
|
},
|
|
) catch |err| {
|
|
self.endStreamedText();
|
|
if (self.cancel_requested.load(.acquire)) return self.drainCancellation(msg_baseline);
|
|
log.err(.app, "AI synthesis error", .{ .err = err });
|
|
self.conversation.rollback(synth_baseline);
|
|
break :blk null;
|
|
};
|
|
self.endStreamedText();
|
|
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 in the conversation
|
|
// arena, and `conversation.prune()` 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 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;
|
|
const prefix = string.truncateUtf8(output, tool_output_max_bytes);
|
|
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));
|
|
// Close any assistant text streamed this turn before the tool spinner shows.
|
|
self.endStreamedText();
|
|
// 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();
|
|
|
|
const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments)) |result|
|
|
.{ .content = capToolOutput(allocator, result.text), .is_error = result.is_error }
|
|
else |err|
|
|
.{ .content = std.fmt.allocPrint(allocator, "Error: {s}", .{@errorName(err)}) catch "Error: tool execution failed", .is_error = true };
|
|
|
|
self.terminal.agentToolDone(tool_name, args_str, !outcome.is_error);
|
|
if (self.terminal.verbosity == .high) self.terminal.printToolOutcome(tool_name, outcome.content, outcome.is_error);
|
|
return outcome;
|
|
}
|
|
|
|
/// 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.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 settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider;
|
|
const llm = resolved.credentials;
|
|
defer if (resolved.key_owned) allocator.free(llm.key);
|
|
|
|
var arena: std.heap.ArenaAllocator = .init(allocator);
|
|
defer arena.deinit();
|
|
const ids = zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, opts.base_url) catch |err| {
|
|
if (llm.provider == .vertex and !settings.vertexProjectMode()) {
|
|
std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{});
|
|
}
|
|
return err;
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
const ModelCompletions = struct {
|
|
provider: Config.AiProvider,
|
|
/// Empty when the fetch failed — cached so the per-keystroke hinter doesn't
|
|
/// re-hit the network each press.
|
|
ids: []const []const u8,
|
|
};
|
|
|
|
/// `CompletionSource.providers`. Reuses pre-detected available providers to
|
|
/// avoid reading environment variables on each autocomplete keypress.
|
|
fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const []const u8 {
|
|
const self: *Agent = @ptrCast(@alignCast(context));
|
|
// A local server joins completions only when it answers (placeholder key).
|
|
var reachable: [local_providers.len]bool = undefined;
|
|
var extra: usize = 0;
|
|
for (local_providers, 0..) |tag, i| {
|
|
reachable[i] = self.local_completable[i] orelse blk: {
|
|
const v = settings.detectLocalProvider(self.allocator, tag, self.model_base_url) != null;
|
|
self.local_completable[i] = v;
|
|
break :blk v;
|
|
};
|
|
if (reachable[i]) extra += 1;
|
|
}
|
|
const names = arena.alloc([]const u8, self.available_providers.len + 1 + extra) catch return &.{};
|
|
for (self.available_providers, 0..) |p, i| {
|
|
names[i] = arena.dupe(u8, p) catch return &.{};
|
|
}
|
|
var n = self.available_providers.len;
|
|
for (local_providers, reachable) |tag, r| if (r) {
|
|
names[n] = @tagName(tag);
|
|
n += 1;
|
|
};
|
|
names[n] = provider_off_keyword;
|
|
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 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 {
|
|
_ = save;
|
|
_ = settings;
|
|
}
|
|
|
|
test "capToolOutput: passes through when under cap" {
|
|
const ta = std.testing.allocator;
|
|
const out = capToolOutput(ta, "short");
|
|
try std.testing.expectEqualStrings("short", out);
|
|
}
|
|
|
|
// Boundary correctness lives in string.zig's `truncateUtf8` tests; here we only
|
|
// assert the agent-specific policy: an over-cap body keeps valid UTF-8 and gains
|
|
// the truncation marker.
|
|
test "capToolOutput: appends a marker when truncating" {
|
|
const ta = std.testing.allocator;
|
|
|
|
// 3-byte Hangul codepoint (U+D55C '한' = 0xED 0x95 0x9C) straddling the cap.
|
|
const cap = tool_output_max_bytes;
|
|
const 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));
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "truncated") != null);
|
|
}
|