Skip to main content

atman_runtime/tools/
web.rs

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 call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
543        Box::pin(async move {
544            let query = extract_string(&args, "query", 0)?;
545            let results = self.provider.call(&query).await?;
546            Ok(Value::List(
547                results.into_iter().map(SearchResult::into_value).collect(),
548            ))
549        })
550    }
551}
552
553pub struct WebFetch {
554    pub config: Arc<WebConfig>,
555    pub client: reqwest::Client,
556}
557
558impl WebFetch {
559    pub fn new(config: WebConfig) -> Self {
560        Self {
561            config: Arc::new(config),
562            client: reqwest::Client::builder()
563                .redirect(reqwest::redirect::Policy::limited(5))
564                .build()
565                .expect("build reqwest client"),
566        }
567    }
568}
569
570impl Tool for WebFetch {
571    fn name(&self) -> &str {
572        "web.fetch"
573    }
574
575    fn tier(&self) -> Tier {
576        Tier::Three
577    }
578
579    fn description(&self) -> Option<&str> {
580        Some(
581            "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.",
582        )
583    }
584
585    fn input_schema(&self) -> serde_json::Value {
586        serde_json::json!({
587            "type": "object",
588            "properties": {
589                "url": {"type": "string", "description": "HTTP or HTTPS URL to fetch."}
590            },
591            "required": ["url"]
592        })
593    }
594
595    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
596        Box::pin(async move {
597            let url = extract_string(&args, "url", 0)?;
598            if !self.config.url_allowed(&url) {
599                return Err(RuntimeError::ToolFailed(format!(
600                    "web.fetch: url `{url}` blocked by policy (denylist / allowlist)"
601                )));
602            }
603            let resp = self
604                .client
605                .get(&url)
606                .send()
607                .await
608                .map_err(|e| RuntimeError::ToolFailed(format!("web.fetch({url}): {e}")))?;
609            let status = resp.status().as_u16() as i64;
610            let content_type = resp
611                .headers()
612                .get(reqwest::header::CONTENT_TYPE)
613                .and_then(|v| v.to_str().ok())
614                .unwrap_or_default()
615                .to_string();
616            let bytes = resp
617                .bytes()
618                .await
619                .map_err(|e| RuntimeError::ToolFailed(format!("web.fetch read body: {e}")))?;
620            let truncated = bytes.len() > self.config.max_bytes;
621            let raw = if truncated {
622                let slice = &bytes[..self.config.max_bytes];
623                String::from_utf8_lossy(slice).into_owned()
624            } else {
625                String::from_utf8_lossy(&bytes).into_owned()
626            };
627            let is_html = content_type.contains("text/html");
628            let body = if is_html {
629                match htmd::convert(&raw) {
630                    Ok(md) => md,
631                    Err(_) => raw,
632                }
633            } else {
634                raw
635            };
636            let body = if truncated {
637                format!(
638                    "{}\n[atman: truncated at {} bytes; full length {}]",
639                    body,
640                    self.config.max_bytes,
641                    bytes.len()
642                )
643            } else {
644                body
645            };
646            Ok(Value::Struct(vec![
647                ("status".into(), Value::Int(status)),
648                ("body".into(), Value::Str(body)),
649                ("truncated".into(), Value::Bool(truncated)),
650                ("content_type".into(), Value::Str(content_type)),
651            ]))
652        })
653    }
654}
655
656fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
657    let value = match args.named(name) {
658        Some(v) => v,
659        None => args.positional(pos)?,
660    };
661    match value {
662        Value::Str(s) => Ok(s.clone()),
663        Value::Path(p) => Ok(p.display().to_string()),
664        other => Err(RuntimeError::TypeMismatch {
665            expected: "string".into(),
666            actual: other.kind_name().into(),
667        }),
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use wiremock::matchers::{method, path};
675    use wiremock::{Mock, MockServer, ResponseTemplate};
676
677    #[test]
678    fn allowlist_empty_permits_any() {
679        let cfg = WebConfig::default();
680        assert!(cfg.url_allowed("https://anywhere.example/foo"));
681    }
682
683    #[test]
684    fn denylist_takes_precedence() {
685        let cfg = WebConfig {
686            url_allowlist: vec!["https://ok.example".into()],
687            url_denylist: vec!["https://ok.example/secret".into()],
688            ..WebConfig::default()
689        };
690        assert!(cfg.url_allowed("https://ok.example/public"));
691        assert!(!cfg.url_allowed("https://ok.example/secret/x"));
692    }
693
694    #[test]
695    fn allowlist_non_empty_rejects_others() {
696        let cfg = WebConfig {
697            url_allowlist: vec!["https://ok.example".into()],
698            ..WebConfig::default()
699        };
700        assert!(!cfg.url_allowed("https://elsewhere.example/x"));
701    }
702
703    #[tokio::test]
704    async fn fetch_returns_body_and_status() {
705        let server = MockServer::start().await;
706        Mock::given(method("GET"))
707            .and(path("/hello"))
708            .respond_with(ResponseTemplate::new(200).set_body_string("hi world"))
709            .mount(&server)
710            .await;
711
712        let tool = WebFetch::new(WebConfig::default());
713        let ctx = ToolCtx::new();
714        let url = format!("{}/hello", server.uri());
715        let args = ToolArgs {
716            positional: vec![Value::Str(url)],
717            named: vec![],
718        };
719        let v = tool.call(args, &ctx).await.unwrap();
720        let Value::Struct(f) = v else {
721            panic!("expected struct");
722        };
723        assert!(matches!(
724            f.iter().find(|(k, _)| k == "status").unwrap().1,
725            Value::Int(200)
726        ));
727        assert!(matches!(
728            &f.iter().find(|(k, _)| k == "body").unwrap().1,
729            Value::Str(s) if s == "hi world"
730        ));
731        assert!(matches!(
732            f.iter().find(|(k, _)| k == "truncated").unwrap().1,
733            Value::Bool(false)
734        ));
735    }
736
737    #[tokio::test]
738    async fn fetch_truncates_when_over_max_bytes() {
739        let server = MockServer::start().await;
740        let body = "A".repeat(50);
741        Mock::given(method("GET"))
742            .and(path("/big"))
743            .respond_with(ResponseTemplate::new(200).set_body_string(body))
744            .mount(&server)
745            .await;
746
747        let cfg = WebConfig {
748            max_bytes: 10,
749            ..WebConfig::default()
750        };
751        let tool = WebFetch::new(cfg);
752        let ctx = ToolCtx::new();
753        let args = ToolArgs {
754            positional: vec![Value::Str(format!("{}/big", server.uri()))],
755            named: vec![],
756        };
757        let v = tool.call(args, &ctx).await.unwrap();
758        let Value::Struct(f) = v else {
759            panic!("expected struct");
760        };
761        assert!(matches!(
762            f.iter().find(|(k, _)| k == "truncated").unwrap().1,
763            Value::Bool(true)
764        ));
765        let Value::Str(body) = &f.iter().find(|(k, _)| k == "body").unwrap().1 else {
766            panic!("body not str");
767        };
768        assert!(body.contains("truncated at 10 bytes"));
769        assert!(body.contains("full length 50"));
770    }
771
772    #[tokio::test]
773    async fn fetch_rejects_when_url_denylisted() {
774        let cfg = WebConfig {
775            url_denylist: vec!["https://bad.example".into()],
776            ..WebConfig::default()
777        };
778        let tool = WebFetch::new(cfg);
779        let ctx = ToolCtx::new();
780        let args = ToolArgs {
781            positional: vec![Value::Str("https://bad.example/x".into())],
782            named: vec![],
783        };
784        let err = tool.call(args, &ctx).await.err().unwrap();
785        assert!(format!("{err}").contains("blocked by policy"));
786    }
787
788    #[tokio::test]
789    async fn brave_search_maps_results_and_forwards_api_key() {
790        let server = MockServer::start().await;
791        let body = serde_json::json!({
792            "web": {
793                "results": [
794                    {"title": "atman flow DSL", "url": "https://example.com/a", "description": "flow-driven code agent"},
795                    {"title": "runtime notes", "url": "https://example.com/b", "description": "watch supervisor"}
796                ]
797            }
798        });
799        Mock::given(method("GET"))
800            .and(path("/web/search"))
801            .and(wiremock::matchers::header(
802                "X-Subscription-Token",
803                "secret-key",
804            ))
805            .and(wiremock::matchers::query_param("q", "atman"))
806            .respond_with(ResponseTemplate::new(200).set_body_json(body))
807            .expect(1)
808            .mount(&server)
809            .await;
810
811        let provider = Arc::new(BraveSearch::with_endpoint(
812            "secret-key",
813            format!("{}/web/search", server.uri()),
814        ));
815        let tool = WebSearch::new(provider);
816        let ctx = ToolCtx::new();
817        let args = ToolArgs {
818            positional: vec![Value::Str("atman".into())],
819            named: vec![],
820        };
821        let v = tool.call(args, &ctx).await.unwrap();
822        let Value::List(items) = v else {
823            panic!("expected list");
824        };
825        assert_eq!(items.len(), 2);
826        let Value::Struct(first) = &items[0] else {
827            panic!("first not struct");
828        };
829        assert!(
830            matches!(&first.iter().find(|(k, _)| k == "title").unwrap().1, Value::Str(s) if s == "atman flow DSL")
831        );
832        assert!(
833            matches!(&first.iter().find(|(k, _)| k == "url").unwrap().1, Value::Str(s) if s == "https://example.com/a")
834        );
835    }
836
837    #[tokio::test]
838    async fn brave_search_surfaces_http_error() {
839        let server = MockServer::start().await;
840        Mock::given(method("GET"))
841            .and(path("/web/search"))
842            .respond_with(
843                ResponseTemplate::new(429)
844                    .set_body_json(serde_json::json!({"error": "rate limit"})),
845            )
846            .mount(&server)
847            .await;
848
849        let provider = Arc::new(BraveSearch::with_endpoint(
850            "k",
851            format!("{}/web/search", server.uri()),
852        ));
853        let tool = WebSearch::new(provider);
854        let ctx = ToolCtx::new();
855        let args = ToolArgs {
856            positional: vec![Value::Str("x".into())],
857            named: vec![],
858        };
859        let err = tool.call(args, &ctx).await.err().unwrap();
860        let msg = format!("{err}");
861        assert!(
862            msg.contains("429") || msg.contains("rate limit"),
863            "msg: {msg}"
864        );
865    }
866
867    #[tokio::test]
868    async fn tavily_search_keyless_sends_keyless_header() {
869        let server = MockServer::start().await;
870        let body = serde_json::json!({
871            "results": [
872                {"title": "Rust async", "url": "https://example.com/a", "content": "tokio primer", "score": 0.9}
873            ]
874        });
875        Mock::given(method("POST"))
876            .and(path("/search"))
877            .and(wiremock::matchers::header(
878                "X-Tavily-Access-Mode",
879                "keyless",
880            ))
881            .respond_with(ResponseTemplate::new(200).set_body_json(body))
882            .expect(1)
883            .mount(&server)
884            .await;
885
886        let provider = TavilySearch::new(None, server.uri(), 5);
887        let results = provider.call("rust async").await.unwrap();
888        assert_eq!(results.len(), 1);
889        assert_eq!(results[0].title, "Rust async");
890        assert_eq!(results[0].url, "https://example.com/a");
891        assert_eq!(results[0].snippet, "tokio primer");
892        assert_eq!(results[0].score, Some(0.9));
893    }
894
895    #[tokio::test]
896    async fn tavily_search_keyed_sends_bearer() {
897        let server = MockServer::start().await;
898        Mock::given(method("POST"))
899            .and(path("/search"))
900            .and(wiremock::matchers::header(
901                "Authorization",
902                "Bearer tvly-secret",
903            ))
904            .respond_with(
905                ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
906            )
907            .expect(1)
908            .mount(&server)
909            .await;
910
911        let provider = TavilySearch::new(Some("tvly-secret".into()), server.uri(), 5);
912        provider.call("q").await.unwrap();
913    }
914
915    #[tokio::test]
916    async fn searxng_search_maps_results() {
917        let server = MockServer::start().await;
918        let body = serde_json::json!({
919            "results": [
920                {"title": "SearXNG", "url": "https://example.com/s", "content": "meta search"},
921                {"title": "second", "url": "https://example.com/b", "content": "more"}
922            ]
923        });
924        Mock::given(method("GET"))
925            .and(path("/search"))
926            .and(wiremock::matchers::query_param("format", "json"))
927            .and(wiremock::matchers::query_param("q", "test"))
928            .respond_with(ResponseTemplate::new(200).set_body_json(body))
929            .mount(&server)
930            .await;
931
932        let provider = SearxngSearch::new(server.uri(), 1);
933        let results = provider.call("test").await.unwrap();
934        assert_eq!(results.len(), 1, "max_results should cap");
935        assert_eq!(results[0].title, "SearXNG");
936    }
937
938    #[tokio::test]
939    async fn parallel_search_maps_excerpts_to_content() {
940        let server = MockServer::start().await;
941        let body = serde_json::json!({
942            "results": [
943                {"title": "Parallel", "url": "https://example.com/p", "excerpts": ["line one", "line two"], "publish_date": "2026-01-01"}
944            ]
945        });
946        Mock::given(method("POST"))
947            .and(path("/v1/search"))
948            .and(wiremock::matchers::header("x-api-key", "par-key"))
949            .respond_with(ResponseTemplate::new(200).set_body_json(body))
950            .mount(&server)
951            .await;
952
953        let provider = ParallelSearch::new("par-key".into(), server.uri(), 5);
954        let results = provider.call("objective").await.unwrap();
955        assert_eq!(results.len(), 1);
956        assert_eq!(results[0].title, "Parallel");
957        assert!(results[0].content.as_deref().unwrap().contains("line one"));
958        assert!(results[0].content.as_deref().unwrap().contains("line two"));
959        assert_eq!(results[0].published_date.as_deref(), Some("2026-01-01"));
960    }
961
962    #[tokio::test]
963    async fn parallel_search_surfaces_http_error() {
964        let server = MockServer::start().await;
965        Mock::given(method("POST"))
966            .and(path("/v1/search"))
967            .respond_with(
968                ResponseTemplate::new(401).set_body_json(serde_json::json!({"message": "no key"})),
969            )
970            .mount(&server)
971            .await;
972        let provider = ParallelSearch::new("k".into(), server.uri(), 5);
973        let err = provider.call("q").await.err().unwrap();
974        assert!(format!("{err}").contains("401"), "{}", err);
975    }
976
977    #[tokio::test]
978    async fn fetch_converts_html_to_markdown() {
979        let server = MockServer::start().await;
980        let html = "<html><body><h1>Title</h1><p>hello <a href=\"x\">link</a></p></body></html>";
981        Mock::given(method("GET"))
982            .and(path("/page"))
983            .respond_with(
984                ResponseTemplate::new(200)
985                    .set_body_bytes(html.as_bytes())
986                    .insert_header("content-type", "text/html; charset=utf-8"),
987            )
988            .mount(&server)
989            .await;
990
991        let tool = WebFetch::new(WebConfig::default());
992        let ctx = ToolCtx::new();
993        let args = ToolArgs {
994            positional: vec![Value::Str(format!("{}/page", server.uri()))],
995            named: vec![],
996        };
997        let v = tool.call(args, &ctx).await.unwrap();
998        let Value::Struct(f) = v else {
999            panic!("expected struct")
1000        };
1001        let Value::Str(body) = &f.iter().find(|(k, _)| k == "body").unwrap().1 else {
1002            panic!("body not str");
1003        };
1004        assert!(body.contains("# Title"), "h1 → markdown heading: {body}");
1005        assert!(body.contains("hello"), "paragraph text preserved: {body}");
1006        assert!(!body.contains("<html>"), "html tags stripped: {body}");
1007    }
1008
1009    #[tokio::test]
1010    async fn fetch_returns_raw_for_non_html() {
1011        let server = MockServer::start().await;
1012        Mock::given(method("GET"))
1013            .and(path("/json"))
1014            .respond_with(
1015                ResponseTemplate::new(200)
1016                    .insert_header("content-type", "application/json")
1017                    .set_body_string(r#"{"key":"value"}"#),
1018            )
1019            .mount(&server)
1020            .await;
1021
1022        let tool = WebFetch::new(WebConfig::default());
1023        let ctx = ToolCtx::new();
1024        let args = ToolArgs {
1025            positional: vec![Value::Str(format!("{}/json", server.uri()))],
1026            named: vec![],
1027        };
1028        let v = tool.call(args, &ctx).await.unwrap();
1029        let Value::Struct(f) = v else {
1030            panic!("expected struct")
1031        };
1032        let Value::Str(body) = &f.iter().find(|(k, _)| k == "body").unwrap().1 else {
1033            panic!("body not str");
1034        };
1035        assert_eq!(
1036            body, r#"{"key":"value"}"#,
1037            "json returned raw, no conversion"
1038        );
1039    }
1040
1041    #[test]
1042    fn build_search_provider_returns_none_for_disabled() {
1043        assert!(build_search_provider(&SearchConfig::None).is_none());
1044    }
1045
1046    #[test]
1047    fn build_search_provider_tavily() {
1048        let cfg = SearchConfig::Tavily {
1049            api_key: None,
1050            base_url: Some("https://api.tavily.com".into()),
1051            max_results: Some(5),
1052        };
1053        let p = build_search_provider(&cfg).unwrap();
1054        assert_eq!(p.name(), "tavily");
1055    }
1056
1057    #[test]
1058    fn build_search_provider_searxng() {
1059        let cfg = SearchConfig::Searxng {
1060            base_url: Some("http://localhost:8080".into()),
1061            max_results: Some(10),
1062        };
1063        let p = build_search_provider(&cfg).unwrap();
1064        assert_eq!(p.name(), "searxng");
1065    }
1066
1067    #[test]
1068    fn build_search_provider_tavily_uses_defaults_when_fields_missing() {
1069        let cfg = SearchConfig::Tavily {
1070            api_key: None,
1071            base_url: None,
1072            max_results: None,
1073        };
1074        let p = build_search_provider(&cfg).unwrap();
1075        assert_eq!(p.name(), "tavily");
1076    }
1077
1078    #[test]
1079    fn default_search_config_is_tavily_keyless() {
1080        let cfg = SearchConfig::default();
1081        assert!(matches!(cfg, SearchConfig::Tavily { api_key: None, .. }));
1082        assert!(build_search_provider(&cfg).is_some());
1083    }
1084
1085    #[test]
1086    fn search_config_deserializes_minimal_tavily() {
1087        let toml = r#"
1088[web.search]
1089provider = "tavily"
1090"#;
1091        #[derive(serde::Deserialize)]
1092        struct W {
1093            #[serde(default)]
1094            web: WebWrap,
1095        }
1096        #[derive(serde::Deserialize, Default)]
1097        struct WebWrap {
1098            #[serde(default)]
1099            search: Option<SearchConfig>,
1100        }
1101        let w: W = toml::from_str(toml).unwrap();
1102        match w.web.search {
1103            Some(SearchConfig::Tavily {
1104                api_key: None,
1105                base_url: None,
1106                max_results: None,
1107            }) => {}
1108            other => panic!("expected minimal Tavily, got {other:?}"),
1109        }
1110    }
1111
1112    #[test]
1113    fn search_config_deserializes_none() {
1114        let toml = r#"
1115[web.search]
1116provider = "none"
1117"#;
1118        #[derive(serde::Deserialize)]
1119        struct W {
1120            #[serde(default)]
1121            web: WebWrap,
1122        }
1123        #[derive(serde::Deserialize, Default)]
1124        struct WebWrap {
1125            #[serde(default)]
1126            search: Option<SearchConfig>,
1127        }
1128        let w: W = toml::from_str(toml).unwrap();
1129        assert!(matches!(w.web.search, Some(SearchConfig::None)));
1130    }
1131}