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}