agent: improve eval parsing and command recording

- Extract `getJsonString` helper in Agent.zig.
- Match specific triple quote types in EVAL blocks to allow nested quotes.
- Use `cmd.format` and larger buffers in Recorder.zig.
This commit is contained in:
Adrià Arrufat
2026-04-12 10:20:22 +02:00
parent 654e11719c
commit e1747d5f29
3 changed files with 52 additions and 31 deletions

View File

@@ -415,31 +415,22 @@ fn toolCallToCommand(arena: std.mem.Allocator, tool_name: []const u8, arguments:
else => return null,
};
const getString = struct {
fn f(o: std.json.ObjectMap, key: []const u8) ?[]const u8 {
return switch (o.get(key) orelse return null) {
.string => |s| s,
else => null,
};
}
}.f;
return switch (action) {
.goto => .{ .goto = getString(obj, "url") orelse return null },
.click => .{ .click = getString(obj, "selector") orelse return null },
.hover => .{ .hover = getString(obj, "selector") orelse return null },
.eval => .{ .eval_js = getString(obj, "script") orelse return null },
.waitForSelector => .{ .wait = getString(obj, "selector") orelse return null },
.goto => .{ .goto = getJsonString(obj, "url") orelse return null },
.click => .{ .click = getJsonString(obj, "selector") orelse return null },
.hover => .{ .hover = getJsonString(obj, "selector") orelse return null },
.eval => .{ .eval_js = getJsonString(obj, "script") orelse return null },
.waitForSelector => .{ .wait = getJsonString(obj, "selector") orelse return null },
.fill => .{ .type_cmd = .{
.selector = getString(obj, "selector") orelse return null,
.value = getString(obj, "value") orelse return null,
.selector = getJsonString(obj, "selector") orelse return null,
.value = getJsonString(obj, "value") orelse return null,
} },
.selectOption => .{ .select = .{
.selector = getString(obj, "selector") orelse return null,
.value = getString(obj, "value") orelse return null,
.selector = getJsonString(obj, "selector") orelse return null,
.value = getJsonString(obj, "value") orelse return null,
} },
.setChecked => .{ .check = .{
.selector = getString(obj, "selector") orelse return null,
.selector = getJsonString(obj, "selector") orelse return null,
.checked = switch (obj.get("checked") orelse return null) {
.bool => |b| b,
else => return null,
@@ -461,6 +452,13 @@ fn toolCallToCommand(arena: std.mem.Allocator, tool_name: []const u8, arguments:
};
}
fn getJsonString(o: std.json.ObjectMap, key: []const u8) ?[]const u8 {
return switch (o.get(key) orelse return null) {
.string => |s| s,
else => null,
};
}
fn getEnvApiKey(provider_type: Config.AiProvider) ?[:0]const u8 {
return switch (provider_type) {
.anthropic => std.posix.getenv("ANTHROPIC_API_KEY"),

View File

@@ -248,9 +248,9 @@ pub const ScriptIterator = struct {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
if (isEvalTripleQuote(trimmed)) {
if (isEvalTripleQuote(trimmed)) |quote_type| {
const start_line = self.line_num;
if (self.collectEvalBlock()) |js| {
if (self.collectEvalBlock(quote_type)) |js| {
return .{
.line_num = start_line,
.raw_line = trimmed,
@@ -274,21 +274,23 @@ pub const ScriptIterator = struct {
return null;
}
fn isEvalTripleQuote(line: []const u8) bool {
fn isEvalTripleQuote(line: []const u8) ?[]const u8 {
const cmd_end = std.mem.indexOfAny(u8, line, &std.ascii.whitespace) orelse line.len;
const cmd_word = line[0..cmd_end];
if (!std.ascii.eqlIgnoreCase(cmd_word, "EVAL")) return false;
if (!std.ascii.eqlIgnoreCase(cmd_word, "EVAL")) return null;
const rest = std.mem.trim(u8, line[cmd_end..], &std.ascii.whitespace);
return std.mem.startsWith(u8, rest, "\"\"\"") or std.mem.startsWith(u8, rest, "'''");
if (std.mem.startsWith(u8, rest, "\"\"\"")) return "\"\"\"";
if (std.mem.startsWith(u8, rest, "'''")) return "'''";
return null;
}
/// Collect lines until closing triple quote (""" or '''), return the JS content.
fn collectEvalBlock(self: *ScriptIterator) ?[]const u8 {
/// Collect lines until matching closing triple quote, return the JS content.
fn collectEvalBlock(self: *ScriptIterator, quote_type: []const u8) ?[]const u8 {
var parts: std.ArrayListUnmanaged(u8) = .empty;
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, "\"\"\"") or std.mem.eql(u8, trimmed, "'''")) {
if (std.mem.eql(u8, trimmed, quote_type)) {
return parts.toOwnedSlice(self.allocator) catch null;
}
if (parts.items.len > 0) {
@@ -671,6 +673,21 @@ test "ScriptIterator unterminated EVAL" {
try std.testing.expectEqualStrings("unterminated EVAL block", e1.command.natural_language);
}
test "ScriptIterator multi-line EVAL mismatched triple quote" {
const script =
\\EVAL """
\\ const s = " ''' ";
\\ console.log(s);
\\"""
;
var iter: ScriptIterator = .init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .eval_js);
try std.testing.expectEqualStrings(" const s = \" ''' \";\n console.log(s);", e1.command.eval_js);
std.testing.allocator.free(e1.command.eval_js);
}
test "trimMatchingQuotes" {
const cases = [_]struct { in: []const u8, out: ?[]const u8 }{
.{ .in = "'hello'", .out = "hello" },

View File

@@ -37,16 +37,22 @@ pub fn record(self: *Self, cmd: Command.Command) void {
const f = self.file orelse return;
if (!cmd.isRecorded()) return;
var buf: [1024]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "{f}\n", .{cmd}) catch return;
_ = f.write(line) catch return;
var buf: [8192]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
var aw: std.Io.Writer.Allocating = .init(fba.allocator());
cmd.format(&aw.writer) catch return;
aw.writer.writeByte('\n') catch return;
_ = f.write(aw.written()) catch return;
self.needs_separator = true;
}
pub fn recordComment(self: *Self, comment: []const u8) void {
const f = self.file orelse return;
var buf: [1024]u8 = undefined;
const prefix: []const u8 = if (self.needs_separator) "\n# " else "# ";
var buf: [8192]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "{s}{s}\n", .{ prefix, comment }) catch return;
_ = f.write(line) catch return;
self.needs_separator = true;