command: add /logout and refactor LLM commands

This commit is contained in:
Adrià Arrufat
2026-05-30 22:03:46 +02:00
parent b98a79e14e
commit c92dad165f
5 changed files with 95 additions and 76 deletions

View File

@@ -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_<SITE>_<FIELD>) 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,

View File

@@ -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;

View File

@@ -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| {

View File

@@ -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" {

View File

@@ -16,9 +16,9 @@
// 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: 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_<SITE>_<FIELD>) 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());
}