1use std::sync::Arc;
2
3use crate::error::RuntimeError;
4use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
5use crate::value::Value;
6
7#[derive(Debug, Clone)]
8pub struct WebConfig {
9 pub max_bytes: usize,
10 pub url_allowlist: Vec<String>,
11 pub url_denylist: Vec<String>,
12}
13
14impl Default for WebConfig {
15 fn default() -> Self {
16 Self {
17 max_bytes: 1_000_000,
18 url_allowlist: Vec::new(),
19 url_denylist: Vec::new(),
20 }
21 }
22}
23
24impl WebConfig {
25 pub fn url_allowed(&self, url: &str) -> bool {
26 if self.url_denylist.iter().any(|p| url.starts_with(p)) {
27 return false;
28 }
29 if self.url_allowlist.is_empty() {
30 return true;
31 }
32 self.url_allowlist.iter().any(|p| url.starts_with(p))
33 }
34}
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37#[serde(tag = "provider", rename_all = "lowercase")]
38pub enum SearchConfig {
39 #[serde(rename = "none")]
40 None,
41 Tavily {
42 #[serde(default)]
43 api_key: Option<String>,
44 #[serde(default)]
45 base_url: Option<String>,
46 #[serde(default)]
47 max_results: Option<usize>,
48 },
49 Searxng {
50 #[serde(default)]
51 base_url: Option<String>,
52 #[serde(default)]
53 max_results: Option<usize>,
54 },
55 Parallel {
56 api_key: String,
57 #[serde(default)]
58 base_url: Option<String>,
59 #[serde(default)]
60 max_results: Option<usize>,
61 },
62 Brave {
63 api_key: String,
64 #[serde(default)]
65 base_url: Option<String>,
66 },
67}
68
69impl Default for SearchConfig {
70 fn default() -> Self {
71 Self::Tavily {
72 api_key: None,
73 base_url: None,
74 max_results: None,
75 }
76 }
77}
78
79fn tavily_default_endpoint() -> String {
80 "https://api.tavily.com".into()
81}
82fn parallel_default_endpoint() -> String {
83 "https://api.parallel.ai".into()
84}
85fn brave_default_endpoint() -> String {
86 "https://api.search.brave.com/res/v1/web/search".into()
87}
88fn default_max_results() -> usize {
89 8
90}
91
92impl SearchConfig {
93 pub fn provider_name(&self) -> &'static str {
94 match self {
95 Self::None => "none",
96 Self::Tavily { .. } => "tavily",
97 Self::Searxng { .. } => "searxng",
98 Self::Parallel { .. } => "parallel",
99 Self::Brave { .. } => "brave",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct SearchResult {
106 pub title: String,
107 pub url: String,
108 pub snippet: String,
109 pub content: Option<String>,
110 pub published_date: Option<String>,
111 pub score: Option<f64>,
112}
113
114impl SearchResult {
115 pub fn into_value(self) -> Value {
116 let mut fields: Vec<(String, Value)> = vec![
117 ("title".into(), Value::Str(self.title)),
118 ("url".into(), Value::Str(self.url)),
119 ("snippet".into(), Value::Str(self.snippet)),
120 ];
121 if let Some(c) = self.content {
122 fields.push(("content".into(), Value::Str(c)));
123 }
124 if let Some(d) = self.published_date {
125 fields.push(("published_date".into(), Value::Str(d)));
126 }
127 if let Some(s) = self.score {
128 fields.push(("score".into(), Value::Float(s)));
129 }
130 Value::Struct(fields)
131 }
132}
133
134pub trait SearchProvider: Send + Sync {
135 fn name(&self) -> &'static str;
136 fn call<'a>(&'a self, query: &'a str) -> BoxFut<'a, Result<Vec<SearchResult>, RuntimeError>>;
137}
138
139pub fn build_search_provider(cfg: &SearchConfig) -> Option<Arc<dyn SearchProvider>> {
140 match cfg {
141 SearchConfig::None => None,
142 SearchConfig::Tavily {
143 api_key,
144 base_url,
145 max_results,
146 } => Some(Arc::new(TavilySearch::new(
147 api_key.clone(),
148 base_url.clone().unwrap_or_else(tavily_default_endpoint),
149 max_results.unwrap_or_else(default_max_results),
150 ))),
151 SearchConfig::Searxng {
152 base_url,
153 max_results,
154 } => Some(Arc::new(SearxngSearch::new(
155 base_url
156 .clone()
157 .unwrap_or_else(|| "http://localhost:8080".into()),
158 max_results.unwrap_or_else(default_max_results),
159 ))),
160 SearchConfig::Parallel {
161 api_key,
162 base_url,
163 max_results,
164 } => Some(Arc::new(ParallelSearch::new(
165 api_key.clone(),
166 base_url.clone().unwrap_or_else(parallel_default_endpoint),
167 max_results.unwrap_or_else(default_max_results),
168 ))),
169 SearchConfig::Brave { api_key, base_url } => Some(Arc::new(BraveSearch::with_endpoint(
170 api_key.clone(),
171 base_url.clone().unwrap_or_else(brave_default_endpoint),
172 ))),
173 }
174}
175
176pub struct TavilySearch {
177 api_key: Option<String>,
178 base_url: String,
179 max_results: usize,
180 client: reqwest::Client,
181}
182
183impl TavilySearch {
184 pub fn new(api_key: Option<String>, base_url: String, max_results: usize) -> Self {
185 Self {
186 api_key,
187 base_url,
188 max_results: max_results.max(1),
189 client: reqwest::Client::new(),
190 }
191 }
192}
193
194impl SearchProvider for TavilySearch {
195 fn name(&self) -> &'static str {
196 "tavily"
197 }
198
199 fn call<'a>(&'a self, query: &'a str) -> BoxFut<'a, Result<Vec<SearchResult>, RuntimeError>> {
200 Box::pin(async move {
201 let body = serde_json::json!({
202 "query": query,
203 "max_results": self.max_results,
204 "search_depth": "basic",
205 });
206 let mut req = self
207 .client
208 .post(format!("{}/search", self.base_url))
209 .header("Content-Type", "application/json")
210 .json(&body);
211 if let Some(key) = &self.api_key {
212 req = req.header("Authorization", format!("Bearer {key}"));
213 } else {
214 req = req.header("X-Tavily-Access-Mode", "keyless");
215 }
216 let resp = req
217 .send()
218 .await
219 .map_err(|e| RuntimeError::ToolFailed(format!("web.search: tavily: {e}")))?;
220 let status = resp.status();
221 let json: serde_json::Value = resp
222 .json()
223 .await
224 .map_err(|e| RuntimeError::ToolFailed(format!("web.search: tavily decode: {e}")))?;
225 if !status.is_success() {
226 return Err(RuntimeError::ToolFailed(format!(
227 "web.search: tavily returned {status}: {json}"
228 )));
229 }
230 let items = json
231 .get("results")
232 .and_then(|v| v.as_array())
233 .cloned()
234 .unwrap_or_default();
235 Ok(items
236 .into_iter()
237 .map(|item| SearchResult {
238 title: item
239 .get("title")
240 .and_then(|v| v.as_str())
241 .unwrap_or_default()
242 .into(),
243 url: item
244 .get("url")
245 .and_then(|v| v.as_str())
246 .unwrap_or_default()
247 .into(),
248 snippet: item
249 .get("content")
250 .and_then(|v| v.as_str())
251 .unwrap_or_default()
252 .into(),
253 content: item
254 .get("raw_content")
255 .and_then(|v| v.as_str())
256 .map(String::from),
257 published_date: None,
258 score: item.get("score").and_then(|v| v.as_f64()),
259 })
260 .collect())
261 })
262 }
263}
264
265pub struct SearxngSearch {
266 base_url: String,
267 max_results: usize,
268 client: reqwest::Client,
269}
270
271impl SearxngSearch {
272 pub fn new(base_url: String, max_results: usize) -> Self {
273 Self {
274 base_url,
275 max_results: max_results.max(1),
276 client: reqwest::Client::new(),
277 }
278 }
279}
280
281impl SearchProvider for SearxngSearch {
282 fn name(&self) -> &'static str {
283 "searxng"
284 }
285
286 fn call<'a>(&'a self, query: &'a str) -> BoxFut<'a, Result<Vec<SearchResult>, RuntimeError>> {
287 Box::pin(async move {
288 let resp = self
289 .client
290 .get(format!("{}/search", self.base_url.trim_end_matches('/')))
291 .query(&[("q", query), ("format", "json")])
292 .send()
293 .await
294 .map_err(|e| RuntimeError::ToolFailed(format!("web.search: searxng: {e}")))?;
295 let status = resp.status();
296 let json: serde_json::Value = resp.json().await.map_err(|e| {
297 RuntimeError::ToolFailed(format!("web.search: searxng decode: {e}"))
298 })?;
299 if !status.is_success() {
300 return Err(RuntimeError::ToolFailed(format!(
301 "web.search: searxng returned {status}: {json}"
302 )));
303 }
304 let items = json
305 .get("results")
306 .and_then(|v| v.as_array())
307 .cloned()
308 .unwrap_or_default();
309 Ok(items
310 .into_iter()
311 .take(self.max_results)
312 .map(|item| SearchResult {
313 title: item
314 .get("title")
315 .and_then(|v| v.as_str())
316 .unwrap_or_default()
317 .into(),
318 url: item
319 .get("url")
320 .and_then(|v| v.as_str())
321 .unwrap_or_default()
322 .into(),
323 snippet: item
324 .get("content")
325 .and_then(|v| v.as_str())
326 .unwrap_or_default()
327 .into(),
328 content: None,
329 published_date: None,
330 score: None,
331 })
332 .collect())
333 })
334 }
335}
336
337pub struct ParallelSearch {
338 api_key: String,
339 base_url: String,
340 max_results: usize,
341 client: reqwest::Client,
342}
343
344impl ParallelSearch {
345 pub fn new(api_key: String, base_url: String, max_results: usize) -> Self {
346 Self {
347 api_key,
348 base_url,
349 max_results: max_results.max(1),
350 client: reqwest::Client::new(),
351 }
352 }
353}
354
355impl SearchProvider for ParallelSearch {
356 fn name(&self) -> &'static str {
357 "parallel"
358 }
359
360 fn call<'a>(&'a self, query: &'a str) -> BoxFut<'a, Result<Vec<SearchResult>, RuntimeError>> {
361 Box::pin(async move {
362 let body = serde_json::json!({
363 "objective": query,
364 "search_queries": [query],
365 });
366 let resp = self
367 .client
368 .post(format!("{}/v1/search", self.base_url.trim_end_matches('/')))
369 .header("Content-Type", "application/json")
370 .header("x-api-key", &self.api_key)
371 .json(&body)
372 .send()
373 .await
374 .map_err(|e| RuntimeError::ToolFailed(format!("web.search: parallel: {e}")))?;
375 let status = resp.status();
376 let json: serde_json::Value = resp.json().await.map_err(|e| {
377 RuntimeError::ToolFailed(format!("web.search: parallel decode: {e}"))
378 })?;
379 if !status.is_success() {
380 return Err(RuntimeError::ToolFailed(format!(
381 "web.search: parallel returned {status}: {json}"
382 )));
383 }
384 let items = json
385 .get("results")
386 .and_then(|v| v.as_array())
387 .cloned()
388 .unwrap_or_default();
389 Ok(items
390 .into_iter()
391 .take(self.max_results)
392 .map(|item| {
393 let excerpts = item
394 .get("excerpts")
395 .and_then(|v| v.as_array())
396 .and_then(|arr| {
397 arr.iter()
398 .filter_map(|e| e.as_str())
399 .collect::<Vec<_>>()
400 .join("\n\n")
401 .into()
402 })
403 .filter(|s: &String| !s.is_empty());
404 SearchResult {
405 title: item
406 .get("title")
407 .and_then(|v| v.as_str())
408 .unwrap_or_default()
409 .into(),
410 url: item
411 .get("url")
412 .and_then(|v| v.as_str())
413 .unwrap_or_default()
414 .into(),
415 snippet: excerpts.clone().unwrap_or_default(),
416 content: excerpts,
417 published_date: item
418 .get("publish_date")
419 .and_then(|v| v.as_str())
420 .map(String::from),
421 score: None,
422 }
423 })
424 .collect())
425 })
426 }
427}
428
429pub struct BraveSearch {
430 api_key: String,
431 endpoint: String,
432 client: reqwest::Client,
433}
434
435impl BraveSearch {
436 pub fn new(api_key: impl Into<String>) -> Self {
437 Self::with_endpoint(api_key, "https://api.search.brave.com/res/v1/web/search")
438 }
439
440 pub fn with_endpoint(api_key: impl Into<String>, endpoint: impl Into<String>) -> Self {
441 Self {
442 api_key: api_key.into(),
443 endpoint: endpoint.into(),
444 client: reqwest::Client::new(),
445 }
446 }
447}
448
449impl SearchProvider for BraveSearch {
450 fn name(&self) -> &'static str {
451 "brave"
452 }
453
454 fn call<'a>(&'a self, query: &'a str) -> BoxFut<'a, Result<Vec<SearchResult>, RuntimeError>> {
455 Box::pin(async move {
456 let resp = self
457 .client
458 .get(&self.endpoint)
459 .query(&[("q", query)])
460 .header("X-Subscription-Token", &self.api_key)
461 .header("Accept", "application/json")
462 .send()
463 .await
464 .map_err(|e| RuntimeError::ToolFailed(format!("web.search: {e}")))?;
465 let status = resp.status();
466 let json: serde_json::Value = resp
467 .json()
468 .await
469 .map_err(|e| RuntimeError::ToolFailed(format!("web.search decode: {e}")))?;
470 if !status.is_success() {
471 return Err(RuntimeError::ToolFailed(format!(
472 "web.search: brave returned {status}: {json}"
473 )));
474 }
475 let items = json
476 .pointer("/web/results")
477 .and_then(|v| v.as_array())
478 .cloned()
479 .unwrap_or_default();
480 Ok(items
481 .into_iter()
482 .map(|item| SearchResult {
483 title: item
484 .get("title")
485 .and_then(|v| v.as_str())
486 .unwrap_or_default()
487 .into(),
488 url: item
489 .get("url")
490 .and_then(|v| v.as_str())
491 .unwrap_or_default()
492 .into(),
493 snippet: item
494 .get("description")
495 .and_then(|v| v.as_str())
496 .unwrap_or_default()
497 .into(),
498 content: None,
499 published_date: None,
500 score: None,
501 })
502 .collect())
503 })
504 }
505}
506
507pub struct WebSearch {
508 provider: Arc<dyn SearchProvider>,
509}
510
511impl WebSearch {
512 pub fn new(provider: Arc<dyn SearchProvider>) -> Self {
513 Self { provider }
514 }
515}
516
517impl Tool for WebSearch {
518 fn name(&self) -> &str {
519 "web.search"
520 }
521
522 fn tier(&self) -> Tier {
523 Tier::Three
524 }
525
526 fn description(&self) -> Option<&str> {
527 Some(
528 "Search the web with the configured search provider and return result titles, URLs, snippets, and optional metadata. Use it when you need current external information or candidate pages to fetch.",
529 )
530 }
531
532 fn input_schema(&self) -> serde_json::Value {
533 serde_json::json!({
534 "type": "object",
535 "properties": {
536 "query": {"type": "string", "description": "Search query text."}
537 },
538 "required": ["query"]
539 })
540 }
541
542 fn invocation_provenance(
543 &self,
544 _args: &ToolArgs,
545 ctx: &ToolCtx,
546 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
547 Ok(crate::permission::ResourceProvenance::for_ctx(ctx).with_network())
548 }
549
550 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
551 Box::pin(async move {
552 let query = extract_string(&args, "query", 0)?;
553 let results = self.provider.call(&query).await?;
554 Ok(Value::List(
555 results.into_iter().map(SearchResult::into_value).collect(),
556 ))
557 })
558 }
559}
560
561pub struct WebFetch {
562 pub config: Arc<WebConfig>,
563 pub client: reqwest::Client,
564}
565
566impl WebFetch {
567 pub fn new(config: WebConfig) -> Self {
568 Self {
569 config: Arc::new(config),
570 client: reqwest::Client::builder()
571 .redirect(reqwest::redirect::Policy::limited(5))
572 .build()
573 .expect("build reqwest client"),
574 }
575 }
576}
577
578impl Tool for WebFetch {
579 fn name(&self) -> &str {
580 "web.fetch"
581 }
582
583 fn tier(&self) -> Tier {
584 Tier::Three
585 }
586
587 fn description(&self) -> Option<&str> {
588 Some(
589 "Fetch a URL subject to configured allow/deny policy and return status, content type, body, and truncation status. HTML responses are converted to markdown for easier reading.",
590 )
591 }
592
593 fn input_schema(&self) -> serde_json::Value {
594 serde_json::json!({
595 "type": "object",
596 "properties": {
597 "url": {"type": "string", "description": "HTTP or HTTPS URL to fetch."}
598 },
599 "required": ["url"]
600 })
601 }
602
603 fn invocation_provenance(
604 &self,
605 _args: &ToolArgs,
606 ctx: &ToolCtx,
607 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
608 Ok(crate::permission::ResourceProvenance::for_ctx(ctx).with_network())
609 }
610
611 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
612 Box::pin(async move {
613 let url = extract_string(&args, "url", 0)?;
614 if !self.config.url_allowed(&url) {
615 return Err(RuntimeError::ToolFailed(format!(
616 "web.fetch: url `{url}` blocked by policy (denylist / allowlist)"
617 )));
618 }
619 let resp = self
620 .client
621 .get(&url)
622 .send()
623 .await
624 .map_err(|e| RuntimeError::ToolFailed(format!("web.fetch({url}): {e}")))?;
625 let status = resp.status().as_u16() as i64;
626 let content_type = resp
627 .headers()
628 .get(reqwest::header::CONTENT_TYPE)
629 .and_then(|v| v.to_str().ok())
630 .unwrap_or_default()
631 .to_string();
632 let bytes = resp
633 .bytes()
634 .await
635 .map_err(|e| RuntimeError::ToolFailed(format!("web.fetch read body: {e}")))?;
636 let truncated = bytes.len() > self.config.max_bytes;
637 let raw = if truncated {
638 let slice = &bytes[..self.config.max_bytes];
639 String::from_utf8_lossy(slice).into_owned()
640 } else {
641 String::from_utf8_lossy(&bytes).into_owned()
642 };
643 let is_html = content_type.contains("text/html");
644 let body = if is_html {
645 match htmd::convert(&raw) {
646 Ok(md) => md,
647 Err(_) => raw,
648 }
649 } else {
650 raw
651 };
652 let body = if truncated {
653 format!(
654 "{}\n[atman: truncated at {} bytes; full length {}]",
655 body,
656 self.config.max_bytes,
657 bytes.len()
658 )
659 } else {
660 body
661 };
662 Ok(Value::Struct(vec![
663 ("status".into(), Value::Int(status)),
664 ("body".into(), Value::Str(body)),
665 ("truncated".into(), Value::Bool(truncated)),
666 ("content_type".into(), Value::Str(content_type)),
667 ]))
668 })
669 }
670}
671
672fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
673 let value = match args.named(name) {
674 Some(v) => v,
675 None => args.positional(pos)?,
676 };
677 match value {
678 Value::Str(s) => Ok(s.clone()),
679 Value::Path(p) => Ok(p.display().to_string()),
680 other => Err(RuntimeError::TypeMismatch {
681 expected: "string".into(),
682 actual: other.kind_name().into(),
683 }),
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use wiremock::matchers::{method, path};
691 use wiremock::{Mock, MockServer, ResponseTemplate};
692
693 #[test]
694 fn allowlist_empty_permits_any() {
695 let cfg = WebConfig::default();
696 assert!(cfg.url_allowed("https://anywhere.example/foo"));
697 }
698
699 #[test]
700 fn web_provenance_reports_network_not_a_path() {
701 let ctx = ToolCtx::default();
702 let fetch = WebFetch {
703 config: Arc::new(WebConfig::default()),
704 client: reqwest::Client::new(),
705 };
706 let args = ToolArgs {
707 named: vec![("url".into(), Value::Str("https://example.invalid/x".into()))],
708 ..ToolArgs::default()
709 };
710 let provenance = fetch.invocation_provenance(&args, &ctx).unwrap();
711 assert!(provenance.network);
712 assert_eq!(provenance.path, None);
713 assert!(provenance.risks.is_empty());
714 }
715
716 #[test]
717 fn denylist_takes_precedence() {
718 let cfg = WebConfig {
719 url_allowlist: vec!["https://ok.example".into()],
720 url_denylist: vec!["https://ok.example/secret".into()],
721 ..WebConfig::default()
722 };
723 assert!(cfg.url_allowed("https://ok.example/public"));
724 assert!(!cfg.url_allowed("https://ok.example/secret/x"));
725 }
726
727 #[test]
728 fn allowlist_non_empty_rejects_others() {
729 let cfg = WebConfig {
730 url_allowlist: vec!["https://ok.example".into()],
731 ..WebConfig::default()
732 };
733 assert!(!cfg.url_allowed("https://elsewhere.example/x"));
734 }
735
736 #[tokio::test]
737 async fn fetch_returns_body_and_status() {
738 let server = MockServer::start().await;
739 Mock::given(method("GET"))
740 .and(path("/hello"))
741 .respond_with(ResponseTemplate::new(200).set_body_string("hi world"))
742 .mount(&server)
743 .await;
744
745 let tool = WebFetch::new(WebConfig::default());
746 let ctx = ToolCtx::new();
747 let url = format!("{}/hello", server.uri());
748 let args = ToolArgs {
749 positional: vec![Value::Str(url)],
750 named: vec![],
751 };
752 let v = tool.call(args, &ctx).await.unwrap();
753 let Value::Struct(f) = v else {
754 panic!("expected struct");
755 };
756 assert!(matches!(
757 f.iter().find(|(k, _)| k == "status").unwrap().1,
758 Value::Int(200)
759 ));
760 assert!(matches!(
761 &f.iter().find(|(k, _)| k == "body").unwrap().1,
762 Value::Str(s) if s == "hi world"
763 ));
764 assert!(matches!(
765 f.iter().find(|(k, _)| k == "truncated").unwrap().1,
766 Value::Bool(false)
767 ));
768 }
769
770 #[tokio::test]
771 async fn fetch_truncates_when_over_max_bytes() {
772 let server = MockServer::start().await;
773 let body = "A".repeat(50);
774 Mock::given(method("GET"))
775 .and(path("/big"))
776 .respond_with(ResponseTemplate::new(200).set_body_string(body))
777 .mount(&server)
778 .await;
779
780 let cfg = WebConfig {
781 max_bytes: 10,
782 ..WebConfig::default()
783 };
784 let tool = WebFetch::new(cfg);
785 let ctx = ToolCtx::new();
786 let args = ToolArgs {
787 positional: vec![Value::Str(format!("{}/big", server.uri()))],
788 named: vec![],
789 };
790 let v = tool.call(args, &ctx).await.unwrap();
791 let Value::Struct(f) = v else {
792 panic!("expected struct");
793 };
794 assert!(matches!(
795 f.iter().find(|(k, _)| k == "truncated").unwrap().1,
796 Value::Bool(true)
797 ));
798 let Value::Str(body) = &f.iter().find(|(k, _)| k == "body").unwrap().1 else {
799 panic!("body not str");
800 };
801 assert!(body.contains("truncated at 10 bytes"));
802 assert!(body.contains("full length 50"));
803 }
804
805 #[tokio::test]
806 async fn fetch_rejects_when_url_denylisted() {
807 let cfg = WebConfig {
808 url_denylist: vec!["https://bad.example".into()],
809 ..WebConfig::default()
810 };
811 let tool = WebFetch::new(cfg);
812 let ctx = ToolCtx::new();
813 let args = ToolArgs {
814 positional: vec![Value::Str("https://bad.example/x".into())],
815 named: vec![],
816 };
817 let err = tool.call(args, &ctx).await.err().unwrap();
818 assert!(format!("{err}").contains("blocked by policy"));
819 }
820
821 #[tokio::test]
822 async fn brave_search_maps_results_and_forwards_api_key() {
823 let server = MockServer::start().await;
824 let body = serde_json::json!({
825 "web": {
826 "results": [
827 {"title": "atman flow DSL", "url": "https://example.com/a", "description": "flow-driven code agent"},
828 {"title": "runtime notes", "url": "https://example.com/b", "description": "watch supervisor"}
829 ]
830 }
831 });
832 Mock::given(method("GET"))
833 .and(path("/web/search"))
834 .and(wiremock::matchers::header(
835 "X-Subscription-Token",
836 "secret-key",
837 ))
838 .and(wiremock::matchers::query_param("q", "atman"))
839 .respond_with(ResponseTemplate::new(200).set_body_json(body))
840 .expect(1)
841 .mount(&server)
842 .await;
843
844 let provider = Arc::new(BraveSearch::with_endpoint(
845 "secret-key",
846 format!("{}/web/search", server.uri()),
847 ));
848 let tool = WebSearch::new(provider);
849 let ctx = ToolCtx::new();
850 let args = ToolArgs {
851 positional: vec![Value::Str("atman".into())],
852 named: vec![],
853 };
854 let v = tool.call(args, &ctx).await.unwrap();
855 let Value::List(items) = v else {
856 panic!("expected list");
857 };
858 assert_eq!(items.len(), 2);
859 let Value::Struct(first) = &items[0] else {
860 panic!("first not struct");
861 };
862 assert!(
863 matches!(&first.iter().find(|(k, _)| k == "title").unwrap().1, Value::Str(s) if s == "atman flow DSL")
864 );
865 assert!(
866 matches!(&first.iter().find(|(k, _)| k == "url").unwrap().1, Value::Str(s) if s == "https://example.com/a")
867 );
868 }
869
870 #[tokio::test]
871 async fn brave_search_surfaces_http_error() {
872 let server = MockServer::start().await;
873 Mock::given(method("GET"))
874 .and(path("/web/search"))
875 .respond_with(
876 ResponseTemplate::new(429)
877 .set_body_json(serde_json::json!({"error": "rate limit"})),
878 )
879 .mount(&server)
880 .await;
881
882 let provider = Arc::new(BraveSearch::with_endpoint(
883 "k",
884 format!("{}/web/search", server.uri()),
885 ));
886 let tool = WebSearch::new(provider);
887 let ctx = ToolCtx::new();
888 let args = ToolArgs {
889 positional: vec![Value::Str("x".into())],
890 named: vec![],
891 };
892 let err = tool.call(args, &ctx).await.err().unwrap();
893 let msg = format!("{err}");
894 assert!(
895 msg.contains("429") || msg.contains("rate limit"),
896 "msg: {msg}"
897 );
898 }
899
900 #[tokio::test]
901 async fn tavily_search_keyless_sends_keyless_header() {
902 let server = MockServer::start().await;
903 let body = serde_json::json!({
904 "results": [
905 {"title": "Rust async", "url": "https://example.com/a", "content": "tokio primer", "score": 0.9}
906 ]
907 });
908 Mock::given(method("POST"))
909 .and(path("/search"))
910 .and(wiremock::matchers::header(
911 "X-Tavily-Access-Mode",
912 "keyless",
913 ))
914 .respond_with(ResponseTemplate::new(200).set_body_json(body))
915 .expect(1)
916 .mount(&server)
917 .await;
918
919 let provider = TavilySearch::new(None, server.uri(), 5);
920 let results = provider.call("rust async").await.unwrap();
921 assert_eq!(results.len(), 1);
922 assert_eq!(results[0].title, "Rust async");
923 assert_eq!(results[0].url, "https://example.com/a");
924 assert_eq!(results[0].snippet, "tokio primer");
925 assert_eq!(results[0].score, Some(0.9));
926 }
927
928 #[tokio::test]
929 async fn tavily_search_keyed_sends_bearer() {
930 let server = MockServer::start().await;
931 Mock::given(method("POST"))
932 .and(path("/search"))
933 .and(wiremock::matchers::header(
934 "Authorization",
935 "Bearer tvly-secret",
936 ))
937 .respond_with(
938 ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
939 )
940 .expect(1)
941 .mount(&server)
942 .await;
943
944 let provider = TavilySearch::new(Some("tvly-secret".into()), server.uri(), 5);
945 provider.call("q").await.unwrap();
946 }
947
948 #[tokio::test]
949 async fn searxng_search_maps_results() {
950 let server = MockServer::start().await;
951 let body = serde_json::json!({
952 "results": [
953 {"title": "SearXNG", "url": "https://example.com/s", "content": "meta search"},
954 {"title": "second", "url": "https://example.com/b", "content": "more"}
955 ]
956 });
957 Mock::given(method("GET"))
958 .and(path("/search"))
959 .and(wiremock::matchers::query_param("format", "json"))
960 .and(wiremock::matchers::query_param("q", "test"))
961 .respond_with(ResponseTemplate::new(200).set_body_json(body))
962 .mount(&server)
963 .await;
964
965 let provider = SearxngSearch::new(server.uri(), 1);
966 let results = provider.call("test").await.unwrap();
967 assert_eq!(results.len(), 1, "max_results should cap");
968 assert_eq!(results[0].title, "SearXNG");
969 }
970
971 #[tokio::test]
972 async fn parallel_search_maps_excerpts_to_content() {
973 let server = MockServer::start().await;
974 let body = serde_json::json!({
975 "results": [
976 {"title": "Parallel", "url": "https://example.com/p", "excerpts": ["line one", "line two"], "publish_date": "2026-01-01"}
977 ]
978 });
979 Mock::given(method("POST"))
980 .and(path("/v1/search"))
981 .and(wiremock::matchers::header("x-api-key", "par-key"))
982 .respond_with(ResponseTemplate::new(200).set_body_json(body))
983 .mount(&server)
984 .await;
985
986 let provider = ParallelSearch::new("par-key".into(), server.uri(), 5);
987 let results = provider.call("objective").await.unwrap();
988 assert_eq!(results.len(), 1);
989 assert_eq!(results[0].title, "Parallel");
990 assert!(results[0].content.as_deref().unwrap().contains("line one"));
991 assert!(results[0].content.as_deref().unwrap().contains("line two"));
992 assert_eq!(results[0].published_date.as_deref(), Some("2026-01-01"));
993 }
994
995 #[tokio::test]
996 async fn parallel_search_surfaces_http_error() {
997 let server = MockServer::start().await;
998 Mock::given(method("POST"))
999 .and(path("/v1/search"))
1000 .respond_with(
1001 ResponseTemplate::new(401).set_body_json(serde_json::json!({"message": "no key"})),
1002 )
1003 .mount(&server)
1004 .await;
1005 let provider = ParallelSearch::new("k".into(), server.uri(), 5);
1006 let err = provider.call("q").await.err().unwrap();
1007 assert!(format!("{err}").contains("401"), "{}", err);
1008 }
1009
1010 #[tokio::test]
1011 async fn fetch_converts_html_to_markdown() {
1012 let server = MockServer::start().await;
1013 let html = "<html><body><h1>Title</h1><p>hello <a href=\"x\">link</a></p></body></html>";
1014 Mock::given(method("GET"))
1015 .and(path("/page"))
1016 .respond_with(
1017 ResponseTemplate::new(200)
1018 .set_body_bytes(html.as_bytes())
1019 .insert_header("content-type", "text/html; charset=utf-8"),
1020 )
1021 .mount(&server)
1022 .await;
1023
1024 let tool = WebFetch::new(WebConfig::default());
1025 let ctx = ToolCtx::new();
1026 let args = ToolArgs {
1027 positional: vec![Value::Str(format!("{}/page", server.uri()))],
1028 named: vec![],
1029 };
1030 let v = tool.call(args, &ctx).await.unwrap();
1031 let Value::Struct(f) = v else {
1032 panic!("expected struct")
1033 };
1034 let Value::Str(body) = &f.iter().find(|(k, _)| k == "body").unwrap().1 else {
1035 panic!("body not str");
1036 };
1037 assert!(body.contains("# Title"), "h1 → markdown heading: {body}");
1038 assert!(body.contains("hello"), "paragraph text preserved: {body}");
1039 assert!(!body.contains("<html>"), "html tags stripped: {body}");
1040 }
1041
1042 #[tokio::test]
1043 async fn fetch_returns_raw_for_non_html() {
1044 let server = MockServer::start().await;
1045 Mock::given(method("GET"))
1046 .and(path("/json"))
1047 .respond_with(
1048 ResponseTemplate::new(200)
1049 .insert_header("content-type", "application/json")
1050 .set_body_string(r#"{"key":"value"}"#),
1051 )
1052 .mount(&server)
1053 .await;
1054
1055 let tool = WebFetch::new(WebConfig::default());
1056 let ctx = ToolCtx::new();
1057 let args = ToolArgs {
1058 positional: vec![Value::Str(format!("{}/json", server.uri()))],
1059 named: vec![],
1060 };
1061 let v = tool.call(args, &ctx).await.unwrap();
1062 let Value::Struct(f) = v else {
1063 panic!("expected struct")
1064 };
1065 let Value::Str(body) = &f.iter().find(|(k, _)| k == "body").unwrap().1 else {
1066 panic!("body not str");
1067 };
1068 assert_eq!(
1069 body, r#"{"key":"value"}"#,
1070 "json returned raw, no conversion"
1071 );
1072 }
1073
1074 #[test]
1075 fn build_search_provider_returns_none_for_disabled() {
1076 assert!(build_search_provider(&SearchConfig::None).is_none());
1077 }
1078
1079 #[test]
1080 fn build_search_provider_tavily() {
1081 let cfg = SearchConfig::Tavily {
1082 api_key: None,
1083 base_url: Some("https://api.tavily.com".into()),
1084 max_results: Some(5),
1085 };
1086 let p = build_search_provider(&cfg).unwrap();
1087 assert_eq!(p.name(), "tavily");
1088 }
1089
1090 #[test]
1091 fn build_search_provider_searxng() {
1092 let cfg = SearchConfig::Searxng {
1093 base_url: Some("http://localhost:8080".into()),
1094 max_results: Some(10),
1095 };
1096 let p = build_search_provider(&cfg).unwrap();
1097 assert_eq!(p.name(), "searxng");
1098 }
1099
1100 #[test]
1101 fn build_search_provider_tavily_uses_defaults_when_fields_missing() {
1102 let cfg = SearchConfig::Tavily {
1103 api_key: None,
1104 base_url: None,
1105 max_results: None,
1106 };
1107 let p = build_search_provider(&cfg).unwrap();
1108 assert_eq!(p.name(), "tavily");
1109 }
1110
1111 #[test]
1112 fn default_search_config_is_tavily_keyless() {
1113 let cfg = SearchConfig::default();
1114 assert!(matches!(cfg, SearchConfig::Tavily { api_key: None, .. }));
1115 assert!(build_search_provider(&cfg).is_some());
1116 }
1117
1118 #[test]
1119 fn search_config_deserializes_minimal_tavily() {
1120 let toml = r#"
1121[web.search]
1122provider = "tavily"
1123"#;
1124 #[derive(serde::Deserialize)]
1125 struct W {
1126 #[serde(default)]
1127 web: WebWrap,
1128 }
1129 #[derive(serde::Deserialize, Default)]
1130 struct WebWrap {
1131 #[serde(default)]
1132 search: Option<SearchConfig>,
1133 }
1134 let w: W = toml::from_str(toml).unwrap();
1135 match w.web.search {
1136 Some(SearchConfig::Tavily {
1137 api_key: None,
1138 base_url: None,
1139 max_results: None,
1140 }) => {}
1141 other => panic!("expected minimal Tavily, got {other:?}"),
1142 }
1143 }
1144
1145 #[test]
1146 fn search_config_deserializes_none() {
1147 let toml = r#"
1148[web.search]
1149provider = "none"
1150"#;
1151 #[derive(serde::Deserialize)]
1152 struct W {
1153 #[serde(default)]
1154 web: WebWrap,
1155 }
1156 #[derive(serde::Deserialize, Default)]
1157 struct WebWrap {
1158 #[serde(default)]
1159 search: Option<SearchConfig>,
1160 }
1161 let w: W = toml::from_str(toml).unwrap();
1162 assert!(matches!(w.web.search, Some(SearchConfig::None)));
1163 }
1164}