mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 01:36:15 -04:00
feat(tools): add Brave search + /searchEngine metacommand
Add Brave as a second API-backed web search engine: the search tool's auto mode tries Brave then Tavily (keyed on BRAVE_API_KEY/TAVILY_API_KEY) before the DuckDuckGo scrape. A new /searchEngine REPL metacommand pins the engine explicitly (auto|tavily|brave|duckduckgo) — an explicit engine surfaces its failures instead of silently degrading — and is persisted to .lp-agent.zon like the other REPL settings. Bumps zenai to pick up the zenai.search.brave client.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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 = "<path>", .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
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 <strong> 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"));
|
||||
|
||||
Reference in New Issue
Block a user