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: Vec<SearchResult> = 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        // A SearXNG instance answers 200 with an empty `results` list whether
310        // the web has nothing or every engine behind it is rate-limited, and
311        // the difference is only in `unresponsive_engines`. Ignoring it made
312        // an outage indistinguishable from an answer — measured live, with
313        // all four engines reporting `Suspended: too many requests` and
314        // `CAPTCHA` while the tool reported no results. So an empty page with
315        // an unresponsive engine behind it is a backend *failure*: it falls
316        // through to the next backend and, if there is none, says the search
317        // broke rather than that the web is silent.
318        let unresponsive = unresponsive_engines(&v);
319        if results.is_empty() && !unresponsive.is_empty() {
320            bail!(
321                "searxng asked no working engine — {}",
322                unresponsive.join("; ")
323            );
324        }
325        // Partial degradation still answers, but the operator should see it:
326        // results thinned to one surviving engine look like a quiet web.
327        if !unresponsive.is_empty() {
328            tracing::warn!(
329                unresponsive = unresponsive.join("; "),
330                returned = results.len(),
331                "searxng answered with engines missing"
332            );
333        }
334
335        Ok(SearchResponse {
336            results,
337            answer: None,
338            backend: "searxng".into(),
339        })
340    }
341}
342
343/// `[["brave", "Suspended: too many requests"], ["duckduckgo", "CAPTCHA"]]` —
344/// read defensively, because this is a third-party instance's shape and an
345/// unexpected one must read as "nothing to report", never panic a search.
346fn unresponsive_engines(v: &Value) -> Vec<String> {
347    v.get("unresponsive_engines")
348        .and_then(Value::as_array)
349        .map(|es| {
350            es.iter()
351                .map(|e| match e.as_array() {
352                    Some(pair) => {
353                        let name = pair.first().and_then(Value::as_str).unwrap_or("?");
354                        match pair.get(1).and_then(Value::as_str) {
355                            Some(why) => format!("{name}: {why}"),
356                            None => name.to_string(),
357                        }
358                    }
359                    None => e.as_str().unwrap_or("?").to_string(),
360                })
361                .collect()
362        })
363        .unwrap_or_default()
364}
365
366fn str_field(v: &Value, key: &str) -> Option<String> {
367    v.get(key)
368        .and_then(Value::as_str)
369        .map(str::to_string)
370        .filter(|s| !s.is_empty())
371}
372
373// --------------------------------------------------------------------------
374// The chain
375// --------------------------------------------------------------------------
376
377/// Backends in preference order, with fall-through on failure.
378///
379/// This is what makes stacking free tiers work: when the first backend returns
380/// 429 or 402 because the month's allowance is gone, the next one answers, and
381/// the agent never sees a failure.
382pub struct SearchChain {
383    entries: Vec<ChainEntry>,
384}
385
386/// One backend and the one thing the chain needs to know about it beyond how
387/// to call it.
388pub struct ChainEntry {
389    pub backend: Box<dyn SearchBackend>,
390    /// Move this backend to the front when the caller asked for [`Depth::Deep`].
391    ///
392    /// `Depth` used to change only *how* a backend searched, never *which* one
393    /// ran, so a research question went to whatever was cheapest and first —
394    /// and a paid backend chosen precisely for hard questions was reached only
395    /// when the free one came up empty. This is the other half: config says
396    /// which backends are worth their price on a hard question, and the chain
397    /// puts them first for exactly those.
398    ///
399    /// It reorders rather than filters, deliberately. A preferred backend that
400    /// is rate-limited must still fall through to the free one, and a quick
401    /// query must still be able to reach the paid backend as a *fallback* when
402    /// the free one is down — which is the arrangement that kept working
403    /// through a total searxng outage.
404    pub prefer_deep: bool,
405}
406
407impl SearchChain {
408    pub fn new(backends: Vec<Box<dyn SearchBackend>>) -> Self {
409        SearchChain {
410            entries: backends
411                .into_iter()
412                .map(|backend| ChainEntry {
413                    backend,
414                    prefer_deep: false,
415                })
416                .collect(),
417        }
418    }
419
420    pub fn with_entries(entries: Vec<ChainEntry>) -> Self {
421        SearchChain { entries }
422    }
423
424    pub fn is_empty(&self) -> bool {
425        self.entries.is_empty()
426    }
427
428    pub fn ids(&self) -> Vec<&str> {
429        self.entries.iter().map(|e| e.backend.id()).collect()
430    }
431
432    /// The order this depth should try backends in. A stable partition, so
433    /// config order still decides everything within each group — the only
434    /// thing depth moves is which group goes first.
435    fn order_for(&self, depth: Depth) -> Vec<&ChainEntry> {
436        match depth {
437            Depth::Quick => self.entries.iter().collect(),
438            Depth::Deep => self
439                .entries
440                .iter()
441                .filter(|e| e.prefer_deep)
442                .chain(self.entries.iter().filter(|e| !e.prefer_deep))
443                .collect(),
444        }
445    }
446
447    pub async fn search(&self, query: &str, limit: usize, depth: Depth) -> Result<SearchResponse> {
448        let mut failures = Vec::new();
449        // A backend that answered, even with nothing, is the difference
450        // between "the web does not have this" and "the search is broken",
451        // and only the second is an error. Exhausting the chain on empties
452        // used to report the first as the second, which is worse than
453        // useless: a model told its tools are broken rewords and retries —
454        // eight times in one recorded run — where a model told there are no
455        // results moves on. `bail!` is reserved for the case where nothing
456        // answered at all, which is the one the model genuinely cannot route
457        // around.
458        let mut empty_from: Option<String> = None;
459
460        for entry in self.order_for(depth) {
461            let backend = &entry.backend;
462            match backend.search(query, limit, depth).await {
463                // A backend that answers with nothing is not an error, but it
464                // is worth trying the next one before giving up.
465                Ok(r) if r.results.is_empty() && r.answer.is_none() => {
466                    failures.push(format!("{}: no results", backend.id()));
467                    empty_from.get_or_insert_with(|| backend.id().to_string());
468                }
469                Ok(r) => return Ok(r),
470                Err(e) => {
471                    tracing::warn!(backend = backend.id(), error = %e, "search backend failed");
472                    failures.push(format!("{}: {e}", backend.id()));
473                }
474            }
475        }
476
477        if let Some(backend) = empty_from {
478            // Whichever backends did break are in the operator's log above;
479            // the model gets the answer the working ones gave.
480            return Ok(SearchResponse {
481                backend,
482                ..Default::default()
483            });
484        }
485
486        bail!("every search backend failed — {}", failures.join("; "))
487    }
488}
489
490// --------------------------------------------------------------------------
491// The tool
492// --------------------------------------------------------------------------
493
494pub struct WebSearch {
495    chain: Arc<SearchChain>,
496}
497
498impl WebSearch {
499    pub fn new(chain: Arc<SearchChain>) -> Self {
500        WebSearch { chain }
501    }
502}
503
504#[async_trait]
505impl Tool for WebSearch {
506    fn name(&self) -> &str {
507        "web_search"
508    }
509
510    fn description(&self) -> &str {
511        "Search the web. Returns titles, URLs, and extracts — use http_fetch afterwards if \
512         you need a full page. Set depth to \"deep\" only for genuine research questions \
513         that need several hops; it is much slower and costs more, and a plain lookup does \
514         not need it."
515    }
516
517    fn input_schema(&self) -> Value {
518        json!({
519            "type": "object",
520            "properties": {
521                "query": {
522                    "type": "string",
523                    "description": "What to search for. Write it as a search query, not a question."
524                },
525                "limit": {
526                    "type": "integer",
527                    "description": "How many results to return. Default 8."
528                },
529                "depth": {
530                    "type": "string",
531                    "enum": ["quick", "deep"],
532                    "description": "Default \"quick\"."
533                }
534            },
535            "required": ["query"]
536        })
537    }
538
539    fn read_only(&self) -> bool {
540        // Changes nothing of yours — but see `capabilities`: the query itself
541        // leaves the machine.
542        true
543    }
544
545    fn capabilities(&self) -> Capabilities {
546        Capabilities::default().untrusted().sends()
547    }
548
549    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
550        let Some(query) = input.get("query").and_then(Value::as_str) else {
551            return Ok(ToolOutput::err("missing required string argument `query`"));
552        };
553        let limit = input.get("limit").and_then(Value::as_u64).unwrap_or(8) as usize;
554        let depth = match input.get("depth").and_then(Value::as_str) {
555            Some("deep") => Depth::Deep,
556            _ => Depth::Quick,
557        };
558
559        let response = match self.chain.search(query, limit, depth).await {
560            Ok(r) => r,
561            Err(e) => return Ok(ToolOutput::err(format!("{e:#}"))),
562        };
563
564        if response.results.is_empty() && response.answer.is_none() {
565            return Ok(ToolOutput::ok(format!("no results for {query:?}")).from_outside());
566        }
567
568        let mut out = String::new();
569        if let Some(answer) = &response.answer {
570            out.push_str(&format!("Synthesized answer: {answer}\n\n"));
571        }
572        for (i, r) in response.results.iter().enumerate() {
573            out.push_str(&format!("{}. {}\n   {}\n", i + 1, r.title, r.url));
574            if let Some(date) = &r.published {
575                out.push_str(&format!("   published: {date}\n"));
576            }
577            if !r.snippet.is_empty() {
578                let snippet: String = r.snippet.chars().take(700).collect();
579                out.push_str(&format!("   {}\n", snippet.replace('\n', " ")));
580            }
581            out.push('\n');
582        }
583        out.push_str(&format!("(via {})", response.backend));
584
585        // Everything above was written by strangers.
586        Ok(ToolOutput::ok(out).from_outside())
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use std::sync::atomic::{AtomicUsize, Ordering};
594
595    struct Stub {
596        id: &'static str,
597        calls: Arc<AtomicUsize>,
598        behaviour: Behaviour,
599    }
600
601    enum Behaviour {
602        Fail,
603        Empty,
604        One,
605    }
606
607    #[async_trait]
608    impl SearchBackend for Stub {
609        fn id(&self) -> &str {
610            self.id
611        }
612        async fn search(&self, _q: &str, _l: usize, _d: Depth) -> Result<SearchResponse> {
613            self.calls.fetch_add(1, Ordering::SeqCst);
614            match self.behaviour {
615                Behaviour::Fail => bail!("quota exhausted"),
616                Behaviour::Empty => Ok(SearchResponse {
617                    backend: self.id.into(),
618                    ..Default::default()
619                }),
620                Behaviour::One => Ok(SearchResponse {
621                    results: vec![SearchResult {
622                        title: "A page".into(),
623                        url: "https://example.com".into(),
624                        snippet: "words".into(),
625                        published: None,
626                        score: None,
627                    }],
628                    answer: None,
629                    backend: self.id.into(),
630                }),
631            }
632        }
633    }
634
635    fn stub(id: &'static str, behaviour: Behaviour) -> (Box<dyn SearchBackend>, Arc<AtomicUsize>) {
636        let calls = Arc::new(AtomicUsize::new(0));
637        (
638            Box::new(Stub {
639                id,
640                calls: Arc::clone(&calls),
641                behaviour,
642            }),
643            calls,
644        )
645    }
646
647    /// The measured case: every engine behind the instance suspended or
648    /// CAPTCHA'd, `results: []`, HTTP 200. Without reading
649    /// `unresponsive_engines` this is byte-identical to a genuine no-match,
650    /// so a total search outage would report as "the web has nothing" — the
651    /// silently-degrading shape, arriving through a third party's JSON.
652    #[test]
653    fn an_instance_with_every_engine_suspended_is_a_failure_not_an_empty_web() {
654        let v: Value = serde_json::json!({
655            "results": [],
656            "unresponsive_engines": [
657                ["brave", "Suspended: too many requests"],
658                ["duckduckgo", "CAPTCHA"],
659            ],
660        });
661        let reasons = unresponsive_engines(&v);
662        assert_eq!(
663            reasons,
664            vec![
665                "brave: Suspended: too many requests".to_string(),
666                "duckduckgo: CAPTCHA".to_string()
667            ]
668        );
669    }
670
671    /// And the honest empty: engines answered, the web had nothing. Nothing
672    /// to report, so the chain is free to call it an answer.
673    #[test]
674    fn an_empty_page_with_every_engine_healthy_reports_nothing_unresponsive() {
675        let v: Value = serde_json::json!({ "results": [], "unresponsive_engines": [] });
676        assert!(unresponsive_engines(&v).is_empty());
677    }
678
679    /// A third-party instance is free to change this shape; an unexpected one
680    /// must read as "nothing to report" rather than panicking a search.
681    #[test]
682    fn an_unexpected_unresponsive_shape_is_read_defensively() {
683        assert!(unresponsive_engines(&serde_json::json!({})).is_empty());
684        assert!(
685            unresponsive_engines(&serde_json::json!({"unresponsive_engines": "brave"})).is_empty()
686        );
687        assert_eq!(
688            unresponsive_engines(&serde_json::json!({"unresponsive_engines": ["brave", ["ddg"]]})),
689            vec!["brave".to_string(), "ddg".to_string()]
690        );
691    }
692
693    /// The recorded failure: one configured backend, a query the web has no
694    /// answer for, and the model told `every search backend failed` — which
695    /// it read as broken infrastructure and answered by rewording the query
696    /// eight times. "Nothing found" is an answer and must arrive as one.
697    #[tokio::test]
698    async fn an_exhausted_chain_of_empties_is_an_answer_not_a_failure() {
699        let (only, calls) = stub("searxng", Behaviour::Empty);
700        let chain = SearchChain::new(vec![only]);
701
702        let r = chain.search("q", 5, Depth::Quick).await.unwrap();
703        assert!(r.results.is_empty() && r.answer.is_none());
704        assert_eq!(r.backend, "searxng");
705        assert_eq!(calls.load(Ordering::SeqCst), 1);
706    }
707
708    /// A broken backend beside an empty one still yields the empty one's
709    /// answer: the breakage is the operator's to see in the log, and hiding
710    /// a real "nothing found" behind it tells the model to retry.
711    #[tokio::test]
712    async fn one_broken_backend_does_not_hide_anothers_empty_answer() {
713        let (first, _) = stub("exa", Behaviour::Fail);
714        let (second, _) = stub("searxng", Behaviour::Empty);
715        let chain = SearchChain::new(vec![first, second]);
716
717        let r = chain.search("q", 5, Depth::Quick).await.unwrap();
718        assert_eq!(r.backend, "searxng");
719        assert!(r.results.is_empty());
720    }
721
722    /// And the case `bail!` is reserved for: nothing answered at all.
723    #[tokio::test]
724    async fn a_chain_where_nothing_answered_is_still_an_error() {
725        let (first, _) = stub("exa", Behaviour::Fail);
726        let (second, _) = stub("tavily", Behaviour::Fail);
727        let chain = SearchChain::new(vec![first, second]);
728
729        let e = chain.search("q", 5, Depth::Quick).await.unwrap_err();
730        assert!(format!("{e:#}").contains("every search backend failed"));
731    }
732
733    fn entry(id: &'static str, behaviour: Behaviour, prefer_deep: bool) -> ChainEntry {
734        ChainEntry {
735            backend: stub(id, behaviour).0,
736            prefer_deep,
737        }
738    }
739
740    /// An ordinary lookup takes config order, so the free backend stays the
741    /// head and the paid one is never reached while it is answering.
742    #[tokio::test]
743    async fn a_quick_search_keeps_config_order() {
744        let chain = SearchChain::with_entries(vec![
745            entry("searxng", Behaviour::One, false),
746            entry("exa", Behaviour::One, true),
747        ]);
748        let r = chain.search("q", 5, Depth::Quick).await.unwrap();
749        assert_eq!(r.backend, "searxng");
750    }
751
752    /// A research question goes to the backend that was configured for one,
753    /// even though it sits second. This is the half `Depth` was missing: it
754    /// chose how a backend searched and never which one ran.
755    #[tokio::test]
756    async fn a_deep_search_promotes_the_preferred_backend() {
757        let chain = SearchChain::with_entries(vec![
758            entry("searxng", Behaviour::One, false),
759            entry("exa", Behaviour::One, true),
760        ]);
761        let r = chain.search("q", 5, Depth::Deep).await.unwrap();
762        assert_eq!(r.backend, "exa");
763    }
764
765    /// Promotion reorders and never filters, in both directions — otherwise a
766    /// rate-limited preferred backend would take a deep query down with it,
767    /// and a quick query could not reach the paid backend during the free
768    /// one's outage, which is the arrangement that survived a real searxng
769    /// blackout.
770    #[tokio::test]
771    async fn every_backend_stays_reachable_at_either_depth() {
772        let deep = SearchChain::with_entries(vec![
773            entry("searxng", Behaviour::One, false),
774            entry("exa", Behaviour::Fail, true),
775        ]);
776        assert_eq!(
777            deep.search("q", 5, Depth::Deep).await.unwrap().backend,
778            "searxng",
779            "a broken preferred backend must fall through, not fail the query"
780        );
781
782        let quick = SearchChain::with_entries(vec![
783            entry("searxng", Behaviour::Fail, false),
784            entry("exa", Behaviour::One, true),
785        ]);
786        assert_eq!(
787            quick.search("q", 5, Depth::Quick).await.unwrap().backend,
788            "exa",
789            "a quick query must still reach the paid backend when the free one is down"
790        );
791    }
792
793    /// Config order still decides within each group: promotion moves a group,
794    /// not an individual backend past its peers.
795    #[tokio::test]
796    async fn promotion_is_a_stable_partition() {
797        let chain = SearchChain::with_entries(vec![
798            entry("free-a", Behaviour::Empty, false),
799            entry("paid-a", Behaviour::Empty, true),
800            entry("paid-b", Behaviour::One, true),
801        ]);
802        // paid-a and paid-b both promote, and paid-a still precedes paid-b.
803        let r = chain.search("q", 5, Depth::Deep).await.unwrap();
804        assert_eq!(r.backend, "paid-b");
805    }
806
807    #[tokio::test]
808    async fn a_failed_backend_falls_through_to_the_next() {
809        let (first, first_calls) = stub("exa", Behaviour::Fail);
810        let (second, second_calls) = stub("tavily", Behaviour::One);
811        let chain = SearchChain::new(vec![first, second]);
812
813        let r = chain.search("q", 5, Depth::Quick).await.unwrap();
814        assert_eq!(r.backend, "tavily");
815        assert_eq!(first_calls.load(Ordering::SeqCst), 1);
816        assert_eq!(second_calls.load(Ordering::SeqCst), 1);
817    }
818
819    #[tokio::test]
820    async fn an_empty_result_set_also_falls_through() {
821        let (first, _) = stub("exa", Behaviour::Empty);
822        let (second, _) = stub("tavily", Behaviour::One);
823        let chain = SearchChain::new(vec![first, second]);
824        assert_eq!(
825            chain.search("q", 5, Depth::Quick).await.unwrap().backend,
826            "tavily"
827        );
828    }
829
830    #[tokio::test]
831    async fn the_first_working_backend_wins_and_the_rest_are_not_called() {
832        let (first, first_calls) = stub("exa", Behaviour::One);
833        let (second, second_calls) = stub("tavily", Behaviour::One);
834        let chain = SearchChain::new(vec![first, second]);
835
836        assert_eq!(
837            chain.search("q", 5, Depth::Quick).await.unwrap().backend,
838            "exa"
839        );
840        assert_eq!(first_calls.load(Ordering::SeqCst), 1);
841        assert_eq!(second_calls.load(Ordering::SeqCst), 0, "no wasted quota");
842    }
843
844    #[tokio::test]
845    async fn all_backends_failing_reports_every_reason() {
846        let (first, _) = stub("exa", Behaviour::Fail);
847        let (second, _) = stub("tavily", Behaviour::Fail);
848        let chain = SearchChain::new(vec![first, second]);
849
850        let err = chain
851            .search("q", 5, Depth::Quick)
852            .await
853            .unwrap_err()
854            .to_string();
855        assert!(err.contains("exa"), "{err}");
856        assert!(err.contains("tavily"), "{err}");
857    }
858
859    #[tokio::test]
860    async fn results_are_marked_as_coming_from_outside() {
861        let (backend, _) = stub("exa", Behaviour::One);
862        let tool = WebSearch::new(Arc::new(SearchChain::new(vec![backend])));
863
864        let out = tool
865            .call(json!({"query": "rust"}), &ToolCtx::default())
866            .await
867            .unwrap();
868        assert!(
869            out.external,
870            "search output must taint the conversation as untrusted"
871        );
872        assert!(out.content.contains("https://example.com"));
873        assert!(out.content.contains("(via exa)"));
874    }
875
876    #[test]
877    fn the_search_tool_declares_both_trifecta_legs_it_touches() {
878        let tool = WebSearch::new(Arc::new(SearchChain::new(Vec::new())));
879        let caps = tool.capabilities();
880        assert!(caps.untrusted_input, "results are attacker-influenced");
881        assert!(caps.external_send, "the query itself leaves the machine");
882    }
883}