tools/web: add gemini_search as an alternative search tool (#5)

Issue #5 (spqrz) flagged that web_search using DuckDuckGo
occasionally flakes out, and Google search directly is blocked
behind CAPTCHAs for non-browser clients. The Gemini free-tier API
exposes a grounded-search tool that effectively queries Google's
index and returns an LLM-summarized answer with source URLs.

Added as a SEPARATE tool rather than a transparent fallback for
web_search:

* web_search (DDG) returns raw results — title, URL, snippet per
  hit — which the agent can reason over itself.
* gemini_search returns an LLM-pre-digested summary plus grounding
  URLs. Useful for synthesis queries ("what's the consensus on X")
  or when DDG is flaky, but it's another LLM in the loop so the
  agent may want the raw variant for certain tasks.

Tool descriptions tell the agent to prefer web_search for raw
results and use gemini_search for synthesis / fallback. The agent
picks based on query shape.

Only registered when GEMINI_API_KEY is set in the environment
(gracefully absent otherwise). Uses gemini-2.0-flash which has a
generous free-tier rate limit. Parses grounding metadata for
source URLs so the agent can follow links.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-18 13:02:01 -04:00
parent c7b0052f1d
commit 5f06577ead

View file

@ -5,8 +5,8 @@ use anyhow::{Context, Result};
use serde::Deserialize; use serde::Deserialize;
use html2md::parse_html; use html2md::parse_html;
pub fn tools() -> [super::Tool; 2] { pub fn tools() -> Vec<super::Tool> {
[ let mut tools = vec![
super::Tool { super::Tool {
name: "web_fetch", name: "web_fetch",
description: "Fetch content from a URL and return it as text. Use for reading web pages, API responses, documentation.", description: "Fetch content from a URL and return it as text. Use for reading web pages, API responses, documentation.",
@ -15,11 +15,24 @@ pub fn tools() -> [super::Tool; 2] {
}, },
super::Tool { super::Tool {
name: "web_search", name: "web_search",
description: "Search the web and return results. Use for finding documentation, looking up APIs, researching topics.", description: "Search the web via DuckDuckGo and return a list of results (title, URL, snippet). Use for finding documentation, looking up APIs, researching topics. Returns raw results you can reason over yourself.",
parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"The search query"},"num_results":{"type":"integer","description":"Number of results to return (default 5)"}},"required":["query"]}"#, parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"The search query"},"num_results":{"type":"integer","description":"Number of results to return (default 5)"}},"required":["query"]}"#,
handler: Arc::new(|_a, v| Box::pin(async move { web_search(&v).await })), handler: Arc::new(|_a, v| Box::pin(async move { web_search(&v).await })),
}, },
] ];
// Gemini-grounded search (Google's index via Gemini's google_search tool)
// is only available if GEMINI_API_KEY is set. Returns an LLM-summarized
// answer with source URLs — use when you want a synthesized take rather
// than raw results, or as a fallback when DDG is flaky.
if std::env::var("GEMINI_API_KEY").is_ok() {
tools.push(super::Tool {
name: "gemini_search",
description: "Search Google (via Gemini's grounded-search tool) and return an LLM-summarized answer with source URLs. Prefer web_search for raw results; use this for synthesis, 'what's the consensus on X', or when DDG fails. Free-tier rate limited; don't spam it.",
parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"The search query"}},"required":["query"]}"#,
handler: Arc::new(|_a, v| Box::pin(async move { gemini_search(&v).await })),
});
}
tools
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -114,6 +127,119 @@ async fn web_search(args: &serde_json::Value) -> Result<String> {
} }
} }
// ── Gemini grounded search ──────────────────────────────────────
#[derive(Deserialize)]
struct GeminiSearchArgs {
query: String,
}
async fn gemini_search(args: &serde_json::Value) -> Result<String> {
let a: GeminiSearchArgs = serde_json::from_value(args.clone())
.context("invalid gemini_search arguments")?;
let api_key = std::env::var("GEMINI_API_KEY")
.context("GEMINI_API_KEY not set")?;
// gemini-2.0-flash has a free tier with Google search grounding.
// Request shape: `{"contents": [{"parts": [{"text": query}]}],
// "tools": [{"google_search": {}}]}`.
// Response carries the summary in candidates[0].content.parts[].text
// and grounding URLs in candidates[0].groundingMetadata.groundingChunks[].web.
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={}",
api_key
);
let body = serde_json::json!({
"contents": [{"parts": [{"text": a.query}]}],
"tools": [{"google_search": {}}],
});
let client = http_client();
let response = client.send_json("POST", &url, &[], &body).await
.context("gemini API request failed")?;
let status = response.status();
if !status.is_success() {
let err_body = response.text().await.unwrap_or_default();
let n = err_body.floor_char_boundary(err_body.len().min(500));
anyhow::bail!("gemini_search HTTP {}: {}", status, &err_body[..n]);
}
let parsed: GeminiResponse = response.json().await
.context("gemini response parse failed")?;
let candidate = parsed.candidates.into_iter().next()
.context("gemini returned no candidates")?;
let summary: String = candidate.content.parts.iter()
.filter_map(|p| p.text.as_deref())
.collect::<Vec<_>>()
.join("");
let mut out = summary.trim().to_string();
if let Some(meta) = candidate.grounding_metadata {
let sources: Vec<String> = meta.grounding_chunks.iter().enumerate()
.filter_map(|(i, c)| c.web.as_ref().map(|w| {
let title = w.title.as_deref().unwrap_or("(untitled)");
let uri = w.uri.as_deref().unwrap_or("");
format!(" [{}] {}{}", i + 1, title, uri)
}))
.collect();
if !sources.is_empty() {
out.push_str("\n\nSources:\n");
out.push_str(&sources.join("\n"));
}
}
Ok(super::truncate_output(out, 30000))
}
#[derive(Deserialize)]
struct GeminiResponse {
#[serde(default)]
candidates: Vec<GeminiCandidate>,
}
#[derive(Deserialize)]
struct GeminiCandidate {
content: GeminiContent,
#[serde(rename = "groundingMetadata", default)]
grounding_metadata: Option<GeminiGroundingMetadata>,
}
#[derive(Deserialize)]
struct GeminiContent {
#[serde(default)]
parts: Vec<GeminiPart>,
}
#[derive(Deserialize)]
struct GeminiPart {
#[serde(default)]
text: Option<String>,
}
#[derive(Deserialize)]
struct GeminiGroundingMetadata {
#[serde(rename = "groundingChunks", default)]
grounding_chunks: Vec<GeminiGroundingChunk>,
}
#[derive(Deserialize)]
struct GeminiGroundingChunk {
#[serde(default)]
web: Option<GeminiWebSource>,
}
#[derive(Deserialize)]
struct GeminiWebSource {
#[serde(default)]
uri: Option<String>,
#[serde(default)]
title: Option<String>,
}
// ── Helpers ───────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────
fn http_client() -> crate::agent::api::http::HttpClient { fn http_client() -> crate::agent::api::http::HttpClient {