diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig
index bebbd328..6535566b 100644
--- a/src/agent/Agent.zig
+++ b/src/agent/Agent.zig
@@ -20,11 +20,14 @@ const std = @import("std");
const zenai = @import("zenai");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
+const BrowserTool = browser_tools.Tool;
+const ProviderTool = zenai.provider.Tool;
const log = lp.log;
const Config = lp.Config;
const script = lp.script;
const Command = lp.script.Command;
+const Schema = lp.script.Schema;
const Recorder = lp.script.Recorder;
const Verifier = lp.script.Verifier;
const Credentials = zenai.provider.Credentials;
@@ -317,16 +320,16 @@ pub fn deinit(self: *Agent) void {
}
// Tool definitions are compile-time constant; project them once per process.
-var global_tools_storage: [browser_tools.tool_defs.len]zenai.provider.Tool = undefined;
+var global_tools_storage: [browser_tools.tool_defs.len]ProviderTool = undefined;
var global_tools_once = std.once(initGlobalTools);
fn initGlobalTools() void {
- for (SlashCommand.globalSchemas(), 0..) |s, i| {
+ for (Schema.all(), 0..) |s, i| {
global_tools_storage[i] = .{ .name = s.tool_name, .description = s.description, .parameters = s.parameters };
}
}
-fn globalTools() []const zenai.provider.Tool {
+fn globalTools() []const ProviderTool {
global_tools_once.call();
return global_tools_storage[0..browser_tools.tool_defs.len];
}
@@ -443,7 +446,7 @@ fn runRepl(self: *Agent) void {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
- const slash_split: ?SlashCommand.Split = if (trimmed[0] == '/') SlashCommand.splitNameRest(trimmed[1..]) else null;
+ const slash_split: ?Schema.Split = if (trimmed[0] == '/') Schema.splitNameRest(trimmed[1..]) else null;
if (slash_split) |split| {
if (SlashCommand.findMeta(split.name)) |meta| {
if (self.handleMeta(meta, split.rest)) break :repl;
@@ -465,7 +468,8 @@ fn runRepl(self: *Agent) void {
continue :repl;
},
else => |e| {
- self.printSlashParseError(e, line);
+ const name = if (slash_split) |sp| sp.name else line;
+ self.printSlashParseError(e, name);
continue :repl;
},
};
@@ -498,7 +502,7 @@ fn runRepl(self: *Agent) void {
/// of PandaScript — they're REPL-only and never recorded. Returns `true` if
/// the user asked to quit.
fn handleMeta(self: *Agent, meta: *const SlashCommand.MetaCommand, rest: []const u8) bool {
- switch (meta.kind) {
+ switch (meta.tag) {
.quit => return true,
.help => self.printSlashHelp(rest),
.verbosity => self.handleVerbosity(rest),
@@ -522,7 +526,7 @@ fn handleVerbosity(self: *Agent, rest: []const u8) void {
fn printSlashHelp(self: *Agent, target: []const u8) void {
if (target.len == 0) {
self.terminal.printInfo("Slash commands (no LLM, REPL only):");
- for (SlashCommand.globalSchemas()) |s| {
+ for (Schema.all()) |s| {
const summary = firstSentence(s.description);
self.terminal.printInfoFmt(" /{s} — {s}", .{ s.tool_name, summary });
}
@@ -531,7 +535,7 @@ fn printSlashHelp(self: *Agent, target: []const u8) void {
}
const lookup = if (target[0] == '/') target[1..] else target;
if (SlashCommand.findMeta(lookup)) |meta| {
- switch (meta.kind) {
+ switch (meta.tag) {
.help => self.terminal.printInfo("/help [name] — show help for a slash command, or list all when [name] is omitted"),
.quit => self.terminal.printInfo("/quit — exit the REPL"),
.verbosity => self.terminal.printInfoFmt(
@@ -541,25 +545,26 @@ fn printSlashHelp(self: *Agent, target: []const u8) void {
}
return;
}
- const schema = SlashCommand.findSchema(SlashCommand.globalSchemas(), lookup) orelse {
+ const tool_schema = Schema.find(Schema.all(), lookup) orelse {
self.terminal.printErrorFmt("unknown tool: {s}", .{lookup});
return;
};
- self.terminal.printInfoFmt("/{s} — {s}", .{ schema.tool_name, schema.description });
+ self.terminal.printInfoFmt("/{s} — {s}", .{ tool_schema.tool_name, tool_schema.description });
var arena: std.heap.ArenaAllocator = .init(self.allocator);
defer arena.deinit();
var aw: std.Io.Writer.Allocating = .init(arena.allocator());
- std.json.Stringify.value(schema.parameters, .{ .whitespace = .indent_2 }, &aw.writer) catch return;
+ std.json.Stringify.value(tool_schema.parameters, .{ .whitespace = .indent_2 }, &aw.writer) catch return;
self.terminal.printInfoFmt("schema:\n{s}", .{aw.written()});
}
-fn printSlashParseError(self: *Agent, err: SlashCommand.ParseError, name: []const u8) void {
+fn printSlashParseError(self: *Agent, err: Schema.ParseError, name: []const u8) void {
const reason: []const u8 = switch (err) {
error.UnknownTool => "unknown tool",
error.MissingName => return self.terminal.printError("missing tool name. Try /help."),
error.MissingRequired => "missing required argument",
error.MalformedKv => "malformed key=value. Use key=value or {json}",
+ error.UnknownField => "unknown field (typo?)",
error.PositionalNotAllowed => "positional only works for tools with one required field. Use key=value",
error.UnterminatedQuote => "unterminated quote",
error.OutOfMemory => return self.terminal.printError("out of memory"),
@@ -591,7 +596,7 @@ fn runScript(self: *Agent, path: []const u8) bool {
return false;
};
- var iter: Command.ScriptIterator = .init(sa, content);
+ var iter: script.Iterator = .init(sa, content);
var last_comment: ?[]const u8 = null;
var replacements: std.ArrayList(Replacement) = .empty;
@@ -667,7 +672,7 @@ const ActionOutcome = union(enum) {
/// Execute one action-style script entry, including post-execution
/// verification, transient-failure retry, and LLM self-heal escalation.
-fn runActionEntry(self: *Agent, sa: std.mem.Allocator, entry: Command.ScriptIterator.Entry, last_comment: ?[]const u8) ActionOutcome {
+fn runActionEntry(self: *Agent, sa: std.mem.Allocator, entry: script.Iterator.Entry, last_comment: ?[]const u8) ActionOutcome {
var cmd_arena: std.heap.ArenaAllocator = .init(self.allocator);
defer cmd_arena.deinit();
const ca = cmd_arena.allocator();
@@ -699,7 +704,10 @@ fn runActionEntry(self: *Agent, sa: std.mem.Allocator, entry: Command.ScriptIter
.failed => |r| r,
.passed, .inconclusive => null,
};
- if (self.attemptSelfHeal(sa, entry.opener_line, reason, last_comment)) |healed_cmds| {
+ // For multi-line blocks (`/eval '''…'''`, `/extract '''…'''`) the
+ // opener alone is useless to the LLM — feed it the full block body.
+ const failed_text = std.mem.trimRight(u8, entry.raw_span, &std.ascii.whitespace);
+ if (self.attemptSelfHeal(sa, failed_text, reason, last_comment)) |healed_cmds| {
const replacement = script.formatHealReplacement(sa, entry.raw_span, entry.opener_line, healed_cmds) catch |err| {
self.terminal.printErrorFmt(
"line {d}: failed to record heal: {s} (script left unchanged)",
@@ -737,7 +745,7 @@ fn isRetryable(cmd: Command) bool {
.tool_call => |t| t,
else => return false,
};
- return switch (tc.action) {
+ return switch (tc.tool) {
.fill, .setChecked, .selectOption => true,
else => false,
};
@@ -830,10 +838,11 @@ fn runHealTurn(self: *Agent, arena: std.mem.Allocator, prompt: []const u8) ![]Co
var cmds: std.ArrayList(Command) = .empty;
for (result.tool_calls_made) |tc| {
if (tc.is_error) continue;
- const action = std.meta.stringToEnum(browser_tools.Action, tc.name) orelse continue;
+ const tool = std.meta.stringToEnum(BrowserTool, tc.name) orelse continue;
// `result.deinit()` (deferred above) frees the args arena before the
// caller formats `cmds`; deep-copy into `arena` to outlive it.
- const cmd = try Command.fromToolCallOwned(arena, action, tc.arguments);
+ const owned_args = if (tc.arguments) |v| try zenai.json.dupeValue(arena, v) else null;
+ const cmd = Command.fromToolCall(tool, owned_args);
if (!cmd.canHeal()) {
self.terminal.printInfoFmt(
"self-heal: ignoring {s} (navigation and eval are not allowed during heal)",
@@ -999,15 +1008,16 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
// last successful one is the answer — earlier probes are noise.
var last_extract_idx: ?usize = null;
for (result.tool_calls_made, 0..) |tc, i| {
- if (!tc.is_error and std.mem.eql(u8, tc.name, "extract")) last_extract_idx = 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;
- if (last_extract_idx) |idx| if (std.mem.eql(u8, tc.name, "extract") and idx != i) continue;
- const action = std.meta.stringToEnum(browser_tools.Action, tc.name) orelse continue;
- const cmd = Command.fromToolCall(action, tc.arguments);
+ const tool = std.meta.stringToEnum(BrowserTool, tc.name) orelse continue;
+ if (last_extract_idx) |idx| if (tool == .extract and idx != i) continue;
+ const cmd = Command.fromToolCall(tool, tc.arguments);
if (!cmd.isRecorded()) continue;
if (!recorded_any) {
if (input.record_comment) |c| r.recordComment(c);
@@ -1141,6 +1151,8 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
// 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
@@ -1343,9 +1355,8 @@ fn promptNumberedChoice(header: []const u8, items: []const []const u8, default:
test "canHeal: only page-local DOM commands are allowed" {
// Table-driven over the live tool flags so adding a new tool can't
// silently drift from the heal allow-list.
- const Action = browser_tools.Action;
- const allow = [_]Action{ .click, .hover, .waitForSelector, .fill, .selectOption, .setChecked, .scroll, .extract, .press };
- const deny = [_]Action{ .goto, .eval, .tree, .markdown, .search, .links };
+ const allow = [_]BrowserTool{ .click, .hover, .waitForSelector, .fill, .selectOption, .setChecked, .scroll, .extract, .press };
+ const deny = [_]BrowserTool{ .goto, .eval, .tree, .markdown, .search, .links };
for (allow) |action| {
const cmd = Command.fromToolCall(action, null);
diff --git a/src/agent/CommandRunner.zig b/src/agent/CommandRunner.zig
index 3b7a04b9..929e2b38 100644
--- a/src/agent/CommandRunner.zig
+++ b/src/agent/CommandRunner.zig
@@ -45,57 +45,15 @@ pub fn executeWithResult(self: *CommandRunner, arena: std.mem.Allocator, cmd: Co
.tool_call => |t| t,
else => return .{ .text = "internal: command has no tool mapping", .is_error = true },
};
- const substituted = substituteStringArgs(arena, tc.action, tc.args) catch
- return .{ .text = "out of memory", .is_error = true };
- return browser_tools.call(arena, self.session, self.node_registry, tc.name(), substituted) catch |err| .{
- .text = std.fmt.allocPrint(arena, "{s} failed: {s}", .{ tc.name(), @errorName(err) }) catch "tool failed",
+ return browser_tools.call(arena, self.session, self.node_registry, tc.name(), tc.args) catch |err| .{
+ .text = if (err == error.OutOfMemory)
+ "out of memory"
+ else
+ std.fmt.allocPrint(arena, "{s} failed: {s}", .{ tc.name(), @errorName(err) }) catch "tool failed",
.is_error = true,
};
}
-/// Resolve `$LP_*` placeholders in every string arg before the tool runs.
-/// `fill.value` is the one exception: the tool resolves it internally and
-/// rewrites the result text so the credential never appears in the echoed
-/// confirmation. Every other string field (selectors, urls, scripts, schemas)
-/// is substituted here.
-fn substituteStringArgs(arena: std.mem.Allocator, action: browser_tools.Action, args: ?std.json.Value) error{OutOfMemory}!?std.json.Value {
- const v = args orelse return null;
- if (v != .object) return v;
-
- const is_fill = action == .fill;
-
- const needsSub = struct {
- fn f(is_fill_: bool, key: []const u8, val: std.json.Value) bool {
- if (is_fill_ and std.mem.eql(u8, key, "value")) return false;
- return val == .string and std.mem.indexOf(u8, val.string, "$LP_") != null;
- }
- }.f;
-
- var needs_any = false;
- var it = v.object.iterator();
- while (it.next()) |entry| {
- if (needsSub(is_fill, entry.key_ptr.*, entry.value_ptr.*)) {
- needs_any = true;
- break;
- }
- }
- if (!needs_any) return v;
-
- var new_obj: std.json.ObjectMap = .init(arena);
- try new_obj.ensureTotalCapacity(v.object.count());
- it = v.object.iterator();
- while (it.next()) |entry| {
- const key = entry.key_ptr.*;
- const val = entry.value_ptr.*;
- const new_val: std.json.Value = if (needsSub(is_fill, key, val))
- .{ .string = try browser_tools.substituteEnvVars(arena, val.string) }
- else
- val;
- try new_obj.put(key, new_val);
- }
- return .{ .object = new_obj };
-}
-
/// Data output (extract/eval/markdown/tree/…) → stdout on success; everything
/// else, including failures from those same commands, → stderr.
pub fn printResult(self: *CommandRunner, cmd: Command, result: browser_tools.ToolResult) void {
diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig
index c12a1866..7446e1d3 100644
--- a/src/agent/SlashCommand.zig
+++ b/src/agent/SlashCommand.zig
@@ -16,26 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! REPL-only meta slash commands and re-exports of the PandaScript schema
-//! primitives. The actual slash-command grammar lives in `script/schema.zig`.
+//! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`). Meta
+//! commands aren't PandaScript — they're handled by `Agent.handleMeta`
+//! and never reach the recorder. PandaScript schema primitives live in
+//! `lp.script.Schema`; consumers should import that directly.
const std = @import("std");
-const lp = @import("lightpanda");
-const schema = lp.script.schema;
-pub const SchemaInfo = schema.SchemaInfo;
-pub const ParseError = schema.ParseError;
-pub const Split = schema.Split;
-
-pub const max_hint_slots = schema.max_hint_slots;
-
-pub const globalSchemas = schema.globalSchemas;
-pub const findSchema = schema.findSchema;
-pub const splitNameRest = schema.splitNameRest;
-
-/// Meta slash commands handled directly by Agent.handleMeta.
pub const MetaCommand = struct {
- kind: Kind,
+ tag: Tag,
name: [:0]const u8,
/// Ghost-text fragment shown after the name + space. Empty when the
/// command takes no args (`/help`, `/quit`).
@@ -43,13 +32,15 @@ pub const MetaCommand = struct {
/// Tab-completion candidates for the first positional arg.
values: []const [:0]const u8,
- pub const Kind = enum { help, quit, verbosity };
+ /// Dispatched by `Agent.handleMeta` via an exhaustive switch so adding
+ /// a new meta command is a compile error until it's wired up there too.
+ pub const Tag = enum { help, quit, verbosity };
};
pub const meta_commands = [_]MetaCommand{
- .{ .kind = .help, .name = "help", .hint = "", .values = &.{} },
- .{ .kind = .quit, .name = "quit", .hint = "", .values = &.{} },
- .{ .kind = .verbosity, .name = "verbosity", .hint = "", .values = &.{ "low", "medium", "high" } },
+ .{ .tag = .help, .name = "help", .hint = "", .values = &.{} },
+ .{ .tag = .quit, .name = "quit", .hint = "", .values = &.{} },
+ .{ .tag = .verbosity, .name = "verbosity", .hint = "", .values = &.{ "low", "medium", "high" } },
};
pub fn findMeta(name: []const u8) ?*const MetaCommand {
diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig
index 0aa0d5e2..64693ef9 100644
--- a/src/agent/Terminal.zig
+++ b/src/agent/Terminal.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
const Config = lp.Config;
+const Schema = lp.script.Schema;
const SlashCommand = @import("SlashCommand.zig");
const Spinner = @import("Spinner.zig");
const c = @cImport({
@@ -64,10 +65,12 @@ stderr_is_tty: bool,
spinner: Spinner,
// Flat name list for the "match any slash command" search/completion paths.
-const all_slash_names: [browser_tools.names.len + SlashCommand.meta_commands.len][]const u8 = blk: {
- var arr: [browser_tools.names.len + SlashCommand.meta_commands.len][]const u8 = undefined;
+const all_slash_names: [browser_tools.names.len + SlashCommand.meta_commands.len + 2][]const u8 = blk: {
+ var arr: [browser_tools.names.len + SlashCommand.meta_commands.len + 2][]const u8 = undefined;
for (browser_tools.names, 0..) |n, i| arr[i] = n;
for (SlashCommand.meta_commands, 0..) |m, i| arr[browser_tools.names.len + i] = m.name;
+ arr[browser_tools.names.len + SlashCommand.meta_commands.len] = "login";
+ arr[browser_tools.names.len + SlashCommand.meta_commands.len + 1] = "acceptCookies";
break :blk arr;
};
@@ -186,11 +189,11 @@ fn addPrefixedCompletion(
_ = c.ic_add_completion_prim(cenv, text.ptr, null, null, @intCast(input.len), 0);
}
-fn parseSlashCommand(input: []const u8) ?SlashCommand.Split {
+fn parseSlashCommand(input: []const u8) ?Schema.Split {
// Reject `/ foo` (bare slash with arg) — `splitNameRest` would otherwise
// accept "foo" as the name after trimming.
if (input.len < 2 or input[0] != '/' or std.ascii.isWhitespace(input[1])) return null;
- return SlashCommand.splitNameRest(input[1..]);
+ return Schema.splitNameRest(input[1..]);
}
// Cap on tokens we read out of the body. Real schemas and CLI inputs have far
@@ -219,7 +222,7 @@ const BodyAnalysis = struct {
}
};
-fn analyzeBody(schema: *const SlashCommand.SchemaInfo, body: []const u8, ends_ws: bool) BodyAnalysis {
+fn analyzeBody(schema: *const Schema, body: []const u8, ends_ws: bool) BodyAnalysis {
var a: BodyAnalysis = .{};
var tokens: [max_tokens][]const u8 = undefined;
@@ -263,7 +266,7 @@ fn addPartialKeyCompletions(
cenv: ?*c.ic_completion_env_t,
input: []const u8,
body: []const u8,
- schema: *const SlashCommand.SchemaInfo,
+ schema: *const Schema,
buf: *[completion_buf_len:0]u8,
) void {
std.debug.assert(input.len > 0);
@@ -336,7 +339,7 @@ fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callc
if (input[0] == '/') {
if (has_space) {
if (parseSlashCommand(input)) |parts| {
- if (SlashCommand.findSchema(SlashCommand.globalSchemas(), parts.name)) |schema| {
+ if (Schema.find(Schema.all(), parts.name)) |schema| {
addPartialKeyCompletions(cenv, input, parts.rest, schema, &buf);
} else if (SlashCommand.findMeta(parts.name)) |meta| {
addMetaValueCompletions(cenv, input, parts.rest, meta, &buf);
@@ -375,7 +378,7 @@ fn hintsCallback(input_c: [*c]const u8, arg: ?*anyopaque) callconv(.c) [*c]const
if (parseSlashCommand(input)) |parts| {
const ends_ws = input[input.len - 1] == ' ';
- if (SlashCommand.findSchema(SlashCommand.globalSchemas(), parts.name)) |schema| {
+ if (Schema.find(Schema.all(), parts.name)) |schema| {
return renderSchemaHint(schema, parts.rest, ends_ws);
}
if (SlashCommand.findMeta(parts.name)) |meta| {
@@ -434,7 +437,7 @@ fn renderMetaHint(meta: *const SlashCommand.MetaCommand, body: []const u8, ends_
// Renders `` and `[optional=…]` for each unused field, or
// `=…` when the user is typing a key prefix.
-fn renderSchemaHint(schema: *const SlashCommand.SchemaInfo, body: []const u8, ends_ws: bool) [*c]const u8 {
+fn renderSchemaHint(schema: *const Schema, body: []const u8, ends_ws: bool) [*c]const u8 {
const a = analyzeBody(schema, body, ends_ws);
if (a.partial_key) |pk| {
@@ -447,7 +450,7 @@ fn renderSchemaHint(schema: *const SlashCommand.SchemaInfo, body: []const u8, en
return null;
}
- var frags: [SlashCommand.max_hint_slots][]const u8 = undefined;
+ var frags: [Schema.max_hint_slots][]const u8 = undefined;
var n: usize = 0;
for (schema.hints) |slot| {
if (a.isUsed(slot.name)) continue;
@@ -516,7 +519,7 @@ fn slashHasPrefix(name: []const u8) bool {
}
fn slashHasParams(name: []const u8) bool {
- if (SlashCommand.findSchema(SlashCommand.globalSchemas(), name)) |s| return s.hints.len > 0;
+ if (Schema.find(Schema.all(), name)) |s| return s.hints.len > 0;
if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0;
return false;
}
@@ -538,10 +541,17 @@ fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi
}
// Returns the index just past the matching closing quote, or `text.len` if
-// unterminated. Does not handle backslash escapes (matches SlashCommand.zig parser).
+// unterminated. Does not handle backslash escapes (matches Schema.tokenize).
fn scanQuoted(text: []const u8, start: usize) usize {
if (start >= text.len) return start;
- const close = std.mem.indexOfScalarPos(u8, text, start + 1, text[start]) orelse return text.len;
+ const ch = text[start];
+ const is_triple = start + 2 < text.len and text[start + 1] == ch and text[start + 2] == ch;
+ if (is_triple) {
+ const triple_delim = text[start .. start + 3];
+ const close = std.mem.indexOfPos(u8, text, start + 3, triple_delim) orelse return text.len;
+ return close + 3;
+ }
+ const close = std.mem.indexOfScalarPos(u8, text, start + 1, ch) orelse return text.len;
return close + 1;
}
@@ -549,15 +559,20 @@ fn scanQuoted(text: []const u8, start: usize) usize {
/// non-slash REPL line path where the rest is freeform prose to the LLM.
fn highlightDollarVars(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void {
var i = start;
- while (i < text.len) : (i += 1) {
- if (text[i] != '$') continue;
+ while (i < text.len) {
+ if (text[i] != '$') {
+ i += 1;
+ continue;
+ }
const tok_start = i;
i += 1;
while (i < text.len and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1;
if (i > tok_start + 1) {
c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_var.ptr);
}
- if (i >= text.len) break;
+ // Don't post-step — the inner loop already landed on the char
+ // after the identifier (or end-of-text). Auto-advancing would
+ // skip an adjacent `$LP_*`.
}
}
diff --git a/src/browser/tools.zig b/src/browser/tools.zig
index 730e2ba3..b2becc0c 100644
--- a/src/browser/tools.zig
+++ b/src/browser/tools.zig
@@ -27,20 +27,355 @@ const DOMNode = @import("webapi/Node.zig");
const CDPNode = @import("../cdp/Node.zig");
const Selector = @import("webapi/selector/Selector.zig");
-pub const ToolDef = struct {
- name: []const u8,
- description: []const u8,
- input_schema: []const u8,
- /// State-mutating: surfaces in `.lp` recordings. Read-only tools (queries,
- /// env probes) stay out so a replay doesn't bloat the script with noise.
- recorded: bool = false,
- /// Safe target for the self-heal LLM to emit when a recorded step fails.
- /// Only deterministic per-element actions; anything that depends on prior
- /// page state or LLM judgment is excluded.
- can_heal: bool = false,
+/// Hand-written so per-tool semantics (record/heal/locator/data) and
+/// LLM-facing metadata (`definition`) live as exhaustive switches on the
+/// tag — adding a new tool is a compile error until each predicate AND
+/// `definition` make an explicit choice. `tool_defs` (below) materializes
+/// `definition` over every tag for callers that iterate.
+pub const Tool = enum {
+ goto,
+ search,
+ markdown,
+ links,
+ eval,
+ extract,
+ tree,
+ nodeDetails,
+ interactiveElements,
+ structuredData,
+ detectForms,
+ click,
+ fill,
+ scroll,
+ waitForSelector,
+ hover,
+ press,
+ selectOption,
+ setChecked,
+ findElement,
+ consoleLogs,
+ getUrl,
+ getCookies,
+ getEnv,
+
+ /// State-mutating: surfaces in `.lp` recordings. Read-only tools
+ /// (queries, env probes) stay out so a replay doesn't bloat the script
+ /// with noise.
+ pub fn isRecorded(self: Tool) bool {
+ return switch (self) {
+ .goto, .eval, .extract, .click, .fill, .scroll, .waitForSelector, .hover, .press, .selectOption, .setChecked => true,
+ .search, .markdown, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
+ };
+ }
+
+ /// Safe target for the self-heal LLM to emit when a recorded step
+ /// fails. Only deterministic per-element actions; anything that depends
+ /// on prior page state or LLM judgment is excluded.
+ pub fn canHeal(self: Tool) bool {
+ return switch (self) {
+ .click, .fill, .scroll, .waitForSelector, .hover, .press, .selectOption, .setChecked, .extract => true,
+ .goto, .search, .markdown, .links, .eval, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
+ };
+ }
+
+ /// Tool requires a target element (selector or backendNodeId) at
+ /// runtime even though the JSON schema marks both as optional. Used by
+ /// the recorder to skip lines that can't be replayed.
+ pub fn needsLocator(self: Tool) bool {
+ return switch (self) {
+ .click, .fill, .hover, .selectOption, .setChecked => true,
+ .goto, .search, .markdown, .links, .eval, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
+ };
+ }
+
/// Result is data the caller probably wants on stdout (extracted JSON,
/// markdown, eval return value) rather than a status line on stderr.
- produces_data: bool = false,
+ pub fn producesData(self: Tool) bool {
+ return switch (self) {
+ .search, .markdown, .links, .eval, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => true,
+ .goto, .click, .fill, .scroll, .waitForSelector, .hover, .press, .selectOption, .setChecked => false,
+ };
+ }
+
+ /// Per-tool LLM-facing metadata. Tool identity (name + predicates) lives
+ /// on the enclosing `Tool` enum; this struct just carries the strings.
+ pub const Definition = struct {
+ description: []const u8,
+ input_schema: []const u8,
+ };
+
+ /// Source of truth for tool ↔ metadata. The exhaustive switch makes
+ /// adding a new `Tool` tag a compile error until its description and
+ /// JSON schema exist. `tool_defs` (below) materializes the array form
+ /// for callers that iterate (MCP `tools/list`, schema build).
+ pub fn definition(self: Tool) Definition {
+ return switch (self) {
+ .goto => .{
+ .description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." },
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
+ \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
+ \\ },
+ \\ "required": ["url"]
+ \\}
+ ),
+ },
+ .search => .{
+ .description = "Run a web search and return results as markdown. When TAVILY_API_KEY is set, queries the Tavily Search API and returns a numbered list of {title, url, snippet}. Otherwise (or on Tavily failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "query": { "type": "string", "description": "The search query." },
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
+ \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
+ \\ },
+ \\ "required": ["query"]
+ \\}
+ ),
+ },
+ .markdown => .{
+ .description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.",
+ .input_schema = url_params_schema,
+ },
+ .links => .{
+ .description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.",
+ .input_schema = url_params_schema,
+ },
+ .eval => .{
+ .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "script": { "type": "string" },
+ \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." },
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
+ \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
+ \\ },
+ \\ "required": ["script"]
+ \\}
+ ),
+ },
+ .extract => .{
+ .description =
+ \\Extract structured data from the current page using a small JSON schema. Prefer this over `markdown` or `eval` whenever the user asked for a specific value or list (a score, price, count, profile field, headlines, …) — the result is returned as JSON AND the call is recorded as an `/extract` PandaScript line, so a later replay (no LLM) prints the answer to stdout. Use `markdown` / `tree` / `interactiveElements` only to discover the right selector, then commit to one `extract` call.
+ \\
+ \\Schema is a JSON object literal (pass it as a string in `schema`). Each value picks what to lift out:
+ \\ "" → first match's textContent.trim() (string|null)
+ \\ "" → element's own textContent.trim() (only meaningful inside `fields`)
+ \\ [""] → every match's text (string[])
+ \\ {"selector":"","attr":""} → first match's attribute (string|null)
+ \\ [{"selector":"","attr":""}] → every match's attribute (string[])
+ \\ [{"selector":"","fields":{…}}] → array of objects, fields resolved relative to each match
+ \\
+ \\Examples (schema → result):
+ \\ {"karma": "#karma"} → {"karma":"42"}
+ \\ {"items": [".story .title"]} → {"items":["Title 1","Title 2"]}
+ \\ {"links": [{"selector":"a.title","attr":"href"}]} → {"links":["/a","/b"]}
+ \\ {"stories": [{"selector":".athing","fields":{"title":".titleline","rank":".rank"}}]} → {"stories":[{"title":"Foo","rank":"1"}]}
+ ,
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "schema": { "type": "string", "description": "JSON schema object (as a string) describing what to extract. Must be a JSON object literal." }
+ \\ },
+ \\ "required": ["schema"]
+ \\}
+ ),
+ },
+ .tree => .{
+ .description = "Simplified semantic DOM tree (role, name, value, backendNodeId per node). Output omits raw HTML attributes; call `nodeDetails` on a backendNodeId to read id/class for selector synthesis. Navigates first if `url` is provided.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
+ \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." },
+ \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
+ \\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
+ \\ }
+ \\}
+ ),
+ },
+ .nodeDetails => .{
+ .description = "Details for a node by backendNodeId: tag, role, name, interactivity, disabled, value, input type, placeholder, href, **id**, **class**, checked, select options. Canonical way to turn a tree backendNodeId into a CSS selector.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to inspect." }
+ \\ },
+ \\ "required": ["backendNodeId"]
+ \\}
+ ),
+ },
+ .interactiveElements => .{
+ .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.",
+ .input_schema = url_params_schema,
+ },
+ .structuredData => .{
+ .description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.",
+ .input_schema = url_params_schema,
+ },
+ .detectForms => .{
+ .description = "Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first.",
+ .input_schema = url_params_schema,
+ },
+ .click => .{
+ .description = "Click on an interactive element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Returns the current page URL and title after the click.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "selector": { "type": "string", "description": "CSS selector of the element to click. Preferred over backendNodeId." },
+ \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to click." }
+ \\ }
+ \\}
+ ),
+ },
+ .fill => .{
+ .description = "Fill text into an input element. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "selector": { "type": "string", "description": "CSS selector of the input element to fill. Preferred over backendNodeId." },
+ \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the input element to fill." },
+ \\ "value": { "type": "string", "description": "The text to fill into the input element." }
+ \\ },
+ \\ "required": ["value"]
+ \\}
+ ),
+ },
+ .scroll => .{
+ .description = "Scroll the page or a specific element. Returns the scroll position and current page URL and title.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." },
+ \\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." },
+ \\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." }
+ \\ }
+ \\}
+ ),
+ },
+ .waitForSelector => .{
+ .description = "Wait for an element matching a CSS selector to appear in the page. Returns the backend node ID of the matched element.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "selector": { "type": "string", "description": "The CSS selector to wait for." },
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." }
+ \\ },
+ \\ "required": ["selector"]
+ \\}
+ ),
+ },
+ .hover => .{
+ .description = "Hover over an element, triggering mouseover and mouseenter events. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Useful for menus, tooltips, and hover states.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "selector": { "type": "string", "description": "CSS selector of the element to hover over. Preferred over backendNodeId." },
+ \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to hover over." }
+ \\ }
+ \\}
+ ),
+ },
+ .press => .{
+ .description = "Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'.",
+ .input_schema = minify(
+ \\{
+ \\ "type": "object",
+ \\ "properties": {
+ \\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." },
+ \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document." }
+ \\ },
+ \\ "required": ["key"]
+ \\}
+ ),
+ },
+ .selectOption => .{
+ .description = "Select an option in a