Skip to main content

mecha_core/
search.rs

1//! Web search.
2//!
3//! Search is a swappable backend for the same reason models are: the landscape
4//! moves, free tiers appear and vanish, and no single provider is right for
5//! every query. Exa ranks by meaning, Tavily returns agent-ready extracts, a
6//! self-hosted SearXNG keeps queries off other people's servers. All three sit
7//! behind [`SearchBackend`].
8//!
9//! Backends are tried in order and the chain falls through on failure, which is
10//! what makes stacking two free tiers a working strategy rather than a hack:
11//! run out on the first, the second answers.
12//!
13//! ## Security
14//!
15//! Search results are the single largest indirect prompt-injection surface an
16//! agent has, and the search *query itself* is an exfiltration channel — the
17//! payload fits in `?q=`. So the tool declares both `untrusted_input` and
18//! `external_send`, and its output is marked `from_outside`. The trifecta
19//! interlock does the rest.
20
21use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
22use anyhow::{bail, Context, Result};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use serde_json::{json, Value};
26use std::sync::Arc;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SearchResult {
30    pub title: String,
31    pub url: String,
32    /// Extract or snippet. Backends differ wildly in how much they return.
33    pub snippet: String,
34    pub published: Option<String>,
35    pub score: Option<f64>,
36}
37
38#[derive(Debug, Clone, Default)]
39pub struct SearchResponse {
40    pub results: Vec<SearchResult>,
41    /// Some backends synthesize an answer. Treated as just another untrusted
42    /// string — it was written from the same pages.
43    pub answer: Option<String>,
44    /// Which backend actually served this, for the trace.
45    pub backend: String,
46}
47
48/// How much to spend on one query.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Depth {
51    /// One cheap round trip. The default, and right for nearly everything.
52    Quick,
53    /// Multi-hop retrieval. Slower and several times the price — worth it for
54    /// a genuine research question, wasted on a lookup.
55    Deep,
56}
57
58#[async_trait]
59pub trait SearchBackend: Send + Sync {
60    fn id(&self) -> &str;
61    async fn search(&self, query: &str, limit: usize, depth: Depth) -> Result<SearchResponse>;
62}
63
64// --------------------------------------------------------------------------
65// Exa — https://api.exa.ai/search
66// --------------------------------------------------------------------------
67
68pub struct Exa {
69    http: reqwest::Client,
70    api_key: String,
71    base_url: String,
72}
73
74impl Exa {
75    pub fn new(api_key: String, base_url: Option<String>) -> Result<Self> {
76        Ok(Exa {
77            http: reqwest::Client::builder()
78                // `deep-reasoning` is documented at 12-50s, so the timeout has
79                // to clear the slow end of that.
80                .timeout(std::time::Duration::from_secs(90))
81                .build()?,
82            api_key,
83            base_url: base_url.unwrap_or_else(|| "https://api.exa.ai".into()),
84        })
85    }
86}
87
88#[async_trait]
89impl SearchBackend for Exa {
90    fn id(&self) -> &str {
91        "exa"
92    }
93
94    async fn search(&self, query: &str, limit: usize, depth: Depth) -> Result<SearchResponse> {
95        // Exa's base price covers 10 results and bills extra beyond that, so
96        // don't quietly exceed it.
97        let num_results = limit.clamp(1, 10);
98
99        let body = json!({
100            "query": query,
101            "numResults": num_results,
102            "type": match depth {
103                Depth::Quick => "auto",
104                Depth::Deep => "deep-reasoning",
105            },
106            // Text extracts, capped: enough to judge relevance without pulling
107            // whole pages into context.
108            "contents": {"text": {"maxCharacters": 1200}},
109        });
110
111        let resp = self
112            .http
113            .post(format!("{}/search", self.base_url.trim_end_matches('/')))
114            .header("x-api-key", &self.api_key)
115            .header("content-type", "application/json")
116            .json(&body)
117            .send()
118            .await
119            .context("exa request failed")?;
120
121        let status = resp.status();
122        let text = resp.text().await.unwrap_or_default();
123        if !status.is_success() {
124            bail!(
125                "exa {}: {}",
126                status,
127                text.chars().take(300).collect::<String>()
128            );
129        }
130
131        let v: Value = serde_json::from_str(&text).context("exa returned malformed JSON")?;
132        let results = v
133            .get("results")
134            .and_then(Value::as_array)
135            .map(|rs| {
136                rs.iter()
137                    .map(|r| SearchResult {
138                        title: str_field(r, "title").unwrap_or_else(|| "(untitled)".into()),
139                        url: str_field(r, "url").unwrap_or_default(),
140                        snippet: str_field(r, "text").unwrap_or_default(),
141                        published: str_field(r, "publishedDate"),
142                        score: r.get("score").and_then(Value::as_f64),
143                    })
144                    .collect()
145            })
146            .unwrap_or_default();
147
148        Ok(SearchResponse {
149            results,
150            answer: None,
151            backend: "exa".into(),
152        })
153    }
154}
155
156// --------------------------------------------------------------------------
157// Tavily — https://api.tavily.com/search
158// --------------------------------------------------------------------------
159
160pub struct Tavily {
161    http: reqwest::Client,
162    api_key: String,
163    base_url: String,
164}
165
166impl Tavily {
167    pub fn new(api_key: String, base_url: Option<String>) -> Result<Self> {
168        Ok(Tavily {
169            http: reqwest::Client::builder()
170                .timeout(std::time::Duration::from_secs(90))
171                .build()?,
172            api_key,
173            base_url: base_url.unwrap_or_else(|| "https://api.tavily.com".into()),
174        })
175    }
176}
177
178#[async_trait]
179impl SearchBackend for Tavily {
180    fn id(&self) -> &str {
181        "tavily"
182    }
183
184    async fn search(&self, query: &str, limit: usize, depth: Depth) -> Result<SearchResponse> {
185        let body = json!({
186            "query": query,
187            "max_results": limit.clamp(1, 20),
188            // basic costs 1 credit, advanced 2.
189            "search_depth": match depth {
190                Depth::Quick => "basic",
191                Depth::Deep => "advanced",
192            },
193            "include_answer": matches!(depth, Depth::Deep),
194        });
195
196        let resp = self
197            .http
198            .post(format!("{}/search", self.base_url.trim_end_matches('/')))
199            .bearer_auth(&self.api_key)
200            .header("content-type", "application/json")
201            .json(&body)
202            .send()
203            .await
204            .context("tavily request failed")?;
205
206        let status = resp.status();
207        let text = resp.text().await.unwrap_or_default();
208        if !status.is_success() {
209            bail!(
210                "tavily {}: {}",
211                status,
212                text.chars().take(300).collect::<String>()
213            );
214        }
215
216        let v: Value = serde_json::from_str(&text).context("tavily returned malformed JSON")?;
217        let results = v
218            .get("results")
219            .and_then(Value::as_array)
220            .map(|rs| {
221                rs.iter()
222                    .map(|r| SearchResult {
223                        title: str_field(r, "title").unwrap_or_else(|| "(untitled)".into()),
224                        url: str_field(r, "url").unwrap_or_default(),
225                        snippet: str_field(r, "content").unwrap_or_default(),
226                        published: None,
227                        score: r.get("score").and_then(Value::as_f64),
228                    })
229                    .collect()
230            })
231            .unwrap_or_default();
232
233        Ok(SearchResponse {
234            results,
235            answer: str_field(&v, "answer"),
236            backend: "tavily".into(),
237        })
238    }
239}
240
241// --------------------------------------------------------------------------
242// SearXNG — a self-hosted metasearch instance
243// --------------------------------------------------------------------------
244
245/// Talks to a SearXNG instance's JSON API. No key, no quota, and the query
246/// never leaves your network — which for an agent that also reads private data
247/// is the only way to stop the *query* being the leak.
248pub struct Searxng {
249    http: reqwest::Client,
250    base_url: String,
251}
252
253impl Searxng {
254    pub fn new(base_url: String) -> Result<Self> {
255        Ok(Searxng {
256            http: reqwest::Client::builder()
257                .timeout(std::time::Duration::from_secs(60))
258                .build()?,
259            base_url,
260        })
261    }
262}
263
264#[async_trait]
265impl SearchBackend for Searxng {
266    fn id(&self) -> &str {
267        "searxng"
268    }
269
270    async fn search(&self, query: &str, limit: usize, _depth: Depth) -> Result<SearchResponse> {
271        // SearXNG has no depth control and returns a fixed page size; we
272        // truncate client-side rather than pretend otherwise.
273        let resp = self
274            .http
275            .get(format!("{}/search", self.base_url.trim_end_matches('/')))
276            .query(&[("q", query), ("format", "json")])
277            .send()
278            .await
279            .context("searxng request failed")?;
280
281        let status = resp.status();
282        let text = resp.text().await.unwrap_or_default();
283        if !status.is_success() {
284            bail!(
285                "searxng {}: {} (a fresh instance must enable the `json` format in settings.yml)",
286                status,
287                text.chars().take(200).collect::<String>()
288            );
289        }
290
291        let v: Value = serde_json::from_str(&text).context("searxng returned malformed JSON")?;
292        let results = v
293            .get("results")
294            .and_then(Value::as_array)
295            .map(|rs| {
296                rs.iter()
297                    .take(limit)
298                    .map(|r| SearchResult {
299                        title: str_field(r, "title").unwrap_or_else(|| "(untitled)".into()),
300                        url: str_field(r, "url").unwrap_or_default(),
301                        snippet: str_field(r, "content").unwrap_or_default(),
302                        published: str_field(r, "publishedDate"),
303                        score: r.get("score").and_then(Value::as_f64),
304                    })
305                    .collect()
306            })
307            .unwrap_or_default();
308
309        Ok(SearchResponse {
310            results,
311            answer: None,
312            backend: "searxng".into(),
313        })
314    }
315}
316
317fn str_field(v: &Value, key: &str) -> Option<String> {
318    v.get(key)
319        .and_then(Value::as_str)
320        .map(str::to_string)
321        .filter(|s| !s.is_empty())
322}
323
324// --------------------------------------------------------------------------
325// The chain
326// --------------------------------------------------------------------------
327
328/// Backends in preference order, with fall-through on failure.
329///
330/// This is what makes stacking free tiers work: when the first backend returns
331/// 429 or 402 because the month's allowance is gone, the next one answers, and
332/// the agent never sees a failure.
333pub struct SearchChain {
334    backends: Vec<Box<dyn SearchBackend>>,
335}
336
337impl SearchChain {
338    pub fn new(backends: Vec<Box<dyn SearchBackend>>) -> Self {
339        SearchChain { backends }
340    }
341
342    pub fn is_empty(&self) -> bool {
343        self.backends.is_empty()
344    }
345
346    pub fn ids(&self) -> Vec<&str> {
347        self.backends.iter().map(|b| b.id()).collect()
348    }
349
350    pub async fn search(&self, query: &str, limit: usize, depth: Depth) -> Result<SearchResponse> {
351        let mut failures = Vec::new();
352
353        for backend in &self.backends {
354            match backend.search(query, limit, depth).await {
355                // A backend that answers with nothing is not an error, but it
356                // is worth trying the next one before giving up.
357                Ok(r) if r.results.is_empty() && r.answer.is_none() => {
358                    failures.push(format!("{}: no results", backend.id()));
359                }
360                Ok(r) => return Ok(r),
361                Err(e) => {
362                    tracing::warn!(backend = backend.id(), error = %e, "search backend failed");
363                    failures.push(format!("{}: {e}", backend.id()));
364                }
365            }
366        }
367
368        bail!("every search backend failed — {}", failures.join("; "))
369    }
370}
371
372// --------------------------------------------------------------------------
373// The tool
374// --------------------------------------------------------------------------
375
376pub struct WebSearch {
377    chain: Arc<SearchChain>,
378}
379
380impl WebSearch {
381    pub fn new(chain: Arc<SearchChain>) -> Self {
382        WebSearch { chain }
383    }
384}
385
386#[async_trait]
387impl Tool for WebSearch {
388    fn name(&self) -> &str {
389        "web_search"
390    }
391
392    fn description(&self) -> &str {
393        "Search the web. Returns titles, URLs, and extracts — use http_fetch afterwards if \
394         you need a full page. Set depth to \"deep\" only for genuine research questions \
395         that need several hops; it is much slower and costs more, and a plain lookup does \
396         not need it."
397    }
398
399    fn input_schema(&self) -> Value {
400        json!({
401            "type": "object",
402            "properties": {
403                "query": {
404                    "type": "string",
405                    "description": "What to search for. Write it as a search query, not a question."
406                },
407                "limit": {
408                    "type": "integer",
409                    "description": "How many results to return. Default 8."
410                },
411                "depth": {
412                    "type": "string",
413                    "enum": ["quick", "deep"],
414                    "description": "Default \"quick\"."
415                }
416            },
417            "required": ["query"]
418        })
419    }
420
421    fn read_only(&self) -> bool {
422        // Changes nothing of yours — but see `capabilities`: the query itself
423        // leaves the machine.
424        true
425    }
426
427    fn capabilities(&self) -> Capabilities {
428        Capabilities::default().untrusted().sends()
429    }
430
431    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
432        let Some(query) = input.get("query").and_then(Value::as_str) else {
433            return Ok(ToolOutput::err("missing required string argument `query`"));
434        };
435        let limit = input.get("limit").and_then(Value::as_u64).unwrap_or(8) as usize;
436        let depth = match input.get("depth").and_then(Value::as_str) {
437            Some("deep") => Depth::Deep,
438            _ => Depth::Quick,
439        };
440
441        let response = match self.chain.search(query, limit, depth).await {
442            Ok(r) => r,
443            Err(e) => return Ok(ToolOutput::err(format!("{e:#}"))),
444        };
445
446        if response.results.is_empty() && response.answer.is_none() {
447            return Ok(ToolOutput::ok(format!("no results for {query:?}")).from_outside());
448        }
449
450        let mut out = String::new();
451        if let Some(answer) = &response.answer {
452            out.push_str(&format!("Synthesized answer: {answer}\n\n"));
453        }
454        for (i, r) in response.results.iter().enumerate() {
455            out.push_str(&format!("{}. {}\n   {}\n", i + 1, r.title, r.url));
456            if let Some(date) = &r.published {
457                out.push_str(&format!("   published: {date}\n"));
458            }
459            if !r.snippet.is_empty() {
460                let snippet: String = r.snippet.chars().take(700).collect();
461                out.push_str(&format!("   {}\n", snippet.replace('\n', " ")));
462            }
463            out.push('\n');
464        }
465        out.push_str(&format!("(via {})", response.backend));
466
467        // Everything above was written by strangers.
468        Ok(ToolOutput::ok(out).from_outside())
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use std::sync::atomic::{AtomicUsize, Ordering};
476
477    struct Stub {
478        id: &'static str,
479        calls: Arc<AtomicUsize>,
480        behaviour: Behaviour,
481    }
482
483    enum Behaviour {
484        Fail,
485        Empty,
486        One,
487    }
488
489    #[async_trait]
490    impl SearchBackend for Stub {
491        fn id(&self) -> &str {
492            self.id
493        }
494        async fn search(&self, _q: &str, _l: usize, _d: Depth) -> Result<SearchResponse> {
495            self.calls.fetch_add(1, Ordering::SeqCst);
496            match self.behaviour {
497                Behaviour::Fail => bail!("quota exhausted"),
498                Behaviour::Empty => Ok(SearchResponse {
499                    backend: self.id.into(),
500                    ..Default::default()
501                }),
502                Behaviour::One => Ok(SearchResponse {
503                    results: vec![SearchResult {
504                        title: "A page".into(),
505                        url: "https://example.com".into(),
506                        snippet: "words".into(),
507                        published: None,
508                        score: None,
509                    }],
510                    answer: None,
511                    backend: self.id.into(),
512                }),
513            }
514        }
515    }
516
517    fn stub(id: &'static str, behaviour: Behaviour) -> (Box<dyn SearchBackend>, Arc<AtomicUsize>) {
518        let calls = Arc::new(AtomicUsize::new(0));
519        (
520            Box::new(Stub {
521                id,
522                calls: Arc::clone(&calls),
523                behaviour,
524            }),
525            calls,
526        )
527    }
528
529    #[tokio::test]
530    async fn a_failed_backend_falls_through_to_the_next() {
531        let (first, first_calls) = stub("exa", Behaviour::Fail);
532        let (second, second_calls) = stub("tavily", Behaviour::One);
533        let chain = SearchChain::new(vec![first, second]);
534
535        let r = chain.search("q", 5, Depth::Quick).await.unwrap();
536        assert_eq!(r.backend, "tavily");
537        assert_eq!(first_calls.load(Ordering::SeqCst), 1);
538        assert_eq!(second_calls.load(Ordering::SeqCst), 1);
539    }
540
541    #[tokio::test]
542    async fn an_empty_result_set_also_falls_through() {
543        let (first, _) = stub("exa", Behaviour::Empty);
544        let (second, _) = stub("tavily", Behaviour::One);
545        let chain = SearchChain::new(vec![first, second]);
546        assert_eq!(
547            chain.search("q", 5, Depth::Quick).await.unwrap().backend,
548            "tavily"
549        );
550    }
551
552    #[tokio::test]
553    async fn the_first_working_backend_wins_and_the_rest_are_not_called() {
554        let (first, first_calls) = stub("exa", Behaviour::One);
555        let (second, second_calls) = stub("tavily", Behaviour::One);
556        let chain = SearchChain::new(vec![first, second]);
557
558        assert_eq!(
559            chain.search("q", 5, Depth::Quick).await.unwrap().backend,
560            "exa"
561        );
562        assert_eq!(first_calls.load(Ordering::SeqCst), 1);
563        assert_eq!(second_calls.load(Ordering::SeqCst), 0, "no wasted quota");
564    }
565
566    #[tokio::test]
567    async fn all_backends_failing_reports_every_reason() {
568        let (first, _) = stub("exa", Behaviour::Fail);
569        let (second, _) = stub("tavily", Behaviour::Fail);
570        let chain = SearchChain::new(vec![first, second]);
571
572        let err = chain
573            .search("q", 5, Depth::Quick)
574            .await
575            .unwrap_err()
576            .to_string();
577        assert!(err.contains("exa"), "{err}");
578        assert!(err.contains("tavily"), "{err}");
579    }
580
581    #[tokio::test]
582    async fn results_are_marked_as_coming_from_outside() {
583        let (backend, _) = stub("exa", Behaviour::One);
584        let tool = WebSearch::new(Arc::new(SearchChain::new(vec![backend])));
585
586        let out = tool
587            .call(json!({"query": "rust"}), &ToolCtx::default())
588            .await
589            .unwrap();
590        assert!(
591            out.external,
592            "search output must taint the conversation as untrusted"
593        );
594        assert!(out.content.contains("https://example.com"));
595        assert!(out.content.contains("(via exa)"));
596    }
597
598    #[test]
599    fn the_search_tool_declares_both_trifecta_legs_it_touches() {
600        let tool = WebSearch::new(Arc::new(SearchChain::new(Vec::new())));
601        let caps = tool.capabilities();
602        assert!(caps.untrusted_input, "results are attacker-influenced");
603        assert!(caps.external_send, "the query itself leaves the machine");
604    }
605}