consciousness/src/agent/tools/web.rs
Kent Overstreet 5f06577ead 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>
2026-04-18 13:02:01 -04:00

278 lines
9.7 KiB
Rust

use std::sync::Arc;
// tools/web.rs — Web fetch and search
use anyhow::{Context, Result};
use serde::Deserialize;
use html2md::parse_html;
pub fn tools() -> Vec<super::Tool> {
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.",
parameters_json: r#"{"type":"object","properties":{"url":{"type":"string","description":"The URL to fetch"}},"required":["url"]}"#,
handler: Arc::new(|_a, v| Box::pin(async move { web_fetch(&v).await })),
},
super::Tool {
name: "web_search",
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)]
struct FetchArgs { url: String }
async fn web_fetch(args: &serde_json::Value) -> Result<String> {
let a: FetchArgs = serde_json::from_value(args.clone())
.context("invalid web_fetch arguments")?;
let client = http_client();
let response = client.get_with_headers(&a.url, &[
("user-agent", "consciousness/0.3"),
]).await
.with_context(|| format!("failed to fetch {}", a.url))?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("HTTP {}: {}", status, a.url);
}
let body = response.text().await
.with_context(|| format!("failed to read body from {}", a.url))?;
// Convert HTML to Markdown, then truncate
let markdown = parse_html(&body);
Ok(super::truncate_output(markdown, 30000))
}
// ── Search ──────────────────────────────────────────────────────
#[derive(Deserialize)]
struct SearchArgs {
query: String,
#[serde(default = "default_num_results")]
num_results: usize,
}
fn default_num_results() -> usize { 5 }
async fn web_search(args: &serde_json::Value) -> Result<String> {
let a: SearchArgs = serde_json::from_value(args.clone())
.context("invalid web_search arguments")?;
// Use DuckDuckGo HTML search — no API key needed
let client = http_client();
let encoded: String = a.query.chars().map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c.to_string()
} else if c == ' ' {
"+".to_string()
} else {
format!("%{:02X}", c as u32)
}
}).collect();
let url = format!("https://html.duckduckgo.com/html/?q={}", encoded);
let response = client.get_with_headers(&url, &[
("user-agent", "consciousness/0.3"),
]).await
.context("search request failed")?;
let body = response.text().await
.context("failed to read search results")?;
// Extract result snippets from DDG HTML
let mut results = Vec::new();
for chunk in body.split("class=\"result__body\"") {
if results.len() >= a.num_results { break; }
if results.is_empty() && !chunk.contains("result__title") {
continue;
}
let title = extract_between(chunk, "class=\"result__a\"", "</a>")
.map(strip_tags)
.unwrap_or_default();
let href = extract_between(chunk, "href=\"", "\"")
.unwrap_or_default();
let snippet = extract_between(chunk, "class=\"result__snippet\"", "</a>")
.map(strip_tags)
.unwrap_or_default();
if !title.is_empty() {
results.push(format!("{}. {}\n {}\n {}", results.len() + 1, title.trim(), href.trim(), snippet.trim()));
}
}
if results.is_empty() {
Ok(format!("No results found for: {}", a.query))
} else {
Ok(results.join("\n\n"))
}
}
// ── 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 ─────────────────────────────────────────────────────
fn http_client() -> crate::agent::api::http::HttpClient {
crate::agent::api::http::HttpClient::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
}
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let start_idx = text.find(start)? + start.len();
// Skip past the closing > of the start tag
let rest = &text[start_idx..];
let gt = rest.find('>')?;
let content_start = start_idx + gt + 1;
let content = &text[content_start..];
let end_idx = content.find(end)?;
Some(&content[..end_idx])
}
fn strip_tags(html: &str) -> String {
let mut out = String::new();
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
out.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#x27;", "'")
}