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:
Adrià Arrufat
2026-04-03 09:53:21 +02:00
parent a5d3d686b8
commit 15c0a7be83
5 changed files with 267 additions and 5 deletions

View File

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

View File

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

View File

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

View 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();
}