1use mermaid_domain::ProgressEvent;
11use std::collections::VecDeque;
12use std::sync::{Arc, Mutex, OnceLock};
13
14use async_trait::async_trait;
15use futures::{StreamExt, stream};
16
17use mermaid_domain::{FetchBackend, SearchBackend, WebConfig};
18use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
19
20use super::super::ctx::ExecContext;
21use super::ToolExecutor;
22use super::web_client::{
23 FetchProvider, ManagedSearxngBackend, NativeFetchClient, OllamaWebClient, SearchProvider,
24 SearxngClient, ValidatedWebUrl, WebFetchError, WebFetchResult, format_results,
25};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Egress {
33 OnMachine,
35 OffMachine,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct WebCapabilityStatus {
45 pub available: bool,
46 pub backend: &'static str,
47 pub trust_destination: &'static str,
48 pub egress: Egress,
49 pub reason: Option<String>,
50}
51
52pub struct WebCapabilities {
56 pub fetch: WebCapabilityStatus,
57 pub search: WebCapabilityStatus,
58 fetch_backend: Option<Arc<dyn FetchProvider>>,
59 search_backend: Option<Arc<dyn SearchProvider>>,
60}
61
62impl WebCapabilities {
63 #[must_use]
64 pub fn resolve(web: &WebConfig) -> Self {
65 let needs_ollama_key = web.fetch_backend == FetchBackend::Ollama
66 || web.search_backend == SearchBackend::Ollama;
67 let ollama_key = needs_ollama_key
68 .then(|| mermaid_model::utils::resolve_provider_key("ollama", "OLLAMA_API_KEY", None))
69 .flatten();
70
71 let (fetch, fetch_backend): (_, Option<Arc<dyn FetchProvider>>) = match web.fetch_backend {
72 FetchBackend::Native => match NativeFetchClient::new() {
73 Ok(client) => (
74 available("native", "direct from this machine", Egress::OnMachine),
75 Some(Arc::new(client)),
76 ),
77 Err(error) => (
78 unavailable(
79 "native",
80 "direct from this machine",
81 Egress::OnMachine,
82 error.to_string(),
83 ),
84 None,
85 ),
86 },
87 FetchBackend::Ollama => {
88 let (status, client) = ollama_cloud_backend(
89 ollama_key.clone(),
90 "Ollama Cloud (target redirects are provider-managed; final URL is not disclosed)",
91 );
92 (status, client.map(|c| c as Arc<dyn FetchProvider>))
93 },
94 };
95
96 let (search, search_backend): (_, Option<Arc<dyn SearchProvider>>) =
97 match web.search_backend {
98 SearchBackend::Auto => match crate::searxng::managed_backend_viability() {
102 Ok(_) => (
103 available(
104 "managed_searxng",
105 "local managed process",
106 Egress::OnMachine,
107 ),
108 Some(Arc::new(ManagedSearxngBackend)),
109 ),
110 Err(reason) => (
111 unavailable(
112 "managed_searxng",
113 "local managed process",
114 Egress::OnMachine,
115 reason,
116 ),
117 None,
118 ),
119 },
120 SearchBackend::Ollama => {
121 let (status, client) = ollama_cloud_backend(ollama_key, "Ollama Cloud");
122 (status, client.map(|c| c as Arc<dyn SearchProvider>))
123 },
124 SearchBackend::Searxng => match SearxngClient::new(web.searxng_url.clone()) {
127 Ok(client) => (
128 available("searxng", "configured SearXNG instance", Egress::OffMachine),
129 Some(Arc::new(client)),
130 ),
131 Err(error) => (
132 unavailable(
133 "searxng",
134 "configured SearXNG instance",
135 Egress::OffMachine,
136 error.to_string(),
137 ),
138 None,
139 ),
140 },
141 };
142
143 Self {
144 fetch,
145 search,
146 fetch_backend,
147 search_backend,
148 }
149 }
150
151 #[cfg(test)]
155 #[must_use]
156 pub fn from_statuses_for_test(fetch: WebCapabilityStatus, search: WebCapabilityStatus) -> Self {
157 Self {
158 fetch,
159 search,
160 fetch_backend: None,
161 search_backend: None,
162 }
163 }
164
165 #[must_use]
166 pub fn fetch_tool(&self) -> Option<WebFetchTool> {
167 self.fetch_backend
168 .clone()
169 .map(|backend| WebFetchTool::new(backend, self.fetch.backend))
170 }
171
172 #[must_use]
173 pub fn search_tool(&self) -> Option<WebSearchTool> {
174 self.search_backend.clone().map(|backend| WebSearchTool {
175 backend,
176 backend_name: self.search.backend,
177 })
178 }
179}
180
181fn ollama_cloud_backend(
185 key: Option<String>,
186 trust_destination: &'static str,
187) -> (WebCapabilityStatus, Option<Arc<OllamaWebClient>>) {
188 match key {
189 Some(key) => match OllamaWebClient::new(key) {
190 Ok(client) => (
191 available("ollama_cloud", trust_destination, Egress::OffMachine),
192 Some(Arc::new(client)),
193 ),
194 Err(error) => (
195 unavailable(
196 "ollama_cloud",
197 "Ollama Cloud",
198 Egress::OffMachine,
199 error.to_string(),
200 ),
201 None,
202 ),
203 },
204 None => (
205 unavailable(
206 "ollama_cloud",
207 "Ollama Cloud",
208 Egress::OffMachine,
209 "OLLAMA_API_KEY is not configured",
210 ),
211 None,
212 ),
213 }
214}
215
216fn available(
217 backend: &'static str,
218 trust_destination: &'static str,
219 egress: Egress,
220) -> WebCapabilityStatus {
221 WebCapabilityStatus {
222 available: true,
223 backend,
224 trust_destination,
225 egress,
226 reason: None,
227 }
228}
229
230fn unavailable(
231 backend: &'static str,
232 trust_destination: &'static str,
233 egress: Egress,
234 reason: impl Into<String>,
235) -> WebCapabilityStatus {
236 WebCapabilityStatus {
237 available: false,
238 backend,
239 trust_destination,
240 egress,
241 reason: Some(reason.into()),
242 }
243}
244
245pub struct WebSearchTool {
249 backend: Arc<dyn SearchProvider>,
250 backend_name: &'static str,
251}
252
253const MAX_WEB_SEARCH_FAILURE_BYTES: usize = 1024;
254
255#[expect(
256 clippy::too_many_lines,
257 reason = "predates the lint; see .github/baselines/expect_budget.txt"
258)]
259#[async_trait]
260impl ToolExecutor for WebSearchTool {
261 fn name(&self) -> &'static str {
262 "web_search"
263 }
264
265 fn schema(&self) -> ToolDefinition {
266 ToolDefinition {
267 name: "web_search".to_string(),
268 description:
269 "Search the web. Takes either a single `query` + `max_results`, or an array of `queries` for parallel fan-out."
270 .to_string(),
271 input_schema: serde_json::json!({
272 "type": "object",
273 "properties": {
274 "query": { "type": "string", "minLength": 1, "maxLength": 2048 },
275 "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 },
276 "queries": {
277 "type": "array",
278 "minItems": 1,
279 "maxItems": mermaid_model::constants::MAX_BATCH_TOOL_ITEMS,
280 "items": {
281 "type": "object",
282 "properties": {
283 "query": { "type": "string", "minLength": 1, "maxLength": 2048 },
284 "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 }
285 },
286 "required": ["query"],
287 "additionalProperties": false
288 }
289 }
290 },
291 "oneOf": [
292 { "required": ["query"], "not": { "required": ["queries"] } },
293 { "required": ["queries"], "not": { "required": ["query"] } }
294 ],
295 "additionalProperties": false
296 }),
297 }
298 }
299
300 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
301 let queries = match parse_queries(&args) {
302 Ok(q) => q,
303 Err(e) => return ToolOutcome::error(e, 0.0),
304 };
305 if queries.is_empty() {
306 return ToolOutcome::error("web_search requires at least one query", 0.0);
307 }
308 if let Some(blocked) = super::policy_gate::gate_external(
309 &ctx,
310 "web_search",
311 mermaid_runtime::ToolCategory::Web,
312 format!("web_search ({} queries)", queries.len()),
313 &args,
314 )
315 .await
316 {
317 return blocked;
318 }
319
320 let start = std::time::Instant::now();
321 let jobs = stream::iter(queries.iter().cloned().enumerate())
322 .map(|(idx, (query, count))| {
323 let backend = self.backend.clone();
324 let progress = ctx.progress.clone();
325 let budget = ctx.web_budget();
326 let total = queries.len();
327 async move {
328 let display_query = mermaid_model::utils::redact_secrets(&query);
329 let _ = progress
330 .send(ProgressEvent::Status(format!(
331 "searching {}/{}: {}",
332 idx + 1,
333 total,
334 display_query
335 )))
336 .await;
337 let result = backend.search(&query, count, budget).await;
338 (idx, query, result)
339 }
340 })
341 .buffer_unordered(mermaid_model::constants::MAX_WEB_SEARCH_CONCURRENCY)
342 .collect::<Vec<_>>();
343 let mut completed = tokio::select! {
344 biased;
345 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
346 completed = jobs => completed,
347 };
348 completed.sort_by_key(|(idx, _, _)| *idx);
349
350 let mut combined = String::new();
351 let mut result_count = 0usize;
352 let mut sources = Vec::new();
353 let mut errors: Vec<mermaid_domain::WebSearchFailure> = Vec::new();
354 for (idx, query, result) in completed {
355 let display_query = mermaid_model::utils::redact_secrets(&query);
356 let section =
360 match result {
361 Ok(results) => {
362 result_count += results.len();
363 sources.extend(results.iter().map(|result| {
364 mermaid_model::utils::sanitize_url_for_display(&result.url)
365 }));
366 if results.is_empty() {
367 "[SEARCH_RESULTS]\n(no results found)\n[/SEARCH_RESULTS]\n".to_string()
368 } else {
369 format_results(&results)
370 }
371 },
372 Err(e) => {
373 let safe_error = mermaid_model::utils::truncate_middle_bytes(
374 &mermaid_model::utils::redact_secrets(&format!("{e:#}")),
375 MAX_WEB_SEARCH_FAILURE_BYTES,
376 );
377 errors.push(mermaid_domain::WebSearchFailure {
378 query_index: idx,
379 error: safe_error.clone(),
380 });
381 format!("(search failed: {safe_error})\n")
382 },
383 };
384 if queries.len() > 1 {
385 combined.push_str(&format!("=== query: {display_query} ===\n{section}\n\n"));
386 } else {
387 combined = section;
388 }
389 }
390
391 if errors.len() == queries.len() {
395 let summary = errors
396 .iter()
397 .map(|failure| format!("query {}: {}", failure.query_index + 1, failure.error))
398 .collect::<Vec<_>>()
399 .join("; ");
400 let message = format!("web_search via {} failed: {summary}", self.backend_name);
401 let message = mermaid_model::utils::truncate_middle_bytes(
402 &message,
403 mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES
404 .saturating_sub("Error: ".len()),
405 );
406 return ToolOutcome::error(message, start.elapsed().as_secs_f64()).with_metadata(
407 ToolRunMetadata {
408 detail: ToolMetadata::WebSearch {
409 queries: queries.iter().map(|(query, _)| query.clone()).collect(),
410 requested_count: queries.iter().map(|(_, count)| *count).sum(),
411 result_count: 0,
412 sources: Vec::new(),
413 backend: self.backend_name.to_string(),
414 succeeded_queries: 0,
415 failed_queries: errors.len(),
416 partial: false,
417 truncated: false,
418 failures: errors,
419 },
420 result_count: Some(0),
421 ..ToolRunMetadata::default()
422 },
423 );
424 }
425
426 let truncated = combined.len() > mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES;
430 let combined = mermaid_model::utils::truncate_middle_bytes(
431 &combined,
432 mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES,
433 );
434
435 let duration_secs = start.elapsed().as_secs_f64();
436 let requested_count = queries.iter().map(|(_, count)| *count).sum();
437 let query_texts = queries.iter().map(|(query, _)| query.clone()).collect();
438 ToolOutcome::success(
439 combined,
440 format!(
441 "{} {} returned",
442 result_count,
443 if result_count == 1 {
444 "result"
445 } else {
446 "results"
447 }
448 ),
449 duration_secs,
450 )
451 .with_metadata(ToolRunMetadata {
452 detail: ToolMetadata::WebSearch {
453 queries: query_texts,
454 requested_count,
455 result_count,
456 sources,
457 backend: self.backend_name.to_string(),
458 succeeded_queries: queries.len() - errors.len(),
459 failed_queries: errors.len(),
460 partial: !errors.is_empty(),
461 truncated,
462 failures: errors,
463 },
464 result_count: Some(result_count),
465 ..ToolRunMetadata::default()
466 })
467 }
468}
469
470pub struct WebFetchTool {
474 backend: Arc<dyn FetchProvider>,
475 backend_name: &'static str,
476 snapshots: Arc<Mutex<FetchSnapshotStore>>,
477}
478
479impl WebFetchTool {
480 fn new(backend: Arc<dyn FetchProvider>, backend_name: &'static str) -> Self {
481 Self {
482 backend,
483 backend_name,
484 snapshots: global_fetch_snapshot_store(),
485 }
486 }
487
488 #[cfg(test)]
489 fn new_with_test_snapshots(
490 backend: Arc<dyn FetchProvider>,
491 backend_name: &'static str,
492 ) -> Self {
493 Self {
494 backend,
495 backend_name,
496 snapshots: Arc::new(Mutex::new(FetchSnapshotStore::default())),
497 }
498 }
499}
500
501fn fetch_failure_outcome(
502 error: &WebFetchError,
503 requested_url: &str,
504 backend: &str,
505 duration_secs: f64,
506 pattern: Option<String>,
507 context_lines: usize,
508) -> ToolOutcome {
509 let requested_url = mermaid_model::utils::sanitize_url_for_display(requested_url);
510 let message = mermaid_model::utils::redact_secrets(&format!(
511 "web_fetch({requested_url}) via {backend}: {error}"
512 ));
513 let pattern_context = pattern.as_ref().map(|_| context_lines);
514 ToolOutcome::error(message, duration_secs).with_metadata(ToolRunMetadata {
515 detail: ToolMetadata::WebFetch {
516 url: requested_url,
517 final_url: None,
518 status: error.status(),
519 error_kind: Some(error.kind().to_string()),
520 media_type: None,
521 charset: None,
522 backend: backend.to_string(),
523 extraction: String::new(),
524 title: None,
525 line_count: 0,
526 byte_count: 0,
527 source_byte_count: 0,
528 output_byte_count: 0,
529 truncated: false,
530 pattern,
531 context_lines: pattern_context,
532 match_count: None,
533 snapshot_id: None,
534 },
535 line_count: Some(0),
536 byte_count: Some(0),
537 ..ToolRunMetadata::default()
538 })
539}
540
541const MAX_FETCH_SNAPSHOTS: usize = 4;
542const MAX_FETCH_SNAPSHOT_BYTES: usize = 32 * 1024 * 1024;
543const MAX_SNAPSHOT_TITLE_BYTES: usize = 300;
544const MAX_SNAPSHOT_URL_BYTES: usize = 8 * 1024;
545const MAX_SNAPSHOT_MEDIA_TYPE_BYTES: usize = 256;
546const MAX_SNAPSHOT_CHARSET_BYTES: usize = 64;
547
548static FETCH_SNAPSHOT_STORE: OnceLock<Arc<Mutex<FetchSnapshotStore>>> = OnceLock::new();
549
550#[derive(Clone, Debug, PartialEq, Eq)]
551struct FetchSnapshotScope {
552 session_id: Option<String>,
553 task_id: Option<String>,
554 fallback_turn: Option<u64>,
555}
556
557impl FetchSnapshotScope {
558 fn from_context(ctx: &ExecContext) -> Self {
559 let has_owner = ctx.session_id.is_some() || ctx.task_id.is_some();
560 Self {
561 session_id: ctx.session_id.as_deref().map(compact_string),
562 task_id: ctx.task_id.as_deref().map(compact_string),
563 fallback_turn: (!has_owner).then_some(ctx.turn.0),
566 }
567 }
568
569 fn retained_string_bytes(&self) -> usize {
570 option_string_capacity(&self.session_id)
571 .saturating_add(option_string_capacity(&self.task_id))
572 }
573}
574
575#[derive(Clone)]
576struct FetchSnapshot {
577 id: String,
578 scope: FetchSnapshotScope,
579 page: Arc<WebFetchResult>,
580 retained_bytes: usize,
581}
582
583#[derive(Default)]
584struct FetchSnapshotStore {
585 entries: VecDeque<FetchSnapshot>,
586 bytes: usize,
587 next_id: u64,
588}
589
590impl FetchSnapshotStore {
591 fn insert(
592 &mut self,
593 scope: FetchSnapshotScope,
594 page: WebFetchResult,
595 ) -> Result<(String, Arc<WebFetchResult>), String> {
596 self.next_id = self.next_id.wrapping_add(1).max(1);
597 let mut id = format!("web-{}", self.next_id);
598 id.shrink_to_fit();
599
600 let fixed_bytes = id.capacity().saturating_add(scope.retained_string_bytes());
601 if fixed_bytes >= MAX_FETCH_SNAPSHOT_BYTES {
602 return Err("web_fetch: snapshot owner identity exceeds the cache budget".to_string());
603 }
604 let page = Arc::new(bound_snapshot_page(
605 page,
606 MAX_FETCH_SNAPSHOT_BYTES - fixed_bytes,
607 ));
608 let retained_bytes = fixed_bytes.saturating_add(page_retained_string_bytes(&page));
609 if retained_bytes > MAX_FETCH_SNAPSHOT_BYTES {
610 return Err("web_fetch: snapshot metadata exceeds the cache budget".to_string());
611 }
612
613 while !self.entries.is_empty()
614 && (self.entries.len() >= MAX_FETCH_SNAPSHOTS
615 || self.bytes.saturating_add(retained_bytes) > MAX_FETCH_SNAPSHOT_BYTES)
616 {
617 if let Some(removed) = self.entries.pop_front() {
618 self.bytes = self.bytes.saturating_sub(removed.retained_bytes);
619 }
620 }
621 self.bytes = self.bytes.saturating_add(retained_bytes);
622 self.entries.push_back(FetchSnapshot {
623 id: id.clone(),
624 scope,
625 page: page.clone(),
626 retained_bytes,
627 });
628 Ok((id, page))
629 }
630
631 fn get(&self, scope: &FetchSnapshotScope, id: &str) -> Option<Arc<WebFetchResult>> {
632 self.entries
633 .iter()
634 .find(|entry| entry.id == id && &entry.scope == scope)
635 .map(|entry| Arc::clone(&entry.page))
636 }
637}
638
639fn global_fetch_snapshot_store() -> Arc<Mutex<FetchSnapshotStore>> {
640 FETCH_SNAPSHOT_STORE
641 .get_or_init(|| Arc::new(Mutex::new(FetchSnapshotStore::default())))
642 .clone()
643}
644
645fn bound_snapshot_page(mut page: WebFetchResult, max_retained_bytes: usize) -> WebFetchResult {
646 page.title = bounded_title(&page.title);
647 page.title.shrink_to_fit();
648 bound_owned_string(&mut page.requested_url, MAX_SNAPSHOT_URL_BYTES);
649 if let Some(final_url) = page.final_url.as_mut() {
650 bound_owned_string(final_url, MAX_SNAPSHOT_URL_BYTES);
651 }
652 bound_optional_string(&mut page.media_type, MAX_SNAPSHOT_MEDIA_TYPE_BYTES);
653 bound_optional_string(&mut page.charset, MAX_SNAPSHOT_CHARSET_BYTES);
654
655 let metadata_bytes = page_retained_string_bytes_without_content(&page);
656 let content_budget = max_retained_bytes.saturating_sub(metadata_bytes);
657 if page.content.len() > content_budget {
658 let cut = page.content.floor_char_boundary(content_budget);
659 page.content.truncate(cut);
660 page.truncated = true;
661 }
662 page.content.shrink_to_fit();
663 page
664}
665
666fn compact_string(value: &str) -> String {
667 let mut value = value.to_string();
668 value.shrink_to_fit();
669 value
670}
671
672fn bound_owned_string(value: &mut String, max_bytes: usize) {
673 if value.len() > max_bytes {
674 value.truncate(value.floor_char_boundary(max_bytes));
675 }
676 value.shrink_to_fit();
677}
678
679fn bound_optional_string(value: &mut Option<String>, max_bytes: usize) {
680 if let Some(value) = value {
681 bound_owned_string(value, max_bytes);
682 }
683}
684
685fn option_string_capacity(value: &Option<String>) -> usize {
686 value.as_ref().map_or(0, String::capacity)
687}
688
689fn page_retained_string_bytes_without_content(page: &WebFetchResult) -> usize {
690 page.requested_url
691 .capacity()
692 .saturating_add(option_string_capacity(&page.final_url))
693 .saturating_add(option_string_capacity(&page.media_type))
694 .saturating_add(option_string_capacity(&page.charset))
695 .saturating_add(page.title.capacity())
696}
697
698fn page_retained_string_bytes(page: &WebFetchResult) -> usize {
699 page_retained_string_bytes_without_content(page).saturating_add(page.content.capacity())
700}
701
702async fn run_snapshot_blocking<T, F>(work: F) -> Result<T, String>
703where
704 T: Send + 'static,
705 F: FnOnce() -> T + Send + 'static,
706{
707 run_snapshot_blocking_with(super::web_client::extraction_semaphore(), work).await
708}
709
710async fn run_snapshot_blocking_with<T, F>(
711 limiter: Arc<tokio::sync::Semaphore>,
712 work: F,
713) -> Result<T, String>
714where
715 T: Send + 'static,
716 F: FnOnce() -> T + Send + 'static,
717{
718 let permit = limiter
719 .acquire_owned()
720 .await
721 .map_err(|_| "web snapshot renderer is closed".to_string())?;
722 tokio::task::spawn_blocking(move || {
723 let _permit = permit;
726 work()
727 })
728 .await
729 .map_err(|error| format!("web snapshot renderer failed: {error}"))
730}
731
732#[expect(
733 clippy::too_many_lines,
734 reason = "predates the lint; see .github/baselines/expect_budget.txt"
735)]
736#[async_trait]
737impl ToolExecutor for WebFetchTool {
738 fn name(&self) -> &'static str {
739 "web_fetch"
740 }
741
742 fn schema(&self) -> ToolDefinition {
743 ToolDefinition {
744 name: "web_fetch".to_string(),
745 description: "Fetch a public HTTP(S) URL into a bounded session snapshot, or inspect \
746 a prior snapshot without refetching. Use pattern for case-insensitive \
747 matching, or start_line + line_count for stable continuation."
748 .to_string(),
749 input_schema: serde_json::json!({
750 "type": "object",
751 "properties": {
752 "url": {
753 "type": "string",
754 "format": "uri",
755 "maxLength": 8192,
756 "description": "Public HTTP(S) URL to fetch"
757 },
758 "snapshot_id": {
759 "type": "string",
760 "pattern": "^web-[0-9]+$",
761 "description": "Snapshot returned by an earlier web_fetch call"
762 },
763 "pattern": {
764 "type": "string",
765 "minLength": 1,
766 "maxLength": 1024,
767 "description": "Case-insensitive substring to find in the page (not a regex)"
768 },
769 "context_lines": {
770 "type": "integer",
771 "minimum": 0,
772 "maximum": 10,
773 "default": 2,
774 "description": "Context lines around each match (default 2, max 10)"
775 },
776 "start_line": {
777 "type": "integer",
778 "minimum": 1,
779 "description": "First 1-based snapshot line to return"
780 },
781 "line_count": {
782 "type": "integer",
783 "minimum": 1,
784 "maximum": 500,
785 "default": 200,
786 "description": "Maximum snapshot lines to return"
787 }
788 },
789 "oneOf": [
790 { "required": ["url"], "not": { "required": ["snapshot_id"] } },
791 { "required": ["snapshot_id"], "not": { "required": ["url"] } }
792 ],
793 "additionalProperties": false
794 }),
795 }
796 }
797
798 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
799 let request = match parse_fetch_args(&args) {
800 Ok(request) => request,
801 Err(error) => return ToolOutcome::error(error, 0.0),
802 };
803 let start = std::time::Instant::now();
804 let snapshot_scope = FetchSnapshotScope::from_context(&ctx);
805 let (page, snapshot_id) = match &request.target {
806 FetchTarget::Snapshot(snapshot_id) => {
807 let page = self
808 .snapshots
809 .lock()
810 .unwrap_or_else(std::sync::PoisonError::into_inner)
811 .get(&snapshot_scope, snapshot_id);
812 let Some(page) = page else {
813 return ToolOutcome::error(
814 format!(
815 "web_fetch: snapshot '{snapshot_id}' is unavailable or was evicted"
816 ),
817 start.elapsed().as_secs_f64(),
818 );
819 };
820 (page, snapshot_id.to_string())
821 },
822 FetchTarget::Url(url) => {
823 let safe_url = mermaid_model::utils::sanitize_url_for_display(url.as_str());
824 if let Some(blocked) = super::policy_gate::gate_external(
825 &ctx,
826 "web_fetch",
827 mermaid_runtime::ToolCategory::Web,
828 format!("web_fetch via {} {safe_url}", self.backend_name),
829 &args,
830 )
831 .await
832 {
833 return blocked;
834 }
835 let fetch = self.backend.fetch(url.as_str(), ctx.web_budget());
836 let page = tokio::select! {
837 biased;
838 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
839 result = fetch => match result {
840 Ok(page) => page,
841 Err(error) => {
842 return fetch_failure_outcome(
843 &error,
844 url.as_str(),
845 self.backend_name,
846 start.elapsed().as_secs_f64(),
847 request.pattern.clone(),
848 request.context_lines,
849 );
850 },
851 },
852 };
853 let inserted = self
854 .snapshots
855 .lock()
856 .unwrap_or_else(std::sync::PoisonError::into_inner)
857 .insert(snapshot_scope, page);
858 match inserted {
859 Ok((snapshot_id, page)) => (page, snapshot_id),
860 Err(error) => {
861 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
862 },
863 }
864 },
865 };
866
867 let render_page = Arc::clone(&page);
868 let render_snapshot_id = snapshot_id.clone();
869 let render_pattern = request.pattern.clone();
870 let render_context_lines = request.context_lines;
871 let render_start_line = request.start_line;
872 let render_line_count = request.line_count;
873 let render = run_snapshot_blocking(move || {
874 format_fetch(
875 &render_page,
876 &render_snapshot_id,
877 render_pattern.as_deref(),
878 render_context_lines,
879 render_start_line,
880 render_line_count,
881 )
882 });
883 let formatted = tokio::select! {
884 biased;
885 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
886 result = render => match result {
887 Ok(formatted) => formatted,
888 Err(error) => {
889 return ToolOutcome::error(error, start.elapsed().as_secs_f64());
890 },
891 },
892 };
893 let duration_secs = start.elapsed().as_secs_f64();
894 let line_count = formatted.output.lines().count();
895 let byte_count = formatted.output.len();
896 let title = (!page.title.is_empty()).then(|| bounded_title(&page.title));
897 let requested_url = mermaid_model::utils::sanitize_url_for_display(&page.requested_url);
898 let final_url = page
899 .final_url
900 .as_deref()
901 .map(mermaid_model::utils::sanitize_url_for_display);
902 let pattern_context = request.pattern.as_ref().map(|_| request.context_lines);
903 ToolOutcome::success(
904 formatted.output,
905 format!(
906 "{} {} fetched via {}",
907 line_count,
908 if line_count == 1 { "line" } else { "lines" },
909 page.backend.as_str()
910 ),
911 duration_secs,
912 )
913 .with_metadata(ToolRunMetadata {
914 detail: ToolMetadata::WebFetch {
915 url: requested_url,
916 final_url,
917 status: page.status,
918 error_kind: None,
919 media_type: page.media_type.clone(),
920 charset: page.charset.clone(),
921 backend: page.backend.as_str().to_string(),
922 extraction: page.extraction.as_str().to_string(),
923 title,
924 line_count,
925 byte_count,
926 source_byte_count: page.source_bytes,
927 output_byte_count: page.output_bytes,
928 truncated: page.truncated || formatted.truncated,
929 pattern: request.pattern,
930 context_lines: pattern_context,
931 match_count: formatted.match_count,
932 snapshot_id: Some(snapshot_id),
933 },
934 line_count: Some(line_count),
935 byte_count: Some(byte_count),
936 ..ToolRunMetadata::default()
937 })
938 }
939}
940
941enum FetchTarget {
945 Url(ValidatedWebUrl),
946 Snapshot(String),
947}
948
949struct ParsedFetchArgs {
950 target: FetchTarget,
951 pattern: Option<String>,
952 context_lines: usize,
953 start_line: Option<usize>,
954 line_count: usize,
955}
956
957fn parse_fetch_args(args: &serde_json::Value) -> Result<ParsedFetchArgs, String> {
958 let obj = args
959 .as_object()
960 .ok_or_else(|| "web_fetch arguments must be an object".to_string())?;
961 for key in obj.keys() {
962 if !matches!(
963 key.as_str(),
964 "url" | "snapshot_id" | "pattern" | "context_lines" | "start_line" | "line_count"
965 ) {
966 return Err(format!("web_fetch: unknown argument '{key}'"));
967 }
968 }
969
970 let url = match obj.get("url") {
971 None => None,
972 Some(value) => {
973 let raw = value
974 .as_str()
975 .ok_or_else(|| "web_fetch: 'url' must be a string".to_string())?
976 .trim();
977 if raw.len() > 8192 {
978 return Err("web_fetch: URL exceeds 8192 bytes".to_string());
979 }
980 Some(ValidatedWebUrl::parse(raw).map_err(|error| format!("web_fetch: {error}"))?)
981 },
982 };
983 let snapshot_id = match obj.get("snapshot_id") {
984 None => None,
985 Some(value) => {
986 let id = value
987 .as_str()
988 .ok_or_else(|| "web_fetch: 'snapshot_id' must be a string".to_string())?;
989 let valid = id.strip_prefix("web-").is_some_and(|suffix| {
990 !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
991 });
992 if !valid {
993 return Err("web_fetch: invalid snapshot id".to_string());
994 }
995 Some(id.to_string())
996 },
997 };
998 let target = match (url, snapshot_id) {
999 (Some(url), None) => FetchTarget::Url(url),
1000 (None, Some(id)) => FetchTarget::Snapshot(id),
1001 _ => {
1002 return Err("web_fetch requires exactly one of 'url' or 'snapshot_id'".to_string());
1003 },
1004 };
1005
1006 let pattern = match obj.get("pattern") {
1007 None => None,
1008 Some(value) => {
1009 let pattern = value
1010 .as_str()
1011 .ok_or_else(|| "web_fetch: 'pattern' must be a string".to_string())?
1012 .trim();
1013 if pattern.is_empty() {
1014 return Err("web_fetch: 'pattern' must not be empty".to_string());
1015 }
1016 if pattern.contains(['\r', '\n']) {
1017 return Err("web_fetch: 'pattern' must be a single line".to_string());
1018 }
1019 if pattern.chars().count() > 1024 {
1020 return Err("web_fetch: 'pattern' exceeds 1024 characters".to_string());
1021 }
1022 Some(pattern.to_string())
1023 },
1024 };
1025 let context_lines = parse_bounded_usize(obj, "context_lines", 2, 0, 10)?;
1026 if pattern.is_none() && obj.contains_key("context_lines") {
1027 return Err("web_fetch: 'context_lines' requires 'pattern'".to_string());
1028 }
1029 let has_range = obj.contains_key("start_line") || obj.contains_key("line_count");
1030 if pattern.is_some() && has_range {
1031 return Err("web_fetch: use either 'pattern' or a line range, not both".to_string());
1032 }
1033 let start_line = has_range
1034 .then(|| parse_bounded_usize(obj, "start_line", 1, 1, usize::MAX))
1035 .transpose()?;
1036 let line_count = parse_bounded_usize(obj, "line_count", 200, 1, 500)?;
1037
1038 Ok(ParsedFetchArgs {
1039 target,
1040 pattern,
1041 context_lines,
1042 start_line,
1043 line_count,
1044 })
1045}
1046
1047fn parse_bounded_usize(
1048 obj: &serde_json::Map<String, serde_json::Value>,
1049 key: &str,
1050 default: usize,
1051 min: usize,
1052 max: usize,
1053) -> Result<usize, String> {
1054 let Some(value) = obj.get(key) else {
1055 return Ok(default);
1056 };
1057 let value = value
1058 .as_u64()
1059 .and_then(|value| usize::try_from(value).ok())
1060 .ok_or_else(|| format!("web_fetch: '{key}' must be an integer"))?;
1061 if value < min || value > max {
1062 return Err(format!("web_fetch: '{key}' must be from {min} to {max}"));
1063 }
1064 Ok(value)
1065}
1066
1067const WEB_FETCH_MAX_BYTES: usize = mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES;
1070const FETCH_TRUNCATION_SUFFIX: &str = "\n\n...[content truncated]\n[/WEB_FETCH]";
1071
1072struct FormattedFetch {
1073 output: String,
1074 truncated: bool,
1075 match_count: Option<usize>,
1076}
1077
1078const MAX_PATTERN_MATCHES: usize = 20;
1082
1083fn format_fetch(
1084 page: &WebFetchResult,
1085 snapshot_id: &str,
1086 pattern: Option<&str>,
1087 ctx_lines: usize,
1088 start_line: Option<usize>,
1089 line_count: usize,
1090) -> FormattedFetch {
1091 let title = if page.title.trim().is_empty() {
1092 "(no title)".to_string()
1093 } else {
1094 bounded_title(&page.title)
1095 };
1096 let requested_url = bounded_url(&page.requested_url);
1097 let final_url = page
1098 .final_url
1099 .as_deref()
1100 .map(bounded_url)
1101 .unwrap_or_else(|| "(not disclosed by backend)".to_string());
1102 let status = page
1103 .status
1104 .map(|status| status.to_string())
1105 .unwrap_or_else(|| "unknown".to_string());
1106 let media = page.media_type.as_deref().unwrap_or("unknown");
1107 let charset = page.charset.as_deref().unwrap_or("unknown");
1108
1109 let (body, match_count) = if let Some(pattern) = pattern {
1110 match extract_matches(&page.content, pattern, ctx_lines, MAX_PATTERN_MATCHES) {
1111 Some((report, count)) => (report, Some(count)),
1112 None => (format!("No matching lines for \"{pattern}\"."), Some(0)),
1113 }
1114 } else if let Some(start_line) = start_line {
1115 (
1116 format_line_range(&page.content, start_line, line_count),
1117 None,
1118 )
1119 } else {
1120 (page.content.clone(), None)
1121 };
1122
1123 let mut output = format!(
1124 "[WEB_FETCH]\nTitle: {title}\nRequested URL: {requested_url}\nFinal URL: {final_url}\nStatus: {status}\nMedia-Type: {media}\nCharset: {charset}\nBackend: {}\nExtraction: {}\nSnapshot: {snapshot_id}\nSource bytes: {}\nExtracted bytes: {}\nSnapshot bytes: {}\nSource lines: {}\n\nContent:\n{body}\n[/WEB_FETCH]",
1125 page.backend.as_str(),
1126 page.extraction.as_str(),
1127 page.source_bytes,
1128 page.output_bytes,
1129 page.content.len(),
1130 page.content.lines().count(),
1131 );
1132 let truncated = output.len() > WEB_FETCH_MAX_BYTES;
1133 if truncated {
1134 let budget = WEB_FETCH_MAX_BYTES.saturating_sub(FETCH_TRUNCATION_SUFFIX.len());
1135 let cut = output.floor_char_boundary(budget);
1136 output.truncate(cut);
1137 output.push_str(FETCH_TRUNCATION_SUFFIX);
1138 }
1139 FormattedFetch {
1140 output,
1141 truncated,
1142 match_count,
1143 }
1144}
1145
1146fn bounded_title(title: &str) -> String {
1147 let mut bounded = String::with_capacity(title.len().min(MAX_SNAPSHOT_TITLE_BYTES));
1148 let content_budget = MAX_SNAPSHOT_TITLE_BYTES.saturating_sub(3);
1149 let mut truncated = false;
1150
1151 for word in title.split_whitespace() {
1152 let separator_bytes = usize::from(!bounded.is_empty());
1153 if bounded
1154 .len()
1155 .saturating_add(separator_bytes)
1156 .saturating_add(word.len())
1157 <= MAX_SNAPSHOT_TITLE_BYTES
1158 {
1159 if separator_bytes != 0 {
1160 bounded.push(' ');
1161 }
1162 bounded.push_str(word);
1163 continue;
1164 }
1165
1166 if bounded.len() > content_budget {
1167 bounded.truncate(bounded.floor_char_boundary(content_budget));
1168 }
1169 if bounded.len() < content_budget {
1170 if separator_bytes != 0 && bounded.len() < content_budget {
1171 bounded.push(' ');
1172 }
1173 let remaining = content_budget.saturating_sub(bounded.len());
1174 let cut = word.floor_char_boundary(remaining);
1175 bounded.push_str(&word[..cut]);
1176 }
1177 truncated = true;
1178 break;
1179 }
1180
1181 if truncated {
1182 bounded.push_str("...");
1183 }
1184 bounded
1185}
1186
1187fn bounded_url(url: &str) -> String {
1188 const MAX_DISPLAY_URL_BYTES: usize = 2048;
1189 let url = mermaid_model::utils::sanitize_url_for_display(url);
1190 if url.len() <= MAX_DISPLAY_URL_BYTES {
1191 return url;
1192 }
1193 let cut = url.floor_char_boundary(MAX_DISPLAY_URL_BYTES.saturating_sub(3));
1194 format!("{}...", &url[..cut])
1195}
1196
1197fn format_line_range(content: &str, start_line: usize, line_count: usize) -> String {
1198 let total = content.lines().count();
1199 if start_line > total {
1200 return format!("Requested line {start_line}, but the snapshot contains {total} lines.");
1201 }
1202 let mut output = format!(
1203 "Lines {start_line}-{} of {total}:\n",
1204 start_line
1205 .saturating_add(line_count)
1206 .saturating_sub(1)
1207 .min(total)
1208 );
1209 for (offset, line) in content
1210 .lines()
1211 .skip(start_line.saturating_sub(1))
1212 .take(line_count)
1213 .enumerate()
1214 {
1215 output.push_str(&format!("L{}: {line}\n", start_line + offset));
1216 }
1217 output
1218}
1219
1220fn normalized_case_fold(value: &str) -> String {
1225 use caseless::Caseless;
1226 use unicode_normalization::UnicodeNormalization;
1227
1228 value.nfd().default_case_fold().nfd().collect()
1229}
1230
1231fn extract_matches(
1238 content: &str,
1239 pattern: &str,
1240 context_lines: usize,
1241 max_blocks: usize,
1242) -> Option<(String, usize)> {
1243 let needle = normalized_case_fold(pattern);
1244 let lines: Vec<&str> = content.lines().collect();
1245 let matched: Vec<usize> = lines
1246 .iter()
1247 .enumerate()
1248 .filter(|(_, line)| normalized_case_fold(line).contains(&needle))
1249 .map(|(i, _)| i)
1250 .collect();
1251 if matched.is_empty() {
1252 return None;
1253 }
1254
1255 let mut blocks: Vec<(usize, usize)> = Vec::new();
1258 for &i in &matched {
1259 let start = i.saturating_sub(context_lines);
1260 let end = (i + context_lines).min(lines.len() - 1);
1261 match blocks.last_mut() {
1262 Some((_, last_end)) if start <= *last_end + 1 => *last_end = (*last_end).max(end),
1263 _ => blocks.push((start, end)),
1264 }
1265 }
1266 let included = &blocks[..blocks.len().min(max_blocks)];
1267 let cutoff = included.last().map(|&(_, end)| end).unwrap_or(0);
1268 let dropped = matched.iter().filter(|&&i| i > cutoff).count();
1269
1270 let mut out = format!(
1271 "{} match{} for \"{}\":\n",
1272 matched.len(),
1273 if matched.len() == 1 { "" } else { "es" },
1274 pattern
1275 );
1276 for (bi, &(start, end)) in included.iter().enumerate() {
1277 if bi > 0 {
1278 out.push_str("---\n");
1279 }
1280 for (offset, line) in lines[start..=end].iter().enumerate() {
1281 out.push_str(&format!("L{}: {}\n", start + offset + 1, line));
1283 }
1284 }
1285 if dropped > 0 {
1286 out.push_str(&format!(
1287 "(+{dropped} more match{})\n",
1288 if dropped == 1 { "" } else { "es" }
1289 ));
1290 }
1291 let match_count = matched.len();
1292 Some((out, match_count))
1293}
1294
1295fn parse_queries(args: &serde_json::Value) -> Result<Vec<(String, usize)>, String> {
1296 let obj = args
1297 .as_object()
1298 .ok_or_else(|| "web_search arguments must be an object".to_string())?;
1299 for key in obj.keys() {
1300 if !matches!(key.as_str(), "query" | "max_results" | "queries") {
1301 return Err(format!("web_search: unknown argument '{key}'"));
1302 }
1303 }
1304 if obj.contains_key("query") && obj.contains_key("queries") {
1305 return Err("web_search accepts either 'query' or 'queries', not both".to_string());
1306 }
1307
1308 if let Some(value) = obj.get("queries") {
1309 let Some(arr) = value.as_array() else {
1310 return Err("web_search: 'queries' must be an array".to_string());
1311 };
1312 if arr.is_empty() {
1313 return Err("web_search: 'queries' must contain at least one entry".to_string());
1314 }
1315 if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
1316 return Err(format!(
1317 "web_search: too many queries ({}); cap is {} per call — split the request",
1318 arr.len(),
1319 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
1320 ));
1321 }
1322 let mut out = Vec::with_capacity(arr.len());
1323 for v in arr {
1324 let Some(obj) = v.as_object() else {
1325 return Err(
1326 "web_search: 'queries' must be an array of {query, max_results}".to_string(),
1327 );
1328 };
1329 for key in obj.keys() {
1330 if !matches!(key.as_str(), "query" | "max_results") {
1331 return Err(format!("web_search: unknown query argument '{key}'"));
1332 }
1333 }
1334 out.push(parse_query_entry(obj)?);
1335 }
1336 return Ok(out);
1337 }
1338 if obj.contains_key("query") {
1339 return Ok(vec![parse_query_entry(obj)?]);
1340 }
1341 Err("web_search requires 'query' (string) or 'queries' (array)".to_string())
1342}
1343
1344fn parse_query_entry(
1345 obj: &serde_json::Map<String, serde_json::Value>,
1346) -> Result<(String, usize), String> {
1347 let query = obj
1348 .get("query")
1349 .and_then(|value| value.as_str())
1350 .ok_or_else(|| "web_search: each query needs 'query' (string)".to_string())?
1351 .trim();
1352 if query.is_empty() {
1353 return Err("web_search: query must not be empty".to_string());
1354 }
1355 if query.contains(['\r', '\n', '\0']) {
1356 return Err("web_search: query must be a single text line".to_string());
1357 }
1358 if query.chars().count() > 2048 {
1359 return Err("web_search: query exceeds 2048 characters".to_string());
1360 }
1361 let count = match obj.get("max_results") {
1362 None => 5,
1363 Some(value) => {
1364 let count = value.as_u64().ok_or_else(|| {
1365 "web_search: 'max_results' must be an integer from 1 to 10".to_string()
1366 })?;
1367 if !(1..=10).contains(&count) {
1368 return Err("web_search: 'max_results' must be from 1 to 10".to_string());
1369 }
1370 count as usize
1371 },
1372 };
1373 Ok((query.to_string(), count))
1374}
1375
1376pub(crate) fn require_http_scheme(url: &str) -> Result<reqwest::Url, String> {
1388 let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
1389 match parsed.scheme() {
1390 "http" | "https" => Ok(parsed),
1391 other => Err(format!(
1392 "unsupported URL scheme '{other}' (only http/https allowed)"
1393 )),
1394 }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399 use super::*;
1400
1401 fn page(content: impl Into<String>) -> WebFetchResult {
1402 let content = content.into();
1403 WebFetchResult {
1404 requested_url: "https://example.com/start".to_string(),
1405 final_url: Some("https://example.com/final".to_string()),
1406 status: Some(200),
1407 media_type: Some("text/html".to_string()),
1408 charset: Some("utf-8".to_string()),
1409 backend: super::super::web_client::FetchBackend::Native,
1410 extraction: super::super::web_client::ExtractionMode::Readability,
1411 source_bytes: content.len(),
1412 output_bytes: content.len(),
1413 truncated: false,
1414 title: "T".to_string(),
1415 content,
1416 }
1417 }
1418
1419 fn scope(session_id: &str) -> FetchSnapshotScope {
1420 FetchSnapshotScope {
1421 session_id: Some(compact_string(session_id)),
1422 task_id: None,
1423 fallback_turn: None,
1424 }
1425 }
1426
1427 #[test]
1428 fn require_http_scheme_accepts_http_rejects_exotic() {
1429 for good in [
1432 "http://example.com",
1433 "https://example.com/path?a=1&b=2",
1434 "http://localhost:3000",
1435 "http://127.0.0.1:8080",
1436 ] {
1437 assert!(require_http_scheme(good).is_ok(), "{good} should pass");
1438 }
1439 for bad in [
1441 "file:///etc/passwd",
1442 "javascript:alert(1)",
1443 "data:text/html,<script>",
1444 "ftp://example.com",
1445 "not a url",
1446 ] {
1447 assert!(
1448 require_http_scheme(bad).is_err(),
1449 "{bad} should be rejected"
1450 );
1451 }
1452 }
1453
1454 #[test]
1455 fn format_fetch_caps_long_content() {
1456 let big = "z".repeat(WEB_FETCH_MAX_BYTES * 2);
1458 let big_page = page(big);
1459 let out = format_fetch(&big_page, "web-1", None, 2, None, 200);
1460 assert!(
1461 out.output.len() <= WEB_FETCH_MAX_BYTES,
1462 "content must be capped, got {} bytes",
1463 out.output.len()
1464 );
1465 assert!(
1466 out.output.contains("truncated"),
1467 "expected truncation marker"
1468 );
1469 assert!(out.truncated);
1470
1471 let small = page("hello world");
1473 let out = format_fetch(&small, "web-1", None, 2, None, 200);
1474 assert!(out.output.contains("hello world"));
1475 assert!(!out.output.contains("truncated"));
1476 }
1477
1478 #[test]
1479 fn format_fetch_caps_the_complete_envelope_and_sanitizes_provenance() {
1480 let mut page = page("body");
1481 page.title = format!(" {}\n{} ", "title ".repeat(100), "tail");
1482 page.requested_url = format!(
1483 "https://alice:hunter2@example.com/page?token=opaque-secret&q={}",
1484 "x".repeat(10_000)
1485 );
1486 page.final_url = Some(page.requested_url.clone());
1487
1488 let out = format_fetch(&page, "web-1", None, 2, None, 200);
1489 assert!(out.output.len() <= WEB_FETCH_MAX_BYTES);
1490 assert!(
1491 !out.output.contains("alice"),
1492 "userinfo leaked: {}",
1493 out.output
1494 );
1495 assert!(
1496 !out.output.contains("hunter2"),
1497 "password leaked: {}",
1498 out.output
1499 );
1500 assert!(!out.output.contains("opaque-secret"), "query secret leaked");
1501 let title = out
1502 .output
1503 .lines()
1504 .find_map(|line| line.strip_prefix("Title: "))
1505 .expect("title header");
1506 assert!(title.len() <= 300);
1507 }
1508
1509 #[test]
1510 fn complete_output_budget_holds_for_multibyte_boundary_sizes() {
1511 for unit in ["a", "é", "界"] {
1512 for units in [0, 1, 14_900, 15_000, 15_100, 40_000] {
1513 let mut candidate = page(unit.repeat(units));
1514 candidate.title = unit.repeat(1_000);
1515 let formatted = format_fetch(&candidate, "web-99", None, 2, None, 200);
1516 assert!(
1517 formatted.output.len() <= WEB_FETCH_MAX_BYTES,
1518 "{} bytes escaped the complete-result cap",
1519 formatted.output.len()
1520 );
1521 assert!(std::str::from_utf8(formatted.output.as_bytes()).is_ok());
1522 assert!(formatted.output.ends_with("[/WEB_FETCH]"));
1523 }
1524 }
1525 }
1526
1527 #[test]
1528 fn snapshot_store_accounts_for_and_bounds_every_retained_string() {
1529 let original_content = "é".repeat(MAX_FETCH_SNAPSHOT_BYTES / 2 + 1_000);
1530 let original_output_bytes = original_content.len();
1531 let mut oversized = page(original_content);
1532 oversized.output_bytes = original_output_bytes;
1533 oversized.title = "title ".repeat(10_000);
1534 oversized.requested_url = format!("https://example.com/{}", "r".repeat(20_000));
1535 oversized.final_url = Some(format!("https://example.com/{}", "f".repeat(20_000)));
1536 oversized.media_type = Some("m".repeat(1_000));
1537 oversized.charset = Some("c".repeat(1_000));
1538
1539 let owner = scope("session-retained-size");
1540 let mut store = FetchSnapshotStore::default();
1541 let (id, bounded) = store.insert(owner.clone(), oversized).unwrap();
1542 let entry = store.entries.back().expect("snapshot entry");
1543 let expected = entry
1544 .id
1545 .capacity()
1546 .saturating_add(entry.scope.retained_string_bytes())
1547 .saturating_add(page_retained_string_bytes(&entry.page));
1548
1549 assert_eq!(entry.id, id);
1550 assert_eq!(entry.retained_bytes, expected);
1551 assert_eq!(store.bytes, expected);
1552 assert!(store.bytes <= MAX_FETCH_SNAPSHOT_BYTES);
1553 assert_eq!(bounded.output_bytes, original_output_bytes);
1554 assert!(bounded.truncated);
1555 assert!(bounded.title.len() <= MAX_SNAPSHOT_TITLE_BYTES);
1556 assert!(bounded.requested_url.len() <= MAX_SNAPSHOT_URL_BYTES);
1557 assert!(bounded.final_url.as_ref().unwrap().len() <= MAX_SNAPSHOT_URL_BYTES);
1558 assert!(bounded.media_type.as_ref().unwrap().len() <= MAX_SNAPSHOT_MEDIA_TYPE_BYTES);
1559 assert!(bounded.charset.as_ref().unwrap().len() <= MAX_SNAPSHOT_CHARSET_BYTES);
1560 assert!(std::str::from_utf8(bounded.content.as_bytes()).is_ok());
1561 }
1562
1563 #[test]
1564 fn snapshot_store_isolates_session_and_task_owners() {
1565 let owner = FetchSnapshotScope {
1566 session_id: Some(compact_string("session-a")),
1567 task_id: Some(compact_string("task-a")),
1568 fallback_turn: None,
1569 };
1570 let mut store = FetchSnapshotStore::default();
1571 let (id, _) = store.insert(owner.clone(), page("private page")).unwrap();
1572
1573 assert!(store.get(&owner, &id).is_some());
1574 for outsider in [
1575 FetchSnapshotScope {
1576 session_id: Some(compact_string("session-b")),
1577 task_id: Some(compact_string("task-a")),
1578 fallback_turn: None,
1579 },
1580 FetchSnapshotScope {
1581 session_id: Some(compact_string("session-a")),
1582 task_id: Some(compact_string("task-b")),
1583 fallback_turn: None,
1584 },
1585 ] {
1586 assert!(store.get(&outsider, &id).is_none());
1587 }
1588 }
1589
1590 #[test]
1591 fn snapshot_store_evicts_the_oldest_entry_at_the_count_limit() {
1592 let mut store = FetchSnapshotStore::default();
1593 let owner = scope("session-eviction");
1594 let mut ids = Vec::new();
1595 for index in 0..=MAX_FETCH_SNAPSHOTS {
1596 ids.push(
1597 store
1598 .insert(owner.clone(), page(format!("page {index}")))
1599 .unwrap()
1600 .0,
1601 );
1602 }
1603 assert!(
1604 store.get(&owner, &ids[0]).is_none(),
1605 "oldest snapshot was not evicted"
1606 );
1607 assert!(store.get(&owner, ids.last().unwrap()).is_some());
1608 assert_eq!(store.entries.len(), MAX_FETCH_SNAPSHOTS);
1609 }
1610
1611 #[test]
1612 fn extract_matches_finds_case_insensitive_with_context() {
1613 let content = "line one\nline two\nTARGET here\nline four\nline five";
1614 let (out, count) = extract_matches(content, "target", 1, 20).unwrap();
1615 assert_eq!(count, 1);
1616 assert!(out.starts_with("1 match for \"target\":"));
1617 assert!(out.contains("L2: line two"));
1618 assert!(out.contains("L3: TARGET here"));
1619 assert!(out.contains("L4: line four"));
1620 assert!(!out.contains("L1:"), "context clipped to 1 line: {out}");
1621 assert!(!out.contains("L5:"));
1622 }
1623
1624 #[test]
1625 fn extract_matches_merges_overlapping_windows() {
1626 let content = "a\nhit one\nhit two\nb\nc\nd\ne\nf\ng\nhit three\nz";
1628 let (out, count) = extract_matches(content, "hit", 1, 20).unwrap();
1629 assert_eq!(count, 3);
1630 assert!(out.starts_with("3 matches"));
1631 assert_eq!(out.matches("---").count(), 1, "two blocks: {out}");
1632 assert_eq!(out.matches("hit one").count(), 1);
1634 }
1635
1636 #[test]
1637 fn extract_matches_caps_blocks_and_reports_tail() {
1638 let content = (0..25)
1640 .map(|i| format!("match {i}\nx\nx\nx\nx\nx"))
1641 .collect::<Vec<_>>()
1642 .join("\n");
1643 let (out, count) = extract_matches(&content, "match", 0, 20).unwrap();
1644 assert_eq!(count, 25);
1645 assert!(out.starts_with("25 matches"));
1646 assert_eq!(out.matches("---").count(), 19, "20 blocks: {out}");
1647 assert!(out.contains("(+5 more matches)"), "tail note: {out}");
1648 }
1649
1650 #[test]
1651 fn extract_matches_none_and_multibyte() {
1652 assert!(extract_matches("nothing here", "absent", 2, 20).is_none());
1653 let content = "voil\u{e0} un r\u{e9}sultat\nplain line";
1655 let (out, count) = extract_matches(content, "R\u{c9}SULTAT", 0, 20).unwrap();
1656 assert_eq!(count, 1);
1657 assert!(out.contains("L1: voil\u{e0} un r\u{e9}sultat"));
1658 assert!(!out.contains("plain line"));
1660 }
1661
1662 #[test]
1663 fn extract_matches_uses_full_unicode_case_folding() {
1664 let content = "Die Straße ist lang\nSTRASSE in capitals\nother";
1665 let (out, count) = extract_matches(content, "strasse", 0, 20).unwrap();
1666 assert!(out.starts_with("2 matches for \"strasse\":"), "{out}");
1667 assert!(out.contains("L1: Die Straße ist lang"), "{out}");
1668 assert!(out.contains("L2: STRASSE in capitals"), "{out}");
1669 assert_eq!(count, 2);
1670 }
1671
1672 #[test]
1673 fn extract_matches_normalizes_composed_and_decomposed_text() {
1674 let content = "Café noir\nCafe\u{301} blanc\nplain";
1675 let decomposed_pattern = "CAFE\u{301}";
1676 let (out, count) = extract_matches(content, decomposed_pattern, 0, 20).unwrap();
1677 assert!(out.starts_with("2 matches"), "{out}");
1678 assert!(out.contains("L1: Café noir"), "{out}");
1679 assert!(out.contains("L2: Cafe\u{301} blanc"), "{out}");
1680 assert_eq!(count, 2);
1681 }
1682
1683 #[test]
1684 fn format_fetch_pattern_paths() {
1685 let page = page("alpha\nbeta\ngamma");
1686 let out = format_fetch(&page, "web-1", Some("beta"), 1, None, 200);
1688 assert!(out.output.contains("1 match for \"beta\""));
1689 assert!(out.output.contains("L2: beta"));
1690 let out = format_fetch(&page, "web-1", Some("nope"), 1, None, 200);
1692 assert!(out.output.contains("No matching lines for \"nope\"."));
1693 assert!(!out.output.contains("alpha"));
1694 }
1695
1696 #[test]
1697 fn find_in_page_runs_before_the_cap() {
1698 let mut content = "x\n".repeat(WEB_FETCH_MAX_BYTES / 2);
1701 content.push_str("needle in the tail\n");
1702 let page = page(content);
1703 let out = format_fetch(&page, "web-1", Some("needle"), 1, None, 200);
1704 assert!(
1705 out.output.contains("1 match for \"needle\""),
1706 "tail match found"
1707 );
1708 assert!(out.output.contains("needle in the tail"));
1709 }
1710
1711 #[test]
1712 fn parse_queries_single_form() {
1713 let args = serde_json::json!({"query": "rust async", "max_results": 3});
1714 let q = parse_queries(&args).unwrap();
1715 assert_eq!(q.len(), 1);
1716 assert_eq!(q[0].0, "rust async");
1717 assert_eq!(q[0].1, 3);
1718 }
1719
1720 #[test]
1721 fn parse_queries_array_form() {
1722 let args = serde_json::json!({"queries": [
1723 {"query": "a", "max_results": 2},
1724 {"query": "b", "max_results": 5},
1725 ]});
1726 let q = parse_queries(&args).unwrap();
1727 assert_eq!(q.len(), 2);
1728 assert_eq!(q[1].1, 5);
1729 }
1730
1731 #[test]
1732 fn parse_queries_missing_errors() {
1733 let args = serde_json::json!({});
1734 assert!(parse_queries(&args).is_err());
1735 }
1736
1737 #[test]
1738 fn parse_queries_rejects_out_of_range_count() {
1739 let args = serde_json::json!({"query": "q", "max_results": 999});
1740 assert!(parse_queries(&args).is_err());
1741 let args = serde_json::json!({"query": "q", "max_results": 0});
1742 assert!(parse_queries(&args).is_err());
1743 let args = serde_json::json!({"query": "q", "max_results": "5"});
1744 assert!(parse_queries(&args).is_err());
1745 }
1746
1747 #[test]
1748 fn parse_queries_rejects_ambiguous_and_unknown_arguments() {
1749 assert!(
1750 parse_queries(&serde_json::json!({"query":"a", "queries":[{"query":"b"}]})).is_err()
1751 );
1752 assert!(parse_queries(&serde_json::json!({"query":"a", "extra":true})).is_err());
1753 assert!(parse_queries(&serde_json::json!({"query":" "})).is_err());
1754 assert!(parse_queries(&serde_json::json!({"query":"safe\n=== injected ==="})).is_err());
1755 }
1756
1757 #[test]
1758 fn parse_queries_rejects_excess_fan_out() {
1759 let many: Vec<_> = (0..mermaid_model::constants::MAX_BATCH_TOOL_ITEMS + 1)
1761 .map(|i| serde_json::json!({"query": format!("q{i}")}))
1762 .collect();
1763 let args = serde_json::json!({ "queries": many });
1764 assert!(parse_queries(&args).is_err());
1765
1766 let at_cap: Vec<_> = (0..mermaid_model::constants::MAX_BATCH_TOOL_ITEMS)
1768 .map(|i| serde_json::json!({"query": format!("q{i}")}))
1769 .collect();
1770 let args = serde_json::json!({ "queries": at_cap });
1771 assert_eq!(
1772 parse_queries(&args).unwrap().len(),
1773 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
1774 );
1775 }
1776
1777 #[test]
1778 fn parse_fetch_args_is_strict_and_rejects_credentialed_urls() {
1779 for invalid in [
1780 serde_json::json!({}),
1781 serde_json::json!({"url": "https://example.com", "snapshot_id": "web-1"}),
1782 serde_json::json!({"url": "https://user:password@example.com"}),
1783 serde_json::json!({"url": "http://127.0.0.1/private"}),
1784 serde_json::json!({"snapshot_id": "bad"}),
1785 serde_json::json!({"snapshot_id": "web-1", "context_lines": 2}),
1786 serde_json::json!({"snapshot_id": "web-1", "pattern": "x", "start_line": 1}),
1787 serde_json::json!({"snapshot_id": "web-1", "unknown": true}),
1788 ] {
1789 assert!(parse_fetch_args(&invalid).is_err(), "accepted {invalid}");
1790 }
1791
1792 let parsed = parse_fetch_args(&serde_json::json!({
1793 "url": "https://example.com/page#fragment",
1794 "start_line": 4,
1795 "line_count": 2
1796 }))
1797 .unwrap();
1798 let FetchTarget::Url(url) = &parsed.target else {
1799 panic!("expected a URL target");
1800 };
1801 assert_eq!(url.as_str(), "https://example.com/page");
1802 assert_eq!(parsed.start_line, Some(4));
1803 assert_eq!(parsed.line_count, 2);
1804 }
1805
1806 #[tokio::test]
1807 async fn web_fetch_failure_retains_typed_backend_provenance() {
1808 use crate::providers::ctx::test_exec_context;
1809 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
1810
1811 struct FailingFetch;
1812
1813 #[async_trait]
1814 impl FetchProvider for FailingFetch {
1815 async fn fetch(
1816 &self,
1817 url: &str,
1818 _budget: crate::providers::ctx::WebByteBudget,
1819 ) -> Result<WebFetchResult, WebFetchError> {
1820 Err(WebFetchError::HttpStatus {
1821 status: 503,
1822 url: url.to_string(),
1823 })
1824 }
1825 }
1826
1827 let tool = WebFetchTool::new_with_test_snapshots(Arc::new(FailingFetch), "mock");
1828 let (ctx, _rx) =
1829 test_exec_context(TurnId(9), ToolCallId(9), std::path::PathBuf::from("/tmp"));
1830 let outcome = tool
1831 .execute(serde_json::json!({"url": "https://example.com/page"}), ctx)
1832 .await;
1833
1834 assert_eq!(outcome.status, ToolStatus::Error);
1835 match &outcome.metadata.detail {
1836 ToolMetadata::WebFetch {
1837 status,
1838 error_kind,
1839 backend,
1840 final_url,
1841 ..
1842 } => {
1843 assert_eq!(*status, Some(503));
1844 assert_eq!(error_kind.as_deref(), Some("http_status"));
1845 assert_eq!(backend, "mock");
1846 assert!(final_url.is_none());
1847 },
1848 other => panic!("expected web metadata, got {other:?}"),
1849 }
1850 }
1851
1852 #[tokio::test]
1853 async fn web_search_progress_redacts_without_changing_transport_query() {
1854 use crate::providers::ctx::test_exec_context;
1855 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
1856
1857 struct RecordingSearch {
1858 seen: Arc<Mutex<Option<String>>>,
1859 }
1860
1861 #[async_trait]
1862 impl SearchProvider for RecordingSearch {
1863 async fn search(
1864 &self,
1865 query: &str,
1866 _count: usize,
1867 _budget: crate::providers::ctx::WebByteBudget,
1868 ) -> anyhow::Result<Vec<crate::providers::tool::web_client::SearchResult>> {
1869 *self
1870 .seen
1871 .lock()
1872 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(query.to_string());
1873 Ok(Vec::new())
1874 }
1875 }
1876
1877 let seen = Arc::new(Mutex::new(None));
1878 let tool = WebSearchTool {
1879 backend: Arc::new(RecordingSearch { seen: seen.clone() }),
1880 backend_name: "mock",
1881 };
1882 let (ctx, mut progress) =
1883 test_exec_context(TurnId(91), ToolCallId(91), std::path::PathBuf::from("/tmp"));
1884 let query = "research OPENAI_API_KEY=abc";
1885 let outcome = tool.execute(serde_json::json!({"query": query}), ctx).await;
1886 assert_eq!(outcome.status, ToolStatus::Success);
1887 assert_eq!(
1888 seen.lock()
1889 .unwrap_or_else(std::sync::PoisonError::into_inner)
1890 .as_deref(),
1891 Some(query),
1892 "redaction must not alter the transport query"
1893 );
1894 let ProgressEvent::Status(status) = progress.recv().await.expect("search progress") else {
1895 panic!("expected search status progress");
1896 };
1897 assert!(
1898 !status.contains("abc"),
1899 "progress leaked query secret: {status}"
1900 );
1901 assert!(status.contains("OPENAI_API_KEY=[REDACTED]"));
1902 }
1903
1904 #[tokio::test]
1905 async fn snapshot_line_ranges_do_not_refetch_mutable_pages() {
1906 use crate::providers::ctx::test_exec_context;
1907 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
1908 use std::sync::atomic::{AtomicUsize, Ordering};
1909
1910 struct MockFetch {
1911 calls: Arc<AtomicUsize>,
1912 }
1913
1914 #[async_trait]
1915 impl FetchProvider for MockFetch {
1916 async fn fetch(
1917 &self,
1918 url: &str,
1919 _budget: crate::providers::ctx::WebByteBudget,
1920 ) -> Result<WebFetchResult, WebFetchError> {
1921 self.calls.fetch_add(1, Ordering::SeqCst);
1922 let mut result = page("one\ntwo\nthree");
1923 result.requested_url = url.to_string();
1924 result.final_url = Some(url.to_string());
1925 Ok(result)
1926 }
1927 }
1928
1929 let calls = Arc::new(AtomicUsize::new(0));
1930 let tool = WebFetchTool::new_with_test_snapshots(
1931 Arc::new(MockFetch {
1932 calls: calls.clone(),
1933 }),
1934 "mock",
1935 );
1936 let (mut ctx, _rx) =
1937 test_exec_context(TurnId(10), ToolCallId(10), std::path::PathBuf::from("/tmp"));
1938 ctx.session_id = Some("session-a".to_string());
1939 let first = tool
1940 .execute(serde_json::json!({"url": "https://example.com/page"}), ctx)
1941 .await;
1942 assert_eq!(first.status, ToolStatus::Success);
1943 let snapshot_id = match &first.metadata.detail {
1944 ToolMetadata::WebFetch { snapshot_id, .. } => snapshot_id.clone().expect("snapshot id"),
1945 other => panic!("expected web metadata, got {other:?}"),
1946 };
1947
1948 let (mut foreign_ctx, _rx) =
1949 test_exec_context(TurnId(11), ToolCallId(11), std::path::PathBuf::from("/tmp"));
1950 foreign_ctx.session_id = Some("session-b".to_string());
1951 let foreign = tool
1952 .execute(
1953 serde_json::json!({
1954 "snapshot_id": snapshot_id.clone(),
1955 "start_line": 2,
1956 "line_count": 1
1957 }),
1958 foreign_ctx,
1959 )
1960 .await;
1961 assert_eq!(foreign.status, ToolStatus::Error);
1962 assert!(foreign.output().contains("unavailable or was evicted"));
1963
1964 let (mut ctx, _rx) =
1965 test_exec_context(TurnId(12), ToolCallId(12), std::path::PathBuf::from("/tmp"));
1966 ctx.session_id = Some("session-a".to_string());
1967 let continuation = tool
1968 .execute(
1969 serde_json::json!({
1970 "snapshot_id": snapshot_id,
1971 "start_line": 2,
1972 "line_count": 1
1973 }),
1974 ctx,
1975 )
1976 .await;
1977 assert_eq!(continuation.status, ToolStatus::Success);
1978 assert!(continuation.output().contains("L2: two"));
1979 assert!(!continuation.output().contains("L1: one"));
1980 assert_eq!(
1981 calls.load(Ordering::SeqCst),
1982 1,
1983 "snapshot triggered a refetch"
1984 );
1985 }
1986
1987 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1988 async fn snapshot_blocking_work_respects_the_global_extractor_limit() {
1989 use std::sync::atomic::{AtomicUsize, Ordering};
1990
1991 let active = Arc::new(AtomicUsize::new(0));
1992 let peak = Arc::new(AtomicUsize::new(0));
1993 let mut jobs = Vec::new();
1994 for _ in 0..8 {
1995 let active = Arc::clone(&active);
1996 let peak = Arc::clone(&peak);
1997 jobs.push(tokio::spawn(async move {
1998 run_snapshot_blocking(move || {
1999 let now = active.fetch_add(1, Ordering::SeqCst) + 1;
2000 peak.fetch_max(now, Ordering::SeqCst);
2001 std::thread::sleep(std::time::Duration::from_millis(20));
2002 active.fetch_sub(1, Ordering::SeqCst);
2003 })
2004 .await
2005 .unwrap();
2006 }));
2007 }
2008 for job in jobs {
2009 job.await.unwrap();
2010 }
2011
2012 assert_eq!(active.load(Ordering::SeqCst), 0);
2013 assert!(
2014 peak.load(Ordering::SeqCst) <= mermaid_model::constants::MAX_WEB_EXTRACTION_CONCURRENCY
2015 );
2016 }
2017
2018 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2019 async fn cancelled_snapshot_waiter_keeps_its_permit_until_blocking_work_finishes() {
2020 let limiter = Arc::new(tokio::sync::Semaphore::new(1));
2021 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2022 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
2023 let worker_limiter = limiter.clone();
2024 let worker = tokio::spawn(async move {
2025 run_snapshot_blocking_with(worker_limiter, move || {
2026 let _ = started_tx.send(());
2027 release_rx.recv().expect("test releases blocking worker");
2028 })
2029 .await
2030 });
2031 started_rx.await.expect("blocking worker started");
2032 worker.abort();
2033 let _ = worker.await;
2034 assert_eq!(
2035 limiter.available_permits(),
2036 0,
2037 "cancelling the async waiter released a still-running blocking job"
2038 );
2039
2040 release_tx.send(()).expect("release blocking worker");
2041 tokio::time::timeout(std::time::Duration::from_secs(1), async {
2042 while limiter.available_permits() == 0 {
2043 tokio::task::yield_now().await;
2044 }
2045 })
2046 .await
2047 .expect("blocking worker did not release its permit");
2048 }
2049
2050 #[tokio::test]
2051 async fn web_search_batch_survives_empty_and_failed_queries() {
2052 use crate::providers::ctx::test_exec_context;
2053 use crate::providers::tool::web_client::SearchResult;
2054 use async_trait::async_trait;
2055 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2056 use std::sync::Arc;
2057
2058 struct Mock;
2059 #[async_trait]
2060 impl SearchProvider for Mock {
2061 async fn search(
2062 &self,
2063 query: &str,
2064 _count: usize,
2065 _budget: crate::providers::ctx::WebByteBudget,
2066 ) -> anyhow::Result<Vec<SearchResult>> {
2067 match query {
2068 "boom" => Err(anyhow::anyhow!("backend down")),
2069 "empty" => Ok(Vec::new()),
2070 _ => Ok(vec![SearchResult {
2071 title: "Title".to_string(),
2072 url: "https://example.com".to_string(),
2073 snippet: "snip".to_string(),
2074 full_content: "content".to_string(),
2075 }]),
2076 }
2077 }
2078 }
2079
2080 let mk = || WebSearchTool {
2081 backend: Arc::new(Mock),
2082 backend_name: "mock",
2083 };
2084 let tmp = std::path::PathBuf::from("/tmp");
2085
2086 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), tmp.clone());
2088 let out = mk()
2089 .execute(
2090 serde_json::json!({"queries": [{"query":"good"},{"query":"empty"},{"query":"boom"}]}),
2091 ctx,
2092 )
2093 .await;
2094 assert_eq!(
2095 out.status,
2096 ToolStatus::Success,
2097 "a partial batch must not abort"
2098 );
2099 assert!(
2100 out.output().contains("https://example.com"),
2101 "keeps the good result"
2102 );
2103 match &out.metadata.detail {
2104 ToolMetadata::WebSearch {
2105 partial, failures, ..
2106 } => {
2107 assert!(*partial);
2108 assert_eq!(failures.len(), 1);
2109 assert_eq!(failures[0].query_index, 2);
2110 assert!(failures[0].error.contains("backend down"));
2111 },
2112 other => panic!("expected web search metadata, got {other:?}"),
2113 }
2114
2115 let (ctx, _rx) = test_exec_context(TurnId(2), ToolCallId(2), tmp.clone());
2117 let out = mk()
2118 .execute(serde_json::json!({"query": "empty"}), ctx)
2119 .await;
2120 assert_eq!(out.status, ToolStatus::Success, "empty is not an error");
2121 assert!(out.output().contains("no results"));
2122
2123 let (ctx, _rx) = test_exec_context(TurnId(3), ToolCallId(3), tmp);
2125 let out = mk()
2126 .execute(
2127 serde_json::json!({"queries": [{"query":"boom"},{"query":"boom"}]}),
2128 ctx,
2129 )
2130 .await;
2131 assert_eq!(out.status, ToolStatus::Error, "total failure is an error");
2132 match &out.metadata.detail {
2133 ToolMetadata::WebSearch {
2134 failed_queries,
2135 failures,
2136 ..
2137 } => {
2138 assert_eq!(*failed_queries, 2);
2139 assert_eq!(failures.len(), 2);
2140 },
2141 other => panic!("expected web search metadata, got {other:?}"),
2142 }
2143 }
2144
2145 #[tokio::test]
2146 async fn web_search_batch_caps_concurrency_and_restores_input_order() {
2147 use crate::providers::ctx::test_exec_context;
2148 use crate::providers::tool::web_client::SearchResult;
2149 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2150 use std::sync::atomic::{AtomicUsize, Ordering};
2151
2152 struct ConcurrencyMock {
2153 active: Arc<AtomicUsize>,
2154 peak: Arc<AtomicUsize>,
2155 }
2156
2157 #[async_trait]
2158 impl SearchProvider for ConcurrencyMock {
2159 async fn search(
2160 &self,
2161 query: &str,
2162 _count: usize,
2163 _budget: crate::providers::ctx::WebByteBudget,
2164 ) -> anyhow::Result<Vec<SearchResult>> {
2165 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
2166 self.peak.fetch_max(active, Ordering::SeqCst);
2167 let delay = 5 + (6 - query.parse::<u64>().unwrap_or(0)) * 5;
2168 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
2169 self.active.fetch_sub(1, Ordering::SeqCst);
2170 Ok(vec![SearchResult {
2171 title: format!("result {query}"),
2172 url: format!("https://example.com/{query}"),
2173 snippet: String::new(),
2174 full_content: format!("content {query}"),
2175 }])
2176 }
2177 }
2178
2179 let active = Arc::new(AtomicUsize::new(0));
2180 let peak = Arc::new(AtomicUsize::new(0));
2181 let tool = WebSearchTool {
2182 backend: Arc::new(ConcurrencyMock {
2183 active: active.clone(),
2184 peak: peak.clone(),
2185 }),
2186 backend_name: "mock",
2187 };
2188 let (ctx, _rx) =
2189 test_exec_context(TurnId(20), ToolCallId(20), std::path::PathBuf::from("/tmp"));
2190 let queries: Vec<_> = (0..6)
2191 .map(|index| serde_json::json!({"query": index.to_string()}))
2192 .collect();
2193 let outcome = tool
2194 .execute(serde_json::json!({"queries": queries}), ctx)
2195 .await;
2196 assert_eq!(outcome.status, ToolStatus::Success);
2197 assert_eq!(active.load(Ordering::SeqCst), 0);
2198 assert_eq!(
2199 peak.load(Ordering::SeqCst),
2200 mermaid_model::constants::MAX_WEB_SEARCH_CONCURRENCY
2201 );
2202 let mut cursor = 0;
2203 for index in 0..6 {
2204 let marker = format!("=== query: {index} ===");
2205 let position = outcome.output()[cursor..]
2206 .find(&marker)
2207 .map(|offset| cursor + offset)
2208 .expect("ordered query section");
2209 assert!(position >= cursor);
2210 cursor = position + marker.len();
2211 }
2212 }
2213
2214 #[tokio::test]
2215 async fn web_search_complete_output_budget_is_byte_exact_for_multibyte_text() {
2216 use crate::providers::ctx::test_exec_context;
2217 use crate::providers::tool::web_client::SearchResult;
2218 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2219
2220 struct MultibyteMock;
2221
2222 #[async_trait]
2223 impl SearchProvider for MultibyteMock {
2224 async fn search(
2225 &self,
2226 _query: &str,
2227 _count: usize,
2228 _budget: crate::providers::ctx::WebByteBudget,
2229 ) -> anyhow::Result<Vec<SearchResult>> {
2230 Ok(vec![SearchResult {
2231 title: "界".repeat(5_000),
2232 url: "https://example.com/result".to_string(),
2233 snippet: String::new(),
2234 full_content: "界".repeat(20_000),
2235 }])
2236 }
2237 }
2238
2239 let tool = WebSearchTool {
2240 backend: Arc::new(MultibyteMock),
2241 backend_name: "mock",
2242 };
2243 let (ctx, _rx) =
2244 test_exec_context(TurnId(21), ToolCallId(21), std::path::PathBuf::from("/tmp"));
2245 let outcome = tool
2246 .execute(serde_json::json!({"query": "multibyte"}), ctx)
2247 .await;
2248
2249 assert_eq!(outcome.status, ToolStatus::Success);
2250 assert!(
2251 outcome.output().len() <= mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES,
2252 "{} bytes escaped the complete search-result cap",
2253 outcome.output().len()
2254 );
2255 assert!(std::str::from_utf8(outcome.output().as_bytes()).is_ok());
2256 }
2257
2258 #[tokio::test]
2259 async fn web_search_total_failure_and_structured_errors_are_byte_bounded() {
2260 use crate::providers::ctx::test_exec_context;
2261 use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2262
2263 struct LargeFailure;
2264
2265 #[async_trait]
2266 impl SearchProvider for LargeFailure {
2267 async fn search(
2268 &self,
2269 _query: &str,
2270 _count: usize,
2271 _budget: crate::providers::ctx::WebByteBudget,
2272 ) -> anyhow::Result<Vec<crate::providers::tool::web_client::SearchResult>> {
2273 Err(anyhow::anyhow!("{}", "界".repeat(20_000)))
2274 }
2275 }
2276
2277 let tool = WebSearchTool {
2278 backend: Arc::new(LargeFailure),
2279 backend_name: "mock",
2280 };
2281 let (ctx, _rx) =
2282 test_exec_context(TurnId(22), ToolCallId(22), std::path::PathBuf::from("/tmp"));
2283 let outcome = tool
2284 .execute(
2285 serde_json::json!({"queries": [{"query": "one"}, {"query": "two"}]}),
2286 ctx,
2287 )
2288 .await;
2289
2290 assert_eq!(outcome.status, ToolStatus::Error);
2291 assert!(
2292 outcome.output().len() <= mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES,
2293 "{} bytes escaped the complete search error cap",
2294 outcome.output().len()
2295 );
2296 let ToolMetadata::WebSearch { failures, .. } = &outcome.metadata.detail else {
2297 panic!("expected web search metadata");
2298 };
2299 assert_eq!(failures.len(), 2);
2300 assert!(
2301 failures
2302 .iter()
2303 .all(|failure| failure.error.len() <= MAX_WEB_SEARCH_FAILURE_BYTES)
2304 );
2305 }
2306}