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