tools: restructure browser tools and script schemas

- Replace `Action` enum with `Tool` enum using exhaustive switches
- Extract `ScriptIterator` to `Iterator.zig`
- Refactor `schema.zig` into `Schema.zig`
- Move string substitution logic into `tools.zig`
- Clean up `SlashCommand.zig` to only handle REPL meta-commands
This commit is contained in:
Adrià Arrufat
2026-05-22 13:41:04 +02:00
parent 6cd75c454e
commit 8fb3c7baed
13 changed files with 1691 additions and 1420 deletions

View File

@@ -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);

View File

@@ -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 {

View File

@@ -16,26 +16,15 @@
// 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/>.
//! 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 = "<low|medium|high>", .values = &.{ "low", "medium", "high" } },
.{ .tag = .help, .name = "help", .hint = "", .values = &.{} },
.{ .tag = .quit, .name = "quit", .hint = "", .values = &.{} },
.{ .tag = .verbosity, .name = "verbosity", .hint = "<low|medium|high>", .values = &.{ "low", "medium", "high" } },
};
pub fn findMeta(name: []const u8) ?*const MetaCommand {

View File

@@ -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 `<required>` and `[optional=…]` for each unused field, or
// `<keyname>=…` 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_*`.
}
}

View File

@@ -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:
\\ "<sel>" → first match's textContent.trim() (string|null)
\\ "" → element's own textContent.trim() (only meaningful inside `fields`)
\\ ["<sel>"] → every match's text (string[])
\\ {"selector":"<sel>","attr":"<name>"} → first match's attribute (string|null)
\\ [{"selector":"<sel>","attr":"<name>"}] → every match's attribute (string[])
\\ [{"selector":"<sel>","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 <select> dropdown element by its value. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input and change events.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "CSS selector of the <select> element. Preferred over backendNodeId." },
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the <select> element." },
\\ "value": { "type": "string", "description": "The value of the option to select." }
\\ },
\\ "required": ["value"]
\\}
),
},
.setChecked => .{
.description = "Check or uncheck a checkbox or radio button. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input, change, and click events.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "CSS selector of the checkbox or radio input element. Preferred over backendNodeId." },
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the checkbox or radio input element." },
\\ "checked": { "type": "boolean", "description": "Whether to check (true) or uncheck (false) the element.", "default": true }
\\ },
\\ "required": ["checked"]
\\}
),
},
.findElement => .{
.description = "Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." },
\\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." }
\\ }
\\}
),
},
.consoleLogs => .{
.description = "Get buffered console.log/warn/error messages from the current page. Returns all messages since last call and clears the buffer.",
.input_schema = minify(
\\{ "type": "object", "properties": {} }
),
},
.getUrl => .{
.description = "Current page URL. The browser may already have a page loaded (slash command, replayed script) not visible in this conversation — call this before assuming nothing is loaded when the user references the current page/site. Also useful to verify a navigation or detect a redirect.",
.input_schema = minify(
\\{ "type": "object", "properties": {} }
),
},
.getCookies => .{
.description = "Get all cookies in the browser. Useful for debugging authentication and session state.",
.input_schema = minify(
\\{ "type": "object", "properties": {} }
),
},
.getEnv => .{
.description = "With `name`: read an LP_* env var (other namespaces report as not set) — for non-secret config only (base URLs, flags). Without `name`: list LP_* names that are set (no values) — safe credential discovery. For secrets, pass `$LP_*` placeholders in tool args; never request a credential by name (the value would land in your context).",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "name": { "type": "string", "description": "Optional. If provided, must start with LP_; returns the value. If omitted, returns the list of LP_* names that are set." }
\\ }
\\}
),
},
};
}
};
pub fn minify(comptime json: []const u8) []const u8 {
@@ -83,337 +418,22 @@ const url_params_schema = minify(
\\}
);
pub const tool_defs = [_]ToolDef{
.{
.name = "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"]
\\}
),
.recorded = true,
},
.{
.name = "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"]
\\}
),
.produces_data = true,
},
.{
.name = "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,
.produces_data = true,
},
.{
.name = "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,
.produces_data = true,
},
.{
.name = "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"]
\\}
),
.recorded = true,
.produces_data = true,
},
.{
.name = "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:
\\ "<sel>" → first match's textContent.trim() (string|null)
\\ "" → element's own textContent.trim() (only meaningful inside `fields`)
\\ ["<sel>"] → every match's text (string[])
\\ {"selector":"<sel>","attr":"<name>"} → first match's attribute (string|null)
\\ [{"selector":"<sel>","attr":"<name>"}] → every match's attribute (string[])
\\ [{"selector":"<sel>","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"]
\\}
),
.recorded = true,
.can_heal = true,
.produces_data = true,
},
.{
.name = "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." }
\\ }
\\}
),
.produces_data = true,
},
.{
.name = "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"]
\\}
),
.produces_data = true,
},
.{
.name = "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,
.produces_data = true,
},
.{
.name = "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,
.produces_data = true,
},
.{
.name = "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,
.produces_data = true,
},
.{
.name = "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." }
\\ }
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "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"]
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "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." }
\\ }
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "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"]
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "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." }
\\ }
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "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"]
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "selectOption",
.description = "Select an option in a <select> dropdown element by its value. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input and change events.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "CSS selector of the <select> element. Preferred over backendNodeId." },
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the <select> element." },
\\ "value": { "type": "string", "description": "The value of the option to select." }
\\ },
\\ "required": ["value"]
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "setChecked",
.description = "Check or uncheck a checkbox or radio button. Provide either a CSS selector (preferred for reproducibility) or a backendNodeId. Dispatches input, change, and click events.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "CSS selector of the checkbox or radio input element. Preferred over backendNodeId." },
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the checkbox or radio input element." },
\\ "checked": { "type": "boolean", "description": "Whether to check (true) or uncheck (false) the element.", "default": true }
\\ },
\\ "required": ["checked"]
\\}
),
.recorded = true,
.can_heal = true,
},
.{
.name = "findElement",
.description = "Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree.",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." },
\\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." }
\\ }
\\}
),
.produces_data = true,
},
.{
.name = "consoleLogs",
.description = "Get buffered console.log/warn/error messages from the current page. Returns all messages since last call and clears the buffer.",
.input_schema = minify(
\\{ "type": "object", "properties": {} }
),
.produces_data = true,
},
.{
.name = "getUrl",
.description = "Current page URL. The browser may already have a page loaded (slash command, replayed script) not visible in this conversation — call this before assuming nothing is loaded when the user references the current page/site. Also useful to verify a navigation or detect a redirect.",
.input_schema = minify(
\\{ "type": "object", "properties": {} }
),
.produces_data = true,
},
.{
.name = "getCookies",
.description = "Get all cookies in the browser. Useful for debugging authentication and session state.",
.input_schema = minify(
\\{ "type": "object", "properties": {} }
),
.produces_data = true,
},
.{
.name = "getEnv",
.description = "With `name`: read an LP_* env var (other namespaces report as not set) — for non-secret config only (base URLs, flags). Without `name`: list LP_* names that are set (no values) — safe credential discovery. For secrets, pass `$LP_*` placeholders in tool args; never request a credential by name (the value would land in your context).",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "name": { "type": "string", "description": "Optional. If provided, must start with LP_; returns the value. If omitted, returns the list of LP_* names that are set." }
\\ }
\\}
),
.produces_data = true,
},
/// Materialized form of `Tool.definition` keyed by `@intFromEnum(Tool)`.
/// Built at comptime by iterating every `Tool` tag — order and count
/// can't drift because both come from the enum itself.
pub const tool_defs: [@typeInfo(Tool).@"enum".fields.len]Tool.Definition = blk: {
var arr: [@typeInfo(Tool).@"enum".fields.len]Tool.Definition = undefined;
for (std.enums.values(Tool), 0..) |t, i| arr[i] = t.definition();
break :blk arr;
};
/// Comptime-built flat array of tool names, in `tool_defs` order. Use this
/// when callers only need the names (slash-command lookup, MCP `tools/list`).
pub const names: [tool_defs.len][]const u8 = blk: {
var arr: [tool_defs.len][]const u8 = undefined;
for (tool_defs, 0..) |td, i| arr[i] = td.name;
/// Comptime-built flat array of tool names, in `Tool` declaration order.
/// Use this when callers only need the names (slash-command lookup, MCP
/// `tools/list`).
pub const names: [@typeInfo(Tool).@"enum".fields.len][]const u8 = blk: {
const fields = @typeInfo(Tool).@"enum".fields;
var arr: [fields.len][]const u8 = undefined;
for (fields, 0..) |f, i| arr[i] = f.name;
break :blk arr;
};
@@ -436,13 +456,6 @@ pub const ToolResult = struct {
text: []const u8,
is_error: bool = false,
/// Collapse a `ToolError!ToolResult` into a single value by surfacing
/// the Zig error name in-band (`is_error = true`). Use when the caller
/// treats operational and JS-level failures the same way.
pub fn unwrap(result: ToolError!ToolResult) ToolResult {
return result catch |err| .{ .text = @errorName(err), .is_error = true };
}
/// The text payload only when the tool succeeded; `null` on failure.
/// Convenient for callers (e.g. `Verifier`) that bail on any error.
pub fn okText(self: ToolResult) ?[]const u8 {
@@ -476,19 +489,6 @@ const ActionTarget = union(enum) {
const NodeAndPage = struct { node: *DOMNode, page: *lp.Frame, target: ActionTarget };
/// Derived from `tool_defs` so the enum and the tool table can't drift.
/// Tag order follows declaration order in `tool_defs`.
pub const Action = blk: {
var fields: [tool_defs.len]std.builtin.Type.EnumField = undefined;
for (tool_defs, 0..) |td, i| fields[i] = .{ .name = td.name[0..td.name.len :0], .value = i };
break :blk @Type(.{ .@"enum" = .{
.tag_type = u8,
.fields = &fields,
.decls = &.{},
.is_exhaustive = true,
} });
};
pub fn call(
arena: std.mem.Allocator,
session: *lp.Session,
@@ -496,30 +496,31 @@ pub fn call(
tool_name: []const u8,
arguments: ?std.json.Value,
) ToolError!ToolResult {
const action = std.meta.stringToEnum(Action, tool_name) orelse return ToolError.InvalidParams;
const tool = std.meta.stringToEnum(Tool, tool_name) orelse return ToolError.InvalidParams;
const substituted = try substituteStringArgs(arena, tool, arguments);
return switch (action) {
.goto => .{ .text = try execGoto(arena, session, registry, arguments) },
.search => .{ .text = try execSearch(arena, session, registry, arguments) },
.markdown => .{ .text = try execMarkdown(arena, session, registry, arguments) },
.links => .{ .text = try execLinks(arena, session, registry, arguments) },
.tree => .{ .text = try execTree(arena, session, registry, arguments) },
.nodeDetails => .{ .text = try execNodeDetails(arena, session, registry, arguments) },
.interactiveElements => .{ .text = try execInteractiveElements(arena, session, registry, arguments) },
.structuredData => .{ .text = try execStructuredData(arena, session, registry, arguments) },
.detectForms => .{ .text = try execDetectForms(arena, session, registry, arguments) },
.click => .{ .text = try execClick(arena, session, registry, arguments) },
.fill => .{ .text = try execFill(arena, session, registry, arguments) },
.scroll => .{ .text = try execScroll(arena, session, registry, arguments) },
.waitForSelector => .{ .text = try execWaitForSelector(arena, session, registry, arguments) },
.hover => .{ .text = try execHover(arena, session, registry, arguments) },
.press => .{ .text = try execPress(arena, session, registry, arguments) },
.selectOption => .{ .text = try execSelectOption(arena, session, registry, arguments) },
.setChecked => .{ .text = try execSetChecked(arena, session, registry, arguments) },
.findElement => .{ .text = try execFindElement(arena, session, registry, arguments) },
.eval => execEval(arena, session, registry, arguments),
.extract => execExtract(arena, session, registry, arguments),
.getEnv => .{ .text = try execGetEnv(arena, arguments) },
return switch (tool) {
.goto => .{ .text = try execGoto(arena, session, registry, substituted) },
.search => .{ .text = try execSearch(arena, session, registry, substituted) },
.markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) },
.links => .{ .text = try execLinks(arena, session, registry, substituted) },
.tree => .{ .text = try execTree(arena, session, registry, substituted) },
.nodeDetails => .{ .text = try execNodeDetails(arena, session, registry, substituted) },
.interactiveElements => .{ .text = try execInteractiveElements(arena, session, registry, substituted) },
.structuredData => .{ .text = try execStructuredData(arena, session, registry, substituted) },
.detectForms => .{ .text = try execDetectForms(arena, session, registry, substituted) },
.click => .{ .text = try execClick(arena, session, registry, substituted) },
.fill => .{ .text = try execFill(arena, session, registry, substituted) },
.scroll => .{ .text = try execScroll(arena, session, registry, substituted) },
.waitForSelector => .{ .text = try execWaitForSelector(arena, session, registry, substituted) },
.hover => .{ .text = try execHover(arena, session, registry, substituted) },
.press => .{ .text = try execPress(arena, session, registry, substituted) },
.selectOption => .{ .text = try execSelectOption(arena, session, registry, substituted) },
.setChecked => .{ .text = try execSetChecked(arena, session, registry, substituted) },
.findElement => .{ .text = try execFindElement(arena, session, registry, substituted) },
.eval => execEval(arena, session, registry, substituted),
.extract => execExtract(arena, session, registry, substituted),
.getEnv => .{ .text = try execGetEnv(arena, substituted) },
.consoleLogs => .{ .text = try execConsoleLogs(arena, session) },
.getUrl => .{ .text = try execGetUrl(session) },
.getCookies => .{ .text = try execGetCookies(arena, session) },
@@ -1209,6 +1210,49 @@ pub fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.jso
return parseValue(T, arena, arguments orelse return error.InvalidParams);
}
/// Resolve `$LP_*` placeholders in every string arg before the tool runs.
/// `fill.value` is the one exception: `execFill` resolves it internally and
/// echoes the original placeholder so the credential never surfaces in the
/// result text. Co-located with `execFill` so both halves of the carve-out
/// live in one file.
fn substituteStringArgs(arena: std.mem.Allocator, tool: Tool, args: ?std.json.Value) error{OutOfMemory}!?std.json.Value {
const v = args orelse return null;
if (v != .object) return v;
const is_fill = tool == .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 substituteEnvVars(arena, val.string) }
else
val;
try new_obj.put(key, new_val);
}
return .{ .object = new_obj };
}
pub fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) error{OutOfMemory}![]const u8 {
// No `$LP_` prefix → no substitution possible, skip the rebuild entirely.
// Pages routinely contain `$5.99`-style content where `$` is incidental.
@@ -1247,7 +1291,8 @@ pub fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) error{OutO
/// agent retyped as a literal doesn't leak into the recording. Values < 4
/// chars are skipped to avoid false-positive substring matches.
pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) error{OutOfMemory}![]const u8 {
const env_names = lpEnvNames(arena) catch return input;
if (input.len < 4) return input;
const env_names = try lpEnvNames(arena);
// Iterate by value length descending. With two LP_* values where one is a
// substring of the other (both ≥4 chars so neither is filtered), name-order

View File

@@ -3,19 +3,24 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = lp.js;
const browser_tools = lp.tools;
const BrowserTool = browser_tools.Tool;
const script = lp.script;
const Command = lp.script.Command;
const Recorder = lp.script.Recorder;
const protocol = @import("protocol.zig");
const Server = @import("Server.zig");
const McpTool = protocol.Tool;
/// Convert browser tool_defs to MCP protocol.Tool format (comptime).
/// Convert browser tool_defs to MCP wire-protocol tools (comptime).
/// Tool identity comes from the `BrowserTool` tag — `tool_defs` only
/// carries the LLM-facing description and JSON schema.
const browser_tool_list = blk: {
var tools: [browser_tools.tool_defs.len]protocol.Tool = undefined;
for (browser_tools.tool_defs, 0..) |td, i| {
const fields = @typeInfo(BrowserTool).@"enum".fields;
var tools: [fields.len]McpTool = undefined;
for (browser_tools.tool_defs, fields, 0..) |td, f, i| {
tools[i] = .{
.name = td.name,
.name = f.name,
.description = td.description,
.inputSchema = td.input_schema,
};
@@ -82,7 +87,7 @@ const script_heal_schema = browser_tools.minify(
\\}
);
const extra_tools = [_]protocol.Tool{
const extra_tools = [_]McpTool{
.{
.name = "record_start",
.description = "Start recording state-mutating browser tool calls into a PandaScript file. Subsequent calls to `goto`, `click`, `fill`, `scroll`, `hover`, `selectOption`, `setChecked`, `waitForSelector`, `eval`, and `extract` get appended as PandaScript lines. Query-only tools (tree, markdown, links, findElement, …) are not recorded.",
@@ -155,14 +160,14 @@ fn dispatchBrowserTool(
name: []const u8,
arguments: ?std.json.Value,
) !void {
const action = std.meta.stringToEnum(browser_tools.Action, name) orelse {
const tool = std.meta.stringToEnum(BrowserTool, name) orelse {
return server.sendError(id, .MethodNotFound, "Tool not found");
};
const result = browser_tools.call(arena, server.session, &server.node_registry, name, arguments) catch |err| {
// eval/extract surface failures in-band so the LLM can self-correct;
// other tools' operational failures are protocol-level.
if (surfacesErrorInBand(action)) {
if (surfacesErrorInBand(tool)) {
return sendToolResultText(server, id, @errorName(err), true);
}
const code: protocol.ErrorCode = switch (err) {
@@ -173,18 +178,18 @@ fn dispatchBrowserTool(
return server.sendError(id, code, @errorName(err));
};
if (!result.is_error) recordIfActive(server, action, arguments);
if (!result.is_error) recordIfActive(server, tool, arguments);
try sendToolResultText(server, id, result.text, result.is_error);
}
fn surfacesErrorInBand(action: browser_tools.Action) bool {
return action == .eval or action == .extract;
fn surfacesErrorInBand(tool: BrowserTool) bool {
return tool == .eval or tool == .extract;
}
fn recordIfActive(server: *Server, action: browser_tools.Action, arguments: ?std.json.Value) void {
fn recordIfActive(server: *Server, tool: BrowserTool, arguments: ?std.json.Value) void {
if (server.recorder == null) return;
const cmd = Command.fromToolCall(action, arguments);
const cmd = Command.fromToolCall(tool, arguments);
// `record` no-ops on non-recorded tools — see `Command.isRecorded`.
server.recorder.?.record(cmd);
}
@@ -266,7 +271,7 @@ fn handleScriptStep(server: *Server, arena: std.mem.Allocator, id: std.json.Valu
const tc = cmd.tool_call;
const result = browser_tools.call(arena, server.session, &server.node_registry, tc.name(), tc.args) catch |err| {
if (surfacesErrorInBand(tc.action)) {
if (surfacesErrorInBand(tc.tool)) {
return sendErrorContent(server, id, @errorName(err));
}
const url = browser_tools.currentUrlOrPlaceholder(server.session);
@@ -310,6 +315,12 @@ fn handleScriptHeal(server: *Server, arena: std.mem.Allocator, id: std.json.Valu
return sendErrorContent(server, id, msg);
};
if (args.replacements.len == 0) {
const msg = std.fmt.allocPrint(arena, "healed 0 line(s) in {s}", .{args.path}) catch "ok";
try sendToolResultText(server, id, msg, false);
return;
}
var splices = arena.alloc(script.Replacement, args.replacements.len) catch return sendErrorContent(server, id, "out of memory");
const index = indexLines(arena, content) catch return sendErrorContent(server, id, "out of memory");
@@ -328,6 +339,21 @@ fn handleScriptHeal(server: *Server, arena: std.mem.Allocator, id: std.json.Valu
return sendErrorContent(server, id, @errorName(err));
}
// applyReplacements requires spans in file order and non-overlapping.
// The LLM may emit replacements unordered, and two specs can resolve to
// the same line. Sort by span offset, then reject duplicates so a single
// line can't be healed twice.
std.mem.sort(script.Replacement, splices, {}, struct {
fn lt(_: void, a: script.Replacement, b: script.Replacement) bool {
return @intFromPtr(a.original_span.ptr) < @intFromPtr(b.original_span.ptr);
}
}.lt);
for (splices[1..], splices[0 .. splices.len - 1]) |cur, prev| {
if (@intFromPtr(cur.original_span.ptr) == @intFromPtr(prev.original_span.ptr)) {
return sendErrorContent(server, id, "two replacements target the same original_line; merge them into one entry");
}
}
script.writeAtomic(arena, std.fs.cwd(), args.path, content, splices) catch |err| {
const msg = std.fmt.allocPrint(arena, "failed to write {s}: {s} (script left unchanged)", .{ args.path, @errorName(err) }) catch @errorName(err);
return sendErrorContent(server, id, msg);

View File

@@ -30,12 +30,13 @@
//! heal roundtrip themselves.
const std = @import("std");
const browser_tools = @import("browser/tools.zig");
const BrowserTool = @import("browser/tools.zig").Tool;
pub const Command = @import("script/command.zig").Command;
pub const Iterator = @import("script/Iterator.zig");
pub const Recorder = @import("script/Recorder.zig");
pub const Schema = @import("script/Schema.zig");
pub const Verifier = @import("script/Verifier.zig");
pub const schema = @import("script/schema.zig");
/// Conventions any LLM driving Lightpanda should follow. The standalone
/// agent prepends this to its own system prompt; the MCP server returns
@@ -341,7 +342,7 @@ test "applyReplacements: heals a multi-line /eval block using iterator span" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
var iter: Iterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try std.testing.expect(e1.command == .tool_call);
try std.testing.expectEqualStrings("goto", e1.command.tool_call.name());
@@ -370,8 +371,8 @@ test "applyReplacements: heals a multi-line /eval block using iterator span" {
fn buildToolCall(arena: std.mem.Allocator, name: []const u8, kvs: []const struct { []const u8, []const u8 }) Command {
var obj: std.json.ObjectMap = .init(arena);
for (kvs) |kv| obj.put(kv[0], .{ .string = kv[1] }) catch unreachable;
const action = std.meta.stringToEnum(browser_tools.Action, name).?;
return .{ .tool_call = .{ .action = action, .args = .{ .object = obj } } };
const tool = std.meta.stringToEnum(BrowserTool, name).?;
return .{ .tool_call = .{ .tool = tool, .args = .{ .object = obj } } };
}
test "formatHealReplacement: single command produces one-line replacement" {

289
src/script/Iterator.zig Normal file
View File

@@ -0,0 +1,289 @@
// 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/>.
//! Iterates `.lp` content, gluing multi-line `'''…'''` blocks into a
//! single entry. Comments surface as `.comment` so the replay can attach
//! the preceding comment to the next executable line.
const std = @import("std");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
const BrowserTool = browser_tools.Tool;
const Schema = @import("Schema.zig");
const command = @import("command.zig");
const Command = command.Command;
const Iterator = @This();
allocator: std.mem.Allocator,
lines: std.mem.SplitIterator(u8, .scalar),
line_num: u32,
pub fn init(allocator: std.mem.Allocator, content: []const u8) Iterator {
return .{
.allocator = allocator,
.lines = std.mem.splitScalar(u8, content, '\n'),
.line_num = 0,
};
}
pub const Entry = struct {
line_num: u32,
/// Trimmed opener line; use `raw_span` for splices that need the
/// full block body.
opener_line: []const u8,
/// Slice of the original content buffer covering this entry,
/// trailing newline included. Multi-line blocks span opener
/// through closing triple-quote.
raw_span: []const u8,
command: Command,
};
pub fn next(self: *Iterator) command.ParseError!?Entry {
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
const line_start = @intFromPtr(line.ptr) - @intFromPtr(self.lines.buffer.ptr);
if (tryBlockOpener(trimmed)) |opener| {
const start_line = self.line_num;
const body = (try self.collectMultiLineBlock(opener.quote_type)) orelse {
// Point the error at the opener line, not at EOF where
// collectMultiLineBlock left line_num.
self.line_num = start_line;
return error.UnterminatedQuote;
};
// body is heap-owned by self.allocator (from toOwnedSlice); reclaim
// it if any allocation between here and successful return fails.
errdefer self.allocator.free(body);
const span_end = self.lines.index orelse self.lines.buffer.len;
var obj: std.json.ObjectMap = .init(self.allocator);
try obj.put(opener.field, .{ .string = body });
return .{
.line_num = start_line,
.opener_line = trimmed,
.raw_span = self.lines.buffer[line_start..span_end],
.command = .{ .tool_call = .{
.tool = opener.tool,
.args = .{ .object = obj },
} },
};
}
const span_end = self.lines.index orelse self.lines.buffer.len;
return .{
.line_num = self.line_num,
.opener_line = trimmed,
.raw_span = self.lines.buffer[line_start..span_end],
.command = try Command.parse(self.allocator, trimmed),
};
}
return null;
}
const BlockOpener = struct {
tool: BrowserTool,
field: []const u8,
quote_type: Schema.QuoteType,
};
fn tryBlockOpener(line: []const u8) ?BlockOpener {
if (line.len < 2 or line[0] != '/') return null;
const split = Schema.splitNameRest(line[1..]) orelse return null;
const s = Schema.find(Schema.all(), split.name) orelse return null;
if (!s.isMultiLineCapable()) return null;
const qt = Schema.QuoteType.fromLiteral(split.rest) orelse return null;
return .{ .tool = s.tool, .field = s.required[0], .quote_type = qt };
}
fn collectMultiLineBlock(self: *Iterator, quote_type: Schema.QuoteType) std.mem.Allocator.Error!?[]const u8 {
const closer = quote_type.toLiteral();
var parts: std.ArrayList(u8) = .empty;
defer parts.deinit(self.allocator);
var first = true;
while (self.lines.next()) |line| {
self.line_num += 1;
const scrubbed = std.mem.trimRight(u8, line, "\r");
if (std.mem.eql(u8, scrubbed, closer)) {
return try parts.toOwnedSlice(self.allocator);
}
if (!first) {
try parts.append(self.allocator, '\n');
} else {
first = false;
}
// Trim CR only; full trim would clobber indentation.
try parts.appendSlice(self.allocator, scrubbed);
}
return null;
}
// --- Tests ---
const testing = @import("../testing.zig");
test "basic slash commands" {
const content =
"/goto https://example.com\n" ++
"/tree\n" ++
"/click selector='Login'\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expect(e1.command == .tool_call);
try testing.expectString("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectString("tree", e2.command.tool_call.name());
const e3 = (try iter.next()).?;
try testing.expectString("click", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "multi-line /eval block" {
const content =
"/goto https://x\n" ++
"/eval '''\n" ++
"const x = 1;\n" ++
"return x;\n" ++
"'''\n" ++
"/tree\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expectString("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectString("eval", e2.command.tool_call.name());
const script_value = e2.command.tool_call.args.?.object.get("script").?.string;
try testing.expect(std.mem.indexOf(u8, script_value, "const x = 1;") != null);
try testing.expect(std.mem.indexOf(u8, script_value, "return x;") != null);
const e3 = (try iter.next()).?;
try testing.expectString("tree", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "comments preserve opener_line for context" {
const content =
"# Navigate\n" ++
"/goto https://x\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expect(e1.command == .comment);
try testing.expectString("# Navigate", e1.opener_line);
const e2 = (try iter.next()).?;
try testing.expect(e2.command == .tool_call);
try testing.expect((try iter.next()) == null);
}
test "bare prose in script errors" {
const content = "click the login button\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
try testing.expectError(error.NotASlashCommand, iter.next());
}
test "UnterminatedQuote reports the opener line" {
const content =
"/goto https://x\n" ++
"/eval '''\n" ++
" const x = 1;\n" ++
" return x;\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
_ = (try iter.next()).?;
try testing.expectError(error.UnterminatedQuote, iter.next());
try testing.expectEqual(@as(u32, 2), iter.line_num);
}
test "strips trailing CR from CRLF-authored bodies" {
const content = "/goto https://x\r\n/extract '''\r\n{\"t\":\"h1\"}\r\n'''\r\n/click selector='#x'\r\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expectString("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectString("extract", e2.command.tool_call.name());
try testing.expectString("{\"t\":\"h1\"}", e2.command.tool_call.args.?.object.get("schema").?.string);
const e3 = (try iter.next()).?;
try testing.expectString("click", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "preserves leading blank lines in multiline block" {
const content =
"/eval '''\n" ++
"\n" ++
"const x = 1;\n" ++
"'''\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
const cmd = (try iter.next()).?;
const script_value = cmd.command.tool_call.args.?.object.get("script").?.string;
try testing.expectString("\nconst x = 1;", script_value);
}
test "ignores indented closer delimiters" {
const content =
"/eval '''\n" ++
" const x = '''foo''';\n" ++
"'''\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Iterator = .init(arena.allocator(), content);
const cmd = (try iter.next()).?;
const script_value = cmd.command.tool_call.args.?.object.get("script").?.string;
try testing.expectString(" const x = '''foo''';", script_value);
}

View File

@@ -86,15 +86,7 @@ fn tryRecord(self: *Recorder, cmd: Command) !void {
self.buf.clearRetainingCapacity();
try cmd.format(&self.buf.writer);
try self.buf.writer.writeByte('\n');
// Reverse-substitute any LP_* env-var values that snuck in as literals
// (e.g. an agent that retyped a username it saw via getUrl) so the
// recording stays portable instead of leaking the resolved secret.
_ = self.arena.reset(.retain_capacity);
const scrubbed = lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written()) catch self.buf.written();
try self.file.?.writeAll(scrubbed);
self.lines += 1;
try self.writeScrubbed();
}
pub fn recordComment(self: *Recorder, comment: []const u8) void {
@@ -114,8 +106,20 @@ fn tryRecordComment(self: *Recorder, comment: []const u8) !void {
try self.buf.writer.writeAll(trimmed);
try self.buf.writer.writeByte('\n');
}
try self.file.?.writeAll(self.buf.written());
self.lines += 1;
try self.writeScrubbed();
}
fn writeScrubbed(self: *Recorder) !void {
// Reverse-substitute any LP_* env-var values that snuck in as literals
// (e.g. an agent that retyped a username it saw via getUrl) so the
// recording stays portable instead of leaking the resolved secret.
// Propagate scrub OOM so the recorder disables itself rather than
// silently writing the unscrubbed buffer.
_ = self.arena.reset(.retain_capacity);
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
try self.file.?.writeAll(scrubbed);
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
}
/// Any failure along the record path — buffer-write OOM, scrub OOM, or file
@@ -260,6 +264,33 @@ test "init appends to an existing file without truncating" {
try std.testing.expect(prior < appended);
}
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
extern fn unsetenv(name: [*:0]u8) c_int;
test "recordComment scrubs literal LP_* values back to placeholders" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const var_name = "LP_RECORDER_COMMENT_TEST";
const var_value = "topsecret";
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
defer _ = unsetenv(@constCast(var_name));
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "scrub.lp");
defer recorder.deinit();
recorder.recordComment("a user noted that their password is topsecret");
const file = tmp.dir.openFile("scrub.lp", .{}) catch unreachable;
defer file.close();
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
try std.testing.expectEqualStrings(
"# a user noted that their password is $LP_RECORDER_COMMENT_TEST\n",
buf[0..n],
);
}
test "recordComment splits embedded newlines into separate comment lines" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@@ -361,7 +392,7 @@ test "record and parse: triple-quote round-trip" {
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
var iter: Command.ScriptIterator = .init(aa, content);
var iter: lp.script.Iterator = .init(aa, content);
const entry = (try iter.next()).?;
const parsed_cmd = entry.command;

683
src/script/Schema.zig Normal file
View File

@@ -0,0 +1,683 @@
// 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/>.
//! Cached, schema-extracted view of a single browser tool. Per-tool
//! semantics (record / heal / locator / data) live on `BrowserTool`.
//! `Schema.all()` is the lazy process-wide cache.
const std = @import("std");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
const BrowserTool = browser_tools.Tool;
const Schema = @This();
tool: BrowserTool,
tool_name: []const u8,
description: []const u8,
required: []const []const u8,
fields: []const FieldEntry,
hints: []const HintSlot,
parameters: std.json.Value,
pub const FieldType = enum { string, integer, number, boolean, other };
pub const FieldEntry = struct {
name: []const u8,
field_type: FieldType,
/// Used by `Command.format` to omit `checked=true` when emitting `/setChecked`.
default_true: bool = false,
/// `backendNodeId` is ephemeral, never replayable. Boolean fields
/// matching the schema default are cosmetic noise.
pub fn skipForFormat(self: FieldEntry, v: std.json.Value) bool {
if (std.mem.eql(u8, self.name, "backendNodeId")) return true;
return v == .bool and v.bool and self.default_true;
}
};
/// REPL argument-syntax hint slot. `fragment` is pre-rendered as `<name>`
/// for required and `[name=…]` for optional.
pub const HintSlot = struct {
name: []const u8,
required: bool,
fragment: []const u8,
};
/// Asserted at schema build time so adding a tool with more fields fails loud.
pub const max_hint_slots: usize = 16;
pub const ParseError = error{
MissingName,
UnknownTool,
UnknownField,
MissingRequired,
MalformedKv,
PositionalNotAllowed,
UnterminatedQuote,
OutOfMemory,
};
pub const Split = struct {
name: []const u8,
rest: []const u8,
};
// --- Per-instance methods ---
/// True when the tool can be addressed as `/<tool> '''<body>'''` —
/// sole required field is a string AND no runtime locator needed.
pub fn isMultiLineCapable(self: Schema) bool {
if (self.tool.needsLocator()) return false;
return self.required.len == 1 and self.fieldType(self.required[0]) == .string;
}
pub fn findField(self: Schema, key: []const u8) ?FieldEntry {
for (self.fields) |f| {
if (std.mem.eql(u8, f.name, key)) return f;
}
return null;
}
pub fn fieldType(self: Schema, key: []const u8) FieldType {
if (self.findField(key)) |f| return f.field_type;
return .other;
}
pub fn isFieldDefaultTrue(self: Schema, key: []const u8) bool {
if (self.findField(key)) |f| return f.default_true;
return false;
}
/// `backendNodeId` is ephemeral, never replayable. Boolean fields
/// matching the schema default are cosmetic noise.
pub fn skipForFormat(self: Schema, key: []const u8, v: std.json.Value) bool {
if (self.findField(key)) |f| return f.skipForFormat(v);
return std.mem.eql(u8, key, "backendNodeId");
}
pub fn visibleArgCount(self: Schema, args: std.json.ObjectMap) usize {
var n: usize = 0;
for (self.fields) |f| {
const v = args.get(f.name) orelse continue;
if (f.skipForFormat(v)) continue;
n += 1;
}
return n;
}
pub fn isSinglePositional(self: Schema, args: std.json.ObjectMap) bool {
if (self.required.len != 1) return false;
const v = args.get(self.required[0]) orelse return false;
return v == .string;
}
/// Parse `rest` (args portion of a slash command) into a `std.json.Value`.
/// Returns null when the schema takes no args and `rest` is empty.
///
/// Argument-binding rules:
/// - Bare `{json}` payload returned as-is.
/// - Single leading positional binds to `required[0]` when there's
/// exactly one required. Otherwise positionals error.
/// - Everything else is `key=value` with type coercion.
pub fn parseValue(self: Schema, arena: std.mem.Allocator, rest: []const u8) ParseError!?std.json.Value {
if (rest.len == 0) {
if (self.required.len > 0) return error.MissingRequired;
return null;
}
if (rest[0] == '{') {
var parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, rest, .{}) catch return error.MalformedKv;
// Same validation the kv path applies: reject unknown keys and
// fill default-true required fields when omitted.
if (parsed != .object) return error.MalformedKv;
try self.validateAndFillObject(&parsed.object);
return parsed;
}
const tokens = try tokenize(arena, rest);
const leading_positional = tokens.len >= 1 and !looksLikeKv(tokens[0]);
if (leading_positional and self.required.len != 1) return error.PositionalNotAllowed;
var list = try std.ArrayList(KvPair).initCapacity(arena, tokens.len + self.required.len);
const kv_start: usize = if (leading_positional) 1 else 0;
if (leading_positional) {
list.appendAssumeCapacity(.{ .key = self.required[0], .value = stripQuotes(tokens[0]) });
}
for (tokens[kv_start..]) |tok| {
const eq = std.mem.indexOfScalar(u8, tok, '=') orelse return error.MalformedKv;
if (eq == 0 or eq == tok.len - 1) return error.MalformedKv;
const key = tok[0..eq];
// Reject unknown keys so a typo (`checke=false`) can't be silently
// absorbed while the actual required field gets default-filled.
if (self.findField(key) == null) return error.UnknownField;
list.appendAssumeCapacity(.{ .key = key, .value = stripQuotes(tok[eq + 1 ..]) });
}
// Default-true booleans (e.g. setChecked.checked) so `/setChecked
// selector='#a'` works without `checked=true`.
required: for (self.required) |req| {
for (list.items) |p| if (std.mem.eql(u8, p.key, req)) continue :required;
if (!self.isFieldDefaultTrue(req)) return error.MissingRequired;
list.appendAssumeCapacity(.{ .key = req, .value = "true" });
}
return try self.buildValue(arena, list.items);
}
pub fn validateAndFillObject(self: Schema, obj: *std.json.ObjectMap) ParseError!void {
var it = obj.iterator();
while (it.next()) |entry| {
if (self.findField(entry.key_ptr.*) == null) return error.UnknownField;
}
for (self.required) |req| {
if (obj.contains(req)) continue;
if (!self.isFieldDefaultTrue(req)) return error.MissingRequired;
try obj.put(req, .{ .bool = true });
}
}
const KvPair = struct {
key: []const u8,
value: []const u8,
};
fn buildValue(self: Schema, arena: std.mem.Allocator, pairs: []const KvPair) error{OutOfMemory}!std.json.Value {
var obj: std.json.ObjectMap = .init(arena);
try obj.ensureTotalCapacity(pairs.len);
for (pairs) |p| {
const v = try self.coerce(arena, p.key, p.value);
try obj.put(p.key, v);
}
return .{ .object = obj };
}
fn coerce(self: Schema, arena: std.mem.Allocator, key: []const u8, value: []const u8) error{OutOfMemory}!std.json.Value {
switch (self.fieldType(key)) {
.integer => {
if (std.fmt.parseInt(i64, value, 10)) |n| return .{ .integer = n } else |_| {}
},
.number => {
if (std.fmt.parseFloat(f64, value)) |n| return .{ .float = n } else |_| {}
},
.boolean => {
if (std.mem.eql(u8, value, "true")) return .{ .bool = true };
if (std.mem.eql(u8, value, "false")) return .{ .bool = false };
},
else => {},
}
return .{ .string = try arena.dupe(u8, value) };
}
// --- Module-level helpers ---
/// Split a slash-command body into `<name> <rest>`. Null on empty input.
pub fn splitNameRest(input: []const u8) ?Split {
const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
if (trimmed.len == 0) return null;
const name_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
return .{
.name = trimmed[0..name_end],
.rest = std.mem.trim(u8, trimmed[name_end..], &std.ascii.whitespace),
};
}
pub fn find(schemas: []const Schema, name: []const u8) ?*const Schema {
if (std.meta.stringToEnum(BrowserTool, name)) |tool| {
const idx = @intFromEnum(tool);
if (idx < schemas.len) return &schemas[idx];
}
for (schemas) |*s| {
if (std.ascii.eqlIgnoreCase(s.tool_name, name)) return s;
}
return null;
}
/// Lazy process-wide cache, keyed by `@intFromEnum(BrowserTool)`.
/// Panics on init failure — `tool_defs` is comptime-constant, so any
/// parse/build error is a build-time bug.
pub fn all() []const Schema {
global_once.call();
return global_storage[0..browser_tools.tool_defs.len];
}
var global_storage: [browser_tools.tool_defs.len]Schema = undefined;
var global_arena: std.heap.ArenaAllocator = undefined;
var global_once = std.once(initGlobal);
fn initGlobal() void {
global_arena = .init(std.heap.page_allocator);
const a = global_arena.allocator();
for (browser_tools.tool_defs, 0..) |td, i| {
const tool: BrowserTool = @enumFromInt(i);
const parsed = std.json.parseFromSliceLeaky(std.json.Value, a, td.input_schema, .{}) catch |err| {
std.debug.panic("failed to parse schema for tool '{s}': {s}", .{ @tagName(tool), @errorName(err) });
};
global_storage[i] = buildOne(a, tool, td, parsed) catch |err| {
std.debug.panic("failed to build schema for tool '{s}': {s}", .{ @tagName(tool), @errorName(err) });
};
}
}
fn buildOne(arena: std.mem.Allocator, tool: BrowserTool, td: BrowserTool.Definition, parsed: std.json.Value) !Schema {
var info: Schema = .{
.tool = tool,
.tool_name = @tagName(tool),
.description = td.description,
.required = &.{},
.fields = &.{},
.hints = &.{},
.parameters = parsed,
};
if (parsed != .object) return info;
if (parsed.object.get("required")) |req| {
if (req == .array) {
var reqs: std.ArrayList([]const u8) = .empty;
try reqs.ensureTotalCapacity(arena, req.array.items.len);
for (req.array.items) |item| {
if (item != .string) continue;
reqs.appendAssumeCapacity(item.string);
}
info.required = try reqs.toOwnedSlice(arena);
}
}
if (parsed.object.get("properties")) |props| {
if (props == .object) {
const map = props.object;
const fields = try arena.alloc(FieldEntry, map.count());
var it = map.iterator();
for (fields) |*f| {
const entry = it.next().?;
f.* = .{
.name = entry.key_ptr.*,
.field_type = fieldTypeOf(entry.value_ptr.*),
.default_true = booleanDefaultTrue(entry.value_ptr.*),
};
}
info.fields = fields;
}
}
info.hints = try buildHints(arena, info.required, info.fields);
std.debug.assert(info.hints.len <= max_hint_slots);
return info;
}
fn buildHints(arena: std.mem.Allocator, required: []const []const u8, fields: []const FieldEntry) ![]const HintSlot {
if (fields.len == 0 and required.len == 0) return &.{};
var optional_count: usize = 0;
for (fields) |f| {
if (!containsName(required, f.name)) optional_count += 1;
}
const out = try arena.alloc(HintSlot, required.len + optional_count);
var idx: usize = 0;
defer std.debug.assert(idx == out.len);
for (required) |name| {
out[idx] = .{
.name = name,
.required = true,
.fragment = try std.fmt.allocPrint(arena, "<{s}>", .{name}),
};
idx += 1;
}
for (fields) |f| {
if (containsName(required, f.name)) continue;
out[idx] = .{
.name = f.name,
.required = false,
.fragment = try std.fmt.allocPrint(arena, "[{s}=…]", .{f.name}),
};
idx += 1;
}
return out;
}
fn containsName(names: []const []const u8, target: []const u8) bool {
for (names) |n| if (std.mem.eql(u8, n, target)) return true;
return false;
}
fn fieldTypeOf(value: std.json.Value) FieldType {
if (value != .object) return .other;
const ty = value.object.get("type") orelse return .other;
if (ty != .string) return .other;
return std.meta.stringToEnum(FieldType, ty.string) orelse .other;
}
fn booleanDefaultTrue(value: std.json.Value) bool {
if (value != .object) return false;
const d = value.object.get("default") orelse return false;
return d == .bool and d.bool;
}
/// Tokenize on whitespace. `"…"` and `'…'` (single or triple) are kept
/// whole; quote stripping happens later. Tokens may contain `=`.
fn tokenize(arena: std.mem.Allocator, input: []const u8) ParseError![][]const u8 {
var out: std.ArrayList([]const u8) = .empty;
var i: usize = 0;
while (i < input.len) {
while (i < input.len and std.ascii.isWhitespace(input[i])) i += 1;
if (i >= input.len) break;
const tok_start = i;
while (i < input.len and !std.ascii.isWhitespace(input[i])) : (i += 1) {
const ch = input[i];
if (ch == '"' or ch == '\'') {
const is_triple = i + 2 < input.len and input[i + 1] == ch and input[i + 2] == ch;
if (is_triple) {
const triple_delim = input[i .. i + 3];
const close = std.mem.indexOfPos(u8, input, i + 3, triple_delim) orelse return error.UnterminatedQuote;
i = close + 2;
} else {
const close = std.mem.indexOfScalarPos(u8, input, i + 1, ch) orelse return error.UnterminatedQuote;
i = close;
}
}
}
try out.append(arena, input[tok_start..i]);
}
return try out.toOwnedSlice(arena);
}
fn stripQuotes(raw: []const u8) []const u8 {
if (raw.len >= 6) {
if (std.mem.startsWith(u8, raw, "'''") and std.mem.endsWith(u8, raw, "'''")) {
return raw[3 .. raw.len - 3];
}
if (std.mem.startsWith(u8, raw, "\"\"\"") and std.mem.endsWith(u8, raw, "\"\"\"")) {
return raw[3 .. raw.len - 3];
}
}
if (raw.len >= 2) {
const first = raw[0];
const last = raw[raw.len - 1];
if ((first == '\'' and last == '\'') or (first == '"' and last == '"')) {
return raw[1 .. raw.len - 1];
}
}
return raw;
}
/// Quoted positionals (`'https://x?id=42'`) must not be misread as kv —
/// only look for `=` in the unquoted prefix.
fn looksLikeKv(tok: []const u8) bool {
if (tok.len == 0) return false;
if (tok[0] == '\'' or tok[0] == '"') return false;
const end = std.mem.indexOfAny(u8, tok, "'\"") orelse tok.len;
return std.mem.indexOfScalar(u8, tok[0..end], '=') != null;
}
// --- Recorder-side formatting primitives ---
//
// Counterparts to `parseValue` / `tokenize` above. Kept here so the
// format → parse round-trip lives in one file.
pub const QuoteType = enum {
triple_double,
triple_single,
pub fn fromLiteral(s: []const u8) ?QuoteType {
return if (s.len == 3) fromPrefix(s) else null;
}
pub fn fromPrefix(s: []const u8) ?QuoteType {
if (std.mem.startsWith(u8, s, "\"\"\"")) return .triple_double;
if (std.mem.startsWith(u8, s, "'''")) return .triple_single;
return null;
}
pub fn toLiteral(self: QuoteType) []const u8 {
return switch (self) {
.triple_double => "\"\"\"",
.triple_single => "'''",
};
}
/// Pick a triple-quote delimiter not appearing in `body`. Null when
/// both appear and neither can wrap unambiguously.
pub fn pickFor(body: []const u8) ?QuoteType {
const has_single = std.mem.indexOf(u8, body, "'''") != null;
const has_double = std.mem.indexOf(u8, body, "\"\"\"") != null;
if (has_single and has_double) return null;
if (has_single) return .triple_double;
return .triple_single;
}
};
/// `body=true`: string is emitted as a `'''…'''` block (newlines OK).
/// `body=false`: single-line kv quoting (no newlines representable).
pub fn quotableInline(s: []const u8, body: bool) bool {
const has_triple_single = std.mem.indexOf(u8, s, "'''") != null;
const has_triple_double = std.mem.indexOf(u8, s, "\"\"\"") != null;
if (body) return !(has_triple_single and has_triple_double);
if (std.mem.indexOfScalar(u8, s, '\n') != null) return false;
const has_single = std.mem.indexOfScalar(u8, s, '\'') != null;
const has_double = std.mem.indexOfScalar(u8, s, '"') != null;
if (has_single and has_double) return !(has_triple_single and has_triple_double);
return true;
}
pub fn writeBodyString(writer: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
if (std.mem.indexOfScalar(u8, s, '\n') != null) {
const q = (QuoteType.pickFor(s) orelse return error.AmbiguousQuoting).toLiteral();
try writer.writeAll(q);
try writer.writeByte('\n');
try writer.writeAll(s);
try writer.writeByte('\n');
try writer.writeAll(q);
return;
}
try writeQuoted(writer, s);
}
pub fn writeInlineValue(writer: *std.Io.Writer, v: std.json.Value) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
switch (v) {
.string => |s| try writeQuoted(writer, s),
.integer => |n| try writer.print("{d}", .{n}),
.float => |n| try writer.print("{d}", .{n}),
.bool => |b| try writer.writeAll(if (b) "true" else "false"),
.null => try writer.writeAll("null"),
else => std.json.Stringify.value(v, .{}, writer) catch return error.WriteFailed,
}
}
/// Caller must filter via `quotableInline` first; remaining ambiguous
/// cases trap as `WriteFailed` so a stray path can't emit a broken line.
pub fn writeQuoted(writer: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
if (std.mem.indexOfScalar(u8, s, '\n') != null) return error.WriteFailed;
const has_single = std.mem.indexOfScalar(u8, s, '\'') != null;
const has_double = std.mem.indexOfScalar(u8, s, '"') != null;
if (has_single and has_double) {
const q = (QuoteType.pickFor(s) orelse return error.AmbiguousQuoting).toLiteral();
try writer.writeAll(q);
try writer.writeAll(s);
try writer.writeAll(q);
return;
}
const q: u8 = if (has_single) '"' else '\'';
try writer.writeByte(q);
try writer.writeAll(s);
try writer.writeByte(q);
}
// --- Tests ---
const testing = @import("../testing.zig");
test "all: comptime tool defs reduce cleanly" {
const schemas = Schema.all();
try testing.expect(schemas.len == browser_tools.tool_defs.len);
const goto = Schema.find(schemas, "goto").?;
try testing.expect(goto.isMultiLineCapable());
try testing.expect(goto.tool.isRecorded());
const scroll = Schema.find(schemas, "scroll").?;
try testing.expect(!scroll.isMultiLineCapable());
try testing.expect(scroll.tool.isRecorded());
const tree = Schema.find(schemas, "tree").?;
try testing.expect(!tree.tool.isRecorded());
try testing.expect(tree.tool.producesData());
const set_checked = Schema.find(schemas, "setChecked").?;
var checked_default_true = false;
for (set_checked.fields) |f| {
if (std.mem.eql(u8, f.name, "checked")) checked_default_true = f.default_true;
}
try testing.expect(checked_default_true);
}
test "parseValue: single-required positional binds" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "https://example.com")).?;
try testing.expectString("https://example.com", v.object.get("url").?.string);
}
test "parseValue: positional then kv tail" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "https://example.com timeout=5000")).?;
try testing.expectString("https://example.com", v.object.get("url").?.string);
try testing.expectEqual(@as(i64, 5000), v.object.get("timeout").?.integer);
}
test "parseValue: kv-only multi-required" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const fill = Schema.find(Schema.all(), "fill").?;
const v = (try fill.parseValue(arena.allocator(), "selector='#email' value='foo@x.com'")).?;
try testing.expectString("#email", v.object.get("selector").?.string);
try testing.expectString("foo@x.com", v.object.get("value").?.string);
}
test "parseValue: kv-only zero-required" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const scroll = Schema.find(Schema.all(), "scroll").?;
const v = (try scroll.parseValue(arena.allocator(), "y=200")).?;
try testing.expectEqual(@as(i64, 200), v.object.get("y").?.integer);
}
test "parseValue: missing required errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
try testing.expectError(error.MissingRequired, goto.parseValue(arena.allocator(), ""));
}
test "parseValue: positional with zero-required schema errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const find_el = Schema.find(Schema.all(), "findElement").?;
try testing.expectError(error.PositionalNotAllowed, find_el.parseValue(arena.allocator(), "button"));
}
test "parseValue: unknown field is rejected, not absorbed into default-true fill" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const set_checked = Schema.find(Schema.all(), "setChecked").?;
// Typo `checke=false`: must error, not silently default `checked=true`.
try testing.expectError(error.UnknownField, set_checked.parseValue(arena.allocator(), "selector='#x' checke=false"));
}
test "parseValue: setChecked defaults checked=true when omitted" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const set_checked = Schema.find(Schema.all(), "setChecked").?;
const v = (try set_checked.parseValue(arena.allocator(), "selector='#agree'")).?;
try testing.expectString("#agree", v.object.get("selector").?.string);
try testing.expect(v.object.get("checked").?.bool);
}
test "parseValue: zero-arg tool returns null when rest empty" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const get_cookies = Schema.find(Schema.all(), "getCookies").?;
try testing.expect((try get_cookies.parseValue(arena.allocator(), "")) == null);
}
test "parseValue: quoted positional with '=' in body is not mistaken for kv" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "'https://example.com?id=42'")).?;
try testing.expectString("https://example.com?id=42", v.object.get("url").?.string);
}
test "parseValue: bare JSON passthrough" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const find_el = Schema.find(Schema.all(), "findElement").?;
const v = (try find_el.parseValue(arena.allocator(), "{\"role\":\"button\"}")).?;
try testing.expectString("button", v.object.get("role").?.string);
}
test "parseValue: bare JSON enforces required, default-true, and unknown keys" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// `checked` is required but default-true: empty object fills it.
const set_checked = Schema.find(Schema.all(), "setChecked").?;
const filled = (try set_checked.parseValue(arena.allocator(), "{\"selector\":\"#x\"}")).?;
try testing.expect(filled.object.get("checked").?.bool);
// Unknown key in JSON must error.
try testing.expectError(error.UnknownField, set_checked.parseValue(arena.allocator(), "{\"selector\":\"#x\",\"checke\":false}"));
// Required field without a default must error MissingRequired.
const goto = Schema.find(Schema.all(), "goto").?;
try testing.expectError(error.MissingRequired, goto.parseValue(arena.allocator(), "{}"));
}
test "splitNameRest: trims and handles empty" {
try testing.expect(Schema.splitNameRest("") == null);
try testing.expect(Schema.splitNameRest(" ") == null);
const r = Schema.splitNameRest(" goto https://x ").?;
try testing.expectString("goto", r.name);
try testing.expectString("https://x", r.rest);
}
test "tokenize: inline triple quotes with spaces" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const tokens = try tokenize(arena.allocator(), "selector='''hello world''' value=\"\"\"foo bar\"\"\"");
try testing.expectEqual(@as(usize, 2), tokens.len);
try testing.expectString("selector='''hello world'''", tokens[0]);
try testing.expectString("value=\"\"\"foo bar\"\"\"", tokens[1]);
}
test "parseValue: rejects non-object JSON payloads" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
try testing.expectError(error.MalformedKv, goto.parseValue(arena.allocator(), "[1, 2, 3]"));
// "\"hello\"" is a valid positional argument, not a JSON payload, so it should succeed
const v = (try goto.parseValue(arena.allocator(), "\"hello\"")).?;
try testing.expectString("hello", v.object.get("url").?.string);
}

View File

@@ -54,6 +54,11 @@ const failed_reason_oom = "verification failed (out of memory while formatting r
/// when the command did not hard-fail (ToolResult.is_error == false).
/// Commands without a dedicated verifier return `.inconclusive` so callers
/// can distinguish "no verification available" from "explicitly verified".
///
/// backendNodeId-addressed commands are intentionally `.inconclusive`: the
/// id is a CDP-side handle with no in-page accessor, and recorded paths use
/// CSS selectors per `mcp_driver_guidance` (backendNodeId calls can't be
/// recorded as PandaScript anyway).
pub fn verify(self: *Verifier, arena: std.mem.Allocator, cmd: Command) VerifyResult {
const tc = switch (cmd) {
.tool_call => |t| t,
@@ -64,7 +69,7 @@ pub fn verify(self: *Verifier, arena: std.mem.Allocator, cmd: Command) VerifyRes
const selector = (args.object.get("selector") orelse return .inconclusive);
if (selector != .string) return .inconclusive;
switch (tc.action) {
switch (tc.tool) {
.fill => {
const value = args.object.get("value") orelse return .inconclusive;
if (value != .string) return .inconclusive;

View File

@@ -16,17 +16,16 @@
// 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/>.
//! PandaScript Command: a slash command, `#`-comment, or `/login` /
//! `/acceptCookies` LLM trigger. Bare prose is the REPL's job, not the parser's.
//! Multi-line `'''…'''` blocks are assembled by `ScriptIterator` before parse.
//! PandaScript Command: slash command, `#`-comment, or `/login` /
//! `/acceptCookies` LLM trigger. Multi-line `'''…'''` blocks are
//! assembled by `script.Iterator` before parse.
const std = @import("std");
const lp = @import("lightpanda");
const zenai = @import("zenai");
const browser_tools = lp.tools;
const schema = @import("schema.zig");
const BrowserTool = lp.tools.Tool;
const Schema = @import("Schema.zig");
pub const ParseError = schema.ParseError || error{
pub const ParseError = Schema.ParseError || error{
NotASlashCommand,
};
@@ -37,40 +36,89 @@ pub const Command = union(enum) {
comment: void,
pub const ToolCall = struct {
action: browser_tools.Action,
tool: BrowserTool,
args: ?std.json.Value,
pub fn name(self: ToolCall) [:0]const u8 {
return @tagName(self.action);
return @tagName(self.tool);
}
pub fn schema(self: ToolCall) *const Schema {
return &Schema.all()[@intFromEnum(self.tool)];
}
/// Skip the line when the recorded form would not round-trip:
/// - no `selector` AND (tool needs one OR only locator is the
/// ephemeral `backendNodeId`);
/// - a string field can't be quoted unambiguously.
pub fn isRecorded(self: ToolCall) bool {
if (!self.tool.isRecorded()) return false;
const s = self.schema();
const args = self.args orelse return s.required.len == 0;
if (args != .object) return !self.tool.needsLocator();
const has_selector = args.object.contains("selector");
if (!has_selector and (self.tool.needsLocator() or args.object.contains("backendNodeId"))) return false;
const visible = s.visibleArgCount(args.object);
const positional = s.required.len == 1 and visible == 1 and s.isSinglePositional(args.object);
var it = args.object.iterator();
while (it.next()) |entry| {
if (s.skipForFormat(entry.key_ptr.*, entry.value_ptr.*)) continue;
if (entry.value_ptr.* != .string) continue;
const is_body = positional and std.mem.eql(u8, entry.key_ptr.*, s.required[0]);
if (!Schema.quotableInline(entry.value_ptr.string, is_body)) return false;
}
return true;
}
/// Canonical recorder format. Round-trips with `Command.parse`.
pub fn format(self: ToolCall, writer: *std.Io.Writer) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
const s = self.schema();
try writer.writeByte('/');
try writer.writeAll(s.tool_name);
const args_val = self.args orelse return;
if (args_val != .object) return;
const args = args_val.object;
if (args.count() == 0) return;
const visible = s.visibleArgCount(args);
const positional = s.required.len == 1 and visible == 1 and s.isSinglePositional(args);
if (positional) {
const v = args.get(s.required[0]).?;
try writer.writeByte(' ');
try Schema.writeBodyString(writer, v.string);
return;
}
// Iterate the schema (not the ObjectMap) so the line order is
// stable across providers — MCP script_heal looks lines up
// verbatim.
for (s.fields) |f| {
const v = args.get(f.name) orelse continue;
if (f.skipForFormat(v)) continue;
try writer.writeByte(' ');
try writer.writeAll(f.name);
try writer.writeByte('=');
try Schema.writeInlineValue(writer, v);
}
}
};
fn schemaOf(tc: ToolCall) *const schema.SchemaInfo {
return &schema.globalSchemas()[@intFromEnum(tc.action)];
}
pub fn isRecorded(self: Command) bool {
return switch (self) {
.comment => false,
.login, .accept_cookies => true,
.tool_call => |tc| blk: {
const s = schemaOf(tc);
if (!s.recorded) break :blk false;
const args = tc.args orelse break :blk s.required.len == 0;
if (args != .object) break :blk true;
// backendNodeId is invalidated by any DOM mutation, so it's
// never replayable. Drop the line only when it's the sole
// identifier; selector-bearing calls are still recordable
// (formatToolCall strips backendNodeId from the output).
if (args.object.contains("backendNodeId") and !args.object.contains("selector")) break :blk false;
break :blk true;
},
.tool_call => |tc| tc.isRecorded(),
};
}
pub fn producesData(self: Command) bool {
return switch (self) {
.tool_call => |tc| schemaOf(tc).produces_data,
.tool_call => |tc| tc.tool.producesData(),
else => false,
};
}
@@ -84,7 +132,7 @@ pub const Command = union(enum) {
pub fn canHeal(self: Command) bool {
return switch (self) {
.tool_call => |tc| schemaOf(tc).can_heal,
.tool_call => |tc| tc.tool.canHeal(),
else => false,
};
}
@@ -95,7 +143,7 @@ pub const Command = union(enum) {
if (trimmed[0] == '#') return .{ .comment = {} };
if (trimmed[0] != '/') return error.NotASlashCommand;
const split = schema.splitNameRest(trimmed[1..]) orelse return error.MissingName;
const split = Schema.splitNameRest(trimmed[1..]) orelse return error.MissingName;
if (std.ascii.eqlIgnoreCase(split.name, "login")) {
if (split.rest.len > 0) return error.MalformedKv;
@@ -106,266 +154,33 @@ pub const Command = union(enum) {
return .{ .accept_cookies = {} };
}
const s = schema.findSchema(schema.globalSchemas(), split.name) orelse return error.UnknownTool;
const args = try schema.parseValue(arena, s, split.rest);
return .{ .tool_call = .{ .action = s.action, .args = args } };
const s = Schema.find(Schema.all(), split.name) orelse return error.UnknownTool;
const args = try s.parseValue(arena, split.rest);
return .{ .tool_call = .{ .tool = s.tool, .args = args } };
}
/// Canonical recorder format. Round-trips with `parse`.
pub fn format(self: Command, writer: *std.Io.Writer) std.Io.Writer.Error!void {
pub fn format(self: Command, writer: *std.Io.Writer) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
switch (self) {
.login => try writer.writeAll("/login"),
.accept_cookies => try writer.writeAll("/acceptCookies"),
.comment => try writer.writeAll("#"),
.tool_call => |tc| try formatToolCall(tc, writer),
.tool_call => |tc| try tc.format(writer),
}
}
/// `arguments` must outlive the returned Command — use `fromToolCallOwned`
/// to deep-copy when it doesn't.
pub fn fromToolCall(action: browser_tools.Action, arguments: ?std.json.Value) Command {
return .{ .tool_call = .{ .action = action, .args = arguments } };
}
pub fn fromToolCallOwned(arena: std.mem.Allocator, action: browser_tools.Action, arguments: ?std.json.Value) std.mem.Allocator.Error!Command {
const owned_args = if (arguments) |v| try zenai.json.dupeValue(arena, v) else null;
return .{ .tool_call = .{ .action = action, .args = owned_args } };
}
/// Iterates `.lp` content, gluing multi-line `'''…'''` blocks into a
/// single entry. Comments surface as `.comment` so the replay can attach
/// the preceding comment to the next executable line.
pub const ScriptIterator = struct {
allocator: std.mem.Allocator,
lines: std.mem.SplitIterator(u8, .scalar),
line_num: u32,
pub fn init(allocator: std.mem.Allocator, content: []const u8) ScriptIterator {
return .{
.allocator = allocator,
.lines = std.mem.splitScalar(u8, content, '\n'),
.line_num = 0,
};
}
pub const Entry = struct {
line_num: u32,
/// Trimmed opener line; use `raw_span` for splices that need the
/// full block body.
opener_line: []const u8,
/// Slice of the original content buffer covering this entry,
/// trailing newline included. Multi-line blocks span opener
/// through closing triple-quote.
raw_span: []const u8,
command: Command,
};
pub fn next(self: *ScriptIterator) ParseError!?Entry {
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
const line_start = @intFromPtr(line.ptr) - @intFromPtr(self.lines.buffer.ptr);
if (tryBlockOpener(trimmed)) |opener| {
const start_line = self.line_num;
const body = try self.collectMultiLineBlock(opener.quote_type);
const span_end = self.lines.index orelse self.lines.buffer.len;
if (body == null) {
// Point the error at the opener line, not at EOF
// (where collectMultiLineBlock left line_num after
// scanning the rest of the file for the closer).
self.line_num = start_line;
return error.UnterminatedQuote;
}
var obj: std.json.ObjectMap = .init(self.allocator);
try obj.put(opener.field, .{ .string = body.? });
return .{
.line_num = start_line,
.opener_line = trimmed,
.raw_span = self.lines.buffer[line_start..span_end],
.command = .{ .tool_call = .{
.action = opener.action,
.args = .{ .object = obj },
} },
};
}
const span_end = self.lines.index orelse self.lines.buffer.len;
return .{
.line_num = self.line_num,
.opener_line = trimmed,
.raw_span = self.lines.buffer[line_start..span_end],
.command = try Command.parse(self.allocator, trimmed),
};
}
return null;
}
const BlockOpener = struct {
action: browser_tools.Action,
field: []const u8,
quote_type: QuoteType,
};
fn tryBlockOpener(line: []const u8) ?BlockOpener {
if (line.len < 2 or line[0] != '/') return null;
const split = schema.splitNameRest(line[1..]) orelse return null;
const s = schema.findSchema(schema.globalSchemas(), split.name) orelse return null;
if (!s.isMultiLineCapable()) return null;
const qt = QuoteType.fromLiteral(split.rest) orelse return null;
return .{ .action = s.action, .field = s.required[0], .quote_type = qt };
}
fn collectMultiLineBlock(self: *ScriptIterator, quote_type: QuoteType) std.mem.Allocator.Error!?[]const u8 {
const closer = quote_type.toLiteral();
var parts: std.ArrayList(u8) = .empty;
defer parts.deinit(self.allocator);
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, closer)) {
return try parts.toOwnedSlice(self.allocator);
}
if (parts.items.len > 0) {
try parts.append(self.allocator, '\n');
}
// Trim CR only; full trim would clobber indentation.
try parts.appendSlice(self.allocator, std.mem.trimRight(u8, line, "\r"));
}
return null;
}
};
};
// --- Formatting ---
fn formatToolCall(tc: Command.ToolCall, writer: *std.Io.Writer) std.Io.Writer.Error!void {
const s = &schema.globalSchemas()[@intFromEnum(tc.action)];
try writer.writeByte('/');
try writer.writeAll(s.tool_name);
const args_val = tc.args orelse return;
if (args_val != .object) return;
const args = args_val.object;
if (args.count() == 0) return;
// Positional form `/goto '<url>'` only when args reduce to the single
// required field; extra fields force kv so recordings stay unambiguous.
var positional_emitted: ?[]const u8 = null;
{
const has_one_required = s.required.len == 1;
var visible: usize = 0;
var it_v = args.iterator();
while (it_v.next()) |entry| {
if (skipForFormat(s, entry.key_ptr.*, entry.value_ptr.*)) continue;
visible += 1;
}
if (has_one_required and visible == 1) blk: {
const req_name = s.required[0];
const v = args.get(req_name) orelse break :blk;
if (v != .string) break :blk;
try writer.writeByte(' ');
try formatString(writer, v.string);
positional_emitted = req_name;
}
}
var it = args.iterator();
while (it.next()) |entry| {
const key = entry.key_ptr.*;
if (positional_emitted) |p| if (std.mem.eql(u8, key, p)) continue;
if (skipForFormat(s, key, entry.value_ptr.*)) continue;
try writer.writeByte(' ');
try writer.writeAll(key);
try writer.writeByte('=');
try formatKvValue(writer, entry.value_ptr.*);
}
}
/// Args that the recorder must NOT emit:
/// - `backendNodeId`: ephemeral identifier, never replayable.
/// - boolean fields whose value equals the schema default (cosmetic).
fn skipForFormat(s: *const schema.SchemaInfo, key: []const u8, v: std.json.Value) bool {
if (std.mem.eql(u8, key, "backendNodeId")) return true;
return v == .bool and v.bool and s.isFieldDefaultTrue(key);
}
fn formatString(writer: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void {
if (std.mem.indexOfScalar(u8, s, '\n') != null) {
const q = QuoteType.pickFor(s).toLiteral();
try writer.writeAll(q);
try writer.writeByte('\n');
try writer.writeAll(s);
try writer.writeByte('\n');
try writer.writeAll(q);
return;
}
try writeQuoted(writer, s);
}
fn formatKvValue(writer: *std.Io.Writer, v: std.json.Value) std.Io.Writer.Error!void {
switch (v) {
.string => |s| try formatString(writer, s),
.integer => |n| try writer.print("{d}", .{n}),
.float => |n| try writer.print("{d}", .{n}),
.bool => |b| try writer.writeAll(if (b) "true" else "false"),
.null => try writer.writeAll("null"),
else => std.json.Stringify.value(v, .{}, writer) catch return error.WriteFailed,
}
}
fn writeQuoted(writer: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void {
const has_single = std.mem.indexOfScalar(u8, s, '\'') != null;
const has_double = std.mem.indexOfScalar(u8, s, '"') != null;
if (has_single and has_double) {
const q = QuoteType.pickFor(s).toLiteral();
try writer.writeAll(q);
try writer.writeAll(s);
try writer.writeAll(q);
return;
}
const q: u8 = if (has_single) '"' else '\'';
try writer.writeByte(q);
try writer.writeAll(s);
try writer.writeByte(q);
}
// --- Quoting primitives (kept for ScriptIterator block-opener detection) ---
pub const QuoteType = enum {
triple_double,
triple_single,
pub fn fromLiteral(s: []const u8) ?QuoteType {
return if (s.len == 3) fromPrefix(s) else null;
}
pub fn fromPrefix(s: []const u8) ?QuoteType {
if (std.mem.startsWith(u8, s, "\"\"\"")) return .triple_double;
if (std.mem.startsWith(u8, s, "'''")) return .triple_single;
return null;
}
pub fn toLiteral(self: QuoteType) []const u8 {
return switch (self) {
.triple_double => "\"\"\"",
.triple_single => "'''",
};
}
/// Default `'''`; swaps to `"""` only when the body already contains `'''`.
pub fn pickFor(body: []const u8) QuoteType {
if (std.mem.indexOf(u8, body, "'''") != null) return .triple_double;
return .triple_single;
/// `arguments` must outlive the returned Command. Callers that hand the
/// Command to anything past the args' arena lifetime (e.g. heal, which
/// reuses cmds after `RunToolsResult.deinit`) must deep-copy the arguments
/// into their own arena before calling this.
pub fn fromToolCall(tool: BrowserTool, arguments: ?std.json.Value) Command {
return .{ .tool_call = .{ .tool = tool, .args = arguments } };
}
};
// --- Tests ---
const testing = std.testing;
const testing = @import("../testing.zig");
test "parse: blank and # lines are comments" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
@@ -394,8 +209,8 @@ test "parse: /goto positional" {
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/goto https://example.com");
try testing.expect(cmd == .tool_call);
try testing.expectEqualStrings("goto", cmd.tool_call.name());
try testing.expectEqualStrings("https://example.com", cmd.tool_call.args.?.object.get("url").?.string);
try testing.expectString("goto", cmd.tool_call.name());
try testing.expectString("https://example.com", cmd.tool_call.args.?.object.get("url").?.string);
}
test "parse: /click rejects positional (zero required fields)" {
@@ -403,7 +218,7 @@ test "parse: /click rejects positional (zero required fields)" {
defer arena.deinit();
try testing.expectError(error.PositionalNotAllowed, Command.parse(arena.allocator(), "/click 'Login'"));
const cmd = try Command.parse(arena.allocator(), "/click selector='Login'");
try testing.expectEqualStrings("Login", cmd.tool_call.args.?.object.get("selector").?.string);
try testing.expectString("Login", cmd.tool_call.args.?.object.get("selector").?.string);
}
test "parse: /scroll y=200" {
@@ -417,7 +232,7 @@ test "parse: /setChecked omits checked (default-true)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#agree'");
try testing.expectEqualStrings("#agree", cmd.tool_call.args.?.object.get("selector").?.string);
try testing.expectString("#agree", cmd.tool_call.args.?.object.get("selector").?.string);
try testing.expect(cmd.tool_call.args.?.object.get("checked").?.bool);
}
@@ -434,7 +249,7 @@ test "format: /goto round-trip" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/goto 'https://example.com'", aw.written());
try testing.expectString("/goto 'https://example.com'", aw.written());
}
test "format: /click stays kv (zero required fields)" {
@@ -444,7 +259,7 @@ test "format: /click stays kv (zero required fields)" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/click selector='Login'", aw.written());
try testing.expectString("/click selector='Login'", aw.written());
}
test "format: /eval emits triple-quote block for multi-line script" {
@@ -455,12 +270,12 @@ test "format: /eval emits triple-quote block for multi-line script" {
try obj.put("script", .{ .string = "const x = 1;\nreturn x;" });
break :blk std.json.Value{ .object = obj };
};
const cmd: Command = .{ .tool_call = .{ .action = .eval, .args = args } };
const cmd: Command = .{ .tool_call = .{ .tool = .eval, .args = args } };
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/eval '''\nconst x = 1;\nreturn x;\n'''", aw.written());
try testing.expectString("/eval '''\nconst x = 1;\nreturn x;\n'''", aw.written());
}
test "format: /setChecked omits checked=true (matches default)" {
@@ -470,7 +285,7 @@ test "format: /setChecked omits checked=true (matches default)" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/setChecked selector='#agree'", aw.written());
try testing.expectString("/setChecked selector='#agree'", aw.written());
}
test "format: /setChecked keeps checked=false (non-default)" {
@@ -480,19 +295,19 @@ test "format: /setChecked keeps checked=false (non-default)" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/setChecked selector='#x' checked=false", aw.written());
try testing.expectString("/setChecked selector='#x' checked=false", aw.written());
}
test "format: /login and /acceptCookies" {
var aw1: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw1.deinit();
try (Command{ .login = {} }).format(&aw1.writer);
try testing.expectEqualStrings("/login", aw1.written());
try testing.expectString("/login", aw1.written());
var aw2: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw2.deinit();
try (Command{ .accept_cookies = {} }).format(&aw2.writer);
try testing.expectEqualStrings("/acceptCookies", aw2.written());
try testing.expectString("/acceptCookies", aw2.written());
}
test "isRecorded / canHeal / producesData via tool flags" {
@@ -540,7 +355,7 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/click selector='#submit'", aw.written());
try testing.expectString("/click selector='#submit'", aw.written());
}
// backendNodeId only: still skipped — no replayable identifier.
@@ -552,126 +367,6 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
}
}
test "ScriptIterator: basic slash commands" {
const content =
"/goto https://example.com\n" ++
"/tree\n" ++
"/click selector='Login'\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expect(e1.command == .tool_call);
try testing.expectEqualStrings("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectEqualStrings("tree", e2.command.tool_call.name());
const e3 = (try iter.next()).?;
try testing.expectEqualStrings("click", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "ScriptIterator: multi-line /eval block" {
const content =
"/goto https://x\n" ++
"/eval '''\n" ++
"const x = 1;\n" ++
"return x;\n" ++
"'''\n" ++
"/tree\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expectEqualStrings("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectEqualStrings("eval", e2.command.tool_call.name());
const script_value = e2.command.tool_call.args.?.object.get("script").?.string;
try testing.expect(std.mem.indexOf(u8, script_value, "const x = 1;") != null);
try testing.expect(std.mem.indexOf(u8, script_value, "return x;") != null);
const e3 = (try iter.next()).?;
try testing.expectEqualStrings("tree", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "ScriptIterator: comments preserve opener_line for context" {
const content =
"# Navigate\n" ++
"/goto https://x\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expect(e1.command == .comment);
try testing.expectEqualStrings("# Navigate", e1.opener_line);
const e2 = (try iter.next()).?;
try testing.expect(e2.command == .tool_call);
try testing.expect((try iter.next()) == null);
}
test "ScriptIterator: bare prose in script errors" {
const content = "click the login button\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
try testing.expectError(error.NotASlashCommand, iter.next());
}
test "ScriptIterator: UnterminatedQuote reports the opener line" {
// Opener is on line 2; the closer is missing. line_num should point at
// line 2 (the opener), not at EOF where the scan stopped.
const content =
"/goto https://x\n" ++
"/eval '''\n" ++
" const x = 1;\n" ++
" return x;\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
_ = (try iter.next()).?; // /goto
try testing.expectError(error.UnterminatedQuote, iter.next());
try testing.expectEqual(@as(u32, 2), iter.line_num);
}
test "ScriptIterator: strips trailing CR from CRLF-authored bodies" {
const content = "/goto https://x\r\n/extract '''\r\n{\"t\":\"h1\"}\r\n'''\r\n/click selector='#x'\r\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expectEqualStrings("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectEqualStrings("extract", e2.command.tool_call.name());
try testing.expectEqualStrings("{\"t\":\"h1\"}", e2.command.tool_call.args.?.object.get("schema").?.string);
const e3 = (try iter.next()).?;
try testing.expectEqualStrings("click", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "fromToolCall: builds a tool_call Command" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
@@ -680,5 +375,15 @@ test "fromToolCall: builds a tool_call Command" {
try obj.put("url", .{ .string = "https://x" });
const cmd = Command.fromToolCall(.goto, .{ .object = obj });
try testing.expect(cmd == .tool_call);
try testing.expectEqualStrings("goto", cmd.tool_call.name());
try testing.expectString("goto", cmd.tool_call.name());
}
test "isRecorded: non-object args check locator presence" {
// goto does not need a locator: isRecorded returns true even if args is not object
const goto_non_obj = Command.fromToolCall(.goto, .{ .string = "https://x" });
try testing.expect(goto_non_obj.isRecorded());
// click needs a locator: isRecorded returns false if args is not object
const click_non_obj = Command.fromToolCall(.click, .{ .string = "#submit" });
try testing.expect(!click_non_obj.isRecorded());
}

View File

@@ -1,489 +0,0 @@
// 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/>.
//! Flat view of `browser_tools.tool_defs` shared by PandaScript and the REPL.
//! `globalSchemas()` is the lazy process-wide cache.
const std = @import("std");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
pub const FieldType = enum { string, integer, number, boolean, other };
pub const FieldEntry = struct {
name: []const u8,
field_type: FieldType,
/// Used by `Command.format` to omit `checked=true` when emitting `/setChecked`.
default_true: bool = false,
};
/// REPL argument-syntax hint slot. `fragment` is pre-rendered as `<name>` for
/// required and `[name=…]` for optional.
pub const HintSlot = struct {
name: []const u8,
required: bool,
fragment: []const u8,
};
/// Asserted at schema build time so adding a tool with more fields fails loud.
pub const max_hint_slots: usize = 16;
/// Cached, schema-extracted view of a single browser tool.
pub const SchemaInfo = struct {
action: browser_tools.Action,
tool_name: []const u8,
description: []const u8,
required: []const []const u8,
fields: []const FieldEntry,
hints: []const HintSlot,
recorded: bool,
can_heal: bool,
produces_data: bool,
parameters: std.json.Value,
pub fn isMultiLineCapable(self: *const SchemaInfo) bool {
return self.required.len == 1 and self.fieldType(self.required[0]) == .string;
}
pub fn findField(self: *const SchemaInfo, key: []const u8) ?FieldEntry {
for (self.fields) |f| {
if (std.mem.eql(u8, f.name, key)) return f;
}
return null;
}
pub fn fieldType(self: *const SchemaInfo, key: []const u8) FieldType {
if (self.findField(key)) |f| return f.field_type;
return .other;
}
pub fn isFieldDefaultTrue(self: *const SchemaInfo, key: []const u8) bool {
if (self.findField(key)) |f| return f.default_true;
return false;
}
};
pub const ParseError = error{
MissingName,
UnknownTool,
MissingRequired,
MalformedKv,
PositionalNotAllowed,
UnterminatedQuote,
OutOfMemory,
};
fn buildOne(arena: std.mem.Allocator, action: browser_tools.Action, td: browser_tools.ToolDef, parsed: std.json.Value) !SchemaInfo {
var info: SchemaInfo = .{
.action = action,
.tool_name = td.name,
.description = td.description,
.required = &.{},
.fields = &.{},
.hints = &.{},
.recorded = td.recorded,
.can_heal = td.can_heal,
.produces_data = td.produces_data,
.parameters = parsed,
};
if (parsed != .object) return info;
if (parsed.object.get("required")) |req| {
if (req == .array) {
var reqs: std.ArrayList([]const u8) = .empty;
try reqs.ensureTotalCapacity(arena, req.array.items.len);
for (req.array.items) |item| {
if (item != .string) continue;
reqs.appendAssumeCapacity(item.string);
}
info.required = try reqs.toOwnedSlice(arena);
}
}
if (parsed.object.get("properties")) |props| {
if (props == .object) {
const map = props.object;
const fields = try arena.alloc(FieldEntry, map.count());
var it = map.iterator();
for (fields) |*f| {
const entry = it.next().?;
f.* = .{
.name = entry.key_ptr.*,
.field_type = fieldTypeOf(entry.value_ptr.*),
.default_true = booleanDefaultTrue(entry.value_ptr.*),
};
}
info.fields = fields;
}
}
info.hints = try buildHints(arena, info.required, info.fields);
std.debug.assert(info.hints.len <= max_hint_slots);
return info;
}
fn buildHints(arena: std.mem.Allocator, required: []const []const u8, fields: []const FieldEntry) ![]const HintSlot {
if (fields.len == 0 and required.len == 0) return &.{};
var optional_count: usize = 0;
for (fields) |f| {
if (!containsName(required, f.name)) {
optional_count += 1;
}
}
const out = try arena.alloc(HintSlot, required.len + optional_count);
var idx: usize = 0;
defer std.debug.assert(idx == out.len);
for (required) |name| {
out[idx] = .{
.name = name,
.required = true,
.fragment = try std.fmt.allocPrint(arena, "<{s}>", .{name}),
};
idx += 1;
}
for (fields) |f| {
if (containsName(required, f.name)) continue;
out[idx] = .{
.name = f.name,
.required = false,
.fragment = try std.fmt.allocPrint(arena, "[{s}=…]", .{f.name}),
};
idx += 1;
}
return out;
}
fn containsName(names: []const []const u8, target: []const u8) bool {
for (names) |n| if (std.mem.eql(u8, n, target)) return true;
return false;
}
fn fieldTypeOf(value: std.json.Value) FieldType {
if (value != .object) return .other;
const ty = value.object.get("type") orelse return .other;
if (ty != .string) return .other;
return std.meta.stringToEnum(FieldType, ty.string) orelse .other;
}
fn booleanDefaultTrue(value: std.json.Value) bool {
if (value != .object) return false;
const d = value.object.get("default") orelse return false;
return d == .bool and d.bool;
}
pub fn findSchema(schemas: []const SchemaInfo, name: []const u8) ?*const SchemaInfo {
for (schemas) |*s| {
if (std.ascii.eqlIgnoreCase(s.tool_name, name)) return s;
}
return null;
}
pub const Split = struct {
name: []const u8,
rest: []const u8,
};
/// Split a slash-command body into `<name> <rest>`. Returns null on empty input.
pub fn splitNameRest(input: []const u8) ?Split {
const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
if (trimmed.len == 0) return null;
const name_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
return .{
.name = trimmed[0..name_end],
.rest = std.mem.trim(u8, trimmed[name_end..], &std.ascii.whitespace),
};
}
/// Parse `rest` (args portion of a slash command) into a `std.json.Value`.
/// Returns null when the schema takes no args and `rest` is empty.
///
/// Argument-binding rules:
/// - Bare `{json}` payload returned as-is.
/// - Single leading positional binds to `schema.required[0]` when
/// `schema.required.len == 1`. Otherwise positionals error.
/// - Everything else is `key=value` with type coercion via `coerce`.
pub fn parseValue(arena: std.mem.Allocator, schema: *const SchemaInfo, rest: []const u8) ParseError!?std.json.Value {
if (rest.len == 0) {
if (schema.required.len > 0) return error.MissingRequired;
return null;
}
if (rest[0] == '{') {
return std.json.parseFromSliceLeaky(std.json.Value, arena, rest, .{}) catch return error.MalformedKv;
}
const tokens = try tokenize(arena, rest);
const leading_positional = tokens.len >= 1 and std.mem.indexOfScalar(u8, tokens[0], '=') == null;
if (leading_positional and schema.required.len != 1) return error.PositionalNotAllowed;
var list = try std.ArrayList(KvPair).initCapacity(arena, tokens.len + schema.required.len);
const kv_start: usize = if (leading_positional) 1 else 0;
if (leading_positional) {
list.appendAssumeCapacity(.{ .key = schema.required[0], .value = stripQuotes(tokens[0]) });
}
for (tokens[kv_start..]) |tok| {
const eq = std.mem.indexOfScalar(u8, tok, '=') orelse return error.MalformedKv;
if (eq == 0 or eq == tok.len - 1) return error.MalformedKv;
list.appendAssumeCapacity(.{ .key = tok[0..eq], .value = stripQuotes(tok[eq + 1 ..]) });
}
// Default-true booleans (e.g. setChecked.checked) so `/setChecked
// selector='#a'` works without `checked=true`.
required: for (schema.required) |req| {
for (list.items) |p| if (std.mem.eql(u8, p.key, req)) continue :required;
if (!schema.isFieldDefaultTrue(req)) return error.MissingRequired;
list.appendAssumeCapacity(.{ .key = req, .value = "true" });
}
return try buildValue(arena, schema, list.items);
}
const KvPair = struct {
key: []const u8,
value: []const u8,
};
/// Tokenize on whitespace. `"…"` and `'…'` (single or triple) are kept whole;
/// quote stripping happens later. Tokens may contain `=`.
fn tokenize(arena: std.mem.Allocator, input: []const u8) ParseError![][]const u8 {
var out: std.ArrayList([]const u8) = .empty;
var i: usize = 0;
while (i < input.len) {
while (i < input.len and std.ascii.isWhitespace(input[i])) i += 1;
if (i >= input.len) break;
const tok_start = i;
while (i < input.len and !std.ascii.isWhitespace(input[i])) : (i += 1) {
const ch = input[i];
if (ch == '"' or ch == '\'') {
const is_triple = i + 2 < input.len and input[i + 1] == ch and input[i + 2] == ch;
if (is_triple) {
const triple_delim = input[i .. i + 3];
const close = std.mem.indexOfPos(u8, input, i + 3, triple_delim) orelse return error.UnterminatedQuote;
i = close + 2;
} else {
const close = std.mem.indexOfScalarPos(u8, input, i + 1, ch) orelse return error.UnterminatedQuote;
i = close;
}
}
}
try out.append(arena, input[tok_start..i]);
}
return try out.toOwnedSlice(arena);
}
fn stripQuotes(raw: []const u8) []const u8 {
if (raw.len >= 6) {
if (std.mem.startsWith(u8, raw, "'''") and std.mem.endsWith(u8, raw, "'''")) {
return raw[3 .. raw.len - 3];
}
if (std.mem.startsWith(u8, raw, "\"\"\"") and std.mem.endsWith(u8, raw, "\"\"\"")) {
return raw[3 .. raw.len - 3];
}
}
if (raw.len >= 2) {
const first = raw[0];
const last = raw[raw.len - 1];
if ((first == '\'' and last == '\'') or (first == '"' and last == '"')) {
return raw[1 .. raw.len - 1];
}
}
return raw;
}
fn buildValue(arena: std.mem.Allocator, schema: *const SchemaInfo, pairs: []const KvPair) error{OutOfMemory}!std.json.Value {
var obj: std.json.ObjectMap = .init(arena);
try obj.ensureTotalCapacity(pairs.len);
for (pairs) |p| {
const v = try coerce(arena, schema, p.key, p.value);
try obj.put(p.key, v);
}
return .{ .object = obj };
}
fn coerce(arena: std.mem.Allocator, schema: *const SchemaInfo, key: []const u8, value: []const u8) error{OutOfMemory}!std.json.Value {
switch (schema.fieldType(key)) {
.integer => {
if (std.fmt.parseInt(i64, value, 10)) |n| return .{ .integer = n } else |_| {}
},
.number => {
if (std.fmt.parseFloat(f64, value)) |n| return .{ .float = n } else |_| {}
},
.boolean => {
if (std.mem.eql(u8, value, "true")) return .{ .bool = true };
if (std.mem.eql(u8, value, "false")) return .{ .bool = false };
},
else => {},
}
return .{ .string = try arena.dupe(u8, value) };
}
// --- Global lazy schema cache ---
//
// `global_arena` is never deinit'd: it's process-lifetime, freed at exit.
var global_schemas_storage: [browser_tools.tool_defs.len]SchemaInfo = undefined;
var global_arena: std.heap.ArenaAllocator = undefined;
var global_once = std.once(initGlobal);
/// Panics on init failure — `tool_defs` is compile-time constant, so any
/// parse/build error is a build-time bug.
pub fn globalSchemas() []const SchemaInfo {
global_once.call();
return global_schemas_storage[0..browser_tools.tool_defs.len];
}
fn initGlobal() void {
global_arena = .init(std.heap.page_allocator);
const a = global_arena.allocator();
for (browser_tools.tool_defs, 0..) |td, i| {
const parsed = std.json.parseFromSliceLeaky(std.json.Value, a, td.input_schema, .{}) catch |err| {
std.debug.panic("failed to parse schema for tool '{s}': {s}", .{ td.name, @errorName(err) });
};
global_schemas_storage[i] = buildOne(a, @enumFromInt(i), td, parsed) catch |err| {
std.debug.panic("failed to build schema for tool '{s}': {s}", .{ td.name, @errorName(err) });
};
}
}
// --- Tests ---
const testing = @import("../testing.zig");
test "globalSchemas: comptime tool defs reduce cleanly" {
const schemas = globalSchemas();
try testing.expect(schemas.len == browser_tools.tool_defs.len);
const goto = findSchema(schemas, "goto").?;
try testing.expect(goto.isMultiLineCapable());
try testing.expect(goto.recorded);
const scroll = findSchema(schemas, "scroll").?;
try testing.expect(!scroll.isMultiLineCapable());
try testing.expect(scroll.recorded);
const tree = findSchema(schemas, "tree").?;
try testing.expect(!tree.recorded);
try testing.expect(tree.produces_data);
const set_checked = findSchema(schemas, "setChecked").?;
var checked_default_true = false;
for (set_checked.fields) |f| {
if (std.mem.eql(u8, f.name, "checked")) checked_default_true = f.default_true;
}
try testing.expect(checked_default_true);
}
test "parseValue: single-required positional binds" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const goto = findSchema(schemas, "goto").?;
const v = (try parseValue(arena.allocator(), goto, "https://example.com")).?;
try testing.expectString("https://example.com", v.object.get("url").?.string);
}
test "parseValue: positional then kv tail" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const goto = findSchema(schemas, "goto").?;
const v = (try parseValue(arena.allocator(), goto, "https://example.com timeout=5000")).?;
try testing.expectString("https://example.com", v.object.get("url").?.string);
try testing.expectEqual(@as(i64, 5000), v.object.get("timeout").?.integer);
}
test "parseValue: kv-only multi-required" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const fill = findSchema(schemas, "fill").?;
const v = (try parseValue(arena.allocator(), fill, "selector='#email' value='foo@x.com'")).?;
try testing.expectString("#email", v.object.get("selector").?.string);
try testing.expectString("foo@x.com", v.object.get("value").?.string);
}
test "parseValue: kv-only zero-required" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const scroll = findSchema(schemas, "scroll").?;
const v = (try parseValue(arena.allocator(), scroll, "y=200")).?;
try testing.expectEqual(@as(i64, 200), v.object.get("y").?.integer);
}
test "parseValue: missing required errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const goto = findSchema(schemas, "goto").?;
try testing.expectError(error.MissingRequired, parseValue(arena.allocator(), goto, ""));
}
test "parseValue: positional with zero-required schema errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const find = findSchema(schemas, "findElement").?;
try testing.expectError(error.PositionalNotAllowed, parseValue(arena.allocator(), find, "button"));
}
test "parseValue: setChecked defaults checked=true when omitted" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const set_checked = findSchema(schemas, "setChecked").?;
const v = (try parseValue(arena.allocator(), set_checked, "selector='#agree'")).?;
try testing.expectString("#agree", v.object.get("selector").?.string);
try testing.expect(v.object.get("checked").?.bool);
}
test "parseValue: zero-arg tool returns null when rest empty" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const get_cookies = findSchema(schemas, "getCookies").?;
try testing.expect((try parseValue(arena.allocator(), get_cookies, "")) == null);
}
test "parseValue: bare JSON passthrough" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const schemas = globalSchemas();
const find = findSchema(schemas, "findElement").?;
const v = (try parseValue(arena.allocator(), find, "{\"role\":\"button\"}")).?;
try testing.expectString("button", v.object.get("role").?.string);
}
test "splitNameRest: trims and handles empty" {
try testing.expect(splitNameRest("") == null);
try testing.expect(splitNameRest(" ") == null);
const r = splitNameRest(" goto https://x ").?;
try testing.expectString("goto", r.name);
try testing.expectString("https://x", r.rest);
}
test "tokenize: inline triple quotes with spaces" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const tokens = try tokenize(arena.allocator(), "selector='''hello world''' value=\"\"\"foo bar\"\"\"");
try testing.expectEqual(@as(usize, 2), tokens.len);
try testing.expectString("selector='''hello world'''", tokens[0]);
try testing.expectString("value=\"\"\"foo bar\"\"\"", tokens[1]);
}