1use std::sync::Arc;
10
11use async_trait::async_trait;
12
13use crate::app::{FetchBackend, SearchBackend, WebConfig};
14use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
15
16use super::super::ctx::{ExecContext, ProgressEvent};
17use super::ToolExecutor;
18use super::web_client::{
19 FetchProvider, ManagedSearxngBackend, NativeFetchClient, OllamaWebClient, SearchProvider,
20 SearxngClient, WebFetchResult, format_results,
21};
22
23pub fn web_fetch_tool(web: &WebConfig) -> Option<WebFetchTool> {
27 match web.fetch_backend {
28 FetchBackend::Native => Some(WebFetchTool::native()),
29 FetchBackend::Ollama => {
30 crate::utils::resolve_api_key("OLLAMA_API_KEY", None).map(WebFetchTool::ollama)
31 },
32 }
33}
34
35pub fn web_search_tool(web: &WebConfig) -> Option<WebSearchTool> {
39 match web.search_backend {
40 SearchBackend::Auto => Some(
43 crate::utils::resolve_api_key("OLLAMA_API_KEY", None)
44 .map(WebSearchTool::ollama)
45 .unwrap_or_else(WebSearchTool::managed_searxng),
46 ),
47 SearchBackend::Ollama => {
48 crate::utils::resolve_api_key("OLLAMA_API_KEY", None).map(WebSearchTool::ollama)
49 },
50 SearchBackend::Searxng => Some(WebSearchTool::searxng(web.searxng_url.clone())),
51 }
52}
53
54pub struct WebSearchTool {
58 backend: Arc<dyn SearchProvider>,
59}
60
61impl WebSearchTool {
62 pub fn ollama(api_key: String) -> Self {
64 Self {
65 backend: Arc::new(OllamaWebClient::new(api_key)),
66 }
67 }
68
69 pub fn searxng(base_url: String) -> Self {
71 Self {
72 backend: Arc::new(SearxngClient::new(base_url)),
73 }
74 }
75
76 pub fn managed_searxng() -> Self {
78 Self {
79 backend: Arc::new(ManagedSearxngBackend),
80 }
81 }
82}
83
84#[async_trait]
85impl ToolExecutor for WebSearchTool {
86 fn name(&self) -> &'static str {
87 "web_search"
88 }
89
90 fn schema(&self) -> ToolDefinition {
91 ToolDefinition {
92 name: "web_search".to_string(),
93 description:
94 "Search the web. Takes either a single `query` + `max_results`, or an array of `queries` for parallel fan-out."
95 .to_string(),
96 input_schema: serde_json::json!({
97 "type": "object",
98 "properties": {
99 "query": { "type": "string" },
100 "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 },
101 "queries": {
102 "type": "array",
103 "items": {
104 "type": "object",
105 "properties": {
106 "query": { "type": "string" },
107 "max_results": { "type": "integer", "minimum": 1, "maximum": 10 }
108 },
109 "required": ["query"]
110 }
111 }
112 }
113 }),
114 }
115 }
116
117 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
118 let queries = match parse_queries(&args) {
119 Ok(q) => q,
120 Err(e) => return ToolOutcome::error(e, 0.0),
121 };
122 if queries.is_empty() {
123 return ToolOutcome::error("web_search requires at least one query", 0.0);
124 }
125 if let Some(blocked) = super::policy_gate::gate_external(
126 &ctx,
127 "web_search",
128 crate::runtime::ToolCategory::Web,
129 format!("web_search ({} queries)", queries.len()),
130 &args,
131 )
132 .await
133 {
134 return blocked;
135 }
136
137 let start = std::time::Instant::now();
138 let mut combined = String::new();
139 let mut result_count = 0usize;
140 let mut sources = Vec::new();
141 let mut errors: Vec<String> = Vec::new();
142 for (idx, (query, count)) in queries.iter().enumerate() {
143 let _ = ctx
144 .progress
145 .send(ProgressEvent::Status(format!(
146 "searching {}/{}: {}",
147 idx + 1,
148 queries.len(),
149 query
150 )))
151 .await;
152
153 let search = self.backend.search(query, *count);
154 let result = tokio::select! {
155 biased;
156 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
157 result = search => result,
158 };
159 let section = match result {
163 Ok(results) => {
164 result_count += results.len();
165 sources.extend(results.iter().map(|result| result.url.clone()));
166 if results.is_empty() {
167 "[SEARCH_RESULTS]\n(no results found)\n[/SEARCH_RESULTS]\n".to_string()
168 } else {
169 format_results(&results)
170 }
171 },
172 Err(e) => {
173 errors.push(format!("{query}: {e}"));
174 format!("(search failed: {e})\n")
175 },
176 };
177 if queries.len() > 1 {
178 combined.push_str(&format!("=== query: {query} ===\n{section}\n\n"));
179 } else {
180 combined = section;
181 }
182 }
183
184 if errors.len() == queries.len() {
188 return ToolOutcome::error(
189 format!("web_search failed: {}", errors.join("; ")),
190 start.elapsed().as_secs_f64(),
191 );
192 }
193
194 let combined = crate::utils::truncate_content(
198 &combined,
199 crate::constants::WEB_SEARCH_AGGREGATE_MAX_CHARS,
200 );
201
202 let duration_secs = start.elapsed().as_secs_f64();
203 let requested_count = queries.iter().map(|(_, count)| *count).sum();
204 let query_texts = queries.iter().map(|(query, _)| query.clone()).collect();
205 ToolOutcome::success(
206 combined,
207 format!(
208 "{} {} returned",
209 result_count,
210 if result_count == 1 {
211 "result"
212 } else {
213 "results"
214 }
215 ),
216 duration_secs,
217 )
218 .with_metadata(ToolRunMetadata {
219 detail: ToolMetadata::WebSearch {
220 queries: query_texts,
221 requested_count,
222 result_count,
223 sources,
224 },
225 result_count: Some(result_count),
226 ..ToolRunMetadata::default()
227 })
228 }
229}
230
231pub struct WebFetchTool {
235 backend: Arc<dyn FetchProvider>,
236}
237
238impl WebFetchTool {
239 pub fn native() -> Self {
241 Self {
242 backend: Arc::new(NativeFetchClient::new()),
243 }
244 }
245
246 pub fn ollama(api_key: String) -> Self {
248 Self {
249 backend: Arc::new(OllamaWebClient::new(api_key)),
250 }
251 }
252}
253
254#[async_trait]
255impl ToolExecutor for WebFetchTool {
256 fn name(&self) -> &'static str {
257 "web_fetch"
258 }
259
260 fn schema(&self) -> ToolDefinition {
261 ToolDefinition {
262 name: "web_fetch".to_string(),
263 description: "Retrieve a single URL's main content as markdown.".to_string(),
264 input_schema: serde_json::json!({
265 "type": "object",
266 "properties": { "url": { "type": "string" } },
267 "required": ["url"]
268 }),
269 }
270 }
271
272 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
273 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
274 return ToolOutcome::error("web_fetch requires 'url' (string)", 0.0);
275 };
276 if let Err(reason) = validate_fetch_url(url) {
277 return ToolOutcome::error(format!("web_fetch: {reason}"), 0.0);
278 }
279 if let Some(blocked) = super::policy_gate::gate_external(
280 &ctx,
281 "web_fetch",
282 crate::runtime::ToolCategory::Web,
283 format!("web_fetch {}", url),
284 &args,
285 )
286 .await
287 {
288 return blocked;
289 }
290 let start = std::time::Instant::now();
291 let fetch = self.backend.fetch(url);
292
293 tokio::select! {
294 biased;
295 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
296 result = fetch => match result {
297 Ok(page) => {
298 let output = format_fetch(url, &page);
299 let duration_secs = start.elapsed().as_secs_f64();
300 let line_count = output.lines().count();
301 let byte_count = output.len();
302 let title = if page.title.is_empty() {
303 None
304 } else {
305 Some(page.title)
306 };
307 ToolOutcome::success(
308 output,
309 format!("{} {} fetched", line_count, if line_count == 1 { "line" } else { "lines" }),
310 duration_secs,
311 )
312 .with_metadata(ToolRunMetadata {
313 detail: ToolMetadata::WebFetch {
314 url: url.to_string(),
315 title,
316 line_count,
317 byte_count,
318 },
319 line_count: Some(line_count),
320 byte_count: Some(byte_count),
321 ..ToolRunMetadata::default()
322 })
323 },
324 Err(e) => ToolOutcome::error(
325 format!("web_fetch({}): {}", url, e),
326 start.elapsed().as_secs_f64(),
327 ),
328 },
329 }
330 }
331}
332
333const WEB_FETCH_MAX_CHARS: usize = crate::constants::WEB_SEARCH_AGGREGATE_MAX_CHARS;
339
340fn cap_fetch_content(content: &str) -> std::borrow::Cow<'_, str> {
344 if content.len() <= WEB_FETCH_MAX_CHARS {
345 return std::borrow::Cow::Borrowed(content);
346 }
347 let cut = content.floor_char_boundary(WEB_FETCH_MAX_CHARS);
348 std::borrow::Cow::Owned(format!("{}\n\n...[content truncated]", &content[..cut]))
349}
350
351fn format_fetch(url: &str, page: &WebFetchResult) -> String {
352 let title = if page.title.is_empty() {
353 "(no title)"
354 } else {
355 page.title.as_str()
356 };
357 let content = cap_fetch_content(&page.content);
358 format!("# {}\n\nURL: {}\n\n{}", title, url, content)
359}
360
361fn parse_queries(args: &serde_json::Value) -> Result<Vec<(String, usize)>, String> {
362 if let Some(arr) = args.get("queries").and_then(|v| v.as_array()) {
363 if arr.len() > crate::constants::MAX_BATCH_TOOL_ITEMS {
364 return Err(format!(
365 "web_search: too many queries ({}); cap is {} per call — split the request",
366 arr.len(),
367 crate::constants::MAX_BATCH_TOOL_ITEMS
368 ));
369 }
370 let mut out = Vec::with_capacity(arr.len());
371 for v in arr {
372 let Some(obj) = v.as_object() else {
373 return Err(
374 "web_search: 'queries' must be an array of {query, max_results}".to_string(),
375 );
376 };
377 let Some(query) = obj.get("query").and_then(|x| x.as_str()) else {
378 return Err("web_search: each query entry needs 'query' (string)".to_string());
379 };
380 let count = obj
381 .get("max_results")
382 .or_else(|| obj.get("result_count"))
383 .and_then(|x| x.as_u64())
384 .unwrap_or(5)
385 .clamp(1, 10) as usize;
386 out.push((query.to_string(), count));
387 }
388 return Ok(out);
389 }
390 if let Some(query) = args.get("query").and_then(|v| v.as_str()) {
391 let count = args
392 .get("max_results")
393 .or_else(|| args.get("result_count"))
394 .and_then(|v| v.as_u64())
395 .unwrap_or(5)
396 .clamp(1, 10) as usize;
397 return Ok(vec![(query.to_string(), count)]);
398 }
399 Err("web_search requires 'query' (string) or 'queries' (array)".to_string())
400}
401
402fn validate_fetch_url(url: &str) -> Result<(), String> {
409 let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
410 match parsed.scheme() {
411 "http" | "https" => {},
412 other => {
413 return Err(format!(
414 "unsupported URL scheme '{other}' (only http/https allowed)"
415 ));
416 },
417 }
418 let host = parsed
419 .host_str()
420 .ok_or_else(|| "URL has no host".to_string())?;
421 if is_blocked_host(host) {
422 return Err(format!("refusing to fetch internal/loopback host '{host}'"));
423 }
424 Ok(())
425}
426
427const METADATA_HOSTNAMES: &[&str] = &[
433 "metadata.google.internal", "metadata.goog", "metadata", "instance-data", "instance-data.ec2.internal", ];
439
440fn is_blocked_host(host: &str) -> bool {
461 let normalized = host
462 .trim_start_matches('[')
463 .trim_end_matches(']')
464 .trim_end_matches('.')
465 .to_ascii_lowercase();
466 if METADATA_HOSTNAMES.contains(&normalized.as_str()) {
467 return true;
468 }
469 crate::utils::classify_host(host).is_internal()
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn validate_fetch_url_blocks_unsafe_targets() {
478 for bad in [
480 "file:///etc/passwd",
481 "ftp://example.com/x",
482 "http://localhost/admin",
483 "http://127.0.0.1:8080",
484 "http://169.254.169.254/latest/meta-data/",
485 "http://10.0.0.5/",
486 "http://192.168.1.1/",
487 "http://[::1]/",
488 "http://[::ffff:169.254.169.254]/latest/meta-data/",
490 "http://[fc00::1]/",
491 "http://[fe80::1]/",
492 "http://100.100.100.200/",
493 "http://metadata.google.internal/computeMetadata/v1/",
496 "http://metadata.goog/",
497 "http://metadata/",
498 "http://instance-data/latest/meta-data/",
499 "https://METADATA.GOOGLE.INTERNAL./",
500 "not a url",
501 ] {
502 assert!(
503 validate_fetch_url(bad).is_err(),
504 "expected reject for {bad:?}",
505 );
506 }
507 for good in [
508 "https://example.com",
509 "http://example.com/page?x=1",
510 "https://docs.rs/serde",
511 ] {
512 assert!(
513 validate_fetch_url(good).is_ok(),
514 "expected accept for {good:?}",
515 );
516 }
517 }
518
519 #[test]
520 fn is_blocked_host_covers_metadata_names_and_ip_forms() {
521 for h in [
524 "metadata.google.internal",
525 "metadata.google.internal.", "Metadata.Google.Internal", "metadata.goog",
528 "metadata",
529 "instance-data",
530 "instance-data.ec2.internal",
531 ] {
532 assert!(is_blocked_host(h), "metadata host {h:?} must be blocked");
533 }
534 for h in ["0.0.0.0", "::1", "169.254.169.254", "127.0.0.1"] {
537 assert!(is_blocked_host(h), "internal IP {h:?} must be blocked");
538 }
539 for h in ["example.com", "docs.rs", "abc.goog", "8.8.8.8"] {
542 assert!(!is_blocked_host(h), "public host {h:?} must be allowed");
543 }
544 }
545
546 #[test]
547 fn format_fetch_caps_long_content() {
548 let big = "z".repeat(WEB_FETCH_MAX_CHARS * 2);
550 let page = WebFetchResult {
551 title: "T".to_string(),
552 content: big,
553 };
554 let out = format_fetch("https://example.com", &page);
555 assert!(
556 out.len() < WEB_FETCH_MAX_CHARS + 256,
557 "content must be capped, got {} bytes",
558 out.len()
559 );
560 assert!(out.contains("truncated"), "expected truncation marker");
561
562 let small = WebFetchResult {
564 title: "T".to_string(),
565 content: "hello world".to_string(),
566 };
567 let out = format_fetch("https://example.com", &small);
568 assert!(out.contains("hello world"));
569 assert!(!out.contains("truncated"));
570 }
571
572 #[test]
573 fn parse_queries_single_form() {
574 let args = serde_json::json!({"query": "rust async", "max_results": 3});
575 let q = parse_queries(&args).unwrap();
576 assert_eq!(q.len(), 1);
577 assert_eq!(q[0].0, "rust async");
578 assert_eq!(q[0].1, 3);
579 }
580
581 #[test]
582 fn parse_queries_array_form() {
583 let args = serde_json::json!({"queries": [
584 {"query": "a", "max_results": 2},
585 {"query": "b", "result_count": 5},
586 ]});
587 let q = parse_queries(&args).unwrap();
588 assert_eq!(q.len(), 2);
589 assert_eq!(q[1].1, 5);
590 }
591
592 #[test]
593 fn parse_queries_missing_errors() {
594 let args = serde_json::json!({});
595 assert!(parse_queries(&args).is_err());
596 }
597
598 #[test]
599 fn parse_queries_clamps_count() {
600 let args = serde_json::json!({"query": "q", "max_results": 999});
601 let q = parse_queries(&args).unwrap();
602 assert_eq!(q[0].1, 10);
603 let args = serde_json::json!({"query": "q", "max_results": 0});
604 let q = parse_queries(&args).unwrap();
605 assert_eq!(q[0].1, 1);
606 }
607
608 #[test]
609 fn parse_queries_rejects_excess_fan_out() {
610 let many: Vec<_> = (0..crate::constants::MAX_BATCH_TOOL_ITEMS + 1)
612 .map(|i| serde_json::json!({"query": format!("q{i}")}))
613 .collect();
614 let args = serde_json::json!({ "queries": many });
615 assert!(parse_queries(&args).is_err());
616
617 let at_cap: Vec<_> = (0..crate::constants::MAX_BATCH_TOOL_ITEMS)
619 .map(|i| serde_json::json!({"query": format!("q{i}")}))
620 .collect();
621 let args = serde_json::json!({ "queries": at_cap });
622 assert_eq!(
623 parse_queries(&args).unwrap().len(),
624 crate::constants::MAX_BATCH_TOOL_ITEMS
625 );
626 }
627
628 #[tokio::test]
629 async fn web_search_batch_survives_empty_and_failed_queries() {
630 use crate::domain::{ToolCallId, ToolStatus, TurnId};
631 use crate::providers::ctx::test_exec_context;
632 use crate::providers::tool::web_client::SearchResult;
633 use async_trait::async_trait;
634 use std::sync::Arc;
635
636 struct Mock;
637 #[async_trait]
638 impl SearchProvider for Mock {
639 async fn search(
640 &self,
641 query: &str,
642 _count: usize,
643 ) -> anyhow::Result<Vec<SearchResult>> {
644 match query {
645 "boom" => Err(anyhow::anyhow!("backend down")),
646 "empty" => Ok(Vec::new()),
647 _ => Ok(vec![SearchResult {
648 title: "Title".to_string(),
649 url: "https://example.com".to_string(),
650 snippet: "snip".to_string(),
651 full_content: "content".to_string(),
652 }]),
653 }
654 }
655 }
656
657 let mk = || WebSearchTool {
658 backend: Arc::new(Mock),
659 };
660 let tmp = std::path::PathBuf::from("/tmp");
661
662 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), tmp.clone());
664 let out = mk()
665 .execute(
666 serde_json::json!({"queries": [{"query":"good"},{"query":"empty"},{"query":"boom"}]}),
667 ctx,
668 )
669 .await;
670 assert_eq!(
671 out.status,
672 ToolStatus::Success,
673 "a partial batch must not abort"
674 );
675 assert!(
676 out.output().contains("https://example.com"),
677 "keeps the good result"
678 );
679
680 let (ctx, _rx) = test_exec_context(TurnId(2), ToolCallId(2), tmp.clone());
682 let out = mk()
683 .execute(serde_json::json!({"query": "empty"}), ctx)
684 .await;
685 assert_eq!(out.status, ToolStatus::Success, "empty is not an error");
686 assert!(out.output().contains("no results"));
687
688 let (ctx, _rx) = test_exec_context(TurnId(3), ToolCallId(3), tmp);
690 let out = mk()
691 .execute(
692 serde_json::json!({"queries": [{"query":"boom"},{"query":"boom"}]}),
693 ctx,
694 )
695 .await;
696 assert_eq!(out.status, ToolStatus::Error, "total failure is an error");
697 }
698}