refactor: improve tool execution and terminal formatting

- Standardize `fill` tool to use `value` instead of `text`.
- Add formatted output methods (`printInfoFmt`, `printErrorFmt`) to `Terminal`.
- Improve error handling in `ToolExecutor` and `browser/tools` by using error unions.
- Deduplicate `minify` utility and remove redundant code comments.
- Refactor tool dispatch to use `Action` enum tags instead of hardcoded strings.
This commit is contained in:
Adrià Arrufat
2026-04-10 14:49:27 +02:00
parent d1b429655f
commit a5fd70f5dd
10 changed files with 126 additions and 239 deletions

View File

@@ -165,15 +165,11 @@ pub fn run(self: *Self) bool {
fn runRepl(self: *Self) void {
self.terminal.printInfo("Lightpanda Agent (type 'quit' to exit)");
log.debug(.app, "tools loaded", .{ .count = self.tools.len });
const info = if (self.ai_client) |ai_client|
std.fmt.allocPrint(self.allocator, "Provider: {s}, Model: {s}", .{
@tagName(std.meta.activeTag(ai_client)),
self.model,
}) catch null
else
null;
self.terminal.printInfo(info orelse "Ready.");
if (info) |i| self.allocator.free(i);
if (self.ai_client) |ai_client| {
self.terminal.printInfoFmt("Provider: {s}, Model: {s}", .{ @tagName(std.meta.activeTag(ai_client)), self.model });
} else {
self.terminal.printInfo("Ready.");
}
while (true) {
const line = self.terminal.readLine("> ") orelse break;
@@ -196,7 +192,6 @@ fn runRepl(self: *Self) void {
};
},
.natural_language => {
// "quit" as a convenience alias
if (std.mem.eql(u8, line, "quit")) break;
self.processUserMessage(line, line) catch |err| {
@@ -214,12 +209,7 @@ fn runRepl(self: *Self) void {
}
fn printAllocError(self: *Self, comptime fmt: []const u8, args: anytype) void {
const msg = std.fmt.allocPrint(self.allocator, fmt, args) catch {
self.terminal.printError(fmt);
return;
};
defer self.allocator.free(msg);
self.terminal.printError(msg);
self.terminal.printErrorFmt(fmt, args);
}
fn runScript(self: *Self, path: []const u8) bool {
@@ -235,9 +225,7 @@ fn runScript(self: *Self, path: []const u8) bool {
};
defer self.allocator.free(content);
const script_info = std.fmt.allocPrint(self.allocator, "Running script: {s}", .{path}) catch null;
self.terminal.printInfo(script_info orelse "Running script...");
if (script_info) |i| self.allocator.free(i);
self.terminal.printInfoFmt("Running script: {s}", .{path});
var script_arena = std.heap.ArenaAllocator.init(self.allocator);
defer script_arena.deinit();
@@ -252,7 +240,6 @@ fn runScript(self: *Self, path: []const u8) bool {
return true;
},
.comment => {
// Track # INTENT: comments for self-healing
if (std.mem.startsWith(u8, entry.raw_line, "# INTENT:")) {
last_intent = std.mem.trim(u8, entry.raw_line["# INTENT:".len..], &std.ascii.whitespace);
}
@@ -263,7 +250,6 @@ fn runScript(self: *Self, path: []const u8) bool {
return false;
},
.login, .accept_cookies => {
// High-level commands require LLM
if (self.ai_client == null) {
self.printAllocError("line {d}: {s} requires an API key for LLM resolution", .{
entry.line_num,
@@ -282,11 +268,8 @@ fn runScript(self: *Self, path: []const u8) bool {
};
},
else => {
const line_info = std.fmt.allocPrint(self.allocator, "[{d}] {s}", .{ entry.line_num, entry.raw_line }) catch null;
self.terminal.printInfo(line_info orelse entry.raw_line);
if (line_info) |li| self.allocator.free(li);
self.terminal.printInfoFmt("[{d}] {s}", .{ entry.line_num, entry.raw_line });
// Execute with result checking for self-healing
var cmd_arena = std.heap.ArenaAllocator.init(self.allocator);
defer cmd_arena.deinit();
@@ -295,7 +278,6 @@ fn runScript(self: *Self, path: []const u8) bool {
std.debug.print("\n", .{});
if (result.failed) {
// Attempt self-healing via LLM (opt-in with --self-heal)
if (self.self_heal and self.ai_client != null) {
self.terminal.printInfo("Command failed, attempting self-healing...");
if (self.attemptSelfHeal(last_intent, entry.raw_line)) {
@@ -318,8 +300,6 @@ fn runScript(self: *Self, path: []const u8) bool {
const self_heal_max_attempts = 3;
/// Attempt to self-heal a failed command by asking the LLM to resolve it.
/// Retries up to `self_heal_max_attempts` times on transient API errors.
fn attemptSelfHeal(self: *Self, intent: ?[]const u8, failed_command: []const u8) bool {
var heal_arena = std.heap.ArenaAllocator.init(self.allocator);
defer heal_arena.deinit();
@@ -351,7 +331,6 @@ fn attemptSelfHeal(self: *Self, intent: ?[]const u8, failed_command: []const u8)
fn processUserMessage(self: *Self, user_input: []const u8, record_comment: []const u8) !void {
const ma = self.message_arena.allocator();
// Add system prompt as first message if this is the first user message
if (self.messages.items.len == 0) {
try self.messages.append(self.allocator, .{
.role = .system,
@@ -359,7 +338,6 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: []con
});
}
// Add user message
try self.messages.append(self.allocator, .{
.role = .user,
.content = try ma.dupe(u8, user_input),
@@ -384,7 +362,6 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: []con
};
defer result.deinit();
// Record tool calls as Pandascript (only if they produce commands)
var recorded_any = false;
for (result.tool_calls_made) |tc| {
if (!std.mem.startsWith(u8, tc.result, "Error:")) {
@@ -417,90 +394,58 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
return tool_result;
}
/// Convert a tool call (name + JSON arguments) into a Pandascript command.
fn toolCallToCommand(arena: std.mem.Allocator, tool_name: []const u8, arguments: []const u8) ?Command.Command {
const action = std.meta.stringToEnum(lp.tools.Action, tool_name) orelse return null;
const parsed = std.json.parseFromSlice(std.json.Value, arena, arguments, .{}) catch return null;
const obj = switch (parsed.value) {
.object => |o| o,
else => return null,
};
return if (std.mem.eql(u8, tool_name, "goto")) blk: {
break :blk switch (obj.get("url") orelse break :blk null) {
.string => |s| .{ .goto = s },
else => null,
};
} else if (std.mem.eql(u8, tool_name, "click")) blk: {
if (obj.get("selector")) |sel_val| {
break :blk switch (sel_val) {
.string => |s| .{ .click = s },
const getString = struct {
fn f(o: std.json.ObjectMap, key: []const u8) ?[]const u8 {
return switch (o.get(key) orelse return null) {
.string => |s| s,
else => null,
};
}
// Can't meaningfully record a backendNodeId as Pandascript
break :blk null;
} else if (std.mem.eql(u8, tool_name, "fill")) blk: {
const sel = switch (obj.get("selector") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
const val = switch (obj.get("value") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
break :blk .{ .type_cmd = .{ .selector = sel, .value = val } };
} else if (std.mem.eql(u8, tool_name, "eval")) blk: {
break :blk switch (obj.get("script") orelse break :blk null) {
.string => |s| .{ .eval_js = s },
else => null,
};
} else if (std.mem.eql(u8, tool_name, "waitForSelector")) blk: {
break :blk switch (obj.get("selector") orelse break :blk null) {
.string => |s| .{ .wait = s },
else => null,
};
} else if (std.mem.eql(u8, tool_name, "scroll")) blk: {
// Only record window scrolls — element scrolls use ephemeral backendNodeId.
if (obj.get("backendNodeId") != null) break :blk null;
const x: i32 = switch (obj.get("x") orelse std.json.Value{ .integer = 0 }) {
.integer => |i| @intCast(i),
else => 0,
};
const y: i32 = switch (obj.get("y") orelse std.json.Value{ .integer = 0 }) {
.integer => |i| @intCast(i),
else => 0,
};
break :blk .{ .scroll = .{ .x = x, .y = y } };
} else if (std.mem.eql(u8, tool_name, "hover")) blk: {
if (obj.get("selector")) |sel_val| {
break :blk switch (sel_val) {
.string => |s| .{ .hover = s },
else => null,
}.f;
return switch (action) {
.goto => .{ .goto = getString(obj, "url") orelse return null },
.click => .{ .click = getString(obj, "selector") orelse return null },
.hover => .{ .hover = getString(obj, "selector") orelse return null },
.eval => .{ .eval_js = getString(obj, "script") orelse return null },
.waitForSelector => .{ .wait = getString(obj, "selector") orelse return null },
.fill => .{ .type_cmd = .{
.selector = getString(obj, "selector") orelse return null,
.value = getString(obj, "value") orelse return null,
} },
.selectOption => .{ .select = .{
.selector = getString(obj, "selector") orelse return null,
.value = getString(obj, "value") orelse return null,
} },
.setChecked => .{ .check = .{
.selector = getString(obj, "selector") orelse return null,
.checked = switch (obj.get("checked") orelse return null) {
.bool => |b| b,
else => return null,
},
} },
.scroll => blk: {
if (obj.get("backendNodeId") != null) break :blk null;
const x: i32 = switch (obj.get("x") orelse std.json.Value{ .integer = 0 }) {
.integer => |i| @intCast(i),
else => 0,
};
}
// backendNodeId-only path stays unrecordable
break :blk null;
} else if (std.mem.eql(u8, tool_name, "selectOption")) blk: {
const sel = switch (obj.get("selector") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
const val = switch (obj.get("value") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
break :blk .{ .select = .{ .selector = sel, .value = val } };
} else if (std.mem.eql(u8, tool_name, "setChecked")) blk: {
const sel = switch (obj.get("selector") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
const checked = switch (obj.get("checked") orelse break :blk null) {
.bool => |b| b,
else => break :blk null,
};
break :blk .{ .check = .{ .selector = sel, .checked = checked } };
} else null;
const y: i32 = switch (obj.get("y") orelse std.json.Value{ .integer = 0 }) {
.integer => |i| @intCast(i),
else => 0,
};
break :blk .{ .scroll = .{ .x = x, .y = y } };
},
else => null,
};
}
fn getEnvApiKey(provider_type: Config.AiProvider) ?[:0]const u8 {

View File

@@ -94,10 +94,8 @@ pub fn parse(line: []const u8) Command {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) return .{ .natural_language = trimmed };
// Skip comment lines
if (trimmed[0] == '#') return .{ .comment = {} };
// Find the command word (first whitespace-delimited token)
const cmd_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
const cmd_word = trimmed[0..cmd_end];
const rest = std.mem.trim(u8, trimmed[cmd_end..], &std.ascii.whitespace);
@@ -246,7 +244,6 @@ pub const ScriptIterator = struct {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
// Check for EVAL """ multi-line block
if (isEvalTripleQuote(trimmed)) {
const start_line = self.line_num;
if (self.collectEvalBlock()) |js| {

View File

@@ -23,27 +23,27 @@ pub const ExecResult = struct {
failed: bool,
};
/// Execute a command and return the result with success/failure status.
pub fn executeWithResult(self: *Self, a: std.mem.Allocator, cmd: Command.Command) ExecResult {
const Action = browser_tools.Action;
return switch (cmd) {
.goto => |url| self.execGoto(a, url),
.click => |sel| self.callTool(a, "click", buildJson(a, .{ .selector = substituteEnvVars(a, sel) })),
.click => |sel| self.callTool(a, @tagName(Action.click), buildJson(a, .{ .selector = substituteEnvVars(a, sel) })),
.type_cmd => |args| self.execType(a, args),
.wait => |selector| self.callTool(a, "waitForSelector", buildJson(a, .{ .selector = selector })),
.scroll => |args| self.callTool(a, "scroll", buildJson(a, .{ .x = args.x, .y = args.y })),
.hover => |sel| self.callTool(a, "hover", buildJson(a, .{ .selector = substituteEnvVars(a, sel) })),
.select => |args| self.callTool(a, "selectOption", buildJson(a, .{
.wait => |selector| self.callTool(a, @tagName(Action.waitForSelector), buildJson(a, .{ .selector = selector })),
.scroll => |args| self.callTool(a, @tagName(Action.scroll), buildJson(a, .{ .x = args.x, .y = args.y })),
.hover => |sel| self.callTool(a, @tagName(Action.hover), buildJson(a, .{ .selector = substituteEnvVars(a, sel) })),
.select => |args| self.callTool(a, @tagName(Action.selectOption), buildJson(a, .{
.selector = substituteEnvVars(a, args.selector),
.value = substituteEnvVars(a, args.value),
})),
.check => |args| self.callTool(a, "setChecked", buildJson(a, .{
.check => |args| self.callTool(a, @tagName(Action.setChecked), buildJson(a, .{
.selector = substituteEnvVars(a, args.selector),
.checked = args.checked,
})),
.tree => self.callTool(a, "semanticTree", ""),
.markdown => self.callTool(a, "markdown", ""),
.tree => self.callTool(a, @tagName(Action.semanticTree), ""),
.markdown => self.callTool(a, @tagName(Action.markdown), ""),
.extract => |args| self.execExtract(a, args),
.eval_js => |script| self.callTool(a, "eval", buildJson(a, .{ .script = script })),
.eval_js => |script| self.callTool(a, @tagName(Action.eval), buildJson(a, .{ .script = script })),
.exit, .natural_language, .comment, .login, .accept_cookies => unreachable,
};
}
@@ -67,13 +67,13 @@ fn callTool(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, argume
fn execGoto(self: *Self, arena: std.mem.Allocator, raw_url: []const u8) ExecResult {
const url = substituteEnvVars(arena, raw_url);
return self.callTool(arena, "goto", buildJson(arena, .{ .url = url }));
return self.callTool(arena, @tagName(browser_tools.Action.goto), buildJson(arena, .{ .url = url }));
}
fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) ExecResult {
const selector = substituteEnvVars(arena, args.selector);
const value = substituteEnvVars(arena, args.value);
return self.callTool(arena, "fill", buildJson(arena, .{ .selector = selector, .value = value }));
return self.callTool(arena, @tagName(browser_tools.Action.fill), buildJson(arena, .{ .selector = selector, .value = value }));
}
fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) ExecResult {
@@ -83,8 +83,8 @@ fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs)
\\JSON.stringify(Array.from(document.querySelectorAll("{s}")).map(el => el.textContent.trim()))
, .{selector}) catch return .{ .output = "failed to build extract script", .failed = true };
const result = self.tool_executor.call(arena, "eval", buildJson(arena, .{ .script = script })) catch
return .{ .output = "extract failed", .failed = true };
const result = self.tool_executor.call(arena, @tagName(browser_tools.Action.eval), buildJson(arena, .{ .script = script })) catch |err|
return .{ .output = std.fmt.allocPrint(arena, "extract failed: {s}", .{@errorName(err)}) catch "extract failed", .failed = true };
if (args.file) |raw_file| {
const file = sanitizePath(raw_file) orelse {
@@ -107,9 +107,7 @@ fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs)
const substituteEnvVars = browser_tools.substituteEnvVars;
/// Escape a string for safe interpolation inside a JS double-quoted string literal.
fn escapeJs(arena: std.mem.Allocator, input: []const u8) []const u8 {
// Quick scan: if nothing to escape, return as-is
const needs_escape = for (input) |ch| {
if (ch == '"' or ch == '\\' or ch == '\n' or ch == '\r' or ch == '\t') break true;
} else false;
@@ -129,12 +127,9 @@ fn escapeJs(arena: std.mem.Allocator, input: []const u8) []const u8 {
return out.toOwnedSlice(arena) catch input;
}
/// Validate that a file path is safe: relative, no traversal above cwd.
fn sanitizePath(path: []const u8) ?[]const u8 {
// Reject absolute paths
if (path.len > 0 and path[0] == '/') return null;
// Reject paths containing ".." components
var iter = std.mem.splitScalar(u8, path, '/');
while (iter.next()) |component| {
if (std.mem.eql(u8, component, "..")) return null;

View File

@@ -22,8 +22,6 @@ pub fn deinit(self: *Self) void {
if (self.file) |f| f.close();
}
/// Record a successfully executed command to the .panda file.
/// Skips read-only commands based on `Command.isRecorded()`.
pub fn record(self: *Self, cmd: Command.Command) void {
const f = self.file orelse return;
if (!cmd.isRecorded()) return;
@@ -36,7 +34,6 @@ pub fn record(self: *Self, cmd: Command.Command) void {
self.needs_separator = true;
}
/// Record a comment line (e.g. user's natural language input).
pub fn recordComment(self: *Self, comment: []const u8) void {
const f = self.file orelse return;
var buf: [4096]u8 = undefined;
@@ -50,8 +47,6 @@ pub fn recordComment(self: *Self, comment: []const u8) void {
writer.flush() catch return;
}
// --- Tests ---
test "record writes state-mutating commands" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@@ -63,9 +58,9 @@ test "record writes state-mutating commands" {
recorder.record(Command.parse("GOTO https://example.com"));
recorder.record(Command.parse("CLICK \"Login\""));
recorder.record(Command.parse("TREE")); // should be skipped
recorder.record(Command.parse("TREE"));
recorder.record(Command.parse("WAIT \".dashboard\""));
recorder.record(Command.parse("MARKDOWN")); // should be skipped
recorder.record(Command.parse("MARKDOWN"));
recorder.record(Command.parse("SCROLL 0 200"));
recorder.record(Command.parse("HOVER '#menu'"));
recorder.record(Command.parse("SELECT '#country' 'France'"));
@@ -90,7 +85,6 @@ test "record writes state-mutating commands" {
try std.testing.expect(std.mem.indexOf(u8, content, "CHECK '#newsletter' false\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "EXTRACT '.title'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "\n# LOGIN\n") != null);
// Verify read-only commands are NOT present
try std.testing.expect(std.mem.indexOf(u8, content, "TREE") == null);
try std.testing.expect(std.mem.indexOf(u8, content, "MARKDOWN") == null);
}

View File

@@ -108,6 +108,14 @@ pub fn printError(_: *Self, msg: []const u8) void {
std.debug.print("{s}{s}Error: {s}{s}\n", .{ ansi_bold, ansi_red, msg, ansi_reset });
}
pub fn printErrorFmt(_: *Self, comptime fmt: []const u8, args: anytype) void {
std.debug.print("{s}{s}Error: " ++ fmt ++ "{s}\n", .{ ansi_bold, ansi_red } ++ args ++ .{ansi_reset});
}
pub fn printInfo(_: *Self, msg: []const u8) void {
std.debug.print("{s}{s}{s}\n", .{ ansi_dim, msg, ansi_reset });
}
pub fn printInfoFmt(_: *Self, comptime fmt: []const u8, args: anytype) void {
std.debug.print("{s}" ++ fmt ++ "{s}\n", .{ansi_dim} ++ args ++ .{ansi_reset});
}

View File

@@ -55,7 +55,8 @@ pub fn deinit(self: *Self) void {
self.allocator.destroy(self);
}
/// Returns the list of tools in zenai provider.Tool format.
pub const CallError = browser_tools.ToolError || error{ InvalidJsonArguments, OutOfMemory };
pub fn getTools(self: *Self) ![]const zenai.provider.Tool {
const arena = self.tool_schema_arena.allocator();
const tools = try arena.alloc(zenai.provider.Tool, browser_tools.tool_defs.len);
@@ -75,15 +76,12 @@ pub fn getTools(self: *Self) ![]const zenai.provider.Tool {
return tools;
}
/// Execute a tool by name with JSON arguments, returning the result as a string.
pub fn call(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments_json: []const u8) ![]const u8 {
const arguments = if (arguments_json.len > 0)
(std.json.parseFromSlice(std.json.Value, arena, arguments_json, .{}) catch
return "Error: invalid JSON arguments").value
else
null;
pub fn call(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments_json: []const u8) CallError![]const u8 {
const arguments = if (arguments_json.len > 0) blk: {
const parsed = std.json.parseFromSlice(std.json.Value, arena, arguments_json, .{}) catch
return error.InvalidJsonArguments;
break :blk parsed.value;
} else null;
return browser_tools.call(self.session, &self.node_registry, arena, tool_name, arguments) catch |err| {
return std.fmt.allocPrint(arena, "Error: {s}", .{@errorName(err)}) catch "Error: tool execution failed";
};
return browser_tools.call(self.session, &self.node_registry, arena, tool_name, arguments);
}

View File

@@ -241,7 +241,7 @@ _parent_notified: bool = false,
_type: enum { root, frame }, // only used for logs right now
_req_id: u32 = 0,
console_messages: std.ArrayListUnmanaged(ConsoleMessage) = .{},
_console_messages: std.ArrayListUnmanaged(ConsoleMessage) = .{},
_navigated_options: ?NavigatedOpts = null,
pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void {
@@ -2547,7 +2547,7 @@ pub fn appendConsoleMessage(self: *Page, level: ConsoleMessage.Level, values: []
value.format(&aw.writer) catch return;
}
const text = aw.written();
self.console_messages.append(self.arena, .{ .level = level, .text = text }) catch return;
self._console_messages.append(self.arena, .{ .level = level, .text = text }) catch return;
}
pub fn dupeString(self: *Page, value: []const u8) ![]const u8 {

View File

@@ -11,7 +11,7 @@ pub const ToolDef = struct {
input_schema: []const u8,
};
fn minify(comptime json: []const u8) []const u8 {
pub fn minify(comptime json: []const u8) []const u8 {
@setEvalBranchQuota(100000);
return comptime blk: {
var res: []const u8 = "";
@@ -307,9 +307,9 @@ pub const ToolError = error{
NodeNotFound,
NavigationFailed,
InternalError,
OutOfMemory,
};
/// Result from eval that may represent a JS error (not a tool failure).
pub const EvalResult = struct {
text: []const u8,
is_error: bool = false,
@@ -329,9 +329,7 @@ pub const UrlParams = struct {
const NodeAndPage = struct { node: *DOMNode, page: *lp.Page };
// --- Tool dispatch ---
const Action = enum {
pub const Action = enum {
goto,
markdown,
links,
@@ -356,8 +354,6 @@ const Action = enum {
getCookies,
};
/// Execute a tool by name. Returns the result text.
/// For `eval`, use `callEval` to distinguish JS errors from tool errors.
pub fn call(
session: *lp.Session,
registry: *CDPNode.Registry,
@@ -368,32 +364,15 @@ pub fn call(
const action = std.meta.stringToEnum(Action, tool_name) orelse return ToolError.InvalidParams;
return switch (action) {
.goto => execGoto(session, registry, arena, arguments),
.markdown => execMarkdown(session, registry, arena, arguments),
.links => execLinks(session, registry, arena, arguments),
.nodeDetails => execNodeDetails(session, registry, arena, arguments),
.interactiveElements => execInteractiveElements(session, registry, arena, arguments),
.structuredData => execStructuredData(session, registry, arena, arguments),
.detectForms => execDetectForms(session, registry, arena, arguments),
.eval => execEval(session, registry, arena, arguments).text,
.semanticTree => execSemanticTree(session, registry, arena, arguments),
.click => execClick(session, registry, arena, arguments),
.fill => execFill(session, registry, arena, arguments),
.scroll => execScroll(session, registry, arena, arguments),
.waitForSelector => execWaitForSelector(session, registry, arena, arguments),
.hover => execHover(session, registry, arena, arguments),
.press => execPress(session, registry, arena, arguments),
.selectOption => execSelectOption(session, registry, arena, arguments),
.setChecked => execSetChecked(session, registry, arena, arguments),
.findElement => execFindElement(session, registry, arena, arguments),
.getEnv => execGetEnv(arena, arguments),
.consoleLogs => execConsoleLogs(session, arena),
.getUrl => execGetUrl(session),
.getCookies => execGetCookies(session, arena),
inline else => |tag| @field(@This(), "exec" ++ [1]u8{@tagName(tag)[0] - 32} ++ @tagName(tag)[1..])(session, registry, arena, arguments),
};
}
/// Like `call`, but for eval returns the full EvalResult with is_error flag.
pub fn callEval(
session: *lp.Session,
registry: *CDPNode.Registry,
@@ -403,21 +382,18 @@ pub fn callEval(
return execEval(session, registry, arena, arguments);
}
/// Check if a tool name is recognized.
pub fn isKnownTool(tool_name: []const u8) bool {
return std.meta.stringToEnum(Action, tool_name) != null;
}
// --- Tool implementations ---
fn execGoto(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrErr(GotoParams, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(GotoParams, arena, arguments) orelse return ToolError.InvalidParams;
try performGoto(session, registry, args.url, args.timeout, args.waitUntil);
return "Navigated successfully.";
}
fn execMarkdown(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const args = try parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
var aw: std.Io.Writer.Allocating = .init(arena);
@@ -427,7 +403,7 @@ fn execMarkdown(session: *lp.Session, registry: *CDPNode.Registry, arena: std.me
}
fn execLinks(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const args = try parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const links_list = lp.links.collectLinks(arena, page.document.asNode(), page) catch
@@ -449,7 +425,7 @@ fn execSemanticTree(session: *lp.Session, registry: *CDPNode.Registry, arena: st
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(TreeParams, arena, arguments);
const args = try parseArgsOrDefault(TreeParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
var root_node = page.document.asNode();
@@ -475,7 +451,7 @@ fn execSemanticTree(session: *lp.Session, registry: *CDPNode.Registry, arena: st
fn execNodeDetails(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
@@ -490,7 +466,7 @@ fn execNodeDetails(session: *lp.Session, registry: *CDPNode.Registry, arena: std
}
fn execInteractiveElements(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const args = try parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch
@@ -504,7 +480,7 @@ fn execInteractiveElements(session: *lp.Session, registry: *CDPNode.Registry, ar
}
fn execStructuredData(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const args = try parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch
@@ -515,7 +491,7 @@ fn execStructuredData(session: *lp.Session, registry: *CDPNode.Registry, arena:
}
fn execDetectForms(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const args = try parseArgsOrDefault(UrlParams, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout, args.waitUntil);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch
@@ -535,7 +511,8 @@ fn execEval(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Al
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return .{ .text = "Error: missing 'script' argument", .is_error = true };
const args = (parseArgsOrErr(Params, arena, arguments) catch return .{ .text = "Error: out of memory", .is_error = true }) orelse
return .{ .text = "Error: missing 'script' argument", .is_error = true };
const page = ensurePage(session, registry, args.url, args.timeout, args.waitUntil) catch return .{ .text = "Error: page not loaded", .is_error = true };
var ls: lp.js.Local.Scope = undefined;
@@ -561,7 +538,7 @@ fn execClick(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A
backendNodeId: ?CDPNode.Id = null,
selector: ?[]const u8 = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = if (args.selector) |sel|
try resolveBySelector(session, sel)
else if (args.backendNodeId) |nid|
@@ -599,11 +576,11 @@ fn execFill(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.Al
const Params = struct {
backendNodeId: ?CDPNode.Id = null,
selector: ?[]const u8 = null,
text: []const u8 = "",
value: []const u8 = "",
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const raw_text = if (args.text.len > 0) args.text else if (args.value.len > 0) args.value else return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
if (args.value.len == 0) return ToolError.InvalidParams;
const raw_text = args.value;
const text = substituteEnvVars(arena, raw_text);
const resolved = if (args.selector) |sel|
try resolveBySelector(session, sel)
@@ -639,7 +616,7 @@ fn execScroll(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.
x: ?i32 = null,
y: ?i32 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
const args = try parseArgsOrDefault(Params, arena, arguments);
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
var target_node: ?*DOMNode = null;
@@ -667,7 +644,7 @@ fn execWaitForSelector(session: *lp.Session, registry: *CDPNode.Registry, arena:
selector: [:0]const u8,
timeout: ?u32 = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
_ = session.currentPage() orelse return ToolError.PageNotLoaded;
@@ -687,7 +664,7 @@ fn execHover(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A
backendNodeId: ?CDPNode.Id = null,
selector: ?[]const u8 = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = if (args.selector) |sel|
try resolveBySelector(session, sel)
else if (args.backendNodeId) |nid|
@@ -718,7 +695,7 @@ fn execPress(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
@@ -754,7 +731,7 @@ fn execSelectOption(session: *lp.Session, registry: *CDPNode.Registry, arena: st
selector: ?[]const u8 = null,
value: []const u8,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = if (args.selector) |sel|
try resolveBySelector(session, sel)
else if (args.backendNodeId) |nid|
@@ -787,7 +764,7 @@ fn execSetChecked(session: *lp.Session, registry: *CDPNode.Registry, arena: std.
selector: ?[]const u8 = null,
checked: bool,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const resolved = if (args.selector) |sel|
try resolveBySelector(session, sel)
else if (args.backendNodeId) |nid|
@@ -820,7 +797,7 @@ fn execFindElement(session: *lp.Session, registry: *CDPNode.Registry, arena: std
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
const args = try parseArgsOrDefault(Params, arena, arguments);
if (args.role == null and args.name == null) return ToolError.InvalidParams;
@@ -853,7 +830,7 @@ fn execFindElement(session: *lp.Session, registry: *CDPNode.Registry, arena: std
fn execGetEnv(arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct { name: []const u8 };
const args = parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const args = try parseArgsOrErr(Params, arena, arguments) orelse return ToolError.InvalidParams;
const name_z = arena.dupeZ(u8, args.name) catch return ToolError.InternalError;
const value = std.posix.getenv(name_z) orelse
return std.fmt.allocPrint(arena, "Environment variable '{s}' is not set", .{args.name}) catch ToolError.InternalError;
@@ -865,7 +842,7 @@ fn execConsoleLogs(
arena: std.mem.Allocator,
) ToolError![]const u8 {
const page = session.currentPage() orelse return ToolError.PageNotLoaded;
const messages = page.console_messages.items;
const messages = page._console_messages.items;
if (messages.len == 0) return "No console messages.";
var aw: std.Io.Writer.Allocating = .init(arena);
@@ -873,7 +850,7 @@ fn execConsoleLogs(
for (messages) |msg| {
writer.print("[{s}] {s}\n", .{ @tagName(msg.level), msg.text }) catch return ToolError.InternalError;
}
page.console_messages.clearRetainingCapacity();
page._console_messages.clearRetainingCapacity();
return aw.written();
}
@@ -898,8 +875,6 @@ fn execGetCookies(session: *lp.Session, arena: std.mem.Allocator) ToolError![]co
return aw.written();
}
// --- Shared helpers ---
fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!*lp.Page {
if (url) |u| {
try performGoto(session, registry, u, timeout, waitUntil);
@@ -938,17 +913,22 @@ fn resolveBySelector(session: *lp.Session, selector: []const u8) ToolError!NodeA
return .{ .node = node, .page = page };
}
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) error{OutOfMemory}!T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => .{},
};
}
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ?T {
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) error{OutOfMemory}!?T {
const args_raw = arguments orelse return null;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch null;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => null,
};
}
/// Substitute $VAR_NAME references with values from the environment.
pub fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) []const u8 {
if (std.mem.indexOfScalar(u8, input, '$') == null) return input;

View File

@@ -123,36 +123,7 @@ pub const Tool = struct {
}
};
pub fn minify(comptime json: []const u8) []const u8 {
@setEvalBranchQuota(100000);
return comptime blk: {
var res: []const u8 = "";
var in_string = false;
var escaped = false;
for (json) |c| {
if (in_string) {
res = res ++ [1]u8{c};
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
in_string = false;
}
} else {
switch (c) {
' ', '\n', '\r', '\t' => continue,
'"' => {
in_string = true;
res = res ++ [1]u8{c};
},
else => res = res ++ [1]u8{c},
}
}
}
break :blk res;
};
}
pub const minify = @import("../browser/tools.zig").minify;
pub const Resource = struct {
uri: []const u8,

View File

@@ -57,8 +57,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
const code: protocol.ErrorCode = switch (err) {
error.PageNotLoaded => .PageNotLoaded,
error.NodeNotFound, error.InvalidParams => .InvalidParams,
error.NavigationFailed => .InternalError,
error.InternalError => .InternalError,
error.NavigationFailed, error.InternalError, error.OutOfMemory => .InternalError,
};
return server.sendError(id, code, @errorName(err));
};
@@ -128,7 +127,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
const inp_id = (try server.node_registry.register(inp)).id;
var inp_id_buf: [12]u8 = undefined;
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" });
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"value\":\"hello\"}}}" });
try router.handleMessage(server, aa, fill_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"hello\\\"") != null);
@@ -141,7 +140,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
const sel_id = (try server.node_registry.register(sel)).id;
var sel_id_buf: [12]u8 = undefined;
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" });
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"value\":\"opt2\"}}}" });
try router.handleMessage(server, aa, fill_sel_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"opt2\\\"") != null);