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"));