diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 83b2b4127..4ecef3857 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1488,7 +1488,7 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool .tool_call => |t| t, else => return .{ .text = "internal: command has no tool mapping", .is_error = true }, }; - const result = browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args) catch |err| return .{ + return browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args, .{}) catch |err| .{ .text = switch (err) { error.OutOfMemory => "out of memory", error.FrameNotLoaded => "no page loaded — run /goto first", @@ -1496,9 +1496,6 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool }, .is_error = true, }; - // Tool results reach the model as text; an inline image has nowhere to go. - if (result.image != null) return .{ .text = "screenshot needs `path` here; the inline image is MCP-only", .is_error = true }; - return result; } /// Data output (/extract, /evaluate, /markdown, /tree, …) → plain stdout on @@ -1925,7 +1922,7 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co self.terminal.spinner.setTool(tool_name, args_str); defer self.terminal.spinner.setThinking(); - const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments)) |result| + const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments, .{})) |result| .{ .content = capToolOutput(allocator, tool_name, result.text), .is_error = result.is_error } else |err| .{ .content = std.fmt.allocPrint(allocator, "Error: {s}", .{browser_tools.errorMessage(err)}) catch "Error: tool execution failed", .is_error = true }; diff --git a/src/browser/screenshot.zig b/src/browser/screenshot.zig index 5beb3f5dc..bbf0cb224 100644 --- a/src/browser/screenshot.zig +++ b/src/browser/screenshot.zig @@ -23,6 +23,7 @@ const Base64Writer = @import("../Base64Writer.zig"); const isAllWhitespace = @import("../string.zig").isAllWhitespace; const Frame = @import("Frame.zig"); +const Viewport = @import("Viewport.zig"); const markdown = @import("markdown.zig"); const Node = @import("webapi/Node.zig"); @@ -44,6 +45,15 @@ pub const Opts = struct { width: f32, height: f32, }; + + /// Height 0 renders the whole content instead of one viewport. + pub fn fromViewport(viewport: Viewport, full_page: bool) Opts { + return .{ + .width = viewport.width, + .height = if (full_page) 0 else viewport.height, + .scale = viewport.scale, + }; + } }; pub fn png(arena: Allocator, node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !u32 { diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 643bd0388..ee2832e73 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -215,6 +215,14 @@ pub const save_script_rules = /// `..` segment. Operator-controlled symlinks already inside CWD are out /// of scope — the threat we close here is "client supplies an arbitrary /// path string". +pub const unsafe_path_message = "path must be relative and must not contain '..' segments"; + +/// The cwd is the server's, not one the user picked, so report where a file +/// really went. +pub fn absolutePath(arena: std.mem.Allocator, path: []const u8) []const u8 { + return std.Io.Dir.cwd().realPathFileAlloc(lp.io, path, arena) catch path; +} + pub fn isPathSafe(path: []const u8) bool { if (path.len == 0) return false; if (std.fs.path.isAbsolute(path)) return false; @@ -302,16 +310,23 @@ pub const Tool = enum { }; } - /// 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 { + /// Args a replay needs even though the schema marks them optional; the + /// recorder skips a line missing one. Exhaustive so a new tool must choose. + pub fn replayRequires(self: Tool) []const []const u8 { return switch (self) { - .click, .fill, .hover, .selectOption, .setChecked => true, - .goto, .search, .markdown, .html, .screenshot, .links, .evaluate, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .waitForScript, .waitForState, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false, + .click, .fill, .hover, .selectOption, .setChecked => &.{"selector"}, + .screenshot => &.{"path"}, + .goto, .search, .markdown, .html, .links, .evaluate, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .waitForScript, .waitForState, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => &.{}, }; } + pub fn needsLocator(self: Tool) bool { + for (self.replayRequires()) |field| { + if (std.mem.eql(u8, field, "selector")) return true; + } + return false; + } + /// Result is data the caller probably wants on stdout (extracted JSON, /// markdown, evaluate return value) rather than a status line on stderr. pub fn producesData(self: Tool) bool { @@ -401,13 +416,13 @@ pub const Tool = enum { ), }, .screenshot => .{ - .description = "Render the page, or one node, as a PNG: the text layout Lightpanda computes, not a pixel-accurate browser rendering (no images, fonts or CSS colours). With `path`, writes the file and returns its location; without it, returns the image inline (MCP only). Use it to see spatial layout; read content with `markdown`/`tree`.", + .description = "Render the page, or one node, as a PNG: the text layout Lightpanda computes, not a pixel-accurate browser rendering (no images, fonts or CSS colours). With `path`, writes the file and returns its location; without it, returns the image inline where the client can display one. Use it to see spatial layout; read content with `markdown`/`tree`.", .summary = "Screenshot of the page or a node", .input_schema = minify( \\{ \\ "type": "object", \\ "properties": { - \\ "path": { "type": "string", "description": "Optional relative path (no '..' segments) to write the PNG to. Created or overwritten. Required outside MCP." }, + \\ "path": { "type": "string", "description": "Optional relative path (no '..' segments) to write the PNG to. Created or overwritten. Without it the image is returned inline, which needs a client that can display images." }, \\ "selector": { "type": "string", "description": "Optional CSS selector. When set, render only that element." }, \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, render only that node. 0 is treated as omitted." }, \\ "fullPage": { "type": "boolean", "description": "Render the whole content height instead of one viewport. Defaults to false." }, @@ -722,6 +737,7 @@ pub const Tool = enum { }; pub fn minify(comptime json: []const u8) []const u8 { + // Cumulative: tool_defs evaluates every schema in one comptime scope. @setEvalBranchQuota(100_000); return comptime blk: { var buf: [json.len]u8 = undefined; @@ -809,7 +825,7 @@ pub fn errorMessage(err: ToolError) []const u8 { pub const ToolResult = struct { text: []const u8, is_error: bool = false, - /// A rendered PNG the caller streams out itself (MCP image content). + /// Only set when the caller passed `CallOpts.inline_image`. image: ?lp.screenshot.Prepared = null, }; @@ -838,12 +854,18 @@ const ActionTarget = union(enum) { const NodeAndPage = struct { node: *DOMNode, page: *lp.Frame, target: ActionTarget }; +/// What the caller can do with a result beyond its text. +pub const CallOpts = struct { + inline_image: bool = false, +}; + pub fn call( arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, tool_name: []const u8, arguments: ?std.json.Value, + opts: CallOpts, ) ToolError!ToolResult { // In-band so an LLM that invented a tool name (e.g. OpenAI's internal // `multi_tool_use.parallel` wrapper) learns the name is wrong instead of @@ -862,7 +884,7 @@ pub fn call( }; const substituted = try substituteStringArgs(arena, tool, normalized); - return dispatch(arena, session, registry, tool, substituted) catch |err| { + return dispatch(arena, session, registry, tool, substituted, opts) catch |err| { if (err == error.NavigationFailed) { if (formatNavigationError(arena, session)) |text| return .{ .text = text, .is_error = true }; @@ -877,13 +899,14 @@ fn dispatch( registry: *CDPNode.Registry, tool: Tool, substituted: ?std.json.Value, + opts: CallOpts, ) ToolError!ToolResult { return switch (tool) { .goto => .{ .text = try execGoto(arena, session, registry, substituted) }, .search => execSearch(arena, substituted), .markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) }, .html => .{ .text = try execHtml(arena, session, registry, substituted) }, - .screenshot => try execScreenshot(arena, session, registry, substituted), + .screenshot => try execScreenshot(arena, session, registry, substituted, opts.inline_image), .links => .{ .text = try execLinks(arena, session, registry, substituted) }, .tree => .{ .text = try execTree(arena, session, registry, substituted) }, .nodeDetails => .{ .text = try execNodeDetails(arena, session, registry, substituted) }, @@ -1289,21 +1312,21 @@ fn execMarkdown(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNo const args = try parseArgsOrDefault(Params, arena, arguments); const page = try ensurePage(session, registry, args.url, args.timeout); - const opts: lp.markdown.Opts = .{ .max_bytes = args.maxBytes }; + const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId); var aw: std.Io.Writer.Allocating = .init(arena); - if (args.selector) |sel| { - const resolved = try resolveBySelector(session, sel); - lp.markdown.dump(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError; - } else if (args.backendNodeId) |nid| { - const resolved = try resolveNodeAndPage(session, registry, nid); - lp.markdown.dump(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError; - } else { - lp.markdown.dump(page.document.asNode(), opts, &aw.writer, page) catch return ToolError.InternalError; - } + lp.markdown.dump(node, .{ .max_bytes = args.maxBytes }, &aw.writer, page) catch return ToolError.InternalError; return aw.written(); } +/// The node a read tool works on: the selector match, the registry node, or +/// the whole document. All three live in the current frame. +fn resolveScope(session: *lp.Session, registry: *CDPNode.Registry, page: *lp.Frame, selector: ?[]const u8, node_id: ?CDPNode.Id) ToolError!*DOMNode { + if (selector) |sel| return (try resolveBySelector(session, sel)).node; + if (node_id) |nid| return (try resolveNodeAndPage(session, registry, nid)).node; + return page.document.asNode(); +} + const HtmlParams = struct { selector: ?[]const u8 = null, backendNodeId: ?CDPNode.Id = null, @@ -1319,19 +1342,16 @@ fn execHtml(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R const opts: lp.dump.Opts = .{ .strip = args.strip, .max_bytes = args.maxBytes }; var aw: std.Io.Writer.Allocating = .init(arena); - if (args.selector) |sel| { - const resolved = try resolveBySelector(session, sel); - lp.dump.deep(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError; - } else if (args.backendNodeId) |nid| { - const resolved = try resolveNodeAndPage(session, registry, nid); - lp.dump.deep(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError; - } else { + if (args.selector == null and args.backendNodeId == null) { lp.dump.root(page.document, opts, &aw.writer, page) catch return ToolError.InternalError; + } else { + const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId); + lp.dump.deep(node, opts, &aw.writer, page) catch return ToolError.InternalError; } return aw.written(); } -fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult { +fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value, inline_image: bool) ToolError!ToolResult { const Params = struct { path: ?[]const u8 = null, selector: ?[]const u8 = null, @@ -1342,41 +1362,27 @@ fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDP }; const args = try parseArgsOrDefault(Params, arena, arguments); if (args.path) |path| { - if (!isPathSafe(path)) return .{ .text = "path must be relative and must not contain '..' segments", .is_error = true }; + if (!isPathSafe(path)) return .{ .text = unsafe_path_message, .is_error = true }; + } else if (!inline_image) { + return .{ .text = "pass `path`: this client cannot display an inline image", .is_error = true }; } const page = try ensurePage(session, registry, args.url, args.timeout); + const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId); + const prepared = lp.screenshot.prepare(arena, node, .fromViewport(page._page.getViewport(), args.fullPage), page) catch + return ToolError.InternalError; + const width = prepared.opts.width; - var node = page.document.asNode(); - var frame = page; - if (args.selector) |sel| { - const resolved = try resolveBySelector(session, sel); - node = resolved.node; - frame = resolved.page; - } else if (args.backendNodeId) |nid| { - const resolved = try resolveNodeAndPage(session, registry, nid); - node = resolved.node; - frame = resolved.page; + if (args.path) |path| { + const height = writePng(&prepared, path) catch |err| return .{ + .text = std.fmt.allocPrint(arena, "could not write {s}: {s}", .{ path, @errorName(err) }) catch return ToolError.OutOfMemory, + .is_error = true, + }; + return .{ .text = std.fmt.allocPrint(arena, "Saved {d}x{d} PNG to {s}", .{ width, height, absolutePath(arena, path) }) catch return ToolError.OutOfMemory }; } - - const viewport = page._page.getViewport(); - const prepared = lp.screenshot.prepare(arena, node, .{ - .width = viewport.width, - .height = if (args.fullPage) 0 else viewport.height, - .scale = viewport.scale, - }, frame) catch return ToolError.InternalError; - - const path = args.path orelse return .{ - .text = std.fmt.allocPrint(arena, "PNG, {d}px wide", .{viewport.width}) catch return ToolError.OutOfMemory, + return .{ + .text = std.fmt.allocPrint(arena, "PNG, {d}px wide", .{width}) catch return ToolError.OutOfMemory, .image = prepared, }; - - const height = writePng(&prepared, path) catch |err| return .{ - .text = std.fmt.allocPrint(arena, "could not write {s}: {s}", .{ path, @errorName(err) }) catch return ToolError.OutOfMemory, - .is_error = true, - }; - // Absolute path: the cwd is the server's, not one the user picked. - const where = std.Io.Dir.cwd().realPathFileAlloc(lp.io, path, arena) catch path; - return .{ .text = std.fmt.allocPrint(arena, "Saved {d}x{d} PNG to {s}", .{ viewport.width, height, where }) catch return ToolError.OutOfMemory }; } fn writePng(prepared: *const lp.screenshot.Prepared, path: []const u8) !u32 { @@ -2455,7 +2461,7 @@ test "call: unknown tool name surfaces in-band" { // Session/registry are never touched on this branch; the name check is // the first thing `call` does. - const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null); + const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null, .{}); try std.testing.expect(r.is_error); try std.testing.expectEqualStrings("Unknown tool: multi_tool_use.parallel", r.text); } diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index d4a17d702..0dab93e82 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -993,11 +993,7 @@ fn captureScreenshot(cmd: *CDP.Command) !void { const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const viewport = cmd.cdp.browser.getViewport(); - var opts: lp.screenshot.Opts = .{ - .scale = viewport.scale, - .width = viewport.width, - .height = if (params.captureBeyondViewport orelse false) 0 else viewport.height, - }; + var opts: lp.screenshot.Opts = .fromViewport(viewport, params.captureBeyondViewport orelse false); if (params.clip) |clip| { opts.clip = .{ .x = @floatCast(clip.x), diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 1ce1e85ff..a6930ded9 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -439,9 +439,7 @@ fn writeResults(app: *App, opts: FetchOpts, pages: []const Session.PageHandle, e } fn prepareShot(arena: std.mem.Allocator, frame: *Frame, opts: FetchOpts) !screenshot.Prepared { - return screenshot.prepare(arena, try dumpRoot(frame, opts.selector), .{ - .width = frame._page.getViewport().width, - }, frame); + return screenshot.prepare(arena, try dumpRoot(frame, opts.selector), .fromViewport(frame._page.getViewport(), true), frame); } fn dumpRoot(frame: *Frame, selector: ?[]const u8) !*Node { @@ -462,9 +460,7 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, opts: FetchOpts, frame: *Fram .png => { var arena: std.heap.ArenaAllocator = .init(app.allocator); defer arena.deinit(); - _ = try screenshot.png(arena.allocator(), root, .{ - .width = frame._page.getViewport().width, - }, writer, frame); + _ = try screenshot.png(arena.allocator(), root, .fromViewport(frame._page.getViewport(), true), writer, frame); }, .semantic_tree, .semantic_tree_text => { var registry = CDPNode.Registry.init(app.allocator); diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 130a62064..8b657bd4b 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -167,6 +167,12 @@ pub const CallParams = struct { arguments: ?std.json.Value = null, }; +pub const ImageContent = struct { + type: []const u8 = "image", + data: @import("../browser/screenshot.zig").Prepared, + mimeType: []const u8 = "image/png", +}; + pub fn TextContent(comptime T: type) type { return struct { type: []const u8 = "text", diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index b8010379c..259e1c497 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -156,7 +156,7 @@ fn dispatchBrowserTool( }; const active = server.active_session; - const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments) catch |err| { + const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments, .{ .inline_image = true }) catch |err| { // evaluate/extract surface failures in-band so the LLM can self-correct; // other tools' operational failures are protocol-level. if (surfacesErrorInBand(tool)) { @@ -173,41 +173,14 @@ fn dispatchBrowserTool( }; if (result.image) |image| { - return server.sendResult(id, ImageResult{ .image = image, .text = result.text }); + return server.sendResult(id, .{ + .content = .{ protocol.ImageContent{ .data = image }, protocol.TextContent([]const u8){ .text = result.text } }, + .isError = result.is_error, + }); } try sendToolResultText(server, id, result.text, result.is_error); } -/// MCP image content: the PNG streams straight into the message as base64. -const ImageResult = struct { - image: lp.screenshot.Prepared, - text: []const u8, - - pub fn jsonStringify(self: @This(), jw: anytype) !void { - try jw.beginObject(); - try jw.objectField("content"); - try jw.beginArray(); - try jw.beginObject(); - try jw.objectField("type"); - try jw.write("image"); - try jw.objectField("data"); - try jw.write(self.image); - try jw.objectField("mimeType"); - try jw.write("image/png"); - try jw.endObject(); - try jw.beginObject(); - try jw.objectField("type"); - try jw.write("text"); - try jw.objectField("text"); - try jw.write(self.text); - try jw.endObject(); - try jw.endArray(); - try jw.objectField("isError"); - try jw.write(false); - try jw.endObject(); - } -}; - fn surfacesErrorInBand(tool: BrowserTool) bool { return tool == .evaluate or tool == .extract; } @@ -219,7 +192,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg }; if (!browser_tools.isPathSafe(args.path)) { - return sendErrorContent(server, id, "path must be relative and must not contain '..' segments"); + return sendErrorContent(server, id, browser_tools.unsafe_path_message); } // The client never sees resolved secrets, but scrub any literal LP_* value @@ -233,8 +206,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg return sendErrorContent(server, id, msg); }; - // Absolute path: the cwd is the client-launched server's, not one the user picked. - const where = std.Io.Dir.cwd().realPathFileAlloc(lp.io, args.path, arena) catch args.path; + const where = browser_tools.absolutePath(arena, args.path); const lines = std.mem.count(u8, script, "\n") + 1; const msg = std.fmt.allocPrint(arena, "saved {d} line(s) to {s}", .{ lines, where }) catch return sendErrorContent(server, id, "out of memory"); @@ -1498,9 +1470,7 @@ test "MCP - screenshot: inline image, file, unsafe path" { defer std.Io.Dir.cwd().deleteFile(lp.io, path) catch {}; out.clearRetainingCapacity(); - const to_file = - \\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screenshot","arguments":{"path":"mcp-screenshot-test.png","selector":"#hoverTarget"}}} - ; + const to_file = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"screenshot\",\"arguments\":{\"path\":\"" ++ path ++ "\",\"selector\":\"#hoverTarget\"}}}"; try router.handleMessage(server, testing.arena_allocator, to_file); try testing.expect(std.mem.indexOf(u8, out.written(), "Saved 1920x") != null); try testing.expect(std.mem.indexOf(u8, out.written(), "\"type\":\"image\"") == null); diff --git a/src/script/Runtime.zig b/src/script/Runtime.zig index e8ddf247c..ff3587858 100644 --- a/src/script/Runtime.zig +++ b/src/script/Runtime.zig @@ -714,14 +714,13 @@ fn callTool( self.session.browser.env.isolate.enter(); defer self.session.browser.env.isolate.exit(); - const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args) catch |err| switch (err) { + const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args, .{}) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.FrameNotLoaded => return .{ .fail = "no page loaded - run page.goto(url) first" }, else => return .{ .fail = std.fmt.allocPrint(arena, "{s} failed: {s}", .{ @tagName(tool), @errorName(err) }) catch return error.OutOfMemory }, }; if (result.is_error) return .{ .fail = result.text }; - if (result.image != null) return .{ .fail = "screenshot needs `path` in a script" }; return .{ .ok = result.text }; } diff --git a/src/script/command.zig b/src/script/command.zig index 0136e7d5d..cc8037eab 100644 --- a/src/script/command.zig +++ b/src/script/command.zig @@ -104,19 +104,20 @@ pub const Command = union(enum) { } /// Skip the line when the recorded form would not round-trip: - /// - no `selector` AND (tool needs one OR only locator is the - /// ephemeral `backendNodeId`); + /// - an arg the replay requires (`Tool.replayRequires`) is missing; + /// - the only locator is the ephemeral `backendNodeId`; /// - a string field can't be quoted unambiguously. 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 and !self.tool.needsLocator() and self.tool != .screenshot; - if (args != .object) return !self.tool.needsLocator(); - // An inline screenshot (no `path`) only exists as MCP image content. - if (self.tool == .screenshot and !args.object.contains("path")) return false; + const required = self.tool.replayRequires(); + const args = self.args orelse return s.required.len == 0 and required.len == 0; + if (args != .object) return required.len == 0; - const has_selector = args.object.contains("selector"); - if (!has_selector and (self.tool.needsLocator() or args.object.contains("backendNodeId"))) return false; + if (!args.object.contains("selector") and args.object.contains("backendNodeId")) return false; + for (required) |field| { + if (!args.object.contains(field)) return false; + } const positional = s.isBarePositional(args.object); diff --git a/src/script/skill.zig b/src/script/skill.zig index 79749cb07..3cbf47b7a 100644 --- a/src/script/skill.zig +++ b/src/script/skill.zig @@ -198,7 +198,7 @@ fn note(tool: browser_tools.Tool) []const u8 { .waitForSelector => "`waitFor*` default timeout 5000 ms.", .waitForScript => "Re-evaluates page JS until truthy.", .waitForState => "", - .screenshot => "Needs `path`; writes a PNG of the text layout.", + .screenshot => "`path` is required: writes a PNG of the text layout.", .press => "Selector first! `page.press(\"Enter\")` binds \"Enter\" to `selector` and fails — use `page.press(null, \"Enter\")` or `page.press({ key: \"Enter\" })`.", .click, .fill, .scroll, .hover, .selectOption, .setChecked => "", .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => "",