Files
browser/src/script/command.zig
Adrià Arrufat e9943a3976 command: use Action enum for ToolCall name
Replaces the string `name` in `ToolCall` with `action: browser_tools.Action`.
This avoids repeated string-to-enum parsing and simplifies schema lookups.
Also updates `zenai` dependency to use its JSON duplication helper.
2026-05-21 22:51:52 +02:00

621 lines
24 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! 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.
const std = @import("std");
const lp = @import("lightpanda");
const zenai = @import("zenai");
const browser_tools = lp.tools;
const schema = @import("schema.zig");
pub const ParseError = schema.ParseError || error{
NotASlashCommand,
};
pub const Command = union(enum) {
tool_call: ToolCall,
login: void,
accept_cookies: void,
comment: void,
pub const ToolCall = struct {
action: browser_tools.Action,
args: ?std.json.Value,
pub fn name(self: ToolCall) [:0]const u8 {
return @tagName(self.action);
}
};
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;
// backendNodeId is invalidated by any DOM mutation, so calls
// using it aren't replayable.
const args = tc.args orelse break :blk true;
if (args == .object and args.object.contains("backendNodeId")) break :blk false;
break :blk true;
},
};
}
pub fn producesData(self: Command) bool {
return switch (self) {
.tool_call => |tc| schemaOf(tc).produces_data,
else => false,
};
}
pub fn needsLlm(self: Command) bool {
return switch (self) {
.login, .accept_cookies => true,
else => false,
};
}
pub fn canHeal(self: Command) bool {
return switch (self) {
.tool_call => |tc| schemaOf(tc).can_heal,
else => false,
};
}
pub fn parse(arena: std.mem.Allocator, line: []const u8) ParseError!Command {
return parseWithSchemas(arena, line, schema.globalSchemas());
}
pub fn parseWithSchemas(arena: std.mem.Allocator, line: []const u8, schemas: []const schema.SchemaInfo) ParseError!Command {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) return .{ .comment = {} };
if (trimmed[0] == '#') return .{ .comment = {} };
if (trimmed[0] != '/') return error.NotASlashCommand;
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;
return .{ .login = {} };
}
if (std.ascii.eqlIgnoreCase(split.name, "acceptCookies")) {
if (split.rest.len > 0) return error.MalformedKv;
return .{ .accept_cookies = {} };
}
const s = schema.findSchema(schemas, split.name) orelse return error.UnknownTool;
const args = try schema.parseValue(arena, s, split.rest);
return .{ .tool_call = .{ .action = s.action, .args = args } };
}
/// Canonical recorder format. Round-trips with `parse`.
pub fn format(self: Command, writer: *std.Io.Writer) std.Io.Writer.Error!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),
}
}
/// `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 {
const schemas = schema.globalSchemas();
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 (try self.tryBlockOpener(trimmed, schemas)) |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) 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.parseWithSchemas(self.allocator, trimmed, schemas),
};
}
return null;
}
const BlockOpener = struct {
action: browser_tools.Action,
field: []const u8,
quote_type: QuoteType,
};
fn tryBlockOpener(_: *ScriptIterator, line: []const u8, schemas: []const schema.SchemaInfo) ParseError!?BlockOpener {
if (line.len < 2 or line[0] != '/') return null;
const split = schema.splitNameRest(line[1..]) orelse return null;
const s = schema.findSchema(schemas, split.name) orelse return null;
if (!s.isMultiLineCapable()) return null;
const qt = QuoteType.fromLiteral(split.rest) orelse return null;
return .{ .action = s.action, .field = s.required[0], .quote_type = qt };
}
fn collectMultiLineBlock(self: *ScriptIterator, quote_type: QuoteType) std.mem.Allocator.Error!?[]const u8 {
const closer = quote_type.toLiteral();
var parts: std.ArrayList(u8) = .empty;
defer parts.deinit(self.allocator);
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, closer)) {
return try parts.toOwnedSlice(self.allocator);
}
if (parts.items.len > 0) {
try parts.append(self.allocator, '\n');
}
// Trim CR only; full trim would clobber indentation.
try parts.appendSlice(self.allocator, std.mem.trimRight(u8, line, "\r"));
}
return null;
}
};
};
// --- Formatting ---
fn formatToolCall(tc: Command.ToolCall, writer: *std.Io.Writer) std.Io.Writer.Error!void {
const s = &schema.globalSchemas()[@intFromEnum(tc.action)];
try writer.writeByte('/');
try writer.writeAll(s.tool_name);
const args_val = tc.args orelse return;
if (args_val != .object) return;
const args = args_val.object;
if (args.count() == 0) return;
// Positional form `/goto '<url>'` only when args reduce to the single
// required field; extra fields force kv so recordings stay unambiguous.
var positional_emitted: ?[]const u8 = null;
{
const has_one_required = s.required.len == 1;
var visible: usize = 0;
var it_v = args.iterator();
while (it_v.next()) |entry| {
if (isDefaultTrueBool(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 (isDefaultTrueBool(s, key, entry.value_ptr.*)) continue;
try writer.writeByte(' ');
try writer.writeAll(key);
try writer.writeByte('=');
try formatKvValue(writer, entry.value_ptr.*);
}
}
fn isDefaultTrueBool(s: *const schema.SchemaInfo, key: []const u8, v: std.json.Value) bool {
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;
}
};
// --- Tests ---
const testing = std.testing;
test "parse: blank and # lines are comments" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expect((try Command.parse(arena.allocator(), "")) == .comment);
try testing.expect((try Command.parse(arena.allocator(), " ")) == .comment);
try testing.expect((try Command.parse(arena.allocator(), "# foo")) == .comment);
}
test "parse: bare prose errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.NotASlashCommand, Command.parse(arena.allocator(), "click the login button"));
try testing.expectError(error.NotASlashCommand, Command.parse(arena.allocator(), "goto https://x"));
}
test "parse: /login and /acceptCookies" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expect((try Command.parse(arena.allocator(), "/login")) == .login);
try testing.expect((try Command.parse(arena.allocator(), "/acceptCookies")) == .accept_cookies);
}
test "parse: /goto positional" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
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);
}
test "parse: /click rejects positional (zero required fields)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
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);
}
test "parse: /scroll y=200" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/scroll y=200");
try testing.expectEqual(@as(i64, 200), cmd.tool_call.args.?.object.get("y").?.integer);
}
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.expect(cmd.tool_call.args.?.object.get("checked").?.bool);
}
test "parse: unknown tool errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.UnknownTool, Command.parse(arena.allocator(), "/bogus"));
}
test "format: /goto round-trip" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/goto https://example.com");
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());
}
test "format: /click stays kv (zero required fields)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/click selector='Login'");
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());
}
test "format: /eval emits triple-quote block for multi-line script" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const args = blk: {
var obj: std.json.ObjectMap = .init(arena.allocator());
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 } };
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());
}
test "format: /setChecked omits checked=true (matches default)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#agree' checked=true");
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());
}
test "format: /setChecked keeps checked=false (non-default)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#x' checked=false");
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());
}
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());
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());
}
test "isRecorded / canHeal / producesData via tool flags" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = try Command.parse(arena.allocator(), "/goto https://x");
try testing.expect(goto.isRecorded());
try testing.expect(!goto.canHeal()); // navigation excluded from heal
try testing.expect(!goto.producesData());
const tree = try Command.parse(arena.allocator(), "/tree");
try testing.expect(!tree.isRecorded());
try testing.expect(tree.producesData());
const login: Command = .{ .login = {} };
try testing.expect(login.isRecorded());
try testing.expect(!login.canHeal());
}
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: 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();
var obj: std.json.ObjectMap = .init(arena.allocator());
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());
}