diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 2f55f75b..a9205bdb 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -98,30 +98,6 @@ const self_heal_prompt_instructions = \\ The script will continue executing the remaining commands after the heal. ; -const login_prompt = - \\Find the login form on the current page. Fill in the credentials using - \\$LP_* placeholders — the substitution happens inside the Lightpanda - \\subprocess so the secret never enters your context. Do NOT call getEnv - \\with a credential name (it would return the value). - \\ - \\Call getEnv with NO `name` argument first to see which LP_* variables - \\are set (names only, values never included). Then pick: - \\- Site-prefixed form (LP__) when the list shows one for - \\ the current site — e.g. $LP_HN_USERNAME for news.ycombinator.com, - \\ $LP_GH_TOKEN for github.com. - \\- Otherwise fall back to the unprefixed $LP_USERNAME / $LP_PASSWORD - \\ (or $LP_EMAIL) form. - \\ - \\Handle any cookie banners or popups first, then submit the form by - \\clicking its submit button or pressing Enter in a filled field — there - \\is no dedicated submit tool. -; - -const accept_cookies_prompt = - \\Find and dismiss the cookie consent banner on the current page. - \\Look for "Accept", "Accept All", "I agree", or similar buttons and click them. -; - const synthesis_prompt = \\You have used your tool budget or cannot finish the exploration. \\Give your best final answer NOW based ONLY on what you actually observed @@ -530,17 +506,17 @@ fn runRepl(self: *Agent) void { }, }; - if (cmd.needsLlm() and self.ai_client == null) { - self.terminal.printError("/{s} requires an LLM. Drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY).", .{@tagName(std.meta.activeTag(cmd))}); + if (cmd == .llm and self.ai_client == null) { + self.terminal.printError("/{s} requires an LLM. Drop --no-llm and set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY).", .{@tagName(cmd.llm)}); continue :repl; } switch (cmd) { .comment => continue :repl, - .login, .acceptCookies => { - const label: []const u8 = if (cmd == .login) "/login" else "/acceptCookies"; - const prompt = if (cmd == .login) login_prompt else accept_cookies_prompt; - _ = self.runTurn(.{ .prompt = prompt, .record_comment = line, .capture_for_save = true, .label = label }); + .llm => |lc| { + var label_buf: [32]u8 = undefined; + const label = std.fmt.bufPrint(&label_buf, "/{s}", .{@tagName(lc)}) catch "/?"; + _ = self.runTurn(.{ .prompt = lc.prompt(), .record_comment = line, .capture_for_save = true, .label = label }); }, .tool_call => |tc| { self.terminal.beginTool(tc.name(), slash_split.?.rest); @@ -889,8 +865,8 @@ fn printSlashParseError(self: *Agent, err: Schema.ParseError, name: []const u8, const Replacement = script.Replacement; -/// Caller contract: `cmd` must be `.tool_call` — `.comment`, `.login`, and -/// `.acceptCookies` are filtered upstream because they have no tool mapping. +/// Caller contract: `cmd` must be `.tool_call` — `.comment` and `.llm` are +/// filtered upstream because they have no tool mapping. fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tools.ToolResult { const tc = switch (cmd) { .tool_call => |t| t, @@ -954,7 +930,7 @@ fn runScript(self: *Agent, path: []const u8) bool { } continue; }, - .login, .acceptCookies => { + .llm => |lc| { if (self.ai_client == null) { self.terminal.printError("line {d}: {s} requires --provider", .{ entry.line_num, @@ -963,7 +939,7 @@ fn runScript(self: *Agent, path: []const u8) bool { self.flushReplacements(path, content, replacements.items); return false; } - const prompt = if (entry.command == .login) login_prompt else accept_cookies_prompt; + const prompt = lc.prompt(); const text = self.processUserMessage(.{ .prompt = prompt }) catch |err| { self.terminal.printError("line {d}: {s} failed: {s}", .{ entry.line_num, diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index ca42c813..b6c0d820 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -59,23 +59,15 @@ pub const meta_commands = [_]MetaCommand{ .{ .tag = .provider, .name = "provider", .hint = "[name]", .values = &.{}, .description = "Change the provider" }, }; -/// Names derive from `Command.llm_tags` so a new trigger there surfaces -/// here automatically; only the description is local. +/// Derived from `Command.LlmCommand` — name and description both come from +/// the enum, so a new trigger there surfaces here automatically. pub const llm_commands = blk: { - const tags = Command.llm_tags; - var rows: [tags.len]Help = undefined; - for (tags, &rows) |tag, *row| row.* = .{ .name = @tagName(tag), .description = llmDescription(tag) }; + const values = std.enums.values(Command.LlmCommand); + var rows: [values.len]Help = undefined; + for (values, &rows) |lc, *row| row.* = .{ .name = @tagName(lc), .description = lc.description() }; break :blk rows; }; -fn llmDescription(tag: std.meta.Tag(Command)) []const u8 { - return switch (tag) { - .login => "Log in using $LP_* credentials", - .acceptCookies => "Dismiss the cookie consent banner", - else => unreachable, // llm_tags only contains the cases above - }; -} - pub fn findMeta(name: []const u8) ?*const MetaCommand { for (&meta_commands) |*m| { if (std.ascii.eqlIgnoreCase(m.name, name)) return m; diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 57b90543..2b9f39bb 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -76,15 +76,16 @@ pub const CompletionSource = struct { }; // Flat name list for the "match any slash command" search/completion paths. -const all_slash_names: [browser_tools.names.len + SlashCommand.meta_commands.len + Command.llm_tags.len][]const u8 = blk: { - var arr: [browser_tools.names.len + SlashCommand.meta_commands.len + Command.llm_tags.len][]const u8 = undefined; +const llm_values = std.enums.values(Command.LlmCommand); +const all_slash_names: [browser_tools.names.len + SlashCommand.meta_commands.len + llm_values.len][]const u8 = blk: { + var arr: [browser_tools.names.len + SlashCommand.meta_commands.len + llm_values.len][]const u8 = undefined; var idx: usize = 0; for (browser_tools.names) |n| { arr[idx] = n; idx += 1; } - for (Command.llm_tags) |tag| { - arr[idx] = @tagName(tag); + for (llm_values) |lc| { + arr[idx] = @tagName(lc); idx += 1; } for (SlashCommand.meta_commands) |m| { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 8d7cfed1..98bdb878 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -269,7 +269,7 @@ fn handleScriptStep(server: *Server, arena: std.mem.Allocator, id: std.json.Valu }; if (cmd.needsLlm()) { - return sendErrorContent(server, id, "/login and /acceptCookies require an LLM and are not handled by lightpanda mcp; the calling agent owns those"); + return sendErrorContent(server, id, "this command requires an LLM and is not handled by lightpanda mcp; the calling agent owns it"); } if (cmd == .comment) { @@ -1109,7 +1109,7 @@ test "MCP - scriptStep rejects /login (LLM-required)" { \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"scriptStep","arguments":{"line":"/login"}}} ; try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expect(std.mem.indexOf(u8, out.written(), "require an LLM") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "requires an LLM") != null); } test "MCP - scriptStep rejects bare prose" { diff --git a/src/script/command.zig b/src/script/command.zig index 2fdc2be9..b0a6070a 100644 --- a/src/script/command.zig +++ b/src/script/command.zig @@ -16,9 +16,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! PandaScript Command: slash command, `#`-comment, or `/login` / -//! `/acceptCookies` LLM trigger. Multi-line `'''…'''` blocks are -//! assembled by `script.Iterator` before parse. +//! PandaScript Command: a tool slash command, a `#`-comment, or an +//! `LlmCommand` trigger (`/login`, `/logout`, `/acceptCookies`). Multi-line +//! `'''…'''` blocks are assembled by `script.Iterator` before parse. const std = @import("std"); const lp = @import("lightpanda"); @@ -29,15 +29,67 @@ pub const ParseError = Schema.ParseError || error{ NotASlashCommand, }; +const login_prompt = + \\Find the login form on the current page. Fill in the credentials using + \\$LP_* placeholders — the substitution happens inside the Lightpanda + \\subprocess so the secret never enters your context. Do NOT call getEnv + \\with a credential name (it would return the value). + \\ + \\Call getEnv with NO `name` argument first to see which LP_* variables + \\are set (names only, values never included). Then pick: + \\- Site-prefixed form (LP__) when the list shows one for + \\ the current site — e.g. $LP_HN_USERNAME for news.ycombinator.com, + \\ $LP_GH_TOKEN for github.com. + \\- Otherwise fall back to the unprefixed $LP_USERNAME / $LP_PASSWORD + \\ (or $LP_EMAIL) form. + \\ + \\Handle any cookie banners or popups first, then submit the form by + \\clicking its submit button or pressing Enter in a filled field — there + \\is no dedicated submit tool. +; + +const logout_prompt = + \\Log out of the current site. Find the logout control — often a link or + \\button labeled "Log out", "Logout", or "Sign out", possibly inside an + \\account or user menu you must open first — and click it. Handle any + \\confirmation prompt, then verify the logged-out state (e.g. a login link + \\reappears). +; + +const accept_cookies_prompt = + \\Find and dismiss the cookie consent banner on the current page. + \\Look for "Accept", "Accept All", "I agree", or similar buttons and click them. +; + pub const Command = union(enum) { tool_call: ToolCall, - login: void, - acceptCookies: void, + llm: LlmCommand, comment: void, - /// Union tags that fire an LLM trigger. Tag names match the wire-format - /// slash command, so `@tagName` is the single source of truth. - pub const llm_tags: []const std.meta.Tag(Command) = &.{ .login, .acceptCookies }; + /// An LLM-driven command: `@tagName` is the wire-format slash name, and + /// each value owns its `prompt()` (sent to the model) and `description()` + /// (shown in `/help`) — mirroring how `tool_call` wraps `BrowserTool`. + pub const LlmCommand = enum { + login, + logout, + acceptCookies, + + pub fn prompt(self: LlmCommand) []const u8 { + return switch (self) { + .login => login_prompt, + .logout => logout_prompt, + .acceptCookies => accept_cookies_prompt, + }; + } + + pub fn description(self: LlmCommand) []const u8 { + return switch (self) { + .login => "Log in using $LP_* credentials", + .logout => "Log out of the current site", + .acceptCookies => "Dismiss the cookie consent banner", + }; + } + }; pub const ToolCall = struct { tool: BrowserTool, @@ -115,7 +167,7 @@ pub const Command = union(enum) { pub fn isRecorded(self: Command) bool { return switch (self) { .comment => false, - .login, .acceptCookies => true, + .llm => true, .tool_call => |tc| tc.isRecorded(), }; } @@ -135,9 +187,7 @@ pub const Command = union(enum) { } pub fn needsLlm(self: Command) bool { - return inline for (llm_tags) |tag| { - if (self == tag) break true; - } else false; + return self == .llm; } pub fn isRetryable(self: Command) bool { @@ -160,10 +210,10 @@ pub const Command = union(enum) { const split = Schema.splitNameRest(trimmed[1..]) orelse return error.MissingName; - inline for (llm_tags) |tag| { - if (std.ascii.eqlIgnoreCase(split.name, @tagName(tag))) { + inline for (std.meta.fields(LlmCommand)) |f| { + if (std.ascii.eqlIgnoreCase(split.name, f.name)) { if (split.rest.len > 0) return error.MalformedKv; - return @unionInit(Command, @tagName(tag), {}); + return .{ .llm = @field(LlmCommand, f.name) }; } } @@ -175,7 +225,7 @@ pub const Command = union(enum) { /// Canonical recorder format. Round-trips with `parse`. pub fn format(self: Command, writer: *std.Io.Writer) (std.Io.Writer.Error || error{AmbiguousQuoting})!void { switch (self) { - inline .login, .acceptCookies => |_, tag| try writer.writeAll("/" ++ @tagName(tag)), + .llm => |lc| try writer.print("/{s}", .{@tagName(lc)}), .comment => try writer.writeAll("#"), .tool_call => |tc| try tc.format(writer), } @@ -210,8 +260,8 @@ test "parse: bare prose errors" { 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")) == .acceptCookies); + try testing.expectEqual(Command.LlmCommand.login, (try Command.parse(arena.allocator(), "/login")).llm); + try testing.expectEqual(Command.LlmCommand.acceptCookies, (try Command.parse(arena.allocator(), "/acceptCookies")).llm); } test "parse: /goto positional" { @@ -309,12 +359,12 @@ test "format: /setChecked omits checked=true (default), keeps checked=false" { test "format: /login and /acceptCookies" { var aw1: std.Io.Writer.Allocating = .init(testing.allocator); defer aw1.deinit(); - try (Command{ .login = {} }).format(&aw1.writer); + try (Command{ .llm = .login }).format(&aw1.writer); try testing.expectString("/login", aw1.written()); var aw2: std.Io.Writer.Allocating = .init(testing.allocator); defer aw2.deinit(); - try (Command{ .acceptCookies = {} }).format(&aw2.writer); + try (Command{ .llm = .acceptCookies }).format(&aw2.writer); try testing.expectString("/acceptCookies", aw2.written()); } @@ -333,8 +383,8 @@ test "canHeal: only page-local DOM commands are allowed" { try testing.expect(!cmd.canHeal()); } - try testing.expect(!(Command{ .login = {} }).canHeal()); - try testing.expect(!(Command{ .acceptCookies = {} }).canHeal()); + try testing.expect(!(Command{ .llm = .login }).canHeal()); + try testing.expect(!(Command{ .llm = .acceptCookies }).canHeal()); try testing.expect(!(Command{ .comment = {} }).canHeal()); } @@ -351,7 +401,7 @@ test "isRecorded / canHeal / producesData via tool flags" { try testing.expect(!tree.isRecorded()); try testing.expect(tree.producesData()); - const login: Command = .{ .login = {} }; + const login: Command = .{ .llm = .login }; try testing.expect(login.isRecorded()); try testing.expect(!login.canHeal()); }