From 93d08a486b4999bbd0374aaffc3b375c37b8c4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 7 May 2026 08:15:51 +0200 Subject: [PATCH] agent: add --verbosity flag to control stderr output --- src/Config.zig | 37 ++++++++++++++++++++++++++++++++++++- src/agent/Agent.zig | 2 +- src/agent/Terminal.zig | 35 ++++++++++++++++++++++++++++------- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index f55490fd..db9b509f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -121,6 +121,20 @@ fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { pub const AiProvider = std.meta.Tag(zenai.provider.Client); +/// Controls how chatty `agent` mode is on stderr. Defined here (rather +/// than alongside Terminal) so it stays alongside the CLI flag and stays +/// reachable from Config without an agent-side import cycle. +pub const AgentVerbosity = enum { + /// Only the final answer (stdout) and errors. + quiet, + /// + REPL banners, status/retry messages, direct-command results, + /// and the `[tool: ...]` line for each LLM tool call. Default. + normal, + /// + the matching `[result: ...]` body for each tool call. + /// Required by the harness in benchmarks/, which parses both lines. + verbose, +}; + fn waitScriptFileValidator(allocator: Allocator, args: *std.process.ArgIterator) !?[:0]const u8 { const path = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--wait-script-file" }); @@ -190,6 +204,7 @@ const Commands = cli.Builder(.{ .{ .name = "task", .type = ?[]const u8 }, .{ .name = "task_attachments", .type = []const u8, .multiple = true }, .{ .name = "mcp", .type = bool }, + .{ .name = "verbosity", .type = AgentVerbosity, .default = AgentVerbosity.normal }, }, .shared_options = CommonOptions, }, @@ -299,7 +314,16 @@ pub fn wsMaxConcurrent(self: *const Config) u8 { pub fn logLevel(self: *const Config) ?log.Level { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.log_level, + // In agent mode, the page itself spams `console.error` (mapped to + // log.warn(.js, ...) in webapi/Console.zig). The build default is + // `.warn`, which is far too chatty for non-verbose runs — most + // sites trip third-party scripts that the agent can ignore. So + // when the user hasn't set --log-level, let --verbosity pick. + .agent => |opts| opts.log_level orelse switch (opts.verbosity) { + .quiet, .normal => .err, + .verbose => null, + }, + inline .serve, .fetch, .mcp => |opts| opts.log_level, else => unreachable, }; } @@ -770,6 +794,17 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ MCP client. Requires --provider; cannot be combined \\ with --task, -i, or a script file. \\ + \\--verbosity Stderr chatter level: quiet, normal, or verbose. + \\ Default: normal. quiet keeps only the final answer + \\ (stdout) and errors; normal adds REPL banners, + \\ direct-command results, and `[tool: ...]` lines; + \\ verbose also prints each `[result: ...]` body + \\ (required by the benchmarks harness). When --log-level + \\ isn't set, quiet/normal raise it to err to suppress + \\ page-side console.error spam; verbose keeps the + \\ build default (warn). Pass --log-level explicitly + \\ to override. + \\ \\The API key is read from the environment: \\ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY. \\Ollama does not require an API key. diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 19a4ba10..6eb5b9f0 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -218,7 +218,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self .allocator = allocator, .ai_client = ai_client, .tool_executor = tool_executor, - .terminal = Terminal.init(history_path), + .terminal = Terminal.init(history_path, opts.verbosity, will_repl), .cmd_executor = undefined, .verifier = .{ .tool_executor = tool_executor }, .recorder = .init(allocator, recorder_path), diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 5a6ce791..b4c43997 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -1,6 +1,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const browser_tools = lp.tools; +const Config = lp.Config; const SlashCommand = @import("SlashCommand.zig"); const c = @cImport({ @cInclude("linenoise.h"); @@ -15,7 +16,22 @@ const ansi_cyan = "\x1b[36m"; const ansi_green = "\x1b[32m"; const ansi_red = "\x1b[31m"; +const Verbosity = Config.AgentVerbosity; + +fn atLeast(level: Verbosity, min: Verbosity) bool { + return @intFromEnum(level) >= @intFromEnum(min); +} + history_path: ?[:0]const u8, +verbosity: Verbosity, +/// True when the user can type at us. Tool results are always shown +/// here, regardless of verbosity, because in the REPL every tool call +/// is something the user just asked for (a slash command, or natural +/// language they sent to the LLM) — suppressing the body would leave +/// them blind. The `--verbosity` dial only matters in non-interactive +/// runs (one-shot `--task`, scripts, `--mcp`), where LLM tool traces +/// are noise. +is_repl: bool, const CommandInfo = struct { name: [:0]const u8, hint: [:0]const u8 }; @@ -66,14 +82,14 @@ pub fn setSlashSchemas(schemas: []const SlashCommand.SchemaInfo) void { slash_schemas = schemas; } -pub fn init(history_path: ?[:0]const u8) Self { +pub fn init(history_path: ?[:0]const u8, verbosity: Verbosity, is_repl: bool) Self { c.linenoiseSetMultiLine(1); c.linenoiseSetCompletionCallback(&completionCallback); c.linenoiseSetHintsCallback(&hintsCallback); if (history_path) |path| { _ = c.linenoiseHistoryLoad(path.ptr); } - return .{ .history_path = history_path }; + return .{ .history_path = history_path, .verbosity = verbosity, .is_repl = is_repl }; } const completion_buf_len = 256; @@ -383,11 +399,13 @@ pub fn printAssistant(_: *Self, text: []const u8) void { /// 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 { +pub fn printActionResult(self: *Self, text: []const u8) void { + if (!atLeast(self.verbosity, .normal)) return; std.debug.print("{s}\n", .{text}); } -pub fn printToolCall(_: *Self, name: []const u8, args: []const u8) void { +pub fn printToolCall(self: *Self, name: []const u8, args: []const u8) void { + if (!self.is_repl and !atLeast(self.verbosity, .normal)) return; std.debug.print("\n{s}{s}[tool: {s}]{s} {s}\n", .{ ansi_dim, ansi_cyan, name, ansi_reset, args }); } @@ -399,7 +417,8 @@ pub fn printToolCall(_: *Self, name: []const u8, args: []const u8) void { // (1 MiB) via Agent.zig:capToolOutput. const max_result_display_len = 2000; -pub fn printToolResult(_: *Self, name: []const u8, result: []const u8) void { +pub fn printToolResult(self: *Self, name: []const u8, result: []const u8) void { + if (!self.is_repl and !atLeast(self.verbosity, .verbose)) return; const truncated = result[0..@min(result.len, max_result_display_len)]; const ellipsis: []const u8 = if (result.len > max_result_display_len) "..." else ""; std.debug.print("{s}{s}[result: {s}]{s} {s}{s}\n", .{ ansi_dim, ansi_green, name, ansi_reset, truncated, ellipsis }); @@ -413,10 +432,12 @@ pub fn printErrorFmt(_: *Self, comptime fmt: []const u8, args: anytype) void { std.debug.print("{s}{s}Error: " ++ fmt ++ "{s}\n", .{ ansi_bold, ansi_red } ++ args ++ .{ansi_reset}); } -pub fn printInfo(_: *Self, msg: []const u8) void { +pub fn printInfo(self: *Self, msg: []const u8) void { + if (!atLeast(self.verbosity, .normal)) return; std.debug.print("{s}{s}{s}\n", .{ ansi_dim, msg, ansi_reset }); } -pub fn printInfoFmt(_: *Self, comptime fmt: []const u8, args: anytype) void { +pub fn printInfoFmt(self: *Self, comptime fmt: []const u8, args: anytype) void { + if (!atLeast(self.verbosity, .normal)) return; std.debug.print("{s}" ++ fmt ++ "{s}\n", .{ansi_dim} ++ args ++ .{ansi_reset}); }