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