1use std::{
11 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
12 sync::Arc,
13 time::Duration,
14};
15
16use async_trait::async_trait;
17use futures_util::StreamExt;
18use reqwest::{
19 Url,
20 dns::{Addrs, Name, Resolve, Resolving},
21 redirect,
22};
23use scv_core::{
24 Tool, ToolContext, ToolError, ToolFailure, ToolOutput, ToolRegistry, ToolRisk, ToolSpec,
25};
26use serde::Deserialize;
27use serde_json::{Value, json};
28
29use crate::args::{bounded, parse_args};
30
31const USER_AGENT: &str = concat!(
32 "scv/",
33 env!("CARGO_PKG_VERSION"),
34 " (+https://github.com/PeiyuanQi/scv)"
35);
36const MAX_URL_BYTES: usize = 4096;
37const MAX_QUERY_BYTES: usize = 512;
38const MAX_SEARCH_RESPONSE_BYTES: usize = 1024 * 1024;
39const HTML_WIDTH: usize = 120;
40
41#[derive(Debug, Clone)]
43pub struct WebToolsConfig {
44 pub fetch_max_bytes: usize,
45 pub fetch_timeout: Duration,
46 pub max_redirects: usize,
47 pub auto_approve_domains: Vec<String>,
50 pub allow_private_addresses: bool,
51 pub search: Option<SearchBackend>,
52 pub max_search_results: usize,
53 pub output_limit: usize,
54}
55
56#[derive(Clone)]
59pub enum SearchBackend {
60 Searxng { url: String },
61 Brave { url: String, api_key: String },
62}
63
64impl std::fmt::Debug for SearchBackend {
65 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::Searxng { url } => formatter.debug_struct("Searxng").field("url", url).finish(),
68 Self::Brave { url, .. } => formatter
69 .debug_struct("Brave")
70 .field("url", url)
71 .field("api_key", &"[REDACTED]")
72 .finish(),
73 }
74 }
75}
76
77pub fn register(registry: &mut ToolRegistry, config: WebToolsConfig) -> Result<(), ToolError> {
79 let config = Arc::new(config);
80 let allow_private = config.allow_private_addresses;
81 registry.register(Arc::new(WebFetchTool {
82 config: Arc::clone(&config),
83 address_allowed: Arc::new(move |address: SocketAddr| {
84 allow_private || is_public(address.ip())
85 }),
86 }))?;
87 if let Some(backend) = config.search.clone() {
88 registry.register(Arc::new(WebSearchTool {
89 backend,
90 config: Arc::clone(&config),
91 }))?;
92 }
93 Ok(())
94}
95
96type AddressCheck = Arc<dyn Fn(SocketAddr) -> bool + Send + Sync>;
99
100struct WebFetchTool {
101 config: Arc<WebToolsConfig>,
102 address_allowed: AddressCheck,
103}
104
105#[derive(Deserialize)]
106#[serde(deny_unknown_fields)]
107struct FetchArgs {
108 url: String,
109 #[serde(default)]
110 offset: Option<usize>,
111}
112
113impl WebFetchTool {
114 fn parse_url(&self, value: &str) -> Result<Url, ToolError> {
115 if value.len() > MAX_URL_BYTES {
116 return Err(ToolError::invalid_arguments(format!(
117 "url exceeds {MAX_URL_BYTES} bytes"
118 )));
119 }
120 let url = Url::parse(value.trim())
121 .map_err(|error| ToolError::invalid_arguments(format!("invalid url: {error}")))?;
122 check_url_shape(&url)?;
123 Ok(url)
124 }
125
126 fn auto_approved(&self, url: &Url) -> bool {
127 url.scheme() == "https"
128 && url
129 .host_str()
130 .is_some_and(|host| domain_listed(&self.config.auto_approve_domains, host))
131 }
132}
133
134fn check_url_shape(url: &Url) -> Result<(), ToolError> {
136 if !matches!(url.scheme(), "http" | "https") {
137 return Err(ToolError::invalid_arguments(format!(
138 "web_fetch supports only http and https URLs, not {}",
139 url.scheme()
140 )));
141 }
142 if url.host_str().is_none_or(str::is_empty) {
143 return Err(ToolError::invalid_arguments("url has no host"));
144 }
145 if !url.username().is_empty() || url.password().is_some() {
146 return Err(ToolError::invalid_arguments(
147 "urls with embedded credentials are not allowed",
148 ));
149 }
150 Ok(())
151}
152
153fn host_ip(url: &Url) -> Option<IpAddr> {
155 let host = url.host_str()?;
156 host.trim_start_matches('[')
157 .trim_end_matches(']')
158 .parse()
159 .ok()
160}
161
162fn check_literal(url: &Url, allowed: &AddressCheck) -> Result<(), String> {
163 if let Some(ip) = host_ip(url) {
164 let port = url.port_or_known_default().unwrap_or(0);
165 if !allowed(SocketAddr::new(ip, port)) {
166 return Err(format!(
167 "{ip} is a loopback, private, or otherwise non-public address"
168 ));
169 }
170 }
171 Ok(())
172}
173
174pub(crate) fn domain_listed(domains: &[String], host: &str) -> bool {
176 let host = host.trim_end_matches('.').to_ascii_lowercase();
177 domains.iter().any(|entry| {
178 let entry = entry.trim_end_matches('.').to_ascii_lowercase();
179 match entry.strip_prefix("*.") {
180 Some(parent) => host
181 .strip_suffix(parent)
182 .is_some_and(|prefix| prefix.len() > 1 && prefix.ends_with('.')),
183 None => host == entry,
184 }
185 })
186}
187
188pub(crate) fn is_public(ip: IpAddr) -> bool {
192 match ip {
193 IpAddr::V4(ip) => is_public_v4(ip),
194 IpAddr::V6(ip) => is_public_v6(ip),
195 }
196}
197
198fn is_public_v4(ip: Ipv4Addr) -> bool {
199 let [a, b, c, _] = ip.octets();
200 !(ip.is_unspecified()
201 || ip.is_loopback()
202 || ip.is_private()
203 || ip.is_link_local()
204 || ip.is_broadcast()
205 || ip.is_multicast()
206 || ip.is_documentation()
207 || a == 0
208 || (a == 100 && (64..128).contains(&b))
209 || (a == 192 && b == 0 && c == 0)
210 || (a == 198 && (b == 18 || b == 19))
211 || a >= 240)
212}
213
214fn is_public_v6(ip: Ipv6Addr) -> bool {
215 let segments = ip.segments();
216 if let Some(v4) = ip.to_ipv4_mapped() {
217 return is_public_v4(v4);
218 }
219 let embedded = Ipv4Addr::from(ip.to_bits() as u32);
222 if segments[..6] == [0; 6] || segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
223 return !ip.is_unspecified() && !ip.is_loopback() && is_public_v4(embedded);
224 }
225 if segments[0] == 0x2002 {
227 let v4 = Ipv4Addr::new(
228 (segments[1] >> 8) as u8,
229 segments[1] as u8,
230 (segments[2] >> 8) as u8,
231 segments[2] as u8,
232 );
233 return is_public_v4(v4);
234 }
235 !(ip.is_unspecified()
236 || ip.is_loopback()
237 || ip.is_multicast()
238 || (segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80 || (segments[0] & 0xffc0) == 0xfec0 || (segments[0] == 0x2001 && segments[1] == 0x0db8) || (segments[0] == 0x2001 && segments[1] == 0)) }
244
245struct CheckedResolver {
248 allowed: AddressCheck,
249}
250
251impl Resolve for CheckedResolver {
252 fn resolve(&self, name: Name) -> Resolving {
253 let allowed = Arc::clone(&self.allowed);
254 let host = name.as_str().to_owned();
255 Box::pin(async move {
256 let addresses = resolve_checked(&host, &allowed).await?;
257 Ok(Box::new(addresses.into_iter()) as Addrs)
258 })
259 }
260}
261
262async fn resolve_checked(
263 host: &str,
264 allowed: &AddressCheck,
265) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error + Send + Sync>> {
266 let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host, 0)).await?.collect();
267 check_resolved(host, &addresses, allowed)?;
268 Ok(addresses)
269}
270
271fn check_resolved(
273 host: &str,
274 addresses: &[SocketAddr],
275 allowed: &AddressCheck,
276) -> Result<(), String> {
277 if addresses.is_empty() {
278 return Err(format!("{host} did not resolve to any address"));
279 }
280 if let Some(refused) = addresses.iter().find(|address| !allowed(**address)) {
281 return Err(format!(
282 "{host} resolves to {}, a loopback, private, or otherwise non-public address",
283 refused.ip()
284 ));
285 }
286 Ok(())
287}
288
289enum Body {
290 Html,
291 Text,
292}
293
294fn classify(content_type: Option<&str>, bytes: &[u8]) -> Result<Body, String> {
296 let Some(media) = content_type.map(|value| {
297 value
298 .split(';')
299 .next()
300 .unwrap_or("")
301 .trim()
302 .to_ascii_lowercase()
303 }) else {
304 let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]).to_ascii_lowercase();
305 return if head.contains("<html") || head.contains("<!doctype html") {
306 Ok(Body::Html)
307 } else if std::str::from_utf8(bytes).is_ok()
308 || std::str::from_utf8(&bytes[..bytes.len().saturating_sub(4)]).is_ok()
309 {
310 Ok(Body::Text)
311 } else {
312 Err("the response has no content type and is not text".into())
313 };
314 };
315 if media == "text/html" || media == "application/xhtml+xml" {
316 return Ok(Body::Html);
317 }
318 let textual = media.starts_with("text/")
319 || media.ends_with("+json")
320 || media.ends_with("+xml")
321 || matches!(
322 media.as_str(),
323 "application/json"
324 | "application/xml"
325 | "application/javascript"
326 | "application/ecmascript"
327 | "application/x-javascript"
328 | "application/toml"
329 | "application/yaml"
330 | "application/x-yaml"
331 | "application/x-ndjson"
332 | "application/sql"
333 | "application/graphql"
334 );
335 if textual {
336 Ok(Body::Text)
337 } else {
338 Err(format!(
339 "web_fetch returns text only; {media} is not a text content type"
340 ))
341 }
342}
343
344fn page(text: &str, offset: usize, budget: usize) -> (String, Option<usize>) {
347 let mut output = String::new();
348 for (taken, character) in text.chars().skip(offset).enumerate() {
349 if output.len() + character.len_utf8() > budget {
350 return (output, Some(offset + taken));
351 }
352 output.push(character);
353 }
354 (output, None)
355}
356
357fn error_chain(error: &reqwest::Error) -> String {
358 let mut message = error.to_string();
359 let mut source = std::error::Error::source(error);
360 while let Some(cause) = source {
361 let text = cause.to_string();
362 if !message.contains(&text) {
363 message.push_str(": ");
364 message.push_str(&text);
365 }
366 source = cause.source();
367 }
368 message
369}
370
371#[async_trait]
372impl Tool for WebFetchTool {
373 fn spec(&self) -> ToolSpec {
374 ToolSpec {
375 name: "web_fetch".into(),
376 description: format!(
377 "Fetch a public web page or HTTP API with a GET request and return it as readable text \
378 (HTML is converted to text; JSON and plain text pass through). Use it to read \
379 documentation, release notes, issues, or a URL the user gave, and to open results \
380 from web search. Long pages are returned in parts: call again with the reported \
381 offset. Only public addresses are reachable. HTTPS pages on {} are fetched without \
382 approval; other hosts need approval because the URL is sent to that site.",
383 if self.config.auto_approve_domains.is_empty() {
384 "no hosts".to_owned()
385 } else {
386 self.config.auto_approve_domains.join(", ")
387 }
388 ),
389 parameters: json!({
390 "type":"object",
391 "properties":{
392 "url":{"type":"string","description":"Absolute http or https URL"},
393 "offset":{"type":"integer","minimum":0,"description":"Character offset of the part to return, from a previous call"}
394 },
395 "required":["url"],
396 "additionalProperties":false
397 }),
398 }
399 }
400
401 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
402 let args: FetchArgs = parse_args(arguments)?;
403 let url = self.parse_url(&args.url)?;
404 Ok(if self.auto_approved(&url) {
405 ToolRisk::ReadOnly
406 } else {
407 ToolRisk::Network
408 })
409 }
410
411 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
412 let args: FetchArgs = parse_args(arguments)?;
413 let url = self.parse_url(&args.url)?;
414 Ok(format!(
415 "Fetch {} with an HTTP GET (no cookies or credentials). The full URL is sent to {}.",
416 bounded(url.as_str(), 2000),
417 url.host_str().unwrap_or("the host")
418 ))
419 }
420
421 async fn execute(
422 &self,
423 arguments: Value,
424 context: ToolContext,
425 ) -> Result<ToolOutput, ToolError> {
426 let args: FetchArgs = parse_args(&arguments)?;
427 let url = self.parse_url(&args.url)?;
428 check_literal(&url, &self.address_allowed).map_err(ToolError::invalid_arguments)?;
429 let auto_approved = self.auto_approved(&url);
430 let max_redirects = self.config.max_redirects;
431 let domains = self.config.auto_approve_domains.clone();
432 let allowed = Arc::clone(&self.address_allowed);
433 let policy = redirect::Policy::custom(move |attempt| {
434 if attempt.previous().len() > max_redirects {
435 return attempt.error(format!("stopped after {max_redirects} redirects"));
436 }
437 let next = attempt.url().clone();
438 if let Err(error) = check_url_shape(&next) {
439 return attempt.error(error.message);
440 }
441 if let Err(error) = check_literal(&next, &allowed) {
442 return attempt.error(error);
443 }
444 if auto_approved
445 && !(next.scheme() == "https"
446 && next
447 .host_str()
448 .is_some_and(|host| domain_listed(&domains, host)))
449 {
450 return attempt.error(format!(
451 "redirected to {next}, outside the auto-approved hosts; call web_fetch with that URL to request approval"
452 ));
453 }
454 attempt.follow()
455 });
456 let client = reqwest::Client::builder()
457 .user_agent(USER_AGENT)
458 .timeout(self.config.fetch_timeout)
459 .connect_timeout(self.config.fetch_timeout.min(Duration::from_secs(10)))
460 .redirect(policy)
461 .referer(false)
462 .no_proxy()
463 .dns_resolver(Arc::new(CheckedResolver {
464 allowed: Arc::clone(&self.address_allowed),
465 }))
466 .build()
467 .map_err(|error| ToolError::failed(format!("create HTTP client: {error}")))?;
468 let request = client.get(url.clone()).header(
469 reqwest::header::ACCEPT,
470 "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.9,*/*;q=0.5",
471 );
472 let response = tokio::select! {
473 result = request.send() => result.map_err(|error| ToolError::failed(format!("fetch {url}: {}", error_chain(&error))))?,
474 () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web fetch cancelled")),
475 };
476 let status = response.status();
477 let final_url = response.url().clone();
478 let content_type = response
479 .headers()
480 .get(reqwest::header::CONTENT_TYPE)
481 .and_then(|value| value.to_str().ok())
482 .map(str::to_owned);
483 if content_type.is_some()
485 && let Err(message) = classify(content_type.as_deref(), &[])
486 {
487 return Ok(ToolOutput::failed(
488 ToolFailure::Failed,
489 format!("URL: {final_url}\nStatus: {}\n{message}", status.as_u16()),
490 ));
491 }
492 let limit = self.config.fetch_max_bytes;
493 let mut stream = response.bytes_stream();
494 let mut bytes = Vec::with_capacity(limit.min(64 * 1024));
495 let mut download_truncated = false;
496 loop {
497 let chunk = tokio::select! {
498 chunk = stream.next() => chunk,
499 () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web fetch cancelled")),
500 };
501 let Some(chunk) = chunk else { break };
502 let chunk = chunk.map_err(|error| {
503 ToolError::failed(format!("read {final_url}: {}", error_chain(&error)))
504 })?;
505 let remaining = limit - bytes.len();
506 if chunk.len() > remaining {
507 bytes.extend_from_slice(&chunk[..remaining]);
508 download_truncated = true;
509 break;
510 }
511 bytes.extend_from_slice(&chunk);
512 }
513 let kind = match classify(content_type.as_deref(), &bytes) {
514 Ok(kind) => kind,
515 Err(message) => {
516 return Ok(ToolOutput::failed(
517 ToolFailure::Failed,
518 format!("URL: {final_url}\nStatus: {}\n{message}", status.as_u16()),
519 ));
520 }
521 };
522 let text = match kind {
523 Body::Text => String::from_utf8_lossy(&bytes).into_owned(),
524 Body::Html => tokio::task::spawn_blocking(move || {
525 html2text::from_read(bytes.as_slice(), HTML_WIDTH)
526 .map_err(|error| ToolError::failed(format!("convert HTML: {error}")))
527 })
528 .await
529 .map_err(|error| {
530 ToolError::failed(format!("HTML conversion task failed: {error}"))
531 })??,
532 };
533 let offset = args.offset.unwrap_or(0);
534 let total = text.chars().count();
535 let header = format!(
536 "URL: {final_url}\nStatus: {}\nContent-Type: {}\nCharacters: {offset}-{{end}} of {total}{}\n\n",
537 status.as_u16(),
538 content_type.as_deref().unwrap_or("unknown"),
539 if download_truncated {
540 format!(" (download stopped at {limit} bytes)")
541 } else {
542 String::new()
543 }
544 );
545 let footer_reserve = 160;
546 let budget = self
547 .config
548 .output_limit
549 .saturating_sub(header.len() + footer_reserve)
550 .max(1);
551 let (body, next) = page(&text, offset, budget);
552 let end = offset + body.chars().count();
553 let mut content = header.replace("{end}", &end.to_string());
554 content.push_str(&body);
555 if let Some(next) = next {
556 content.push_str(&format!(
557 "\n\n[{} more characters; call web_fetch with offset={next} for the next part]",
558 total - next
559 ));
560 }
561 Ok(ToolOutput {
562 content,
563 failure: (status.is_client_error() || status.is_server_error())
564 .then_some(ToolFailure::Failed),
565 truncated: next.is_some() || download_truncated,
566 })
567 }
568}
569
570struct WebSearchTool {
571 backend: SearchBackend,
572 config: Arc<WebToolsConfig>,
573}
574
575#[derive(Deserialize)]
576#[serde(deny_unknown_fields)]
577struct SearchArgs {
578 query: String,
579 #[serde(default)]
580 count: Option<usize>,
581}
582
583impl WebSearchTool {
584 fn validate(&self, args: &SearchArgs) -> Result<(), ToolError> {
585 let query = args.query.trim();
586 if query.is_empty() {
587 return Err(ToolError::invalid_arguments("query must not be empty"));
588 }
589 if query.len() > MAX_QUERY_BYTES {
590 return Err(ToolError::invalid_arguments(format!(
591 "query exceeds {MAX_QUERY_BYTES} bytes"
592 )));
593 }
594 Ok(())
595 }
596
597 fn backend_name(&self) -> &'static str {
598 match self.backend {
599 SearchBackend::Searxng { .. } => "SearXNG",
600 SearchBackend::Brave { .. } => "Brave Search",
601 }
602 }
603}
604
605#[derive(Debug, PartialEq)]
606struct SearchResult {
607 title: String,
608 url: String,
609 snippet: String,
610}
611
612fn parse_results(backend: &SearchBackend, body: &Value) -> Result<Vec<SearchResult>, String> {
613 let (items, snippet_field) = match backend {
614 SearchBackend::Searxng { .. } => (body.get("results"), "content"),
615 SearchBackend::Brave { .. } => (
616 body.get("web").and_then(|web| web.get("results")),
617 "description",
618 ),
619 };
620 let Some(items) = items else {
621 return Ok(Vec::new());
622 };
623 let items = items
624 .as_array()
625 .ok_or_else(|| "search results are not a list".to_owned())?;
626 Ok(items
627 .iter()
628 .filter_map(|item| {
629 let url = item.get("url")?.as_str()?.to_owned();
630 let text = |field: &str| {
631 item.get(field)
632 .and_then(Value::as_str)
633 .map(strip_tags)
634 .unwrap_or_default()
635 };
636 Some(SearchResult {
637 title: text("title"),
638 url,
639 snippet: text(snippet_field),
640 })
641 })
642 .collect())
643}
644
645fn strip_tags(value: &str) -> String {
647 let mut output = String::with_capacity(value.len());
648 let mut in_tag = false;
649 for character in value.chars() {
650 match character {
651 '<' => in_tag = true,
652 '>' if in_tag => in_tag = false,
653 _ if !in_tag => output.push(character),
654 _ => {}
655 }
656 }
657 output
658 .replace("&", "&")
659 .replace("<", "<")
660 .replace(">", ">")
661 .replace(""", "\"")
662 .replace("'", "'")
663 .split_whitespace()
664 .collect::<Vec<_>>()
665 .join(" ")
666}
667
668fn format_results(query: &str, results: &[SearchResult], limit: usize) -> String {
669 if results.is_empty() {
670 return format!("No results for {query:?}.");
671 }
672 let mut output = format!("Results for {query:?}:\n");
673 for (index, result) in results.iter().enumerate() {
674 let entry = format!(
675 "\n{}. {}\n {}\n {}\n",
676 index + 1,
677 if result.title.is_empty() {
678 "(untitled)"
679 } else {
680 &result.title
681 },
682 result.url,
683 bounded(&result.snippet, 400)
684 );
685 if output.len() + entry.len() > limit {
686 break;
687 }
688 output.push_str(&entry);
689 }
690 output
691}
692
693#[async_trait]
694impl Tool for WebSearchTool {
695 fn spec(&self) -> ToolSpec {
696 ToolSpec {
697 name: "web_search".into(),
698 description: format!(
699 "Search the web with {} and return result titles, URLs, and snippets. Use it for \
700 current facts, versions, documentation locations, and error messages, then open \
701 the most relevant results with web_fetch.",
702 self.backend_name()
703 ),
704 parameters: json!({
705 "type":"object",
706 "properties":{
707 "query":{"type":"string"},
708 "count":{"type":"integer","minimum":1,"maximum":self.config.max_search_results,"description":"Number of results (default and maximum shown)"}
709 },
710 "required":["query"],
711 "additionalProperties":false
712 }),
713 }
714 }
715
716 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
719 let args: SearchArgs = parse_args(arguments)?;
720 self.validate(&args)?;
721 Ok(ToolRisk::ReadOnly)
722 }
723
724 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
725 let args: SearchArgs = parse_args(arguments)?;
726 self.validate(&args)?;
727 Ok(format!(
728 "Search {} for {:?}",
729 self.backend_name(),
730 bounded(args.query.trim(), 500)
731 ))
732 }
733
734 async fn execute(
735 &self,
736 arguments: Value,
737 context: ToolContext,
738 ) -> Result<ToolOutput, ToolError> {
739 let args: SearchArgs = parse_args(&arguments)?;
740 self.validate(&args)?;
741 let query = args.query.trim().to_owned();
742 let count = args
743 .count
744 .unwrap_or(self.config.max_search_results)
745 .clamp(1, self.config.max_search_results);
746 let client = reqwest::Client::builder()
747 .user_agent(USER_AGENT)
748 .timeout(self.config.fetch_timeout)
749 .redirect(redirect::Policy::limited(3))
750 .build()
751 .map_err(|error| ToolError::failed(format!("create HTTP client: {error}")))?;
752 let request = match &self.backend {
753 SearchBackend::Searxng { url } => {
754 let endpoint = format!("{}/search", url.trim_end_matches('/'));
755 client
756 .get(endpoint)
757 .query(&[("q", query.as_str()), ("format", "json")])
758 }
759 SearchBackend::Brave { url, api_key } => client
760 .get(url)
761 .query(&[("q", query.as_str()), ("count", &count.to_string())])
762 .header(reqwest::header::ACCEPT, "application/json")
763 .header("X-Subscription-Token", api_key),
764 };
765 let response = tokio::select! {
766 result = request.send() => result.map_err(|error| ToolError::failed(format!("{} request failed: {}", self.backend_name(), error_chain(&error))))?,
767 () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web search cancelled")),
768 };
769 let status = response.status();
770 let mut stream = response.bytes_stream();
771 let mut bytes = Vec::new();
772 loop {
773 let chunk = tokio::select! {
774 chunk = stream.next() => chunk,
775 () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web search cancelled")),
776 };
777 let Some(chunk) = chunk else { break };
778 let chunk = chunk.map_err(|error| {
779 ToolError::failed(format!(
780 "{} response failed: {}",
781 self.backend_name(),
782 error_chain(&error)
783 ))
784 })?;
785 if bytes.len() + chunk.len() > MAX_SEARCH_RESPONSE_BYTES {
786 return Err(ToolError::limit(format!(
787 "{} response exceeded {MAX_SEARCH_RESPONSE_BYTES} bytes",
788 self.backend_name()
789 )));
790 }
791 bytes.extend_from_slice(&chunk);
792 }
793 if !status.is_success() {
794 let body = String::from_utf8_lossy(&bytes[..bytes.len().min(300)]).into_owned();
795 let hint = match (&self.backend, status.as_u16()) {
796 (SearchBackend::Searxng { .. }, 403) => {
797 " (enable the json format under search.formats in SearXNG's settings.yml)"
798 }
799 (SearchBackend::Brave { .. }, 401 | 403 | 422) => {
800 " (check the Brave Search API key)"
801 }
802 _ => "",
803 };
804 return Ok(ToolOutput::failed(
805 ToolFailure::Failed,
806 format!(
807 "{} returned HTTP {}{hint}: {}",
808 self.backend_name(),
809 status.as_u16(),
810 bounded(&body, 300)
811 ),
812 ));
813 }
814 let body: Value = serde_json::from_slice(&bytes).map_err(|error| {
815 ToolError::failed(format!(
816 "{} returned invalid JSON: {error}",
817 self.backend_name()
818 ))
819 })?;
820 let mut results = parse_results(&self.backend, &body).map_err(ToolError::failed)?;
821 results.truncate(count);
822 Ok(ToolOutput::success(format_results(
823 &query,
824 &results,
825 self.config.output_limit,
826 )))
827 }
828}
829
830#[cfg(test)]
831mod tests;