diff --git a/build.zig.zon b/build.zig.zon index 64aff55d1..c2e50cc14 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#1df180de89ef72535d973af7854bf24dc8ab94a7", - .hash = "zenai-0.0.0-iOY_VPyeBQDnDr5NOUWDK7HWpFTJR8JXZ5UYcwNGEMOu", + .url = "git+https://github.com/lightpanda-io/zenai.git#31da07c1fe9805267ab4dbe4ce41c0cc2d16a36a", + .hash = "zenai-0.0.0-iOY_VGnDBQDS-bBNsgXuHhwEMqTh9Q7bQuP0BKziOdrR", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 6666b21b1..ab2a3868d 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -296,10 +296,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); const stream_enabled = settings.resolveStream(remembered); + browser_tools.search_engine = settings.resolveSearchEngine(remembered); if (resolved) |r| { if (r.source == .picked) { - settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {}; + settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); @@ -721,6 +722,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand. .load => self.handleLoad(rest), .model => self.handleModel(arena, rest), .provider => self.handleProvider(arena, rest), + .searchEngine => self.handleSearchEngine(rest), } return false; } @@ -760,6 +762,21 @@ fn handleStream(self: *Agent, rest: []const u8) void { self.reportSaved("stream", if (on) "on" else "off"); } +/// `/searchEngine`: bare prints the current engine; an argument sets and +/// persists it, warning when the chosen engine's API key is unset. +fn handleSearchEngine(self: *Agent, rest: []const u8) void { + self.setEnumOption("searchEngine", &browser_tools.search_engine, rest); + const selected = std.meta.stringToEnum(browser_tools.SearchEngine, rest) orelse return; + const env_var: []const u8 = switch (selected) { + .tavily => "TAVILY_API_KEY", + .brave => "BRAVE_API_KEY", + .auto, .duckduckgo => return, + }; + if (std.posix.getenv(env_var) == null) { + self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{env_var}); + } +} + /// Print cumulative session token usage, broken down so the cache's effect is /// visible — the REPL otherwise never surfaces the `$usage` line `--task` /// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`); @@ -870,7 +887,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void { self.terminal.printInfo("{s}: {s}", .{ label, value }); return; } - if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) { + if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled, .search_engine = browser_tools.search_engine })) { self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path }); } else |_| { self.terminal.printInfo("{s}: {s}", .{ label, value }); @@ -1360,6 +1377,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo "/provider [name|null] — change the provider, or 'null' to disable the LLM (persisted, so the next launch starts in basic mode); Tab completes detected providers, bare /provider shows the current one", .{}, ), + .searchEngine => self.terminal.printInfo( + "/searchEngine " ++ Config.tagHint(browser_tools.SearchEngine) ++ " — set the web search engine behind the search tool (currently: {s}); saved to {s}. 'auto' tries Brave then Tavily (when their API keys are set) and falls back to the DuckDuckGo scrape; an explicit engine is used alone. Bare /searchEngine prints the engine.", + .{ @tagName(browser_tools.search_engine), settings.remembered_path }, + ), } return; } diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 790f48fe7..e84d855db 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -17,7 +17,7 @@ // along with this program. If not, see . //! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`, -//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by +//! `/stream`, `/usage`, `/model`, `/provider`, `/searchEngine`). Not tool slash commands — handled by //! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema //! primitives live in `lp.Schema`; import that directly. @@ -46,7 +46,7 @@ pub const MetaCommand = struct { /// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta /// command is a compile error until it's wired up there too. - const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider }; + const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider, searchEngine }; }; const tagNames = Config.tagNames; @@ -65,6 +65,7 @@ pub const meta_commands = [_]MetaCommand{ .{ .tag = .load, .name = "load", .hint = "", .values = &.{}, .description = "Load and run a script from disk" }, .{ .tag = .model, .name = "model", .hint = "[name]", .values = &.{}, .description = "Change the model" }, .{ .tag = .provider, .name = "provider", .hint = "[name|null]", .values = &.{}, .description = "Change the provider, or 'null' to disable the LLM" }, + .{ .tag = .searchEngine, .name = "searchEngine", .hint = tagHint(browser_tools.SearchEngine), .values = tagNames(browser_tools.SearchEngine), .description = "Change the web search engine" }, }; /// Derived from `Command.LlmCommand` — name and description both come from the diff --git a/src/agent/settings.zig b/src/agent/settings.zig index ad265c72d..1908305df 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -194,6 +194,7 @@ pub const Remembered = struct { effort: ?Config.Effort = null, verbosity: ?Config.AgentVerbosity = null, stream: ?bool = null, + search_engine: ?lp.tools.SearchEngine = null, }; pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered { @@ -278,6 +279,13 @@ pub fn resolveStream(remembered: ?Remembered) bool { return true; } +/// Precedence: remembered `.lp-agent.zon` value > default (auto). No CLI +/// flag — the REPL `/searchEngine` command sets and persists it. +pub fn resolveSearchEngine(remembered: ?Remembered) lp.tools.SearchEngine { + if (remembered) |r| if (r.search_engine) |e| return e; + return .auto; +} + pub const ReconciledModel = union(enum) { /// Owned by the allocator passed to reconcileModel. use: []u8, @@ -348,6 +356,18 @@ test "parseRemembered: stream field round-trips" { try testing.expect(remembered.stream == false); } +test "parseRemembered: search_engine field round-trips" { + const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .search_engine = .brave }").?; + defer std.zon.parse.free(testing.allocator, remembered); + try testing.expect(remembered.search_engine == .brave); +} + +test "resolveSearchEngine: default auto, remembered wins" { + try testing.expect(resolveSearchEngine(null) == .auto); + try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = null }) == .auto); + try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = .duckduckgo }) == .duckduckgo); +} + test "resolveStream: default on, remembered wins" { try testing.expect(resolveStream(null)); try testing.expect(resolveStream(.{ .model = "m", .stream = null })); diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 4812bf624..1d53a66c5 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -22,6 +22,7 @@ const zenai = @import("zenai"); const log = lp.log; const tavily = zenai.search.tavily; +const brave = zenai.search.brave; const DOMNode = @import("webapi/Node.zig"); const CDPNode = @import("../cdp/Node.zig"); @@ -335,7 +336,7 @@ pub const Tool = enum { ), }, .search => .{ - .description = "Run a web search and return results as markdown. When TAVILY_API_KEY is set, queries the Tavily Search API and returns a numbered list of {title, url, snippet}. Otherwise (or on Tavily failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.", + .description = "Run a web search and return results as markdown. When BRAVE_API_KEY or TAVILY_API_KEY is set, queries that search API (Brave preferred) and returns a numbered list of {title, url, snippet}. Otherwise (or on API failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.", .summary = "Web search, results as markdown", .input_schema = minify( \\{ @@ -814,7 +815,7 @@ fn dispatch( ) ToolError!ToolResult { return switch (tool) { .goto => .{ .text = try execGoto(arena, session, registry, substituted) }, - .search => .{ .text = try execSearch(arena, session, registry, substituted) }, + .search => execSearch(arena, session, registry, substituted), .markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) }, .html => .{ .text = try execHtml(arena, session, registry, substituted) }, .links => .{ .text = try execLinks(arena, session, registry, substituted) }, @@ -946,19 +947,38 @@ pub const SearchParams = struct { timeout: ?u32 = null, }; -fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { +/// Search backend for `execSearch`. `.auto` tries Brave then Tavily (each +/// only when its API key is set), then the DuckDuckGo scrape; an explicit +/// engine is used alone. Only the agent REPL's `/searchEngine` mutates this. +pub const SearchEngine = enum { auto, tavily, brave, duckduckgo }; +pub var search_engine: SearchEngine = .auto; + +fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult { const args = try parseArgs(SearchParams, arena, arguments); if (args.query.len == 0) return ToolError.InvalidParams; - // Tavily path: only when TAVILY_API_KEY is set in the process env. On any - // failure (network, non-2xx, parse) fall through to the DuckDuckGo scrape - // so a single Tavily outage doesn't kill a whole benchmark run. - if (std.posix.getenv("TAVILY_API_KEY")) |api_key| { - if (tavilySearch(arena, api_key, args.query)) |markdown_| { - return markdown_; - } else |err| { - log.warn(.browser, "tavily fallback", .{ .err = err }); - } + switch (search_engine) { + // Any failure (network, non-2xx, parse) falls through to the next + // engine so a single outage doesn't kill a whole benchmark run. + .auto => { + if (std.posix.getenv("BRAVE_API_KEY")) |api_key| { + if (braveSearch(arena, api_key, args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, "brave fallback", .{ .err = err }); + } + } + if (std.posix.getenv("TAVILY_API_KEY")) |api_key| { + if (tavilySearch(arena, api_key, args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, "tavily fallback", .{ .err = err }); + } + } + }, + .tavily => return searchExplicit(arena, "tavily", "TAVILY_API_KEY", tavilySearch, args.query), + .brave => return searchExplicit(arena, "brave", "BRAVE_API_KEY", braveSearch, args.query), + .duckduckgo => {}, } const encoded = lp.URL.percentEncodeSegment(arena, args.query, .component) catch return ToolError.OutOfMemory; @@ -970,7 +990,28 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode ) catch return ToolError.OutOfMemory; _ = try performGoto(session, registry, ddg_url, args.timeout); const ddg_frame = try requireFrame(session); - return renderFrameMarkdown(arena, ddg_frame); + return .{ .text = try renderFrameMarkdown(arena, ddg_frame) }; +} + +/// Search with an explicitly selected engine: a missing key or a failed call +/// comes back as an error result — no DDG fallback. +fn searchExplicit( + arena: std.mem.Allocator, + comptime label: []const u8, + comptime env_var: []const u8, + searchFn: anytype, + query: []const u8, +) ToolError!ToolResult { + const api_key = std.posix.getenv(env_var) orelse return .{ + .text = "web search engine is set to " ++ label ++ " but " ++ env_var ++ " is not set in the environment", + .is_error = true, + }; + const markdown_ = searchFn(arena, api_key, query) catch |err| { + const msg = std.fmt.allocPrint(arena, label ++ " search failed: {s}", .{@errorName(err)}) catch + return ToolError.OutOfMemory; + return .{ .text = msg, .is_error = true }; + }; + return .{ .text = markdown_ }; } /// Thin wrapper over `zenai.search.tavily.Client` that handles client @@ -1015,6 +1056,45 @@ fn formatTavilyMarkdown(arena: std.mem.Allocator, resp: tavily.types.SearchRespo return aw.written(); } +/// Thin wrapper over `zenai.search.brave.Client` that handles client +/// lifetime and renders the structured response as markdown for the agent. +/// `arena` owns the returned slice. `api_key` is the value of BRAVE_API_KEY. +fn braveSearch( + arena: std.mem.Allocator, + api_key: []const u8, + query: []const u8, +) ![]const u8 { + var client: brave.Client = .init(arena, api_key, .{}); + defer client.deinit(); + + // text_decorations=false: no markup in model-read snippets. + var response = client.search(query, .{ .count = 10, .text_decorations = false }) catch |err| { + if (client.last_error_status) |status| { + log.warn(.browser, "brave non-2xx", .{ + .status = status, + .body = client.last_error_body orelse "", + }); + } + return err; + }; + defer response.deinit(); + + return formatBraveMarkdown(arena, response.value); +} + +fn formatBraveMarkdown(arena: std.mem.Allocator, resp: brave.types.SearchResponse) ![]const u8 { + var aw: std.Io.Writer.Allocating = .init(arena); + const w = &aw.writer; + const results: []const brave.types.Result = if (resp.web) |web| web.results else &.{}; + for (results, 0..) |r, i| { + try w.print("{d}. **{s}** — {s}\n {s}\n\n", .{ i + 1, r.title, r.url, r.description }); + } + if (results.len == 0) { + try w.writeAll("No results."); + } + return aw.written(); +} + fn renderFrameMarkdown(arena: std.mem.Allocator, frame: *lp.Frame) ToolError![]const u8 { var aw: std.Io.Writer.Allocating = .init(arena); lp.markdown.dump(frame.document.asNode(), .{}, &aw.writer, frame) catch @@ -2236,6 +2316,39 @@ test "formatTavilyMarkdown handles empty results" { try std.testing.expectEqualStrings("No results.", md); } +test "formatBraveMarkdown renders web results" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + const resp: brave.types.SearchResponse = .{ + .web = .{ + .results = &.{ + .{ .title = "Paris - Wikipedia", .url = "https://en.wikipedia.org/wiki/Paris", .description = "Paris is the capital of France." }, + .{ .title = "France", .url = "https://example.org/fr", .description = "Country in Western Europe." }, + }, + }, + }; + + const md = try formatBraveMarkdown(aa, resp); + try std.testing.expect(std.mem.indexOf(u8, md, "1. **Paris - Wikipedia**") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "https://en.wikipedia.org/wiki/Paris") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "Paris is the capital of France.") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "2. **France**") != null); +} + +test "formatBraveMarkdown handles empty results" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + const md_no_web = try formatBraveMarkdown(aa, .{}); + try std.testing.expectEqualStrings("No results.", md_no_web); + + const md_empty_web = try formatBraveMarkdown(aa, .{ .web = .{} }); + try std.testing.expectEqualStrings("No results.", md_empty_web); +} + test "isPathSafe: relative paths without traversal are accepted" { try std.testing.expect(isPathSafe("foo.txt")); try std.testing.expect(isPathSafe("./foo.txt"));