refactor: rename buildJson to stringifyJson and clean up logic

- Rename `Command.buildJson` to `stringifyJson` for clarity.
- Flatten tool call recording loop in `Agent.zig` to reduce nesting.
- Extract `parseValue` helper in `tools.zig` to reduce duplication.
- Optimize `substituteEnvVars` by skipping redundant string scans.
This commit is contained in:
Adrià Arrufat
2026-05-07 11:00:33 +02:00
parent 02ae92d619
commit 0de602695f
5 changed files with 33 additions and 32 deletions

View File

@@ -923,15 +923,13 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: ?[]co
if (self.recorder.file != null) {
var recorded_any = false;
for (result.tool_calls_made) |tc| {
if (!tc.is_error) {
if (Command.fromToolCall(ma, tc.name, tc.arguments)) |cmd| {
if (!recorded_any) {
if (record_comment) |c| self.recorder.recordComment(c);
recorded_any = true;
}
self.recorder.record(cmd);
}
if (tc.is_error) continue;
const cmd = Command.fromToolCall(ma, tc.name, tc.arguments) orelse continue;
if (!recorded_any) {
if (record_comment) |c| self.recorder.recordComment(c);
recorded_any = true;
}
self.recorder.record(cmd);
}
}

View File

@@ -421,26 +421,26 @@ pub fn noSubstitute(_: std.mem.Allocator, input: []const u8) []const u8 {
pub fn toToolCall(arena: std.mem.Allocator, cmd: Command, substitute: SubstituteFn) ?ToolCall {
const Action = lp.tools.Action;
return switch (cmd) {
.goto => |url| .{ .name = @tagName(Action.goto), .args_json = buildJson(arena, .{ .url = substitute(arena, url) }) },
.click => |sel| .{ .name = @tagName(Action.click), .args_json = buildJson(arena, .{ .selector = substitute(arena, sel) }) },
.type_cmd => |args| .{ .name = @tagName(Action.fill), .args_json = buildJson(arena, .{
.goto => |url| .{ .name = @tagName(Action.goto), .args_json = stringifyJson(arena, .{ .url = substitute(arena, url) }) },
.click => |sel| .{ .name = @tagName(Action.click), .args_json = stringifyJson(arena, .{ .selector = substitute(arena, sel) }) },
.type_cmd => |args| .{ .name = @tagName(Action.fill), .args_json = stringifyJson(arena, .{
.selector = substitute(arena, args.selector),
.value = args.value,
}) },
.wait => |sel| .{ .name = @tagName(Action.waitForSelector), .args_json = buildJson(arena, .{ .selector = sel }) },
.scroll => |args| .{ .name = @tagName(Action.scroll), .args_json = buildJson(arena, .{ .x = args.x, .y = args.y }) },
.hover => |sel| .{ .name = @tagName(Action.hover), .args_json = buildJson(arena, .{ .selector = substitute(arena, sel) }) },
.select => |args| .{ .name = @tagName(Action.selectOption), .args_json = buildJson(arena, .{
.wait => |sel| .{ .name = @tagName(Action.waitForSelector), .args_json = stringifyJson(arena, .{ .selector = sel }) },
.scroll => |args| .{ .name = @tagName(Action.scroll), .args_json = stringifyJson(arena, .{ .x = args.x, .y = args.y }) },
.hover => |sel| .{ .name = @tagName(Action.hover), .args_json = stringifyJson(arena, .{ .selector = substitute(arena, sel) }) },
.select => |args| .{ .name = @tagName(Action.selectOption), .args_json = stringifyJson(arena, .{
.selector = substitute(arena, args.selector),
.value = substitute(arena, args.value),
}) },
.check => |args| .{ .name = @tagName(Action.setChecked), .args_json = buildJson(arena, .{
.check => |args| .{ .name = @tagName(Action.setChecked), .args_json = stringifyJson(arena, .{
.selector = substitute(arena, args.selector),
.checked = args.checked,
}) },
.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 }) },
.eval_js => |script| .{ .name = @tagName(Action.eval), .args_json = stringifyJson(arena, .{ .script = script }) },
.extract, .natural_language, .comment, .login, .accept_cookies => null,
};
}
@@ -501,7 +501,7 @@ fn getJsonString(o: std.json.ObjectMap, key: []const u8) ?[]const u8 {
};
}
pub fn buildJson(arena: std.mem.Allocator, value: anytype) []const u8 {
pub fn stringifyJson(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();

View File

@@ -23,6 +23,10 @@ pub const ExecResult = struct {
failed: bool,
};
/// Caller contract: `cmd` must not be `.natural_language`, `.comment`,
/// `.login`, or `.accept_cookies` — those are filtered upstream (see
/// `Agent.runRepl`) because they have no tool mapping and would hit the
/// `unreachable` arm below.
pub fn executeWithResult(self: *Self, arena: std.mem.Allocator, cmd: Command.Command) ExecResult {
if (cmd == .extract) return self.execExtract(arena, cmd.extract);
@@ -64,7 +68,7 @@ fn execExtract(self: *Self, arena: std.mem.Allocator, raw_selector: []const u8)
const script = std.fmt.allocPrint(
arena,
"JSON.stringify(Array.from(document.querySelectorAll({s})).map(el => el.textContent.trim()))",
.{Command.buildJson(arena, selector)},
.{Command.stringifyJson(arena, selector)},
) catch return .{ .output = "failed to build extract script", .failed = true };
const result = self.tool_executor.callEval(arena, script);

View File

@@ -67,7 +67,7 @@ fn queryElementProperty(self: *Self, arena: std.mem.Allocator, selector: []const
const script = std.fmt.allocPrint(
arena,
"(function(){{ var el = document.querySelector({s}); return el ? {s} : null; }})()",
.{ Command.buildJson(arena, selector), js_property },
.{ Command.stringifyJson(arena, selector), js_property },
) catch return null;
const result = self.tool_executor.callEval(arena, script);
if (result.is_error) return null;

View File

@@ -967,23 +967,22 @@ fn resolveBySelector(session: *lp.Session, selector: []const u8) ToolError!NodeA
const ParseArgsError = error{ OutOfMemory, InvalidParams };
/// For tools where every field is optional. Missing args → default `T`;
/// wrong-typed args still error (don't silently default).
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ParseArgsError!T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
fn parseValue(comptime T: type, arena: std.mem.Allocator, value: std.json.Value) ParseArgsError!T {
return std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.InvalidParams,
};
}
/// For tools where every field is optional. Missing args → default `T`;
/// wrong-typed args still error (don't silently default).
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ParseArgsError!T {
return parseValue(T, arena, arguments orelse return .{});
}
/// Required-args parse: missing or malformed both surface as `InvalidParams`.
fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ParseArgsError!T {
const args_raw = arguments orelse return error.InvalidParams;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.InvalidParams,
};
return parseValue(T, arena, arguments orelse return error.InvalidParams);
}
pub fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) []const u8 {
@@ -991,10 +990,10 @@ pub fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) []const u8
// Pages routinely contain `$5.99`-style content where `$` is incidental.
// Lowercase `$lp_…` falls through here too — `std.posix.getenv` is
// case-sensitive on Linux, so it would never resolve anyway.
if (std.mem.indexOf(u8, input, "$LP_") == null) return input;
const first_lp = std.mem.indexOf(u8, input, "$LP_") orelse return input;
var result: std.ArrayList(u8) = .empty;
var i: usize = 0;
var i: usize = first_lp;
var last_copy: usize = 0;
while (std.mem.indexOfScalarPos(u8, input, i, '$')) |dollar| {
const var_start = dollar + 1;