agent: replace QUIT command with /quit slash command

This commit is contained in:
Adrià Arrufat
2026-04-30 16:22:19 +02:00
parent 5b66a4f227
commit 7aef08f28b
5 changed files with 44 additions and 47 deletions

View File

@@ -66,13 +66,12 @@ recorded scripts round-trip through the parser.
| `EXTRACT` | `EXTRACT '<selector>'` | Returns text content. |
| `EVAL` | `EVAL '<js>'` or `EVAL '''…'''` | Triple-quote for multi-line JS. |
| `TREE` | `TREE` | Print the semantic tree (not recorded). |
| `MARKDOWN` / `MD`| `MARKDOWN` | Print page as markdown (not recorded). |
| `MARKDOWN` | `MARKDOWN` | Print page as markdown (not recorded). |
| `LOGIN` | `LOGIN` | LLM-driven: fill `$LP_USERNAME` / `$LP_PASSWORD`. |
| `ACCEPT_COOKIES` | `ACCEPT_COOKIES` | LLM-driven: dismiss the consent banner. |
| `EXIT` / `QUIT` | `EXIT` | REPL only. |
In the REPL, anything that does not parse as a Pandascript command is sent to
the LLM as natural language.
the LLM as natural language. To leave the REPL, use the `/quit` slash command.
### Example script

View File

@@ -263,7 +263,7 @@ fn runOneShot(self: *Self, task: []const u8) bool {
}
fn runRepl(self: *Self) void {
self.terminal.printInfo("Lightpanda Agent (type 'quit' to exit)");
self.terminal.printInfo("Lightpanda Agent (type '/quit' to exit)");
self.terminal.printInfo("Tab completes/cycles throuch commands; the dim grey ghost shows the first match.");
log.debug(.app, "tools loaded", .{ .count = self.tools.len });
if (self.ai_client) |ai_client| {
@@ -291,7 +291,6 @@ fn runRepl(self: *Self) void {
}
switch (cmd) {
.quit => break :repl,
.comment => continue :repl,
.login => self.processUserMessage(login_prompt, line) catch |err| {
self.terminal.printErrorFmt("LOGIN failed: {s}", .{@errorName(err)});
@@ -452,10 +451,6 @@ fn runScript(self: *Self, path: []const u8) bool {
while (iter.next()) |entry| {
switch (entry.command) {
.quit => {
self.terminal.printInfo("QUIT — stopping script.");
break;
},
.comment => {
// Track the most recent comment — recorded scripts
// prefix LLM-generated commands with the natural

View File

@@ -36,13 +36,12 @@ pub const Command = union(enum) {
eval_js: []const u8,
login: void,
accept_cookies: void,
quit: void,
comment: void,
natural_language: []const u8,
pub fn isRecorded(self: Command) bool {
return switch (self) {
.tree, .markdown, .comment, .quit => false,
.tree, .markdown, .comment => false,
.goto, .click, .type_cmd, .wait, .scroll, .hover, .select, .check, .extract, .eval_js, .login, .accept_cookies => true,
.natural_language => |text| text.len > 0,
};
@@ -100,7 +99,6 @@ pub const Command = union(enum) {
try writer.print("EVAL {f}", .{quote(script)}),
.login => try writer.writeAll("LOGIN"),
.accept_cookies => try writer.writeAll("ACCEPT_COOKIES"),
.quit => try writer.writeAll("QUIT"),
.comment => try writer.writeAll("#"),
.natural_language => |text| try writer.writeAll(text),
}
@@ -215,10 +213,6 @@ pub fn parse(line: []const u8) Command {
return .{ .accept_cookies = {} };
}
if (std.ascii.eqlIgnoreCase(cmd_word, "QUIT")) {
return .{ .quit = {} };
}
return .{ .natural_language = trimmed };
}
@@ -417,7 +411,7 @@ pub fn noSubstitute(_: std.mem.Allocator, input: []const u8) []const u8 {
/// Map a Command to its (tool_name, JSON args) representation. Returns
/// null for variants without a 1:1 tool mapping (login, accept_cookies,
/// natural_language, comment, quit, extract — extract is rendered as a
/// natural_language, comment, extract — extract is rendered as a
/// custom `eval` script by the caller).
///
/// `substitute` is applied to selector-like fields. The `value` field of
@@ -447,7 +441,7 @@ pub fn toToolCall(arena: std.mem.Allocator, cmd: Command, substitute: Substitute
.tree => .{ .name = @tagName(Action.tree), .args_json = "" },
.markdown => .{ .name = @tagName(Action.markdown), .args_json = "" },
.eval_js => |script| .{ .name = @tagName(Action.eval), .args_json = buildJson(arena, .{ .script = script }) },
.extract, .quit, .natural_language, .comment, .login, .accept_cookies => null,
.extract, .natural_language, .comment, .login, .accept_cookies => null,
};
}
@@ -709,11 +703,6 @@ test "parse ACCEPT_COOKIES" {
try std.testing.expect(parse("accept_cookies") == .accept_cookies);
}
test "parse QUIT" {
try std.testing.expect(parse("QUIT") == .quit);
try std.testing.expect(parse("quit") == .quit);
}
test "parse comment" {
try std.testing.expect(parse("# this is a comment") == .comment);
try std.testing.expect(parse("# INTENT: LOGIN") == .comment);
@@ -1040,7 +1029,6 @@ test "toToolCall: variants without tool mapping return null" {
try std.testing.expect(toToolCall(a, .{ .extract = ".x" }, noSubstitute) == null);
try std.testing.expect(toToolCall(a, .login, noSubstitute) == null);
try std.testing.expect(toToolCall(a, .accept_cookies, noSubstitute) == null);
try std.testing.expect(toToolCall(a, .quit, noSubstitute) == null);
try std.testing.expect(toToolCall(a, .comment, noSubstitute) == null);
try std.testing.expect(toToolCall(a, .{ .natural_language = "hi" }, noSubstitute) == null);
}

View File

@@ -28,7 +28,7 @@ pub fn executeWithResult(self: *Self, a: std.mem.Allocator, cmd: Command.Command
if (cmd == .extract) return self.execExtract(a, cmd.extract);
const tc = Command.toToolCall(a, cmd, browser_tools.substituteEnvVars) orelse switch (cmd) {
.quit, .natural_language, .comment, .login, .accept_cookies => unreachable,
.natural_language, .comment, .login, .accept_cookies => unreachable,
else => return .{ .output = "command has no tool mapping", .failed = true },
};
return self.callTool(a, tc.name, tc.args_json);

View File

@@ -34,9 +34,12 @@ const commands = [_]CommandInfo{
.{ .name = "EVAL", .hint = " '<script>'" },
.{ .name = "LOGIN", .hint = "" },
.{ .name = "ACCEPT_COOKIES", .hint = "" },
.{ .name = "QUIT", .hint = "" },
};
// Meta slash commands handled directly by the agent (not by ToolExecutor).
// Kept in sync with `handleSlash` in `Agent.zig`.
const meta_slash_commands = [_][:0]const u8{ "help", "quit" };
pub fn init(history_path: ?[:0]const u8) Self {
c.linenoiseSetMultiLine(1);
c.linenoiseSetCompletionCallback(&completionCallback);
@@ -47,6 +50,23 @@ pub fn init(history_path: ?[:0]const u8) Self {
return .{ .history_path = history_path };
}
fn addSlashCompletion(lc: [*c]c.linenoiseCompletions, name_buf: *[64:0]u8, name: []const u8, partial: []const u8) void {
const total = 1 + name.len;
if (total >= name_buf.len) return;
if (name.len < partial.len) return;
if (!std.ascii.eqlIgnoreCase(name[0..partial.len], partial)) return;
name_buf[0] = '/';
@memcpy(name_buf[1..total], name);
name_buf[total] = 0;
c.linenoiseAddCompletion(lc, name_buf);
}
fn slashHint(name: []const u8, partial: []const u8) ?[]const u8 {
if (name.len <= partial.len) return null;
if (!std.ascii.eqlIgnoreCase(name[0..partial.len], partial)) return null;
return name[partial.len..];
}
fn completionCallback(buf: [*c]const u8, lc: [*c]c.linenoiseCompletions) callconv(.c) void {
const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(buf)), 0);
if (input.len == 0) return;
@@ -55,18 +75,10 @@ fn completionCallback(buf: [*c]const u8, lc: [*c]c.linenoiseCompletions) callcon
if (input[0] == '/') {
const partial = input[1..];
// linenoise strdup's the string, so a stack buffer reused per match
// is fine. 64 covers every tool name comfortably.
// is fine. 64 covers every name comfortably.
var name_buf: [64:0]u8 = undefined;
for (browser_tools.tool_defs) |td| {
const total = 1 + td.name.len;
if (total >= name_buf.len) continue;
if (td.name.len < partial.len) continue;
if (!std.ascii.eqlIgnoreCase(td.name[0..partial.len], partial)) continue;
name_buf[0] = '/';
@memcpy(name_buf[1..total], td.name);
name_buf[total] = 0;
c.linenoiseAddCompletion(lc, &name_buf);
}
for (browser_tools.tool_defs) |td| addSlashCompletion(lc, &name_buf, td.name, partial);
for (meta_slash_commands) |name| addSlashCompletion(lc, &name_buf, name, partial);
return;
}
@@ -92,16 +104,19 @@ fn hintsCallback(buf: [*c]const u8, color: [*c]c_int, bold: [*c]c_int) callconv(
if (input[0] == '/') {
const partial = input[1..];
for (browser_tools.tool_defs) |td| {
if (td.name.len <= partial.len) continue;
if (!std.ascii.eqlIgnoreCase(td.name[0..partial.len], partial)) continue;
const suffix = td.name[partial.len..];
if (suffix.len + 1 > hint_buf.len) return null;
@memcpy(hint_buf[0..suffix.len], suffix);
hint_buf[suffix.len] = 0;
return @ptrCast(&hint_buf);
}
return null;
const suffix = blk: {
for (browser_tools.tool_defs) |td| {
if (slashHint(td.name, partial)) |s| break :blk s;
}
for (meta_slash_commands) |name| {
if (slashHint(name, partial)) |s| break :blk s;
}
return null;
};
if (suffix.len + 1 > hint_buf.len) return null;
@memcpy(hint_buf[0..suffix.len], suffix);
hint_buf[suffix.len] = 0;
return @ptrCast(&hint_buf);
}
for (commands) |cmd| {