mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
command: add /logout and refactor LLM commands
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user