From 5f06577eadcee184e7a0ffabc1a79c9d09087d40 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sat, 18 Apr 2026 13:02:01 -0400 Subject: [PATCH] tools/web: add gemini_search as an alternative search tool (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/agent/tools/web.rs | 134 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/src/agent/tools/web.rs b/src/agent/tools/web.rs index 15d011e..36a5b50 100644 --- a/src/agent/tools/web.rs +++ b/src/agent/tools/web.rs @@ -5,8 +5,8 @@ use anyhow::{Context, Result}; use serde::Deserialize; use html2md::parse_html; -pub fn tools() -> [super::Tool; 2] { - [ +pub fn tools() -> Vec { + let mut tools = vec![ super::Tool { name: "web_fetch", 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 { 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"]}"#, 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)] @@ -114,6 +127,119 @@ async fn web_search(args: &serde_json::Value) -> Result { } } +// ── Gemini grounded search ────────────────────────────────────── + +#[derive(Deserialize)] +struct GeminiSearchArgs { + query: String, +} + +async fn gemini_search(args: &serde_json::Value) -> Result { + 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::>() + .join(""); + + let mut out = summary.trim().to_string(); + + if let Some(meta) = candidate.grounding_metadata { + let sources: Vec = 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, +} + +#[derive(Deserialize)] +struct GeminiCandidate { + content: GeminiContent, + #[serde(rename = "groundingMetadata", default)] + grounding_metadata: Option, +} + +#[derive(Deserialize)] +struct GeminiContent { + #[serde(default)] + parts: Vec, +} + +#[derive(Deserialize)] +struct GeminiPart { + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct GeminiGroundingMetadata { + #[serde(rename = "groundingChunks", default)] + grounding_chunks: Vec, +} + +#[derive(Deserialize)] +struct GeminiGroundingChunk { + #[serde(default)] + web: Option, +} + +#[derive(Deserialize)] +struct GeminiWebSource { + #[serde(default)] + uri: Option, + #[serde(default)] + title: Option, +} + // ── Helpers ───────────────────────────────────────────────────── fn http_client() -> crate::agent::api::http::HttpClient {