diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 3b6f0295..e1cb53ca 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -919,7 +919,9 @@ fn processUserMessage(self: *Self, input: TurnInput) !?[]const u8 { ma, .{ .context = @ptrCast(self), .callFn = &handleToolCall }, .{ - .tools = self.tool_executor.tools, + // tool_choice = .none forbids tools; serializing the full + // catalog anyway just pads the request body. + .tools = &.{}, .max_turns = 1, .max_tokens = 4096, .tool_choice = .none, @@ -1005,15 +1007,23 @@ const tool_output_max_bytes: usize = 1 * 1024 * 1024; fn capToolOutput(allocator: std.mem.Allocator, output: []const u8) []const u8 { if (output.len <= tool_output_max_bytes) return output; const prefix = output[0..tool_output_max_bytes]; - return std.fmt.allocPrint(allocator, "{s}\n...[truncated, original {d} bytes]", .{ prefix, output.len }) catch prefix; + // Format the suffix into a tiny scratch buffer then concat — avoids + // duplicating the 1 MiB prefix through `allocPrint`'s format machinery. + var suffix_buf: [64]u8 = undefined; + const suffix = std.fmt.bufPrint(&suffix_buf, "\n...[truncated, original {d} bytes]", .{output.len}) catch return prefix; + return std.mem.concat(allocator, u8, &.{ prefix, suffix }) catch prefix; } fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) zenai.provider.Client.ToolHandler.Result { const self: *Self = @ptrCast(@alignCast(ctx)); - const args_str: []const u8 = if (arguments) |v| + // Stringifying tool args is wasted work for non-interactive low-verbosity + // runs: the spinner doesn't render it and `agentToolDone` skips the bullet + // line. Skip the alloc when no consumer will read it. + const needs_args = self.terminal.spinner.enabled or self.terminal.verbosity != .low; + const args_str: []const u8 = if (needs_args) (if (arguments) |v| std.json.Stringify.valueAlloc(allocator, v, .{}) catch "" else - ""; + "") else ""; self.terminal.spinner.setTool(tool_name, args_str); defer self.terminal.spinner.setThinking(); if (self.tool_executor.callValue(allocator, tool_name, arguments)) |output| { diff --git a/src/agent/Spinner.zig b/src/agent/Spinner.zig index 77da7dbd..459ea3d1 100644 --- a/src/agent/Spinner.zig +++ b/src/agent/Spinner.zig @@ -8,7 +8,7 @@ const interval_ns: u64 = 350 * std.time.ns_per_ms; /// Minimum dwell on a tool label so the user can read it. Slow tools exceed /// this naturally; fast ones (getUrl, getCookies) get padded. const min_tool_display_ns: u64 = 1500 * std.time.ns_per_ms; -const clear_eol = "\x1b[K"; +const clear_eol = ansi.clear_eol; const max_args_bytes: usize = 100; const frame_buf_bytes: usize = 256; diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index fefeb323..e46e577c 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -28,6 +28,7 @@ pub const ansi = struct { pub const green = "\x1b[32m"; pub const yellow = "\x1b[33m"; pub const red = "\x1b[31m"; + pub const clear_eol = "\x1b[K"; }; const Verbosity = Config.AgentVerbosity; @@ -48,11 +49,11 @@ spinner: Spinner, slash_schemas: []const SlashCommand.SchemaInfo = &.{}, // Flat name list for the "match any slash command" search/completion paths. -const all_slash_names: [browser_tools.tool_defs.len + SlashCommand.meta_names.len][]const u8 = blk: { - var names: [browser_tools.tool_defs.len + SlashCommand.meta_names.len][]const u8 = undefined; - for (browser_tools.tool_defs, 0..) |td, i| names[i] = td.name; - for (SlashCommand.meta_names, 0..) |m, i| names[browser_tools.tool_defs.len + i] = m; - break :blk names; +const all_slash_names: [browser_tools.names.len + SlashCommand.meta_names.len][]const u8 = blk: { + var arr: [browser_tools.names.len + SlashCommand.meta_names.len][]const u8 = undefined; + for (browser_tools.names, 0..) |n, i| arr[i] = n; + for (SlashCommand.meta_names, 0..) |m, i| arr[browser_tools.names.len + i] = m; + break :blk arr; }; /// Wires the isocline completer and hinter to `self` so the C callbacks can @@ -115,17 +116,16 @@ const bullet_line_fmt = "{s}●{s} {s}[tool: {s}]{s} {s}\n"; /// gated on `medium`+. In non-TTY contexts ANSI is still emitted — /// pipes that strip color see plain text via the bullet character. pub fn agentToolDone(self: *Self, name: []const u8, args: []const u8, ok: bool) void { + if (self.spinner.enabled and !ok) self.spinner.markToolFailed(); + if (!atLeast(self.verbosity, .medium)) return; + if (self.spinner.enabled) { - if (!ok) self.spinner.markToolFailed(); - if (!atLeast(self.verbosity, .medium)) return; - if (self.repl_arena) |*a| { - defer _ = a.reset(.retain_capacity); - const bytes = formatBulletLine(a.allocator(), name, args, ok) catch return; - _ = self.spinner.emitAbove(bytes); - } + const a = if (self.repl_arena) |*ra| ra else return; + defer _ = a.reset(.retain_capacity); + const bytes = formatBulletLine(a.allocator(), name, args, ok) catch return; + _ = self.spinner.emitAbove(bytes); return; } - if (!atLeast(self.verbosity, .medium)) return; if (self.stderr_is_tty) { const bullet_color = if (ok) ansi.green else ansi.red; std.debug.print(bullet_line_fmt, .{ bullet_color, ansi.reset, ansi.dim, name, ansi.reset, args }); diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 793e34d5..13c1c64f 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -317,6 +317,14 @@ pub const tool_defs = [_]ToolDef{ }, }; +/// Comptime-built flat array of tool names, in `tool_defs` order. Use this +/// when callers only need the names (slash-command lookup, MCP `tools/list`). +pub const names: [tool_defs.len][]const u8 = blk: { + var arr: [tool_defs.len][]const u8 = undefined; + for (tool_defs, 0..) |td, i| arr[i] = td.name; + break :blk arr; +}; + pub const ToolError = error{ FrameNotLoaded, InvalidParams, @@ -484,7 +492,7 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode 0, ) catch return ToolError.OutOfMemory; try performGoto(session, registry, ddg_url, args.timeout, args.waitUntil); - const ddg_frame = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const ddg_frame = try requireFrame(session); return renderFrameMarkdown(arena, ddg_frame); } @@ -589,16 +597,13 @@ fn execNodeDetails(arena: std.mem.Allocator, session: *lp.Session, registry: *CD const Params = struct { backendNodeId: CDPNode.Id }; const args = try parseArgs(Params, arena, arguments); - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const node = registry.lookup_by_id.get(args.backendNodeId) orelse return ToolError.NodeNotFound; const details = lp.SemanticTree.getNodeDetails(arena, node.dom, registry, page) catch return ToolError.InternalError; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(&details, .{}, &aw.writer) catch return ToolError.InternalError; - return aw.written(); + return renderJson(arena, &details); } fn execInteractiveElements(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -609,10 +614,7 @@ fn execInteractiveElements(arena: std.mem.Allocator, session: *lp.Session, regis return ToolError.InternalError; lp.interactive.registerNodes(elements, registry) catch return ToolError.InternalError; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(elements, .{}, &aw.writer) catch return ToolError.InternalError; - return aw.written(); + return renderJson(arena, elements); } fn execStructuredData(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -621,9 +623,7 @@ fn execStructuredData(arena: std.mem.Allocator, session: *lp.Session, registry: const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch return ToolError.InternalError; - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(data, .{}, &aw.writer) catch return ToolError.InternalError; - return aw.written(); + return renderJson(arena, data); } fn execDetectForms(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -634,10 +634,7 @@ fn execDetectForms(arena: std.mem.Allocator, session: *lp.Session, registry: *CD return ToolError.InternalError; lp.forms.registerNodes(forms_data, registry) catch return ToolError.InternalError; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(forms_data, .{}, &aw.writer) catch return ToolError.InternalError; - return aw.written(); + return renderJson(arena, forms_data); } fn execEval(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) EvalResult { @@ -742,7 +739,7 @@ fn execClick(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. try awaitQueuedNavigation(session); - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); return formatActionResult(arena, "Clicked element", args.selector, args.backendNodeId, "", page); } @@ -773,7 +770,7 @@ fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode y: ?i32 = null, }; const args = try parseArgsOrDefault(Params, arena, arguments); - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const target_node = try resolveOptionalNode(registry, args.backendNodeId); lp.actions.scroll(target_node, args.x, args.y, page) catch |err| return mapActionError(err); @@ -794,7 +791,7 @@ fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry: }; const args = try parseArgs(Params, arena, arguments); - _ = session.currentFrame() orelse return ToolError.FrameNotLoaded; + _ = try requireFrame(session); const timeout_ms = args.timeout orelse 5000; @@ -827,7 +824,7 @@ fn execPress(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. }; const args = try parseArgs(Params, arena, arguments); - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const target_node = try resolveOptionalNode(registry, args.backendNodeId); lp.actions.press(target_node, args.key, page) catch |err| return mapActionError(err); @@ -835,7 +832,7 @@ fn execPress(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. // Pressing Enter on a form input triggers implicit form submission. try awaitQueuedNavigation(session); - const current_page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const current_page = try requireFrame(session); const page_title = current_page.getTitle() catch null; return std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{ args.key, @@ -884,7 +881,7 @@ fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *CD if (args.role == null and args.name == null) return ToolError.InvalidParams; - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const matched = lp.interactive.findInteractiveElements(page.document.asNode(), arena, page, .{ .role = args.role, @@ -893,10 +890,7 @@ fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *CD lp.interactive.registerNodes(matched, registry) catch return ToolError.InternalError; - - var aw: std.Io.Writer.Allocating = .init(arena); - std.json.Stringify.value(matched, .{}, &aw.writer) catch return ToolError.InternalError; - return aw.written(); + return renderJson(arena, matched); } fn execGetEnv(arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 { @@ -908,15 +902,15 @@ fn execGetEnv(arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![] return std.fmt.allocPrint(arena, "Environment variable '{s}' is not set", .{name}) catch ToolError.InternalError; } - const names = lpEnvNames() catch return ToolError.InternalError; - return formatLpEnvNames(arena, names); + const env_names = lpEnvNames() catch return ToolError.InternalError; + return formatLpEnvNames(arena, env_names); } -fn formatLpEnvNames(arena: std.mem.Allocator, names: []const []const u8) ToolError![]const u8 { - if (names.len == 0) return "No LP_* environment variables are set."; +fn formatLpEnvNames(arena: std.mem.Allocator, env_names: []const []const u8) ToolError![]const u8 { + if (env_names.len == 0) return "No LP_* environment variables are set."; var aw: std.Io.Writer.Allocating = .init(arena); - aw.writer.print("LP_* environment variables ({d}):\n", .{names.len}) catch return ToolError.InternalError; - for (names) |n| { + aw.writer.print("LP_* environment variables ({d}):\n", .{env_names.len}) catch return ToolError.InternalError; + for (env_names) |n| { aw.writer.print(" {s}\n", .{n}) catch return ToolError.InternalError; } return aw.written(); @@ -934,22 +928,22 @@ pub fn lpEnvNames() error{OutOfMemory}![]const []const u8 { if (lp_env_names_cache) |cached| return cached; const gpa = std.heap.page_allocator; - var names: std.ArrayList([]const u8) = .empty; - errdefer names.deinit(gpa); - try names.ensureTotalCapacity(gpa, std.os.environ.len); + var env_names: std.ArrayList([]const u8) = .empty; + errdefer env_names.deinit(gpa); + try env_names.ensureTotalCapacity(gpa, std.os.environ.len); for (std.os.environ) |entry| { const line = std.mem.span(entry); const eq_idx = std.mem.indexOfScalar(u8, line, '=') orelse continue; const name = line[0..eq_idx]; if (!std.ascii.startsWithIgnoreCase(name, "LP_")) continue; - names.appendAssumeCapacity(name); + env_names.appendAssumeCapacity(name); } - std.mem.sort([]const u8, names.items, {}, struct { + std.mem.sort([]const u8, env_names.items, {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); - const owned = try names.toOwnedSlice(gpa); + const owned = try env_names.toOwnedSlice(gpa); lp_env_names_cache = owned; return owned; } @@ -974,14 +968,14 @@ fn lookupLpEnv(name: []const u8) ?[:0]const u8 { } fn execConsoleLogs(arena: std.mem.Allocator, session: *lp.Session) ToolError![]const u8 { - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const text = page.drainConsoleMessages(); if (text.len == 0) return "No console messages."; return arena.dupe(u8, text) catch ToolError.InternalError; } fn execGetUrl(session: *lp.Session) ToolError![]const u8 { - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); return page.url; } @@ -1009,6 +1003,16 @@ fn execGetCookies(arena: std.mem.Allocator, session: *lp.Session) ToolError![]co return aw.written(); } +fn requireFrame(session: *lp.Session) ToolError!*lp.Frame { + return session.currentFrame() orelse ToolError.FrameNotLoaded; +} + +fn renderJson(arena: std.mem.Allocator, value: anytype) ToolError![]const u8 { + var aw: std.Io.Writer.Allocating = .init(arena); + std.json.Stringify.value(value, .{}, &aw.writer) catch return ToolError.InternalError; + return aw.written(); +} + fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!*lp.Frame { if (url) |u| { if (session.currentFrame()) |frame| { @@ -1038,13 +1042,13 @@ fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const } fn resolveNodeAndPage(session: *lp.Session, registry: *CDPNode.Registry, node_id: CDPNode.Id) ToolError!NodeAndPage { - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const node = registry.lookup_by_id.get(node_id) orelse return ToolError.NodeNotFound; return .{ .node = node.dom, .page = page }; } fn resolveBySelector(session: *lp.Session, selector: []const u8) ToolError!NodeAndPage { - const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; + const page = try requireFrame(session); const element = Selector.querySelector(page.document.asNode(), selector, page) catch return ToolError.InvalidParams; const node = (element orelse return ToolError.NodeNotFound).asNode(); return .{ .node = node, .page = page }; @@ -1202,8 +1206,8 @@ test "formatLpEnvNames renders names without values" { defer arena.deinit(); const aa = arena.allocator(); - const names = [_][]const u8{ "LP_BAR", "LP_FOO" }; - const r = try formatLpEnvNames(aa, &names); + const env_names = [_][]const u8{ "LP_BAR", "LP_FOO" }; + const r = try formatLpEnvNames(aa, &env_names); try std.testing.expect(std.mem.indexOf(u8, r, "LP_FOO") != null); try std.testing.expect(std.mem.indexOf(u8, r, "LP_BAR") != null);