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 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}