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 = 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
324pub 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 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
372pub 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 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 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}