1#![forbid(unsafe_code)]
19
20use async_trait::async_trait;
21
22use serde_json::{Value, json};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use wm_core::security::is_url_safe;
26use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
27
28const USER_AGENT: &str = "WhiteMagic/5.6 (local research agent)";
29const MAX_REDIRECTS: u32 = 5;
30
31pub(crate) struct Fetched {
33 pub(crate) url: String,
34 pub(crate) title: String,
35 pub(crate) content: String,
37 pub(crate) raw: String,
39 pub(crate) status_code: u16,
40 pub(crate) duration_ms: f64,
41 pub(crate) pages: u32,
42}
43
44fn safe_url(url: &str) -> Result<String, wm_core::CoreError> {
46 if !is_url_safe(url) {
47 return Err(wm_core::CoreError::InvalidArgs(format!(
48 "unsafe URL (SSRF guard): {url}"
49 )));
50 }
51 Ok(url.to_string())
52}
53
54pub(crate) fn fetch_bounded(
57 start_url: &str,
58 max_chars: usize,
59 timeout: Duration,
60) -> Result<Fetched, wm_core::CoreError> {
61 let started = Instant::now();
62 let mut current = start_url.to_string();
63 let mut pages = 1u32;
64
65 for _hop in 0..=MAX_REDIRECTS {
66 let agent = ureq::Agent::config_builder()
67 .timeout_global(Some(timeout))
68 .build()
69 .new_agent();
70 let response = agent
71 .get(¤t)
72 .header("User-Agent", USER_AGENT)
73 .call()
74 .map_err(|e| wm_core::CoreError::Tool(format!("fetch {current}: {e}")))?;
75
76 let status = response.status().as_u16();
77 if (300..400).contains(&status) {
78 let location = response
79 .headers()
80 .get("location")
81 .and_then(|v| v.to_str().ok())
82 .ok_or_else(|| {
83 wm_core::CoreError::Tool(format!(
84 "redirect {status} without Location at {current}"
85 ))
86 })?
87 .to_string();
88 let next = resolve_url(¤t, &location);
89 safe_url(&next)?;
90 current = next;
91 pages += 1;
92 continue;
93 }
94 if !(200..300).contains(&status) {
95 return Err(wm_core::CoreError::Tool(format!(
96 "HTTP {status} from {current}"
97 )));
98 }
99
100 let raw_budget = (max_chars as u64)
105 .saturating_mul(8)
106 .clamp(64_000, 1_000_000);
107 let mut reader = response.into_body().into_reader();
108 let mut bytes = Vec::new();
109 std::io::Read::read_to_end(
110 &mut std::io::Read::take(&mut reader, raw_budget),
111 &mut bytes,
112 )
113 .map_err(|e| wm_core::CoreError::Tool(format!("read {current}: {e}")))?;
114 let html = String::from_utf8_lossy(&bytes).into_owned();
115 let title = extract_title(&html).unwrap_or_default();
116 let content = strip_html(&html);
117 let content: String = content.chars().take(max_chars).collect();
118 return Ok(Fetched {
119 url: current,
120 title,
121 content,
122 raw: html,
123 status_code: status,
124 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
125 pages,
126 });
127 }
128
129 Err(wm_core::CoreError::Tool(format!(
130 "too many redirects ({MAX_REDIRECTS})"
131 )))
132}
133
134#[must_use]
138pub fn resolve_url(base: &str, location: &str) -> String {
139 if location.starts_with("http://") || location.starts_with("https://") {
140 return location.to_string();
141 }
142 let (scheme, rest) = base
143 .split_once("://")
144 .map_or(("https", base), |(s, r)| (s, r));
145 if location.starts_with("//") {
146 return format!("{scheme}:{location}");
147 }
148 let slash = rest.find('/').unwrap_or(rest.len());
149 let (host, path) = rest.split_at(slash);
150 if location.starts_with('/') {
151 return format!("{scheme}://{host}{location}");
152 }
153 let dir: String = if path.is_empty() {
155 "/".to_string()
156 } else {
157 format!("{}/", path.rsplit_once('/').map_or("/", |(d, _)| d))
158 };
159 format!("{scheme}://{host}{dir}{location}")
160}
161
162pub(crate) fn extract_title(html: &str) -> Option<String> {
164 let lower = html.to_ascii_lowercase();
165 let start = lower.find("<title")?;
166 let gt = lower[start..].find('>')? + start + 1;
167 let end = lower[gt..].find("</title")? + gt;
168 let raw = &html[gt.min(html.len())..end.min(html.len())];
169 let title = strip_html(raw);
170 let title = title.trim();
171 if title.is_empty() {
172 None
173 } else {
174 Some(title.to_string())
175 }
176}
177
178#[must_use]
181pub fn strip_html(html: &str) -> String {
182 let mut out = String::with_capacity(html.len() / 2);
183 let mut in_script = false;
184 let mut chars = html.chars();
185 while let Some(c) = chars.next() {
186 match c {
187 '<' => {
188 let mut tag = String::new();
189 for pc in chars.by_ref() {
190 tag.push(pc);
191 if pc == '>' {
192 break;
193 }
194 }
195 let lower = tag.to_ascii_lowercase();
196 let trimmed = lower.trim_matches(['<', '>', '/']);
197 let name = trimmed.split_whitespace().next().unwrap_or("");
198 if name == "script" || name == "style" {
199 in_script = !lower.starts_with('/');
202 } else if !in_script
203 && !lower.starts_with("</")
204 && matches!(
205 name,
206 "p" | "br" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "tr"
207 )
208 && !out.ends_with('\n')
209 {
210 out.push('\n');
211 }
212 }
213 _ if in_script => {} '&' => {
215 let mut lookahead = chars.clone();
220 let mut entity = String::new();
221 let mut terminated = false;
222 for _ in 0..=12 {
223 match lookahead.next() {
224 Some(';') => {
225 terminated = true;
226 break;
227 }
228 Some('<') => break,
229 Some(c) => entity.push(c),
230 None => break,
231 }
232 }
233 if terminated {
234 if is_known_entity(&entity) {
235 for _ in entity.chars() {
237 chars.next();
238 }
239 chars.next();
240 out.push_str(&decode_entity(&entity));
241 } else {
242 out.push('&');
245 }
246 } else {
247 out.push('&');
250 }
251 }
252 c => out.push(c),
253 }
254 }
255 let mut result = String::with_capacity(out.len());
257 let mut pending_newline = false;
258 let mut pending_space = false;
259 for c in out.chars() {
260 if c == '\n' {
261 pending_newline = true;
262 pending_space = false;
263 } else if c.is_whitespace() {
264 pending_space = true;
265 } else {
266 if pending_newline {
267 if !result.ends_with('\n') && !result.is_empty() {
268 result.push('\n');
269 }
270 pending_newline = false;
271 } else if pending_space {
272 if !result.ends_with(' ') && !result.ends_with('\n') && !result.is_empty() {
273 result.push(' ');
274 }
275 pending_space = false;
276 }
277 result.push(c);
278 }
279 }
280 result.trim().to_string()
281}
282
283fn decode_entity(entity: &str) -> String {
286 let e = entity.trim_end_matches(';');
287 let out = match e {
288 "amp" => "&",
289 "lt" => "<",
290 "gt" => ">",
291 "quot" => "\"",
292 "apos" | "#39" => "'",
293 "nbsp" => " ",
294 _ => {
295 if let Some(num) = e.strip_prefix('#') {
296 let code = num.parse::<u32>().ok().or_else(|| {
297 num.strip_prefix('x')
298 .and_then(|h| u32::from_str_radix(h, 16).ok())
299 });
300 if let Some(code) = code {
301 if let Some(ch) = char::from_u32(code) {
302 return ch.to_string();
303 }
304 }
305 }
306 return format!("&{e};");
307 }
308 };
309 out.to_string()
310}
311
312fn is_known_entity(entity: &str) -> bool {
315 let e = entity.trim_end_matches(';');
316 if matches!(e, "amp" | "lt" | "gt" | "quot" | "apos" | "#39" | "nbsp") {
317 return true;
318 }
319 if let Some(num) = e.strip_prefix('#') {
320 let code = num.parse::<u32>().ok().or_else(|| {
321 num.strip_prefix('x')
322 .and_then(|h| u32::from_str_radix(h, 16).ok())
323 });
324 if let Some(code) = code {
325 return char::from_u32(code).is_some();
326 }
327 }
328 false
329}
330
331#[must_use]
334pub fn ddg_target(href: &str) -> Option<String> {
335 let idx = href.find("uddg=")?;
336 let encoded = &href[idx + 5..];
337 let end = encoded.find('&').unwrap_or(encoded.len());
338 let bytes = encoded.as_bytes();
339 let end = end.min(bytes.len());
340 let mut out = Vec::new();
341 let mut i = 0;
342 while i < end {
343 if bytes[i] == b'%' && i + 2 < end {
344 if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
345 out.push((hi << 4) | lo);
346 i += 3;
347 continue;
348 }
349 }
350 out.push(bytes[i]);
351 i += 1;
352 }
353 String::from_utf8(out).ok()
354}
355
356#[must_use]
358const fn hex_val(b: u8) -> Option<u8> {
359 match b {
360 b'0'..=b'9' => Some(b - b'0'),
361 b'a'..=b'f' => Some(b - b'a' + 10),
362 b'A'..=b'F' => Some(b - b'A' + 10),
363 _ => None,
364 }
365}
366
367#[must_use]
373pub fn bing_decode(href: &str) -> Option<String> {
374 let unescaped = href.replace("&", "&");
375 let idx = unescaped.find("u=a1")?;
376 let rest = &unescaped[idx + 4..];
377 let end = rest.find('&').unwrap_or(rest.len());
378 let b64 = rest[..end].replace('-', "+").replace('_', "/");
379 let mut bytes = Vec::with_capacity(b64.len() * 3 / 4);
380 let table: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
381 let mut acc = 0u32;
382 let mut bits = 0u8;
383 for c in b64.bytes().filter(|c| *c != b'=') {
384 let v = table.iter().position(|t| *t == c)?;
385 acc = (acc << 6) | v as u32;
386 bits += 6;
387 if bits >= 8 {
388 bits -= 8;
389 bytes.push((acc >> bits) as u8);
390 acc &= (1 << bits) - 1;
391 }
392 }
393 let target = String::from_utf8(bytes).ok()?;
394 if target.starts_with("http://") || target.starts_with("https://") {
397 Some(target)
398 } else {
399 None
400 }
401}
402
403#[must_use]
405pub fn percent_encode_query(query: &str) -> String {
406 query
407 .chars()
408 .flat_map(|c| match c {
409 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => vec![c],
410 ' ' => vec!['+'],
411 _ => {
412 let mut bytes = [0u8; 4];
413 let s = c.encode_utf8(&mut bytes);
414 s.bytes()
415 .flat_map(|b| format!("%{b:02X}").chars().collect::<Vec<_>>())
416 .collect()
417 }
418 })
419 .collect()
420}
421
422#[derive(Debug)]
424pub struct SearchResult {
425 pub url: String,
426 pub title: String,
427 pub snippet: String,
428}
429
430pub(crate) fn web_search(
437 query: &str,
438 num_results: usize,
439 timeout: Duration,
440) -> Result<Vec<SearchResult>, wm_core::CoreError> {
441 let url = format!(
442 "https://www.bing.com/search?q={}&count={}",
443 percent_encode_query(query),
444 num_results
445 );
446 safe_url(&url)?;
447 let fetched = fetch_bounded(&url, 300_000, timeout)?;
448 if fetched.status_code == 202 {
449 return Ok(Vec::new());
450 }
451 Ok(parse_bing_results(&fetched.raw, num_results))
452}
453
454#[must_use]
460pub fn parse_bing_results(html: &str, num_results: usize) -> Vec<SearchResult> {
461 let lower = html.to_ascii_lowercase();
462
463 let mut results: Vec<SearchResult> = Vec::new();
464 let mut pos = 0usize;
465 while results.len() < num_results {
466 let block = lower[pos..].find("<li class=\"b_algo\"");
467 let Some(block) = block else { break };
468 let block = pos + block;
469 let block_end = lower[block..]
470 .find("</li>")
471 .map_or(lower.len(), |e| block + e);
472 let chunk = &html[block..block_end];
473 let chunk_lower = &lower[block..block_end];
474
475 let mut anchor_at = 0usize;
477 let mut href = None;
478 while anchor_at < chunk.len() {
479 let Some(rel) = chunk_lower[anchor_at..].find("<a ") else {
480 break;
481 };
482 let a_start = anchor_at + rel;
483 let Some(href_start) = chunk_lower[a_start..].find("href=\"") else {
484 break;
485 };
486 let href_start = a_start + href_start + 6;
487 let Some(href_end) = chunk_lower[href_start..].find('"') else {
488 break;
489 };
490 let href_end = href_start + href_end;
491 let candidate = &chunk[href_start..href_end];
492 anchor_at = href_end + 1;
493 if candidate.starts_with("javascript:") || candidate.starts_with('#') {
494 continue;
495 }
496 href = Some(candidate.to_string());
497 break;
498 }
499 let Some(href) = href else {
500 pos = block + 7;
501 continue;
502 };
503
504 let title = {
506 let h2 = chunk_lower.find("<h2").unwrap_or(0);
507 let gt = chunk_lower[h2..].find('>').map_or(0, |e| h2 + e + 1);
508 let close = chunk_lower[gt..]
509 .find("</a>")
510 .map_or(chunk.len(), |e| gt + e);
511 strip_html(&chunk[gt..close.min(chunk.len())])
512 };
513
514 let snippet = {
516 let p_start = chunk_lower.find("<p ");
517 match p_start {
518 Some(ps) => {
519 let gt = chunk_lower[ps..].find('>').map(|e| ps + e + 1);
520 match gt {
521 Some(gt) => {
522 let p_close = chunk_lower[gt..].find("</p>").map(|e| gt + e);
523 match p_close {
524 Some(pc) => strip_html(&chunk[gt..pc]),
525 None => String::new(),
526 }
527 }
528 None => String::new(),
529 }
530 }
531 None => String::new(),
532 }
533 };
534
535 let target = if href.contains("/ck/a") {
536 bing_decode(&href)
537 .filter(|t| is_url_safe(t))
538 .unwrap_or_default()
539 } else if href.starts_with("http") && is_url_safe(&href) {
540 href
541 } else {
542 ddg_target(&href)
543 .filter(|t| is_url_safe(t))
544 .unwrap_or_default()
545 };
546
547 if !target.is_empty() {
548 results.push(SearchResult {
549 url: target,
550 title: title.trim().to_string(),
551 snippet: snippet.trim().to_string(),
552 });
553 }
554 pos = block + 7;
555 }
556 results
557}
558
559fn fetch_response(fetched: &Fetched, truncated: bool) -> Value {
561 json!({
562 "status": "success",
563 "url": fetched.url,
564 "title": fetched.title,
565 "content": fetched.content,
566 "content_length": fetched.content.len(),
567 "status_code": fetched.status_code,
568 "duration_ms": fetched.duration_ms,
569 "pages_fetched": fetched.pages,
570 "truncated": truncated,
571 })
572}
573
574pub struct WebFetchTool {
578 stats: ToolStats,
579 effects: EffectRow,
580}
581
582impl WebFetchTool {
583 #[must_use]
584 pub fn new() -> Self {
585 Self {
586 stats: ToolStats::default(),
587 effects: EffectRow::read_only(vec![Resource::Network]),
588 }
589 }
590}
591
592impl Default for WebFetchTool {
593 fn default() -> Self {
594 Self::new()
595 }
596}
597
598#[async_trait]
599impl Tool for WebFetchTool {
600 fn input_schema(&self) -> Value {
601 super::common::schema(
602 &json!({
603 "url": super::common::str_prop("URL to fetch (required; SSRF-checked on every redirect hop)"),
604 "max_chars": super::common::int_prop("Maximum characters of stripped text to return (optional; default 30000)"),
605 "timeout_secs": super::common::num_prop("Per-hop timeout in seconds, clamped 0-300 (optional; default 15)"),
606 }),
607 &["url"],
608 )
609 }
610 fn name(&self) -> &str {
611 "web.fetch"
612 }
613 fn gana(&self) -> Gana {
614 Gana::Chariot
615 }
616 fn effects(&self) -> &EffectRow {
617 &self.effects
618 }
619 fn description(&self) -> &str {
620 "Fetch a URL and return clean text content (no browser needed). Args: url (required), max_chars (default 30000), timeout_secs (default 15)."
621 }
622 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
623 let url = args
624 .get("url")
625 .and_then(Value::as_str)
626 .ok_or_else(|| wm_core::CoreError::InvalidArgs("url is required".into()))?;
627 let max_chars = args
628 .get("max_chars")
629 .and_then(Value::as_u64)
630 .unwrap_or(30_000) as usize;
631 let timeout = args
634 .get("timeout_secs")
635 .and_then(Value::as_f64)
636 .unwrap_or(15.0)
637 .clamp(0.0, 300.0);
638 let url = safe_url(url)?;
639 let fetched = tokio::task::spawn_blocking(move || {
640 fetch_bounded(&url, max_chars, Duration::from_secs_f64(timeout))
641 })
642 .await
643 .map_err(|e| wm_core::CoreError::Tool(format!("web.fetch task: {e}")))??;
644 Ok(fetch_response(&fetched, fetched.content.len() >= max_chars))
645 }
646 fn stats(&self) -> &ToolStats {
647 &self.stats
648 }
649}
650
651pub struct WebDeepFetchTool {
655 stats: ToolStats,
656 effects: EffectRow,
657}
658
659impl WebDeepFetchTool {
660 #[must_use]
661 pub fn new() -> Self {
662 Self {
663 stats: ToolStats::default(),
664 effects: EffectRow::read_only(vec![Resource::Network]),
665 }
666 }
667}
668
669impl Default for WebDeepFetchTool {
670 fn default() -> Self {
671 Self::new()
672 }
673}
674
675#[async_trait]
676impl Tool for WebDeepFetchTool {
677 fn input_schema(&self) -> Value {
678 super::common::schema(
679 &json!({
680 "url": super::common::str_prop("URL to fetch (required; SSRF-checked on every redirect hop)"),
681 "max_chars": super::common::int_prop("Maximum characters of stripped text to return (optional; default 200000)"),
682 "timeout_secs": super::common::num_prop("Per-hop timeout in seconds, clamped 0-300 (optional; default 30)"),
683 }),
684 &["url"],
685 )
686 }
687 fn name(&self) -> &str {
688 "web.deep_fetch"
689 }
690 fn gana(&self) -> Gana {
691 Gana::Chariot
692 }
693 fn effects(&self) -> &EffectRow {
694 &self.effects
695 }
696 fn description(&self) -> &str {
697 "Fetch a URL with full-content retrieval (up to 200K chars, no chunk skimming). Args: url (required), max_chars (default 200000), timeout_secs (default 30)."
698 }
699 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
700 let url = args
701 .get("url")
702 .and_then(Value::as_str)
703 .ok_or_else(|| wm_core::CoreError::InvalidArgs("url is required".into()))?;
704 let max_chars = args
705 .get("max_chars")
706 .and_then(Value::as_u64)
707 .unwrap_or(200_000) as usize;
708 let timeout = args
709 .get("timeout_secs")
710 .and_then(Value::as_f64)
711 .unwrap_or(30.0)
712 .clamp(0.0, 300.0);
713 let url = safe_url(url)?;
714 let fetched = tokio::task::spawn_blocking(move || {
715 fetch_bounded(&url, max_chars, Duration::from_secs_f64(timeout))
716 })
717 .await
718 .map_err(|e| wm_core::CoreError::Tool(format!("web.deep_fetch task: {e}")))??;
719 Ok(fetch_response(&fetched, fetched.content.len() >= max_chars))
720 }
721 fn stats(&self) -> &ToolStats {
722 &self.stats
723 }
724}
725
726pub struct WebSearchTool {
730 stats: ToolStats,
731 effects: EffectRow,
732}
733
734impl WebSearchTool {
735 #[must_use]
736 pub fn new() -> Self {
737 Self {
738 stats: ToolStats::default(),
739 effects: EffectRow::read_only(vec![Resource::Network]),
740 }
741 }
742}
743
744impl Default for WebSearchTool {
745 fn default() -> Self {
746 Self::new()
747 }
748}
749
750#[async_trait]
751impl Tool for WebSearchTool {
752 fn input_schema(&self) -> Value {
753 super::common::schema(
754 &json!({
755 "query": super::common::str_prop("Search query (required)"),
756 "num_results": super::common::int_prop("Maximum results to return (optional; default 8)"),
757 "timeout_secs": super::common::num_prop("Search timeout in seconds, clamped 0-300 (optional; default 10)"),
758 }),
759 &["query"],
760 )
761 }
762 fn name(&self) -> &str {
763 "web.search"
764 }
765 fn gana(&self) -> Gana {
766 Gana::Chariot
767 }
768 fn effects(&self) -> &EffectRow {
769 &self.effects
770 }
771 fn description(&self) -> &str {
772 "Search the web (Bing HTML, no API key needed). Args: query (required), num_results (default 8), timeout_secs (default 10)."
773 }
774 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
775 let query = args
776 .get("query")
777 .and_then(Value::as_str)
778 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
779 let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(8) as usize;
780 let timeout = args
781 .get("timeout_secs")
782 .and_then(Value::as_f64)
783 .unwrap_or(10.0)
784 .clamp(0.0, 300.0);
785 let query = query.to_string();
786 let query_for_task = query.clone();
787 let results = tokio::task::spawn_blocking(move || {
788 web_search(
789 &query_for_task,
790 num_results,
791 Duration::from_secs_f64(timeout),
792 )
793 })
794 .await
795 .map_err(|e| wm_core::CoreError::Tool(format!("web.search task: {e}")))??;
796 let results: Vec<Value> = results
797 .into_iter()
798 .map(|r| json!({"url": r.url, "title": r.title, "snippet": r.snippet}))
799 .collect();
800 Ok(json!({
801 "status": "success",
802 "query": query,
803 "total_results": results.len(),
804 "results": results,
805 }))
806 }
807 fn stats(&self) -> &ToolStats {
808 &self.stats
809 }
810}
811
812pub struct WebSearchAndReadTool {
816 stats: ToolStats,
817 effects: EffectRow,
818}
819
820impl WebSearchAndReadTool {
821 #[must_use]
822 pub fn new() -> Self {
823 Self {
824 stats: ToolStats::default(),
825 effects: EffectRow::read_only(vec![Resource::Network]),
826 }
827 }
828}
829
830impl Default for WebSearchAndReadTool {
831 fn default() -> Self {
832 Self::new()
833 }
834}
835
836#[async_trait]
837impl Tool for WebSearchAndReadTool {
838 fn input_schema(&self) -> Value {
839 super::common::schema(
840 &json!({
841 "query": super::common::str_prop("Search query (required)"),
842 "num_results": super::common::int_prop("Maximum search results to return (optional; default 5)"),
843 "max_fetch": super::common::int_prop("Maximum top results to fetch content for (optional; default 3)"),
844 "max_chars_per_page": super::common::int_prop("Maximum characters of stripped text per fetched page (optional; default 15000)"),
845 "timeout_secs": super::common::num_prop("Search/fetch timeout in seconds, clamped 0-300 (optional; default 15)"),
846 }),
847 &["query"],
848 )
849 }
850 fn name(&self) -> &str {
851 "web.search_and_read"
852 }
853 fn gana(&self) -> Gana {
854 Gana::Chariot
855 }
856 fn effects(&self) -> &EffectRow {
857 &self.effects
858 }
859 fn description(&self) -> &str {
860 "Search the web AND fetch content from top results in one call. Args: query (required), num_results (default 5), max_fetch (default 3), max_chars_per_page (default 15000)."
861 }
862 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
863 let query = args
864 .get("query")
865 .and_then(Value::as_str)
866 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
867 let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(5) as usize;
868 let max_fetch = args.get("max_fetch").and_then(Value::as_u64).unwrap_or(3) as usize;
869 let max_chars = args
870 .get("max_chars_per_page")
871 .and_then(Value::as_u64)
872 .unwrap_or(15_000) as usize;
873 let timeout = args
875 .get("timeout_secs")
876 .and_then(Value::as_f64)
877 .unwrap_or(15.0)
878 .clamp(0.0, 300.0);
879 let query = query.to_string();
880 let query_for_task = query.clone();
881 let results = tokio::task::spawn_blocking(move || {
882 web_search(
883 &query_for_task,
884 num_results,
885 Duration::from_secs_f64(timeout),
886 )
887 })
888 .await
889 .map_err(|e| wm_core::CoreError::Tool(format!("web.search_and_read task: {e}")))??;
890
891 let mut entries: Vec<Value> = results
892 .into_iter()
893 .map(|r| json!({"url": r.url, "title": r.title, "snippet": r.snippet, "content": null}))
894 .collect();
895
896 let mut fetched_count = 0usize;
897 for entry in &mut entries.iter_mut().take(max_fetch) {
898 let url = entry
899 .get("url")
900 .and_then(Value::as_str)
901 .unwrap_or_default()
902 .to_string();
903 if url.is_empty() || !is_url_safe(&url) {
904 continue;
905 }
906 let url_c = url.clone();
907 let max_c = max_chars;
908 let t = timeout;
909 if let Ok(Ok(fetched)) = tokio::task::spawn_blocking(move || {
910 fetch_bounded(&url_c, max_c, Duration::from_secs_f64(t))
911 })
912 .await
913 {
914 entry["content"] = json!(fetched.content);
915 entry["content_length"] = json!(fetched.content.len());
916 if entry["title"].as_str().unwrap_or_default().is_empty() {
917 entry["title"] = json!(fetched.title);
918 }
919 fetched_count += 1;
920 }
921 }
922
923 Ok(json!({
924 "status": "success",
925 "query": query,
926 "results": entries,
927 "total_results": entries.len(),
928 "fetched_count": fetched_count,
929 }))
930 }
931 fn stats(&self) -> &ToolStats {
932 &self.stats
933 }
934}
935
936#[must_use]
938pub fn register_web(registry: &wm_dispatch::ToolRegistry) -> wm_dispatch::ToolRegistry {
939 registry
940 .register(Arc::new(WebFetchTool::new()))
941 .register(Arc::new(WebDeepFetchTool::new()))
942 .register(Arc::new(WebSearchTool::new()))
943 .register(Arc::new(WebSearchAndReadTool::new()))
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 #[tokio::test]
951 async fn negative_timeout_does_not_panic() {
952 let tool = WebFetchTool::new();
956 let result = tool
957 .call(
958 &mut Context::default(),
959 json!({"url": "http://127.0.0.1:1/never", "timeout_secs": -5.0}),
960 )
961 .await;
962 assert!(
963 result.is_err(),
964 "unroutable local URL should fail, not panic"
965 );
966 }
967
968 #[test]
969 fn html_stripping_removes_tags_and_scripts() {
970 let html = "<html><head><title>Test Page</title><script>var x=1;</script></head><body><h1>Hello</h1><p>World wide</p><div>One</div><div>Two</div></body></html>";
971 let text = strip_html(html);
972 assert!(text.contains("Hello"));
973 assert!(text.contains("World wide"));
974 assert!(text.contains("One"));
975 assert!(!text.contains("var x"));
976 assert!(!text.contains("<p>"));
977 }
978
979 #[test]
980 fn html_stripping_decodes_entities() {
981 assert_eq!(
982 strip_html("& <tag> "q" A B"),
983 "& <tag> \"q\" A B"
984 );
985 assert_eq!(strip_html("&unknown;"), "&unknown;");
986 assert_eq!(
989 strip_html("Hello&<script>var x=1;</script><p>World</p>"),
990 "Hello&\nWorld"
991 );
992 assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
994 assert_eq!(
996 strip_html("<script>var a = 1 && b;</script><p>x</p>"),
997 "x"
998 );
999 }
1000
1001 #[test]
1002 fn title_extraction() {
1003 assert_eq!(
1004 extract_title("<html><title> My Page </title></html>"),
1005 Some("My Page".to_string())
1006 );
1007 assert!(extract_title("<html><body>no title</body></html>").is_none());
1008 }
1009
1010 #[test]
1011 fn ddg_redirect_decodes_target() {
1012 let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage%3Fa%3D1&rut=abc";
1013 assert_eq!(
1014 ddg_target(href),
1015 Some("https://example.com/page?a=1".to_string())
1016 );
1017 }
1018
1019 #[test]
1020 fn bing_ck_a_decodes_target() {
1021 let href = "https://www.bing.com/ck/a?!&&p=abc&u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&ntb=1";
1023 assert_eq!(
1024 bing_decode(href),
1025 Some("https://rust-lang.org/".to_string())
1026 );
1027 let href2 = "https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS8_YS1iX3M";
1029 assert_eq!(
1030 bing_decode(href2),
1031 Some("https://example.com/?a-b_s".to_string())
1032 );
1033 assert_eq!(bing_decode("https://www.bing.com/ck/a?p=1"), None);
1034 }
1035
1036 #[test]
1037 fn resolve_url_handles_relative_and_protocol() {
1038 assert_eq!(
1039 resolve_url("https://example.com/a/b", "/c"),
1040 "https://example.com/c"
1041 );
1042 assert_eq!(
1043 resolve_url("https://example.com/a/b", "c.html"),
1044 "https://example.com/a/c.html"
1045 );
1046 assert_eq!(
1047 resolve_url("http://example.com/x", "//other.com/y"),
1048 "http://other.com/y"
1049 );
1050 assert_eq!(
1051 resolve_url("https://example.com/x", "https://other.com/y"),
1052 "https://other.com/y"
1053 );
1054 }
1055
1056 #[test]
1057 fn ssrf_guard_rejects_private_and_non_http() {
1058 assert!(safe_url("http://169.254.169.254/latest/meta-data").is_err());
1059 assert!(safe_url("file:///etc/passwd").is_err());
1060 assert!(safe_url("https://example.com").is_ok());
1061 }
1062
1063 #[test]
1064 fn tool_declarations() {
1065 assert_eq!(WebFetchTool::new().name(), "web.fetch");
1066 assert_eq!(WebSearchTool::new().name(), "web.search");
1067 assert_eq!(WebDeepFetchTool::new().name(), "web.deep_fetch");
1068 assert_eq!(WebSearchAndReadTool::new().name(), "web.search_and_read");
1069 let tools: Vec<Box<dyn Tool>> = vec![
1070 Box::new(WebFetchTool::new()),
1071 Box::new(WebSearchTool::new()),
1072 Box::new(WebDeepFetchTool::new()),
1073 Box::new(WebSearchAndReadTool::new()),
1074 ];
1075 for tool in tools {
1076 assert_eq!(tool.gana(), Gana::Chariot);
1077 assert!(tool.effects().writes.is_empty());
1078 assert!(!tool.effects().destructive);
1079 assert_eq!(tool.effects().reads.len(), 1);
1080 assert_eq!(tool.effects().reads[0], Resource::Network);
1081 }
1082 }
1083
1084 #[tokio::test]
1085 async fn fetch_requires_url() {
1086 let tool = WebFetchTool::new();
1087 let mut ctx = Context::default();
1088 let result = tool.call(&mut ctx, json!({})).await;
1089 assert!(result.is_err());
1090 }
1091
1092 #[tokio::test]
1093 async fn search_requires_query() {
1094 let tool = WebSearchTool::new();
1095 let mut ctx = Context::default();
1096 let result = tool.call(&mut ctx, json!({})).await;
1097 assert!(result.is_err());
1098 }
1099}