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