1use 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 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 pub answer: Option<String>,
44 pub backend: String,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Depth {
51 Quick,
53 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
64pub 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 .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 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 "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
156pub 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 "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
241pub 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 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 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 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
343fn 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
373pub struct SearchChain {
383 entries: Vec<ChainEntry>,
384}
385
386pub struct ChainEntry {
389 pub backend: Box<dyn SearchBackend>,
390 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 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 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 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 return Ok(SearchResponse {
481 backend,
482 ..Default::default()
483 });
484 }
485
486 bail!("every search backend failed — {}", failures.join("; "))
487 }
488}
489
490pub 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}