agent: route data to stdout and actions to stderr

This commit is contained in:
Adrià Arrufat
2026-04-10 18:03:57 +02:00
parent b99fd47c9d
commit 3a9573e1ae
4 changed files with 42 additions and 85 deletions

View File

@@ -274,8 +274,7 @@ fn runScript(self: *Self, path: []const u8) bool {
defer cmd_arena.deinit();
const result = self.cmd_executor.executeWithResult(cmd_arena.allocator(), entry.command);
self.terminal.printAssistant(result.output);
std.debug.print("\n", .{});
self.cmd_executor.printResult(entry.command, result);
if (result.failed) {
if (self.self_heal and self.ai_client != null) {
@@ -378,7 +377,7 @@ fn processUserMessage(self: *Self, user_input: []const u8, record_comment: []con
if (result.text) |text| {
std.debug.print("\n", .{});
self.terminal.printAssistant(text);
std.debug.print("\n\n", .{});
std.debug.print("\n", .{});
} else {
self.terminal.printInfo("(no response from model)");
}

View File

@@ -5,11 +5,6 @@ pub const TypeArgs = struct {
value: []const u8,
};
pub const ExtractArgs = struct {
selector: []const u8,
file: ?[]const u8,
};
pub const ScrollArgs = struct {
x: i32 = 0,
y: i32 = 0,
@@ -36,7 +31,7 @@ pub const Command = union(enum) {
check: CheckArgs,
tree: void,
markdown: void,
extract: ExtractArgs,
extract: []const u8,
eval_js: []const u8,
login: void,
accept_cookies: void,
@@ -52,6 +47,16 @@ pub const Command = union(enum) {
};
}
/// True if running this command produces output the user typically wants to
/// capture (and so should land on stdout). False for action commands whose
/// only output is an acknowledgment.
pub fn producesData(self: Command) bool {
return switch (self) {
.extract, .eval_js, .markdown, .tree => true,
else => false,
};
}
pub fn format(self: Command, writer: *std.Io.Writer) std.Io.Writer.Error!void {
switch (self) {
.goto => |url| try writer.print("GOTO {s}", .{url}),
@@ -67,10 +72,7 @@ pub const Command = union(enum) {
try writer.print("CHECK '{s}' false", .{args.selector}),
.tree => try writer.writeAll("TREE"),
.markdown => try writer.writeAll("MARKDOWN"),
.extract => |args| {
try writer.print("EXTRACT '{s}'", .{args.selector});
if (args.file) |f| try writer.print(" > {s}", .{f});
},
.extract => |selector| try writer.print("EXTRACT '{s}'", .{selector}),
.eval_js => |script| {
if (std.mem.indexOfScalar(u8, script, '\n') != null) {
try writer.print("EVAL '''\n{s}\n'''", .{script});
@@ -181,18 +183,9 @@ pub fn parse(line: []const u8) Command {
}
if (std.ascii.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 (rest.len == 0) return .{ .natural_language = trimmed };
const selector = extractQuoted(rest) orelse rest;
return .{ .extract = selector };
}
if (std.ascii.eqlIgnoreCase(cmd_word, "EVAL")) {
@@ -449,16 +442,9 @@ test "parse MARKDOWN alias MD" {
try std.testing.expect(parse("md") == .markdown);
}
test "parse EXTRACT with file" {
const cmd = parse("EXTRACT \".title\" > titles.json");
try std.testing.expectEqualStrings(".title", cmd.extract.selector);
try std.testing.expectEqualStrings("titles.json", cmd.extract.file.?);
}
test "parse EXTRACT without file" {
test "parse EXTRACT" {
const cmd = parse("EXTRACT \".title\"");
try std.testing.expectEqualStrings(".title", cmd.extract.selector);
try std.testing.expect(cmd.extract.file == null);
try std.testing.expectEqualStrings(".title", cmd.extract);
}
test "parse EVAL single line" {

View File

@@ -42,7 +42,7 @@ pub fn executeWithResult(self: *Self, a: std.mem.Allocator, cmd: Command.Command
})),
.tree => self.callTool(a, @tagName(Action.semanticTree), ""),
.markdown => self.callTool(a, @tagName(Action.markdown), ""),
.extract => |args| self.execExtract(a, args),
.extract => |selector| self.execExtract(a, selector),
.eval_js => |script| self.callTool(a, @tagName(Action.eval), buildJson(a, .{ .script = script })),
.exit, .natural_language, .comment, .login, .accept_cookies => unreachable,
};
@@ -53,9 +53,18 @@ pub fn execute(self: *Self, cmd: Command.Command) void {
defer arena.deinit();
const result = self.executeWithResult(arena.allocator(), cmd);
self.printResult(cmd, result);
}
self.terminal.printAssistant(result.output);
std.debug.print("\n", .{});
/// Route a command's output to stdout (for data-producing commands like
/// EXTRACT/EVAL/MARKDOWN/TREE) or stderr (for action commands like
/// GOTO/CLICK/...) so that shell-redirecting stdout captures only data.
pub fn printResult(self: *Self, cmd: Command.Command, result: ExecResult) void {
if (cmd.producesData()) {
self.terminal.printAssistant(result.output);
} else {
self.terminal.printActionResult(result.output);
}
}
fn callTool(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments_json: []const u8) ExecResult {
@@ -76,33 +85,14 @@ fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) ExecR
return self.callTool(arena, @tagName(browser_tools.Action.fill), buildJson(arena, .{ .selector = selector, .value = value }));
}
fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) ExecResult {
const selector = escapeJs(arena, substituteEnvVars(arena, args.selector));
fn execExtract(self: *Self, arena: std.mem.Allocator, raw_selector: []const u8) ExecResult {
const selector = escapeJs(arena, substituteEnvVars(arena, raw_selector));
const script = std.fmt.allocPrint(arena,
\\JSON.stringify(Array.from(document.querySelectorAll("{s}")).map(el => el.textContent.trim()))
, .{selector}) catch return .{ .output = "failed to build extract script", .failed = true };
const result = self.tool_executor.call(arena, @tagName(browser_tools.Action.eval), buildJson(arena, .{ .script = script })) catch |err|
return .{ .output = std.fmt.allocPrint(arena, "extract failed: {s}", .{@errorName(err)}) catch "extract failed", .failed = true };
if (args.file) |raw_file| {
const file = sanitizePath(raw_file) orelse {
self.terminal.printError("Invalid output path: must be relative and not traverse above working directory");
return .{ .output = result, .failed = false };
};
std.fs.cwd().writeFile(.{
.sub_path = file,
.data = result,
}) catch {
self.terminal.printError("Failed to write to file");
return .{ .output = result, .failed = false };
};
const msg = std.fmt.allocPrint(arena, "Extracted to {s}", .{file}) catch "Extracted.";
return .{ .output = msg, .failed = false };
}
return .{ .output = result, .failed = false };
return self.callTool(arena, @tagName(browser_tools.Action.eval), buildJson(arena, .{ .script = script }));
}
const substituteEnvVars = browser_tools.substituteEnvVars;
@@ -127,17 +117,6 @@ fn escapeJs(arena: std.mem.Allocator, input: []const u8) []const u8 {
return out.toOwnedSlice(arena) catch input;
}
fn sanitizePath(path: []const u8) ?[]const u8 {
if (path.len > 0 and path[0] == '/') return null;
var iter = std.mem.splitScalar(u8, path, '/');
while (iter.next()) |component| {
if (std.mem.eql(u8, component, "..")) return null;
}
return path;
}
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 "{}";
@@ -169,20 +148,6 @@ test "escapeJs injection attempt" {
try std.testing.expectEqualStrings("\\\"; alert(1); //", result);
}
test "sanitizePath allows relative" {
try std.testing.expectEqualStrings("output.json", sanitizePath("output.json").?);
try std.testing.expectEqualStrings("dir/file.json", sanitizePath("dir/file.json").?);
}
test "sanitizePath rejects absolute" {
try std.testing.expect(sanitizePath("/etc/passwd") == null);
}
test "sanitizePath rejects traversal" {
try std.testing.expect(sanitizePath("../../../etc/passwd") == null);
try std.testing.expect(sanitizePath("foo/../../bar") == null);
}
test "substituteEnvVars no vars" {
const result = substituteEnvVars(std.testing.allocator, "hello world");
try std.testing.expectEqualStrings("hello world", result);

View File

@@ -29,7 +29,7 @@ const commands = [_]CommandInfo{
.{ .name = "TREE", .hint = "" },
.{ .name = "MARKDOWN", .hint = "" },
.{ .name = "MD", .hint = "" },
.{ .name = "EXTRACT", .hint = " '<selector>' [> file]" },
.{ .name = "EXTRACT", .hint = " '<selector>'" },
.{ .name = "EVAL", .hint = " '<script>'" },
.{ .name = "LOGIN", .hint = "" },
.{ .name = "ACCEPT_COOKIES", .hint = "" },
@@ -90,6 +90,13 @@ pub fn freeLine(_: *Self, line: []const u8) void {
pub fn printAssistant(_: *Self, text: []const u8) void {
const fd = std.posix.STDOUT_FILENO;
_ = std.posix.write(fd, text) catch {};
_ = std.posix.write(fd, "\n") catch {};
}
/// Print the result of an action command (GOTO, CLICK, ...) to stderr so
/// stdout stays reserved for data-producing commands.
pub fn printActionResult(_: *Self, text: []const u8) void {
std.debug.print("{s}\n", .{text});
}
pub fn printToolCall(_: *Self, name: []const u8, args: []const u8) void {