mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-04-04 08:30:31 +00:00
agent: add manual command support to REPL
Adds a parser and executor for manual commands like GOTO and CLICK. Unrecognized input continues to be processed by the AI.
This commit is contained in:
@@ -252,6 +252,7 @@ pub const Agent = struct {
|
||||
model: ?[:0]const u8 = null,
|
||||
api_key: ?[:0]const u8 = null,
|
||||
system_prompt: ?[:0]const u8 = null,
|
||||
repl: bool = true,
|
||||
};
|
||||
|
||||
pub const DumpFormat = enum {
|
||||
@@ -957,6 +958,11 @@ fn parseAgentArgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--repl", opt)) {
|
||||
result.repl = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--system-prompt", opt) or std.mem.eql(u8, "--system_prompt", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub const Agent = @import("agent/Agent.zig");
|
||||
pub const ToolExecutor = @import("agent/ToolExecutor.zig");
|
||||
pub const Terminal = @import("agent/Terminal.zig");
|
||||
pub const Command = @import("agent/Command.zig");
|
||||
pub const CommandExecutor = @import("agent/CommandExecutor.zig");
|
||||
|
||||
@@ -7,6 +7,8 @@ const Config = lp.Config;
|
||||
const App = @import("../App.zig");
|
||||
const ToolExecutor = @import("ToolExecutor.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const Command = @import("Command.zig");
|
||||
const CommandExecutor = @import("CommandExecutor.zig");
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -24,6 +26,7 @@ allocator: std.mem.Allocator,
|
||||
ai_client: AiClient,
|
||||
tool_executor: *ToolExecutor,
|
||||
terminal: Terminal,
|
||||
cmd_executor: CommandExecutor,
|
||||
messages: std.ArrayListUnmanaged(zenai.provider.Message),
|
||||
message_arena: std.heap.ArenaAllocator,
|
||||
tools: []const zenai.provider.Tool,
|
||||
@@ -86,6 +89,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self
|
||||
.ai_client = ai_client,
|
||||
.tool_executor = tool_executor,
|
||||
.terminal = Terminal.init(null),
|
||||
.cmd_executor = undefined,
|
||||
.messages = .empty,
|
||||
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.tools = tools,
|
||||
@@ -93,6 +97,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self
|
||||
.system_prompt = opts.system_prompt orelse default_system_prompt,
|
||||
};
|
||||
|
||||
self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@@ -123,12 +129,21 @@ pub fn run(self: *Self) void {
|
||||
defer self.terminal.freeLine(line);
|
||||
|
||||
if (line.len == 0) continue;
|
||||
if (std.mem.eql(u8, line, "quit") or std.mem.eql(u8, line, "exit")) break;
|
||||
|
||||
self.processUserMessage(line) catch |err| {
|
||||
const msg = std.fmt.allocPrint(self.allocator, "Request failed: {s}", .{@errorName(err)}) catch "Request failed";
|
||||
self.terminal.printError(msg);
|
||||
};
|
||||
const cmd = Command.parse(line);
|
||||
switch (cmd) {
|
||||
.exit => break,
|
||||
.natural_language => {
|
||||
// "quit" as a convenience alias
|
||||
if (std.mem.eql(u8, line, "quit")) break;
|
||||
|
||||
self.processUserMessage(line) catch |err| {
|
||||
const msg = std.fmt.allocPrint(self.allocator, "Request failed: {s}", .{@errorName(err)}) catch "Request failed";
|
||||
self.terminal.printError(msg);
|
||||
};
|
||||
},
|
||||
else => self.cmd_executor.execute(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
self.terminal.printInfo("Goodbye!");
|
||||
|
||||
117
src/agent/Command.zig
Normal file
117
src/agent/Command.zig
Normal file
@@ -0,0 +1,117 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub const TypeArgs = struct {
|
||||
selector: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
pub const ExtractArgs = struct {
|
||||
selector: []const u8,
|
||||
file: ?[]const u8,
|
||||
};
|
||||
|
||||
pub const Command = union(enum) {
|
||||
goto: []const u8,
|
||||
click: []const u8,
|
||||
type_cmd: TypeArgs,
|
||||
wait: []const u8,
|
||||
tree: void,
|
||||
extract: ExtractArgs,
|
||||
eval_js: []const u8,
|
||||
exit: void,
|
||||
natural_language: []const u8,
|
||||
};
|
||||
|
||||
/// Parse a line of REPL input into a Pandascript command.
|
||||
/// Unrecognized input is returned as `.natural_language`.
|
||||
pub fn parse(line: []const u8) Command {
|
||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
||||
if (trimmed.len == 0) return .{ .natural_language = trimmed };
|
||||
|
||||
// Find the command word (first whitespace-delimited token)
|
||||
const cmd_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
|
||||
const cmd_word = trimmed[0..cmd_end];
|
||||
const rest = std.mem.trim(u8, trimmed[cmd_end..], &std.ascii.whitespace);
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "GOTO")) {
|
||||
if (rest.len == 0) return .{ .natural_language = trimmed };
|
||||
return .{ .goto = rest };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "CLICK")) {
|
||||
const arg = extractQuoted(rest) orelse rest;
|
||||
if (arg.len == 0) return .{ .natural_language = trimmed };
|
||||
return .{ .click = arg };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "TYPE")) {
|
||||
const first = extractQuotedWithRemainder(rest) orelse return .{ .natural_language = trimmed };
|
||||
const second_arg = std.mem.trim(u8, first.remainder, &std.ascii.whitespace);
|
||||
const second = extractQuoted(second_arg) orelse return .{ .natural_language = trimmed };
|
||||
return .{ .type_cmd = .{ .selector = first.value, .value = second } };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "WAIT")) {
|
||||
const arg = extractQuoted(rest) orelse rest;
|
||||
if (arg.len == 0) return .{ .natural_language = trimmed };
|
||||
return .{ .wait = arg };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "TREE")) {
|
||||
return .{ .tree = {} };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "EXTRACT")) {
|
||||
const selector = extractQuoted(rest) orelse {
|
||||
if (rest.len == 0) return .{ .natural_language = trimmed };
|
||||
return .{ .extract = .{ .selector = rest, .file = null } };
|
||||
};
|
||||
// Look for > filename after the quoted selector
|
||||
const after_quote = extractQuotedWithRemainder(rest) orelse return .{ .extract = .{ .selector = selector, .file = null } };
|
||||
const after = std.mem.trim(u8, after_quote.remainder, &std.ascii.whitespace);
|
||||
if (after.len > 0 and after[0] == '>') {
|
||||
const file = std.mem.trim(u8, after[1..], &std.ascii.whitespace);
|
||||
return .{ .extract = .{ .selector = selector, .file = if (file.len > 0) file else null } };
|
||||
}
|
||||
return .{ .extract = .{ .selector = selector, .file = null } };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "EVAL")) {
|
||||
if (rest.len == 0) return .{ .natural_language = trimmed };
|
||||
const arg = extractQuoted(rest) orelse rest;
|
||||
return .{ .eval_js = arg };
|
||||
}
|
||||
|
||||
if (eqlIgnoreCase(cmd_word, "EXIT")) {
|
||||
return .{ .exit = {} };
|
||||
}
|
||||
|
||||
return .{ .natural_language = trimmed };
|
||||
}
|
||||
|
||||
const QuotedResult = struct {
|
||||
value: []const u8,
|
||||
remainder: []const u8,
|
||||
};
|
||||
|
||||
fn extractQuotedWithRemainder(s: []const u8) ?QuotedResult {
|
||||
if (s.len < 2 or s[0] != '"') return null;
|
||||
const end = std.mem.indexOfScalarPos(u8, s, 1, '"') orelse return null;
|
||||
return .{
|
||||
.value = s[1..end],
|
||||
.remainder = s[end + 1 ..],
|
||||
};
|
||||
}
|
||||
|
||||
fn extractQuoted(s: []const u8) ?[]const u8 {
|
||||
const result = extractQuotedWithRemainder(s) orelse return null;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool {
|
||||
if (a.len != upper.len) return false;
|
||||
for (a, upper) |ac, uc| {
|
||||
if (std.ascii.toUpper(ac) != uc) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
122
src/agent/CommandExecutor.zig
Normal file
122
src/agent/CommandExecutor.zig
Normal file
@@ -0,0 +1,122 @@
|
||||
const std = @import("std");
|
||||
const Command = @import("Command.zig");
|
||||
const ToolExecutor = @import("ToolExecutor.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
|
||||
const Self = @This();
|
||||
|
||||
tool_executor: *ToolExecutor,
|
||||
terminal: *Terminal,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, tool_executor: *ToolExecutor, terminal: *Terminal) Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.tool_executor = tool_executor,
|
||||
.terminal = terminal,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn execute(self: *Self, cmd: Command.Command) void {
|
||||
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const result = switch (cmd) {
|
||||
.goto => |url| self.tool_executor.call(a, "goto", buildJson(a, .{ .url = url })) catch "Error: goto failed",
|
||||
.click => |target| self.execClick(a, target),
|
||||
.type_cmd => |args| self.execType(a, args),
|
||||
.wait => |selector| self.tool_executor.call(a, "waitForSelector", buildJson(a, .{ .selector = selector })) catch "Error: wait failed",
|
||||
.tree => self.tool_executor.call(a, "semantic_tree", "") catch "Error: tree failed",
|
||||
.extract => |args| self.execExtract(a, args),
|
||||
.eval_js => |script| self.tool_executor.call(a, "evaluate", buildJson(a, .{ .script = script })) catch "Error: eval failed",
|
||||
.exit, .natural_language => unreachable,
|
||||
};
|
||||
|
||||
self.terminal.printAssistant(result);
|
||||
std.debug.print("\n", .{});
|
||||
}
|
||||
|
||||
fn execClick(self: *Self, arena: std.mem.Allocator, target: []const u8) []const u8 {
|
||||
// Try as CSS selector via interactiveElements + click
|
||||
// First get interactive elements to find the target
|
||||
const elements_result = self.tool_executor.call(arena, "interactiveElements", "") catch
|
||||
return "Error: failed to get interactive elements";
|
||||
|
||||
// Try to find a backendNodeId by searching the elements result for the target text
|
||||
if (findNodeIdByText(arena, elements_result, target)) |node_id| {
|
||||
const args = std.fmt.allocPrint(arena, "{{\"backendNodeId\":{d}}}", .{node_id}) catch
|
||||
return "Error: failed to build click args";
|
||||
return self.tool_executor.call(arena, "click", args) catch "Error: click failed";
|
||||
}
|
||||
|
||||
return "Error: could not find element matching the target";
|
||||
}
|
||||
|
||||
fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) []const u8 {
|
||||
// Use JavaScript to set the value on the element matching the selector
|
||||
const script = std.fmt.allocPrint(arena,
|
||||
\\(function() {{
|
||||
\\ var el = document.querySelector("{s}");
|
||||
\\ if (!el) return "Error: element not found";
|
||||
\\ el.value = "{s}";
|
||||
\\ el.dispatchEvent(new Event("input", {{bubbles: true}}));
|
||||
\\ return "Typed into " + el.tagName;
|
||||
\\}})()
|
||||
, .{ args.selector, args.value }) catch return "Error: failed to build type script";
|
||||
|
||||
return self.tool_executor.call(arena, "evaluate", buildJson(arena, .{ .script = script })) catch "Error: type failed";
|
||||
}
|
||||
|
||||
fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) []const u8 {
|
||||
const script = std.fmt.allocPrint(arena,
|
||||
\\JSON.stringify(Array.from(document.querySelectorAll("{s}")).map(el => el.textContent.trim()))
|
||||
, .{args.selector}) catch return "Error: failed to build extract script";
|
||||
|
||||
const result = self.tool_executor.call(arena, "evaluate", buildJson(arena, .{ .script = script })) catch
|
||||
return "Error: extract failed";
|
||||
|
||||
if (args.file) |file| {
|
||||
std.fs.cwd().writeFile(.{
|
||||
.sub_path = file,
|
||||
.data = result,
|
||||
}) catch {
|
||||
self.terminal.printError("Failed to write to file");
|
||||
return result;
|
||||
};
|
||||
const msg = std.fmt.allocPrint(arena, "Extracted to {s}", .{file}) catch return "Extracted.";
|
||||
return msg;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
fn findNodeIdByText(arena: std.mem.Allocator, elements_json: []const u8, target: []const u8) ?u32 {
|
||||
_ = arena;
|
||||
// Simple text search in the JSON result for the target text
|
||||
// Look for patterns like "backendNodeId":N near the target text
|
||||
// This is a heuristic — search for the target text, then scan backwards for backendNodeId
|
||||
var pos: usize = 0;
|
||||
while (std.mem.indexOfPos(u8, elements_json, pos, target)) |idx| {
|
||||
// Search backwards from idx for "backendNodeId":
|
||||
const search_start = if (idx > 200) idx - 200 else 0;
|
||||
const window = elements_json[search_start..idx];
|
||||
if (std.mem.lastIndexOf(u8, window, "\"backendNodeId\":")) |bid_offset| {
|
||||
const num_start = search_start + bid_offset + "\"backendNodeId\":".len;
|
||||
const num_end = std.mem.indexOfAnyPos(u8, elements_json, num_start, ",}] \n") orelse continue;
|
||||
const num_str = elements_json[num_start..num_end];
|
||||
return std.fmt.parseInt(u32, num_str, 10) catch {
|
||||
pos = idx + 1;
|
||||
continue;
|
||||
};
|
||||
}
|
||||
pos = idx + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn buildJson(arena: std.mem.Allocator, value: anytype) []const u8 {
|
||||
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||
std.json.Stringify.value(value, .{}, &aw.writer) catch return "{}";
|
||||
return aw.written();
|
||||
}
|
||||
Reference in New Issue
Block a user