agent: add --verbosity flag to control stderr output

This commit is contained in:
Adrià Arrufat
2026-05-07 08:15:51 +02:00
parent 1be0efece6
commit 93d08a486b
3 changed files with 65 additions and 9 deletions

View File

@@ -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.

View File

@@ -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),

View File

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