From 6cbe30b088703ea3d9ddf525e036b24d684a5eff Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Sun, 23 Aug 2026 15:26:42 +0300 Subject: [PATCH 1/4] search: add Keenable engine with a keyless rung above the DDG scrape Adds keenable to the search tool's engines (KEENABLE_API_KEY, tried after brave/tavily/exa in .auto). Unlike the other engines it also answers without any key: its client routes an empty key to the public endpoint (rate-limited per client IP), so .auto now tries that as the last rung before the DuckDuckGo scrape, and an explicit /searchEngine keenable works keyless instead of erroring. Engine table entries gain init_options so the Keenable client can carry the lightpanda attribution header. Needs the zenai Keenable search client (lightpanda-io/zenai PR #9); the zon pin points at that branch until it lands. --- build.zig.zon | 4 +- src/agent/Agent.zig | 8 +++- src/browser/tools.zig | 96 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 6d14d08a2..e27620331 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#5c208f61c5b6b0bc46ceaf2670d0d267b1eef87e", - .hash = "zenai-0.0.0-iOY_VKQ9BgC0_zD9nj_EedEkNioEJnGqoKsHWcq27D74", + .url = "git+https://github.com/keenableai/zenai.git#d657a7a5d7c080201ac249b4a1e2cb95981f1bbd", + .hash = "zenai-0.0.0-iOY_VBRZBgDgVyBTKY2QfeTorEsE3evpqIBbzrlBONvu", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 21cf4775b..8b00360de 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -771,7 +771,11 @@ fn handleSearchEngine(self: *Agent, rest: []const u8) void { const selected = std.meta.stringToEnum(browser_tools.SearchEngine, rest) orelse return; const env_var = browser_tools.searchEnvVar(selected) orelse return; if (std.c.getenv(env_var) == null) { - self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{env_var}); + if (browser_tools.searchEngineKeyless(selected)) { + self.terminal.printInfo("{s} is not set; using the keyless endpoint (rate-limited per client IP)", .{env_var}); + } else { + self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{env_var}); + } } } @@ -1454,7 +1458,7 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo .{}, ), .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, Tavily, then Exa (when their API keys are set) and falls back to the DuckDuckGo scrape; an explicit engine is used alone. Bare /searchEngine prints the engine.", + "/searchEngine " ++ Config.tagHint(browser_tools.SearchEngine) ++ " — set the web search engine behind the search tool (currently: {s}); saved to {s}. 'auto' tries Brave, Tavily, Exa, then Keenable (each when its API key is set), then Keenable's keyless endpoint, 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 }, ), } diff --git a/src/browser/tools.zig b/src/browser/tools.zig index f0a198be4..94450422a 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -24,6 +24,7 @@ const log = lp.log; const tavily = zenai.search.tavily; const brave = zenai.search.brave; const exa = zenai.search.exa; +const keenable = zenai.search.keenable; const DOMNode = @import("webapi/Node.zig"); const CDPNode = @import("../cdp/Node.zig"); @@ -350,7 +351,7 @@ pub const Tool = enum { ), }, .search => .{ - .description = "Run a web search and return results as markdown. When BRAVE_API_KEY, TAVILY_API_KEY or EXA_API_KEY is set, queries that search API (in that preference order) 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.", + .description = "Run a web search and return results as markdown. When BRAVE_API_KEY, TAVILY_API_KEY, EXA_API_KEY or KEENABLE_API_KEY is set, queries that search API (in that preference order) and returns a numbered list of {title, url, snippet}. Otherwise (or on API failure) tries Keenable's keyless endpoint (no credential, rate-limited per client IP), then 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( \\{ @@ -982,7 +983,7 @@ pub const SearchParams = struct { /// order (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, exa, duckduckgo }; +pub const SearchEngine = enum { auto, tavily, brave, exa, keenable, duckduckgo }; pub var search_engine: SearchEngine = .auto; // Ordered by `.auto` preference; the `search` tool description prose must @@ -992,6 +993,7 @@ const api_engines = .{ .tag = SearchEngine.brave, .env_var = "BRAVE_API_KEY", .Client = brave.Client, + .init_options = brave.Client.InitOptions{}, // text_decorations=false: no markup in model-read snippets. .options = brave.types.SearchOptions{ .count = 10, .text_decorations = false }, .format = formatBraveMarkdown, @@ -1000,6 +1002,7 @@ const api_engines = .{ .tag = SearchEngine.tavily, .env_var = "TAVILY_API_KEY", .Client = tavily.Client, + .init_options = tavily.Client.InitOptions{}, .options = tavily.types.SearchOptions{ .max_results = 10 }, .format = formatTavilyMarkdown, }, @@ -1007,13 +1010,31 @@ const api_engines = .{ .tag = SearchEngine.exa, .env_var = "EXA_API_KEY", .Client = exa.Client, + .init_options = exa.Client.InitOptions{}, // highlights: Exa returns no snippet text unless contents is requested; // capped at 3 sentences since the default excerpts run long. .options = exa.types.SearchOptions{ .numResults = 10, .contents = .{ .highlights = .{ .numSentences = 3 } } }, .format = formatExaMarkdown, }, + .{ + .tag = SearchEngine.keenable, + .env_var = "KEENABLE_API_KEY", + .Client = keenable.Client, + .init_options = keenable.Client.InitOptions{ .app_title = "lightpanda" }, + // snippet_max_length is a hint the API may round up to a word + // boundary; 500 keeps ten results within a few KB of context. + .options = keenable.types.SearchOptions{ .max_results = 10, .snippet_max_length = 500 }, + .format = formatKeenableMarkdown, + }, }; +/// Engines that also work with no API key (an empty key routes the client +/// to the provider's public endpoint). `searchExplicit` and the REPL's +/// `/searchEngine` warning consult this. +pub fn searchEngineKeyless(engine: SearchEngine) bool { + return engine == .keenable; +} + pub fn searchEnvVar(engine: SearchEngine) ?[:0]const u8 { inline for (api_engines) |e| { if (engine == e.tag) return e.env_var; @@ -1035,12 +1056,26 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode 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 => inline for (api_engines) |engine| { - if (std.c.getenv(engine.env_var)) |api_key_z| { - if (apiSearch(engine, arena, std.mem.span(api_key_z), args.query)) |markdown_| { + .auto => { + inline for (api_engines) |engine| { + if (std.c.getenv(engine.env_var)) |api_key_z| { + if (apiSearch(engine, arena, std.mem.span(api_key_z), args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, @tagName(engine.tag) ++ " fallback", .{ .err = err }); + } + } + } + // One rung above the DDG scrape: Keenable also answers keyless + // (public endpoint, rate-limited per client IP) with the same + // structured results as its keyed form. Skipped when its key is + // set — the loop above already tried it keyed. + const kn = api_engines[comptime engineIndex(.keenable)]; + if (std.c.getenv(kn.env_var) == null) { + if (apiSearch(kn, arena, "", args.query)) |markdown_| { return .{ .text = markdown_ }; } else |err| { - log.warn(.browser, @tagName(engine.tag) ++ " fallback", .{ .err = err }); + log.warn(.browser, "keenable keyless fallback", .{ .err = err }); } } }, @@ -1064,11 +1099,11 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode /// comes back as an error result — no DDG fallback. fn searchExplicit(arena: std.mem.Allocator, comptime engine: anytype, query: []const u8) ToolError!ToolResult { const label = @tagName(engine.tag); - const api_key_z = std.c.getenv(engine.env_var) orelse return .{ + const api_key: []const u8 = if (std.c.getenv(engine.env_var)) |z| std.mem.span(z) else if (comptime searchEngineKeyless(engine.tag)) "" else return .{ .text = "web search engine is set to " ++ label ++ " but " ++ engine.env_var ++ " is not set in the environment", .is_error = true, }; - const markdown_ = apiSearch(engine, arena, std.mem.span(api_key_z), query) catch |err| { + const markdown_ = apiSearch(engine, 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 }; @@ -1083,7 +1118,7 @@ fn apiSearch( api_key: []const u8, query: []const u8, ) ![]const u8 { - var client: engine.Client = .init(lp.io, arena, api_key, .{}); + var client: engine.Client = .init(lp.io, arena, api_key, engine.init_options); defer client.deinit(); var response = client.search(query, engine.options) catch |err| { @@ -1136,6 +1171,18 @@ fn formatExaMarkdown(w: *std.Io.Writer, resp: exa.types.SearchResponse) !void { } } +fn formatKeenableMarkdown(w: *std.Io.Writer, resp: keenable.types.SearchResponse) !void { + if (resp.results.len == 0) { + return w.writeAll("No results."); + } + for (resp.results, 0..) |r, i| { + // snippet carries the page text; description (the meta description) + // is empty on essentially every result and is only a fallback. + const snippet = if (r.snippet.len > 0) r.snippet else r.description; + try writeResultItem(w, i, r.title, r.url, snippet); + } +} + fn writeResultItem(w: *std.Io.Writer, i: usize, title: []const u8, url: []const u8, snippet: []const u8) !void { try w.print("{d}. **", .{i + 1}); try writeSingleLine(w, title); @@ -2486,6 +2533,37 @@ test "formatBraveMarkdown handles empty results" { try std.testing.expectEqualStrings("No results.", empty_web.written()); } +test "formatKeenableMarkdown reads snippet, falls back to description" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + // Real response shape: description present but empty, text in snippet. + const resp: keenable.types.SearchResponse = .{ + .query = "zig", + .results = &.{ + .{ .title = "Zig (programming language)", .url = "https://en.wikipedia.org/wiki/Zig", .snippet = "Zig is a system programming language.", .description = "" }, + .{ .title = "Zig guide", .url = "https://example.org/zig", .snippet = "", .description = "Meta description only." }, + }, + }; + + var aw: std.Io.Writer.Allocating = .init(aa); + try formatKeenableMarkdown(&aw.writer, resp); + const md = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, md, "1. **Zig (programming language)**") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "Zig is a system programming language.") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "2. **Zig guide**") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "Meta description only.") != null); +} + +test "formatKeenableMarkdown handles empty results" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + var aw: std.Io.Writer.Allocating = .init(arena.allocator()); + try formatKeenableMarkdown(&aw.writer, .{}); + try std.testing.expectEqualStrings("No results.", aw.written()); +} + test "formatBraveMarkdown flattens newlines in titles and descriptions" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); From 56e5daed546ce7bfc2dd8228bd84eeefebb8c91d Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Mon, 24 Aug 2026 10:44:38 +0300 Subject: [PATCH 2/4] search: track zenai review changes for the keenable engine zenai's review round made keyless explicit in the client's type (init takes ?[]const u8; null selects the public endpoint) and removed the always-empty description field from results: - the .auto keyless rung and searchExplicit pass null instead of ""; the null-key call is comptime-gated to keyless-capable engines and apiSearch's key parameter is anytype to admit both shapes - a set-but-empty KEENABLE_API_KEY is now keyed and fails loudly instead of silently switching to the keyless rate-limit regime - formatKeenableMarkdown reads snippet only (description no longer exists to fall back to) - build.zig.zon repinned to the reviewed zenai commit --- build.zig.zon | 4 ++-- src/browser/tools.zig | 43 +++++++++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index e27620331..a61adc41c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/keenableai/zenai.git#d657a7a5d7c080201ac249b4a1e2cb95981f1bbd", - .hash = "zenai-0.0.0-iOY_VBRZBgDgVyBTKY2QfeTorEsE3evpqIBbzrlBONvu", + .url = "git+https://github.com/keenableai/zenai.git#8f89838179546d2e26b46be857dbab942ba5e6bd", + .hash = "zenai-0.0.0-iOY_VApeBgDxgim4YNZ8UQd3XkAyUGOIrgAMx2pD6zxs", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 94450422a..3507908b7 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -1028,7 +1028,7 @@ const api_engines = .{ }, }; -/// Engines that also work with no API key (an empty key routes the client +/// Engines that also work with no API key (a `null` key routes the client /// to the provider's public endpoint). `searchExplicit` and the REPL's /// `/searchEngine` warning consult this. pub fn searchEngineKeyless(engine: SearchEngine) bool { @@ -1072,7 +1072,7 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode // set — the loop above already tried it keyed. const kn = api_engines[comptime engineIndex(.keenable)]; if (std.c.getenv(kn.env_var) == null) { - if (apiSearch(kn, arena, "", args.query)) |markdown_| { + if (apiSearch(kn, arena, null, args.query)) |markdown_| { return .{ .text = markdown_ }; } else |err| { log.warn(.browser, "keenable keyless fallback", .{ .err = err }); @@ -1099,11 +1099,18 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode /// comes back as an error result — no DDG fallback. fn searchExplicit(arena: std.mem.Allocator, comptime engine: anytype, query: []const u8) ToolError!ToolResult { const label = @tagName(engine.tag); - const api_key: []const u8 = if (std.c.getenv(engine.env_var)) |z| std.mem.span(z) else if (comptime searchEngineKeyless(engine.tag)) "" else return .{ - .text = "web search engine is set to " ++ label ++ " but " ++ engine.env_var ++ " is not set in the environment", - .is_error = true, - }; - const markdown_ = apiSearch(engine, arena, api_key, query) catch |err| { + const markdown_ = blk: { + if (std.c.getenv(engine.env_var)) |z| + break :blk apiSearch(engine, arena, std.mem.span(z), query); + // Comptime-gated so the null-key call is only instantiated for + // clients whose `init` takes an optional key. + if (comptime searchEngineKeyless(engine.tag)) + break :blk apiSearch(engine, arena, null, query); + return .{ + .text = "web search engine is set to " ++ label ++ " but " ++ engine.env_var ++ " is not set in the environment", + .is_error = true, + }; + } catch |err| { const msg = std.fmt.allocPrint(arena, label ++ " search failed: {s}", .{@errorName(err)}) catch return ToolError.OutOfMemory; return .{ .text = msg, .is_error = true }; @@ -1111,11 +1118,13 @@ fn searchExplicit(arena: std.mem.Allocator, comptime engine: anytype, query: []c return .{ .text = markdown_ }; } -/// `arena` owns the returned slice. +/// `arena` owns the returned slice. `api_key` is the environment value, or +/// `null` for an engine that supports keyless calls (its client's `init` +/// takes a `?[]const u8` key — see `searchEngineKeyless`). fn apiSearch( comptime engine: anytype, arena: std.mem.Allocator, - api_key: []const u8, + api_key: anytype, query: []const u8, ) ![]const u8 { var client: engine.Client = .init(lp.io, arena, api_key, engine.init_options); @@ -1176,10 +1185,9 @@ fn formatKeenableMarkdown(w: *std.Io.Writer, resp: keenable.types.SearchResponse return w.writeAll("No results."); } for (resp.results, 0..) |r, i| { - // snippet carries the page text; description (the meta description) - // is empty on essentially every result and is only a fallback. - const snippet = if (r.snippet.len > 0) r.snippet else r.description; - try writeResultItem(w, i, r.title, r.url, snippet); + // snippet carries the page text (the wire format's always-empty + // `description` is deliberately not even mapped by the client). + try writeResultItem(w, i, r.title, r.url, r.snippet); } } @@ -2533,17 +2541,16 @@ test "formatBraveMarkdown handles empty results" { try std.testing.expectEqualStrings("No results.", empty_web.written()); } -test "formatKeenableMarkdown reads snippet, falls back to description" { +test "formatKeenableMarkdown reads snippet" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const aa = arena.allocator(); - // Real response shape: description present but empty, text in snippet. const resp: keenable.types.SearchResponse = .{ .query = "zig", .results = &.{ - .{ .title = "Zig (programming language)", .url = "https://en.wikipedia.org/wiki/Zig", .snippet = "Zig is a system programming language.", .description = "" }, - .{ .title = "Zig guide", .url = "https://example.org/zig", .snippet = "", .description = "Meta description only." }, + .{ .title = "Zig (programming language)", .url = "https://en.wikipedia.org/wiki/Zig", .snippet = "Zig is a system programming language." }, + .{ .title = "Zig guide", .url = "https://example.org/zig", .snippet = "Compile-time execution." }, }, }; @@ -2553,7 +2560,7 @@ test "formatKeenableMarkdown reads snippet, falls back to description" { try std.testing.expect(std.mem.indexOf(u8, md, "1. **Zig (programming language)**") != null); try std.testing.expect(std.mem.indexOf(u8, md, "Zig is a system programming language.") != null); try std.testing.expect(std.mem.indexOf(u8, md, "2. **Zig guide**") != null); - try std.testing.expect(std.mem.indexOf(u8, md, "Meta description only.") != null); + try std.testing.expect(std.mem.indexOf(u8, md, "Compile-time execution.") != null); } test "formatKeenableMarkdown handles empty results" { From f1742ad1afa803c674b7a78b3071a9e08fd07390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 24 Aug 2026 10:39:47 +0200 Subject: [PATCH 3/4] search: repin zenai to the merged commit and finish the review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.zig.zon points at lightpanda-io/zenai's merge commit of zenai#9 (same tree, so the hash is unchanged) - the keyless rung is no longer skipped when KEENABLE_API_KEY is set: the public endpoint has its own rate-limit regime, so it is worth trying after a failed keyed attempt — which the search tool description already promised - keenable retries are disabled: the fallback cascade is the retry mechanism, and honoring the public endpoint's 60s Retry-After would stall the search tool short of the DDG rung - stale SearchEngine/searchExplicit doc comments updated for keyless --- build.zig.zon | 2 +- src/browser/tools.zig | 33 +++++++++++++++++---------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index a61adc41c..c11965f32 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,7 +36,7 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/keenableai/zenai.git#8f89838179546d2e26b46be857dbab942ba5e6bd", + .url = "git+https://github.com/lightpanda-io/zenai.git#4aa2cccdaa765938fd96e4a9e74a0765362e9a17", .hash = "zenai-0.0.0-iOY_VApeBgDxgim4YNZ8UQd3XkAyUGOIrgAMx2pD6zxs", }, .isocline = .{ diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 3507908b7..03a5b44cf 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -980,9 +980,9 @@ pub const SearchParams = struct { }; /// Search backend for `execSearch`. `.auto` tries the `api_engines` in -/// order (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. +/// order (each only when its API key is set), then Keenable's keyless +/// public endpoint, 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, exa, keenable, duckduckgo }; pub var search_engine: SearchEngine = .auto; @@ -1020,7 +1020,10 @@ const api_engines = .{ .tag = SearchEngine.keenable, .env_var = "KEENABLE_API_KEY", .Client = keenable.Client, - .init_options = keenable.Client.InitOptions{ .app_title = "lightpanda" }, + // Retries off: the fallback cascade is the retry mechanism, and + // honoring the public endpoint's Retry-After (60s per sleep) would + // stall the search tool instead of routing to the next rung. + .init_options = keenable.Client.InitOptions{ .app_title = "lightpanda", .retry_policy = .disabled }, // snippet_max_length is a hint the API may round up to a word // boundary; 500 keeps ten results within a few KB of context. .options = keenable.types.SearchOptions{ .max_results = 10, .snippet_max_length = 500 }, @@ -1066,17 +1069,14 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode } } } - // One rung above the DDG scrape: Keenable also answers keyless - // (public endpoint, rate-limited per client IP) with the same - // structured results as its keyed form. Skipped when its key is - // set — the loop above already tried it keyed. + // One rung above the DDG scrape: the keyless public endpoint + // returns the same structured results under its own rate-limit + // regime — worth trying even after a failed keyed attempt. const kn = api_engines[comptime engineIndex(.keenable)]; - if (std.c.getenv(kn.env_var) == null) { - if (apiSearch(kn, arena, null, args.query)) |markdown_| { - return .{ .text = markdown_ }; - } else |err| { - log.warn(.browser, "keenable keyless fallback", .{ .err = err }); - } + if (apiSearch(kn, arena, null, args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, "keenable keyless fallback", .{ .err = err }); } }, .duckduckgo => {}, @@ -1095,8 +1095,9 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode 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. +/// Search with an explicitly selected engine — no DDG fallback. A failed +/// call comes back as an error result, as does a missing key unless the +/// engine is keyless-capable (it then uses its public endpoint). fn searchExplicit(arena: std.mem.Allocator, comptime engine: anytype, query: []const u8) ToolError!ToolResult { const label = @tagName(engine.tag); const markdown_ = blk: { From 12ecf27fb1735568cdaebc7d735966ab53152b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 24 Aug 2026 13:01:31 +0200 Subject: [PATCH 4/4] search: make SearchEngine declaration order the .auto preference order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the zenai provider pattern: the enum's declaration order is the single source of truth for the .auto cascade, asserted at comptime against api_engines (auto first, one table entry per API engine in tag order, duckduckgo last with no entry). The enum reorders brave before tavily to match the table's actual preference — persisted settings are unaffected (ZON stores tag names). Each table entry now carries a .keyless field; the hardcoded keenable keyless rung, searchEngineKeyless, and searchExplicit's comptime gate all derive from it, engineIndex collapses to arithmetic, and apiSearch takes a typed ?[]const u8 key instead of anytype. --- src/browser/tools.zig | 81 ++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 03a5b44cf..5154c8fa7 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -979,19 +979,23 @@ pub const SearchParams = struct { timeout: ?u32 = null, }; -/// Search backend for `execSearch`. `.auto` tries the `api_engines` in -/// order (each only when its API key is set), then Keenable's keyless -/// public endpoint, 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, exa, keenable, duckduckgo }; +/// Search backend for `execSearch`. `.auto` tries the API engines in +/// declaration order (each only when its API key is set), then the keyless +/// public endpoint of `.keyless` engines (keenable), then the DuckDuckGo +/// scrape; an explicit engine is used alone. Declaration order after `auto` +/// is the `.auto` preference order (asserted against `api_engines` below); +/// `duckduckgo` (the scrape fallback, no table entry) is last. Only the +/// agent REPL's `/searchEngine` mutates this. +pub const SearchEngine = enum { auto, brave, tavily, exa, keenable, duckduckgo }; pub var search_engine: SearchEngine = .auto; -// Ordered by `.auto` preference; the `search` tool description prose must -// agree with this table. +// One entry per API engine, in `SearchEngine` declaration order (asserted +// below); the `search` tool description prose must agree with this table. const api_engines = .{ .{ .tag = SearchEngine.brave, .env_var = "BRAVE_API_KEY", + .keyless = false, .Client = brave.Client, .init_options = brave.Client.InitOptions{}, // text_decorations=false: no markup in model-read snippets. @@ -1001,6 +1005,7 @@ const api_engines = .{ .{ .tag = SearchEngine.tavily, .env_var = "TAVILY_API_KEY", + .keyless = false, .Client = tavily.Client, .init_options = tavily.Client.InitOptions{}, .options = tavily.types.SearchOptions{ .max_results = 10 }, @@ -1009,6 +1014,7 @@ const api_engines = .{ .{ .tag = SearchEngine.exa, .env_var = "EXA_API_KEY", + .keyless = false, .Client = exa.Client, .init_options = exa.Client.InitOptions{}, // highlights: Exa returns no snippet text unless contents is requested; @@ -1019,6 +1025,9 @@ const api_engines = .{ .{ .tag = SearchEngine.keenable, .env_var = "KEENABLE_API_KEY", + // Also works with no API key: a `null` key routes the client to the + // keyless public endpoint (rate-limited per client IP). + .keyless = true, .Client = keenable.Client, // Retries off: the fallback cascade is the retry mechanism, and // honoring the public endpoint's Retry-After (60s per sleep) would @@ -1031,11 +1040,27 @@ const api_engines = .{ }, }; +// `SearchEngine` declaration order is the single source of truth: tags must +// appear in `api_engines` in declaration order, with `auto` first and +// `duckduckgo` (scrape fallback, no table entry) last. +comptime { + std.debug.assert(@intFromEnum(SearchEngine.auto) == 0); + std.debug.assert(api_engines.len == std.enums.values(SearchEngine).len - 2); + std.debug.assert(@intFromEnum(SearchEngine.duckduckgo) == api_engines.len + 1); + for (api_engines, 0..) |e, i| { + if (@intFromEnum(e.tag) != i + 1) + @compileError("api_engines order must match SearchEngine declaration order: " ++ @tagName(e.tag)); + } +} + /// Engines that also work with no API key (a `null` key routes the client /// to the provider's public endpoint). `searchExplicit` and the REPL's /// `/searchEngine` warning consult this. pub fn searchEngineKeyless(engine: SearchEngine) bool { - return engine == .keenable; + inline for (api_engines) |e| { + if (engine == e.tag) return e.keyless; + } + return false; } pub fn searchEnvVar(engine: SearchEngine) ?[:0]const u8 { @@ -1045,11 +1070,9 @@ pub fn searchEnvVar(engine: SearchEngine) ?[:0]const u8 { return null; } +/// Valid for any API engine tag; the comptime block above pins tag order. fn engineIndex(comptime tag: SearchEngine) usize { - inline for (api_engines, 0..) |e, i| { - if (e.tag == tag) return i; - } - @compileError("engine missing from api_engines: " ++ @tagName(tag)); + return @intFromEnum(tag) - 1; } fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult { @@ -1069,14 +1092,17 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode } } } - // One rung above the DDG scrape: the keyless public endpoint - // returns the same structured results under its own rate-limit - // regime — worth trying even after a failed keyed attempt. - const kn = api_engines[comptime engineIndex(.keenable)]; - if (apiSearch(kn, arena, null, args.query)) |markdown_| { - return .{ .text = markdown_ }; - } else |err| { - log.warn(.browser, "keenable keyless fallback", .{ .err = err }); + // One rung above the DDG scrape: keyless public endpoints return + // the same structured results under their own rate-limit regime + // — worth trying even after a failed keyed attempt. + inline for (api_engines) |engine| { + if (comptime engine.keyless) { + if (apiSearch(engine, arena, null, args.query)) |markdown_| { + return .{ .text = markdown_ }; + } else |err| { + log.warn(.browser, @tagName(engine.tag) ++ " keyless fallback", .{ .err = err }); + } + } } }, .duckduckgo => {}, @@ -1105,7 +1131,7 @@ fn searchExplicit(arena: std.mem.Allocator, comptime engine: anytype, query: []c break :blk apiSearch(engine, arena, std.mem.span(z), query); // Comptime-gated so the null-key call is only instantiated for // clients whose `init` takes an optional key. - if (comptime searchEngineKeyless(engine.tag)) + if (comptime engine.keyless) break :blk apiSearch(engine, arena, null, query); return .{ .text = "web search engine is set to " ++ label ++ " but " ++ engine.env_var ++ " is not set in the environment", @@ -1120,15 +1146,20 @@ fn searchExplicit(arena: std.mem.Allocator, comptime engine: anytype, query: []c } /// `arena` owns the returned slice. `api_key` is the environment value, or -/// `null` for an engine that supports keyless calls (its client's `init` -/// takes a `?[]const u8` key — see `searchEngineKeyless`). +/// `null` for a `.keyless` engine's public endpoint (its client's `init` +/// takes a `?[]const u8` key; keyed clients get the unwrapped slice). fn apiSearch( comptime engine: anytype, arena: std.mem.Allocator, - api_key: anytype, + api_key: ?[]const u8, query: []const u8, ) ![]const u8 { - var client: engine.Client = .init(lp.io, arena, api_key, engine.init_options); + var client: engine.Client = .init( + lp.io, + arena, + if (comptime engine.keyless) api_key else api_key.?, + engine.init_options, + ); defer client.deinit(); var response = client.search(query, engine.options) catch |err| {