1#[cfg(feature = "bot-auth")]
7use crate::bot_auth::BotAuthConfig;
8use crate::client::{fetch_with_options, FetchOptions};
9use crate::dns::DnsPolicy;
10use crate::error::{FetchError, ToolError};
11use crate::fetchers::FetcherRegistry;
12use crate::file_saver::FileSaver;
13use crate::transport::HttpTransport;
14use crate::types::{FetchRequest, FetchResponse};
15use futures::future::BoxFuture;
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Map, Value};
18use std::sync::Arc;
19use std::task::{Context, Poll};
20use std::time::Instant;
21use tower::Service;
22
23const DEFAULT_LOCALE: &str = "en-US";
24const TOOL_NAME: &str = "web_fetch";
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ToolStatus {
29 pub phase: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub message: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub percent_complete: Option<f32>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub eta_ms: Option<u64>,
40}
41
42impl ToolStatus {
43 pub fn new(phase: impl Into<String>) -> Self {
45 Self {
46 phase: phase.into(),
47 message: None,
48 percent_complete: None,
49 eta_ms: None,
50 }
51 }
52
53 pub fn with_message(mut self, message: impl Into<String>) -> Self {
55 self.message = Some(message.into());
56 self
57 }
58
59 pub fn with_percent(mut self, percent: f32) -> Self {
61 self.percent_complete = Some(percent);
62 self
63 }
64
65 pub fn with_eta(mut self, eta_ms: u64) -> Self {
67 self.eta_ms = Some(eta_ms);
68 self
69 }
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct ToolImage {
75 pub base64: String,
76 pub media_type: String,
77}
78
79#[derive(Debug, Clone)]
81pub struct ToolOutputMetadata {
82 pub duration: std::time::Duration,
83 pub extra: Value,
84}
85
86impl Default for ToolOutputMetadata {
87 fn default() -> Self {
88 Self {
89 duration: std::time::Duration::default(),
90 extra: json!({}),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct ToolOutput {
98 pub result: Value,
99 pub images: Vec<ToolImage>,
100 pub metadata: ToolOutputMetadata,
101}
102
103#[derive(Clone, Default)]
121pub struct ToolBuilder {
122 locale: String,
123 enable_markdown: bool,
125 enable_text: bool,
127 user_agent: Option<String>,
129 allow_prefixes: Vec<String>,
131 block_prefixes: Vec<String>,
133 dns_policy: DnsPolicy,
135 max_body_size: Option<usize>,
137 enable_save_to_file: bool,
139 respect_proxy_env: bool,
141 allowed_ports: Vec<u16>,
143 blocked_hosts: Vec<String>,
145 same_host_redirects_only: bool,
147 #[cfg(feature = "bot-auth")]
149 bot_auth: Option<BotAuthConfig>,
150 transport: Option<Arc<dyn HttpTransport>>,
152}
153
154impl std::fmt::Debug for ToolBuilder {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 let mut d = f.debug_struct("ToolBuilder");
159 d.field("locale", &self.locale)
160 .field("enable_markdown", &self.enable_markdown)
161 .field("enable_text", &self.enable_text)
162 .field("user_agent", &self.user_agent)
163 .field("allow_prefixes", &self.allow_prefixes)
164 .field("block_prefixes", &self.block_prefixes)
165 .field("dns_policy", &self.dns_policy)
166 .field("max_body_size", &self.max_body_size)
167 .field("enable_save_to_file", &self.enable_save_to_file)
168 .field("respect_proxy_env", &self.respect_proxy_env)
169 .field("allowed_ports", &self.allowed_ports)
170 .field("blocked_hosts", &self.blocked_hosts)
171 .field("same_host_redirects_only", &self.same_host_redirects_only);
172 #[cfg(feature = "bot-auth")]
173 d.field("bot_auth", &self.bot_auth);
174 d.field(
175 "transport",
176 &self
177 .transport
178 .as_ref()
179 .map(|_| "<custom>")
180 .unwrap_or("None"),
181 );
182 d.finish()
183 }
184}
185
186impl ToolBuilder {
187 pub fn new() -> Self {
189 Self {
190 locale: DEFAULT_LOCALE.to_string(),
191 enable_markdown: true,
192 enable_text: true,
193 ..Default::default()
194 }
195 }
196
197 pub fn locale(mut self, locale: &str) -> Self {
199 self.locale = normalize_locale(locale);
200 self
201 }
202
203 pub fn enable_markdown(mut self, enable: bool) -> Self {
205 self.enable_markdown = enable;
206 self
207 }
208
209 pub fn enable_text(mut self, enable: bool) -> Self {
211 self.enable_text = enable;
212 self
213 }
214
215 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
217 self.user_agent = Some(ua.into());
218 self
219 }
220
221 pub fn allow_prefix(mut self, prefix: impl Into<String>) -> Self {
223 self.allow_prefixes.push(prefix.into());
224 self
225 }
226
227 pub fn block_prefix(mut self, prefix: impl Into<String>) -> Self {
229 self.block_prefixes.push(prefix.into());
230 self
231 }
232
233 pub fn max_body_size(mut self, size: usize) -> Self {
239 self.max_body_size = Some(size);
240 self
241 }
242
243 pub fn enable_save_to_file(mut self, enable: bool) -> Self {
246 self.enable_save_to_file = enable;
247 self
248 }
249
250 pub fn allow_port(mut self, port: u16) -> Self {
254 if !self.allowed_ports.contains(&port) {
255 self.allowed_ports.push(port);
256 }
257 self
258 }
259
260 pub fn block_host(mut self, host: impl Into<String>) -> Self {
262 self.blocked_hosts.push(host.into());
263 self
264 }
265
266 pub fn block_host_suffix(mut self, suffix: impl Into<String>) -> Self {
270 let mut suffix = suffix.into();
271 if !suffix.starts_with('.') {
272 suffix.insert(0, '.');
273 }
274 self.blocked_hosts.push(suffix);
275 self
276 }
277
278 pub fn same_host_redirects_only(mut self, enable: bool) -> Self {
280 self.same_host_redirects_only = enable;
281 self
282 }
283
284 pub fn same_host_redirects_only_if_set(mut self, enable: Option<bool>) -> Self {
286 if let Some(enable) = enable {
287 self.same_host_redirects_only = enable;
288 }
289 self
290 }
291
292 pub fn block_private_ips(mut self, block: bool) -> Self {
301 self.dns_policy = if block {
302 DnsPolicy::block_private_ips()
303 } else {
304 DnsPolicy::allow_all()
305 };
306 self
307 }
308
309 pub fn respect_proxy_env(mut self, respect: bool) -> Self {
314 self.respect_proxy_env = respect;
315 self
316 }
317
318 pub fn use_env_proxy(mut self, enable: bool) -> Self {
320 self.respect_proxy_env = enable;
321 self
322 }
323
324 #[cfg(feature = "bot-auth")]
329 pub fn bot_auth(mut self, config: BotAuthConfig) -> Self {
330 self.bot_auth = Some(config);
331 self
332 }
333
334 pub fn transport(mut self, transport: Arc<dyn HttpTransport>) -> Self {
343 self.transport = Some(transport);
344 self
345 }
346
347 pub fn hardened(mut self) -> Self {
353 self = self
354 .block_private_ips(true)
355 .use_env_proxy(false)
356 .allow_port(80)
357 .allow_port(443)
358 .block_host("localhost")
359 .block_host_suffix(".local")
360 .block_host_suffix(".internal")
361 .block_host_suffix(".svc")
362 .block_host_suffix(".cluster.local")
363 .same_host_redirects_only(true);
364 self
365 }
366
367 pub fn build(&self) -> Tool {
369 Tool {
370 locale: self.locale.clone(),
371 display_name: display_name(&self.locale).to_string(),
372 description: description(&self.locale, self.enable_save_to_file),
373 version: env!("CARGO_PKG_VERSION").to_string(),
374 enable_markdown: self.enable_markdown,
375 enable_text: self.enable_text,
376 user_agent: self.user_agent.clone(),
377 allow_prefixes: self.allow_prefixes.clone(),
378 block_prefixes: self.block_prefixes.clone(),
379 dns_policy: self.dns_policy.clone(),
380 max_body_size: self.max_body_size,
381 enable_save_to_file: self.enable_save_to_file,
382 respect_proxy_env: self.respect_proxy_env,
383 allowed_ports: self.allowed_ports.clone(),
384 blocked_hosts: self.blocked_hosts.clone(),
385 same_host_redirects_only: self.same_host_redirects_only,
386 #[cfg(feature = "bot-auth")]
387 bot_auth: self.bot_auth.clone(),
388 transport: self.transport.clone(),
389 }
390 }
391
392 pub fn build_service(&self) -> ToolService {
394 ToolService { tool: self.build() }
395 }
396
397 pub fn build_executor(&self) -> ToolService {
399 self.build_service()
400 }
401
402 pub fn build_tool_definition(&self) -> Value {
404 let tool = self.build();
405 json!({
406 "type": "function",
407 "function": {
408 "name": tool.name(),
409 "description": tool.description(),
410 "parameters": tool.input_schema()
411 }
412 })
413 }
414
415 pub fn build_input_schema(&self) -> Value {
417 build_input_schema(
418 self.enable_markdown,
419 self.enable_text,
420 self.enable_save_to_file,
421 )
422 }
423
424 pub fn build_output_schema(&self) -> Value {
426 build_output_schema()
427 }
428}
429
430#[derive(Clone)]
435pub struct Tool {
436 locale: String,
437 display_name: String,
438 description: String,
439 version: String,
440 enable_markdown: bool,
441 enable_text: bool,
442 user_agent: Option<String>,
443 allow_prefixes: Vec<String>,
444 block_prefixes: Vec<String>,
445 dns_policy: DnsPolicy,
446 max_body_size: Option<usize>,
447 enable_save_to_file: bool,
448 respect_proxy_env: bool,
449 allowed_ports: Vec<u16>,
450 blocked_hosts: Vec<String>,
451 same_host_redirects_only: bool,
452 #[cfg(feature = "bot-auth")]
453 bot_auth: Option<BotAuthConfig>,
454 transport: Option<Arc<dyn HttpTransport>>,
455}
456
457impl std::fmt::Debug for Tool {
460 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461 let mut d = f.debug_struct("Tool");
462 d.field("locale", &self.locale)
463 .field("display_name", &self.display_name)
464 .field("description", &self.description)
465 .field("version", &self.version)
466 .field("enable_markdown", &self.enable_markdown)
467 .field("enable_text", &self.enable_text)
468 .field("user_agent", &self.user_agent)
469 .field("allow_prefixes", &self.allow_prefixes)
470 .field("block_prefixes", &self.block_prefixes)
471 .field("dns_policy", &self.dns_policy)
472 .field("max_body_size", &self.max_body_size)
473 .field("enable_save_to_file", &self.enable_save_to_file)
474 .field("respect_proxy_env", &self.respect_proxy_env)
475 .field("allowed_ports", &self.allowed_ports)
476 .field("blocked_hosts", &self.blocked_hosts)
477 .field("same_host_redirects_only", &self.same_host_redirects_only);
478 #[cfg(feature = "bot-auth")]
479 d.field("bot_auth", &self.bot_auth);
480 d.field(
481 "transport",
482 &self
483 .transport
484 .as_ref()
485 .map(|_| "<custom>")
486 .unwrap_or("None"),
487 );
488 d.finish()
489 }
490}
491
492impl Default for Tool {
493 fn default() -> Self {
494 ToolBuilder::new().build()
495 }
496}
497
498impl Tool {
499 pub fn builder() -> ToolBuilder {
501 ToolBuilder::new()
502 }
503
504 pub fn name(&self) -> &str {
506 TOOL_NAME
507 }
508
509 pub fn display_name(&self) -> &str {
511 &self.display_name
512 }
513
514 pub fn version(&self) -> &str {
516 &self.version
517 }
518
519 pub fn description(&self) -> &str {
521 &self.description
522 }
523
524 pub fn locale(&self) -> &str {
526 &self.locale
527 }
528
529 pub fn system_prompt(&self) -> String {
531 system_prompt(
532 &self.locale,
533 self.enable_save_to_file,
534 self.dns_policy.block_private,
535 )
536 }
537
538 pub fn help(&self) -> String {
540 build_help(self)
541 }
542
543 pub fn llmtxt(&self) -> String {
545 self.help()
546 }
547
548 pub fn input_schema(&self) -> Value {
550 build_input_schema(
551 self.enable_markdown,
552 self.enable_text,
553 self.enable_save_to_file,
554 )
555 }
556
557 pub fn output_schema(&self) -> Value {
559 build_output_schema()
560 }
561
562 pub fn execution(&self, args: Value) -> Result<ToolExecution, ToolError> {
564 validate_args(self, &args)?;
565 let mut request: FetchRequest = serde_json::from_value(args)
566 .map_err(|err| invalid_arguments_error(self.locale(), &err.to_string()))?;
567 normalize_request(self, &mut request)?;
568 validate_request(self, &request)?;
569
570 Ok(ToolExecution {
571 tool: self.clone(),
572 request,
573 })
574 }
575
576 pub async fn execute(&self, req: FetchRequest) -> Result<FetchResponse, FetchError> {
578 fetch_with_options(req, self.build_options()).await
579 }
580
581 pub async fn execute_with_status<F>(
583 &self,
584 mut req: FetchRequest,
585 mut status_callback: F,
586 ) -> Result<FetchResponse, FetchError>
587 where
588 F: FnMut(ToolStatus),
589 {
590 status_callback(ToolStatus::new("validate").with_percent(0.0));
591
592 if req.url.is_empty() {
593 return Err(FetchError::MissingUrl);
594 }
595
596 req.normalize_url_for_fetch()?;
597
598 status_callback(ToolStatus::new("connect").with_percent(10.0));
599 status_callback(ToolStatus::new("fetch").with_percent(20.0));
600
601 let result = fetch_with_options(req, self.build_options()).await;
602
603 status_callback(ToolStatus::new("complete").with_percent(100.0));
604
605 result
606 }
607
608 pub async fn execute_with_saver(
616 &self,
617 req: FetchRequest,
618 saver: Option<&dyn FileSaver>,
619 ) -> Result<FetchResponse, FetchError> {
620 let mut req = req;
621 if req.url.is_empty() {
622 return Err(FetchError::MissingUrl);
623 }
624 req.normalize_url_for_fetch()?;
625
626 if req.save_to_file.is_some() {
627 if !self.enable_save_to_file {
628 return Err(FetchError::SaverNotAvailable);
629 }
630
631 let saver = saver.ok_or(FetchError::SaverNotAvailable)?;
632
633 let options = self.build_options();
634 let registry = FetcherRegistry::with_defaults();
635 registry.fetch_to_file(req, options, saver).await
636 } else {
637 self.execute(req).await
638 }
639 }
640
641 fn build_options(&self) -> FetchOptions {
642 FetchOptions {
643 user_agent: self.user_agent.clone(),
644 allow_prefixes: self.allow_prefixes.clone(),
645 block_prefixes: self.block_prefixes.clone(),
646 enable_markdown: self.enable_markdown,
647 enable_text: self.enable_text,
648 dns_policy: self.dns_policy.clone(),
649 max_body_size: self.max_body_size,
650 enable_save_to_file: self.enable_save_to_file,
651 respect_proxy_env: self.respect_proxy_env,
652 allowed_ports: self.allowed_ports.clone(),
653 blocked_hosts: self.blocked_hosts.clone(),
654 same_host_redirects_only: self.same_host_redirects_only,
655 #[cfg(feature = "bot-auth")]
656 bot_auth: self.bot_auth.clone(),
657 transport: self.transport.clone(),
660 }
661 }
662}
663
664#[derive(Debug, Clone)]
666pub struct ToolExecution {
667 tool: Tool,
668 request: FetchRequest,
669}
670
671impl ToolExecution {
672 pub async fn execute(self) -> Result<ToolOutput, ToolError> {
674 self.execute_inner(None).await
675 }
676
677 pub async fn execute_with<A>(self, saver: &A) -> Result<ToolOutput, ToolError>
679 where
680 A: FileSaver,
681 {
682 self.execute_inner(Some(saver)).await
683 }
684
685 async fn execute_inner(self, saver: Option<&dyn FileSaver>) -> Result<ToolOutput, ToolError> {
686 let ToolExecution { tool, request } = self;
687 let started_at = Instant::now();
688 let response = tool
689 .execute_with_saver(request, saver)
690 .await
691 .map_err(|err| map_fetch_error(&tool.locale, err))?;
692
693 build_tool_output(response, started_at)
694 }
695}
696
697#[derive(Debug, Clone)]
699pub struct ToolService {
700 tool: Tool,
701}
702
703impl Service<Value> for ToolService {
704 type Response = Value;
705 type Error = ToolError;
706 type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
707
708 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
709 Poll::Ready(Ok(()))
710 }
711
712 fn call(&mut self, req: Value) -> Self::Future {
713 let tool = self.tool.clone();
714 Box::pin(async move {
715 let output = tool.execution(req)?.execute().await?;
716 Ok(output.result)
717 })
718 }
719}
720
721fn build_tool_output(
722 response: FetchResponse,
723 started_at: Instant,
724) -> Result<ToolOutput, ToolError> {
725 let result = serde_json::to_value(&response)
726 .map_err(|err| ToolError::Internal(format!("failed to serialize tool output: {err}")))?;
727
728 Ok(ToolOutput {
729 result,
730 images: Vec::new(),
731 metadata: ToolOutputMetadata {
732 duration: started_at.elapsed(),
733 extra: json!({
734 "http_status": response.status_code,
735 "content_type": response.content_type,
736 "content_length": response.size,
737 "format": response.format,
738 "truncated": response.truncated.unwrap_or(false),
739 "saved_path": response.saved_path,
740 "bytes_written": response.bytes_written,
741 }),
742 },
743 })
744}
745
746fn validate_args(tool: &Tool, args: &Value) -> Result<(), ToolError> {
747 let object = args
748 .as_object()
749 .ok_or_else(|| invalid_arguments_error(tool.locale(), "arguments must be a JSON object"))?;
750
751 for key in object.keys() {
752 let allowed = match key.as_str() {
753 "url" | "method" => true,
754 "as_markdown" => tool.enable_markdown,
755 "as_text" => tool.enable_text,
756 "save_to_file" => tool.enable_save_to_file,
757 "if_none_match" | "if_modified_since" => true,
758 _ => false,
759 };
760
761 if !allowed {
762 return Err(unknown_parameter_error(tool.locale(), key));
763 }
764 }
765
766 Ok(())
767}
768
769fn validate_request(tool: &Tool, request: &FetchRequest) -> Result<(), ToolError> {
770 if request.url.is_empty() {
771 return Err(map_fetch_error(tool.locale(), FetchError::MissingUrl));
772 }
773
774 if !request.url.starts_with("http://") && !request.url.starts_with("https://") {
775 return Err(map_fetch_error(tool.locale(), FetchError::InvalidUrlScheme));
776 }
777
778 Ok(())
779}
780
781fn normalize_request(tool: &Tool, request: &mut FetchRequest) -> Result<(), ToolError> {
782 if request.url.is_empty() {
783 return Err(map_fetch_error(tool.locale(), FetchError::MissingUrl));
784 }
785
786 request
787 .normalize_url_for_fetch()
788 .map_err(|err| map_fetch_error(tool.locale(), err))
789}
790
791fn build_input_schema(
792 enable_markdown: bool,
793 enable_text: bool,
794 enable_save_to_file: bool,
795) -> Value {
796 let mut properties = Map::new();
797 properties.insert(
798 "url".to_string(),
799 json!({
800 "type": "string",
801 "description": "HTTP/HTTPS URL, or a bare domain URL normalized to https://"
802 }),
803 );
804 properties.insert(
805 "method".to_string(),
806 json!({"type": "string", "enum": ["GET", "HEAD"], "default": "GET"}),
807 );
808
809 if enable_markdown {
810 properties.insert(
811 "as_markdown".to_string(),
812 json!({"type": "boolean", "default": false}),
813 );
814 }
815
816 if enable_text {
817 properties.insert(
818 "as_text".to_string(),
819 json!({"type": "boolean", "default": false}),
820 );
821 }
822
823 if enable_save_to_file {
824 properties.insert(
825 "save_to_file".to_string(),
826 json!({
827 "type": "string",
828 "description": "Adapter-defined destination path"
829 }),
830 );
831 }
832
833 properties.insert(
834 "if_none_match".to_string(),
835 json!({
836 "type": "string",
837 "description": "ETag value for conditional requests (If-None-Match header)"
838 }),
839 );
840 properties.insert(
841 "if_modified_since".to_string(),
842 json!({
843 "type": "string",
844 "description": "Last-Modified value for conditional requests (If-Modified-Since header)"
845 }),
846 );
847
848 json!({
849 "type": "object",
850 "properties": properties,
851 "required": ["url"],
852 "additionalProperties": false,
853 })
854}
855
856fn build_output_schema() -> Value {
857 json!({
858 "type": "object",
859 "properties": {
860 "url": {"type": "string"},
861 "status_code": {"type": "integer", "minimum": 100, "maximum": 599},
862 "content_type": {"type": "string"},
863 "size": {"type": "integer", "minimum": 0},
864 "last_modified": {"type": "string"},
865 "etag": {"type": "string"},
866 "filename": {"type": "string"},
867 "format": {"type": "string", "enum": ["markdown", "text", "raw", "github_repo"]},
868 "content": {"type": "string"},
869 "truncated": {"type": "boolean"},
870 "method": {"type": "string", "enum": ["HEAD"]},
871 "error": {"type": "string"},
872 "saved_path": {"type": "string"},
873 "bytes_written": {"type": "integer", "minimum": 0},
874 "word_count": {"type": "integer", "minimum": 0},
875 "redirect_chain": {"type": "array", "items": {"type": "string"}},
876 "is_paywall": {"type": "boolean"}
877 },
878 "required": ["url", "status_code"],
879 "additionalProperties": false
880 })
881}
882
883fn normalize_locale(locale: &str) -> String {
884 let locale = locale.trim();
885 if locale.is_empty() {
886 DEFAULT_LOCALE.to_string()
887 } else {
888 locale.to_string()
889 }
890}
891
892fn is_ukrainian(locale: &str) -> bool {
893 locale.to_ascii_lowercase().starts_with("uk")
894}
895
896fn display_name(locale: &str) -> &'static str {
897 if is_ukrainian(locale) {
898 "Веб-завантаження"
899 } else {
900 "Web Fetch"
901 }
902}
903
904fn description(locale: &str, enable_save_to_file: bool) -> String {
905 if is_ukrainian(locale) {
906 if enable_save_to_file {
907 "Завантажити URL як текст або markdown; повернути метадані для бінарного вмісту або зберегти байти через save_to_file.".to_string()
908 } else {
909 "Завантажити URL як текст або markdown; повернути метадані для бінарного вмісту."
910 .to_string()
911 }
912 } else if enable_save_to_file {
913 "Fetch URL content as text or markdown; return metadata for binary responses or save bytes with save_to_file.".to_string()
914 } else {
915 "Fetch URL content as text or markdown; return metadata for binary responses.".to_string()
916 }
917}
918
919fn system_prompt(locale: &str, enable_save_to_file: bool, block_private_ips: bool) -> String {
920 if is_ukrainian(locale) {
921 let binary_rule = if enable_save_to_file {
922 "Бінарні відповіді повертають метадані; використовуйте save_to_file, щоб зберегти байти."
923 } else {
924 "Бінарні відповіді повертають лише метадані."
925 };
926 let network_rule = if block_private_ips {
927 "Приватні IP-адреси заблоковані."
928 } else {
929 "Блокування приватних IP-адрес вимкнене."
930 };
931 format!(
932 "{}: повертає truncated=true для часткових відповідей після таймауту. {} {}",
933 TOOL_NAME, binary_rule, network_rule
934 )
935 } else {
936 let binary_rule = if enable_save_to_file {
937 "Binary responses return metadata; use save_to_file to persist bytes."
938 } else {
939 "Binary responses return metadata only."
940 };
941 let network_rule = if block_private_ips {
942 "Private IPs are blocked."
943 } else {
944 "Private IP blocking is disabled."
945 };
946 format!(
947 "{}: returns truncated=true for partial responses after timeout. {} {}",
948 TOOL_NAME, binary_rule, network_rule
949 )
950 }
951}
952
953fn build_help(tool: &Tool) -> String {
954 let (parameters_heading, examples_heading, adapters_heading, errors_heading, locale_label) =
955 if is_ukrainian(tool.locale()) {
956 ("Параметри", "Приклади", "Адаптери", "Помилки", "Локаль")
957 } else {
958 ("Parameters", "Examples", "Adapters", "Errors", "Locale")
959 };
960
961 let mut rows = vec![
962 table_row(
963 "url",
964 "string",
965 "yes",
966 "—",
967 parameter_description(tool.locale(), "url"),
968 ),
969 table_row(
970 "method",
971 "string",
972 "no",
973 "\"GET\"",
974 parameter_description(tool.locale(), "method"),
975 ),
976 ];
977
978 if tool.enable_markdown {
979 rows.push(table_row(
980 "as_markdown",
981 "boolean",
982 "no",
983 "false",
984 parameter_description(tool.locale(), "as_markdown"),
985 ));
986 }
987
988 if tool.enable_text {
989 rows.push(table_row(
990 "as_text",
991 "boolean",
992 "no",
993 "false",
994 parameter_description(tool.locale(), "as_text"),
995 ));
996 }
997
998 if tool.enable_save_to_file {
999 rows.push(table_row(
1000 "save_to_file",
1001 "string",
1002 "no",
1003 "—",
1004 parameter_description(tool.locale(), "save_to_file"),
1005 ));
1006 }
1007
1008 let adapters = if tool.enable_save_to_file {
1009 if is_ukrainian(tool.locale()) {
1010 "- `FileSaver` (необов’язковий): потрібен, коли задано `save_to_file`.\n"
1011 } else {
1012 "- `FileSaver` (optional): required when `save_to_file` is set.\n"
1013 }
1014 } else if is_ukrainian(tool.locale()) {
1015 "- Збереження файлів вимкнене в цій конфігурації.\n"
1016 } else {
1017 "- File saving is disabled in this tool build.\n"
1018 };
1019
1020 let errors = if is_ukrainian(tool.locale()) {
1021 if tool.enable_save_to_file {
1022 "- `MissingUrl` — параметр `url` обов’язковий\n\
1023 - `InvalidUrlScheme` — URL має бути `http://`, `https://` або доменним URL\n\
1024 - `BlockedUrl` — URL заблокований політикою SSRF або allow/block правилами\n\
1025 - `FirstByteTimeout` — сервер не відповів протягом 1 секунди\n\
1026 - `SaverNotAvailable` — `save_to_file` потребує адаптер `FileSaver`\n"
1027 } else {
1028 "- `MissingUrl` — параметр `url` обов’язковий\n\
1029 - `InvalidUrlScheme` — URL має бути `http://`, `https://` або доменним URL\n\
1030 - `BlockedUrl` — URL заблокований політикою SSRF або allow/block правилами\n\
1031 - `FirstByteTimeout` — сервер не відповів протягом 1 секунди\n"
1032 }
1033 } else if tool.enable_save_to_file {
1034 "- `MissingUrl` — `url` is required\n\
1035 - `InvalidUrlScheme` — URL must be `http://`, `https://`, or a bare domain URL\n\
1036 - `BlockedUrl` — URL blocked by SSRF policy or allow/block rules\n\
1037 - `FirstByteTimeout` — server did not respond within 1 second\n\
1038 - `SaverNotAvailable` — `save_to_file` requires a `FileSaver` adapter\n"
1039 } else {
1040 "- `MissingUrl` — `url` is required\n\
1041 - `InvalidUrlScheme` — URL must be `http://`, `https://`, or a bare domain URL\n\
1042 - `BlockedUrl` — URL blocked by SSRF policy or allow/block rules\n\
1043 - `FirstByteTimeout` — server did not respond within 1 second\n"
1044 };
1045
1046 let mut help = String::new();
1047 help.push_str(&format!("# {}\n\n", tool.display_name()));
1048 help.push_str(tool.description());
1049 help.push_str("\n\n");
1050 help.push_str(&format!("**Version:** {}\n", tool.version()));
1051 help.push_str(&format!("**Name:** `{}`\n", tool.name()));
1052 help.push_str(&format!("**{}:** `{}`\n\n", locale_label, tool.locale()));
1053 help.push_str(&format!("## {}\n\n", parameters_heading));
1054 help.push_str("| Name | Type | Required | Default | Description |\n");
1055 help.push_str("|------|------|----------|---------|-------------|\n");
1056 for row in rows {
1057 help.push_str(&row);
1058 }
1059 help.push('\n');
1060 help.push_str(&format!("## {}\n\n", examples_heading));
1061 help.push_str("```json\n");
1062 help.push_str("{\"url\": \"https://example.com\", \"as_markdown\": true}\n");
1063 help.push_str("```\n\n");
1064 help.push_str("```json\n");
1065 help.push_str("{\"url\": \"https://example.com/file.pdf\", \"method\": \"HEAD\"}\n");
1066 help.push_str("```\n");
1067 if tool.enable_save_to_file {
1068 help.push_str("\n```json\n");
1069 help.push_str(
1070 "{\"url\": \"https://example.com/image.png\", \"save_to_file\": \"/tmp/image.png\"}\n",
1071 );
1072 help.push_str("```\n");
1073 }
1074 help.push('\n');
1075 help.push_str(&format!("## {}\n\n", adapters_heading));
1076 help.push_str(adapters);
1077 help.push('\n');
1078 help.push_str(&format!("## {}\n\n", errors_heading));
1079 help.push_str(errors);
1080 help.push('\n');
1081 help.push_str("## System Prompt\n\n");
1082 help.push_str(&tool.system_prompt());
1083 help.push('\n');
1084 help
1085}
1086
1087fn parameter_description(locale: &str, field: &str) -> &'static str {
1088 match (is_ukrainian(locale), field) {
1089 (true, "url") => "HTTP/HTTPS URL або доменний URL, який нормалізується до https://",
1090 (true, "method") => "`GET` або `HEAD`",
1091 (true, "as_markdown") => "Перетворити HTML у markdown",
1092 (true, "as_text") => "Перетворити HTML у plain text",
1093 (true, "save_to_file") => "Шлях призначення, визначений адаптером",
1094 (false, "url") => "HTTP/HTTPS URL, or a bare domain URL normalized to `https://`",
1095 (false, "method") => "`GET` or `HEAD`",
1096 (false, "as_markdown") => "Convert HTML to markdown",
1097 (false, "as_text") => "Convert HTML to plain text",
1098 (false, "save_to_file") => "Adapter-defined destination path",
1099 _ => "",
1100 }
1101}
1102
1103fn table_row(name: &str, ty: &str, required: &str, default: &str, description: &str) -> String {
1104 format!("| `{name}` | {ty} | {required} | {default} | {description} |\n")
1105}
1106
1107fn map_fetch_error(locale: &str, err: FetchError) -> ToolError {
1108 match err {
1109 FetchError::ClientBuildError(_) => internal_error("failed to create HTTP client"),
1110 FetchError::MissingUrl => user_error(locale, user_text(locale, "missing_url")),
1111 FetchError::InvalidUrlScheme => user_error(locale, user_text(locale, "invalid_scheme")),
1112 FetchError::InvalidMethod => user_error(locale, user_text(locale, "invalid_method")),
1113 FetchError::BlockedUrl => user_error(locale, user_text(locale, "blocked_url")),
1114 FetchError::FirstByteTimeout => user_error(locale, user_text(locale, "timeout")),
1115 FetchError::ConnectError(_) => user_error(locale, user_text(locale, "connect_error")),
1116 FetchError::RequestError(_) => user_error(locale, user_text(locale, "request_error")),
1117 FetchError::FetcherError(_) => user_error(locale, user_text(locale, "fetcher_error")),
1118 FetchError::SaveError(_) => user_error(locale, user_text(locale, "save_error")),
1119 FetchError::SaverNotAvailable => user_error(locale, user_text(locale, "saver_missing")),
1120 }
1121}
1122
1123fn invalid_arguments_error(locale: &str, detail: &str) -> ToolError {
1124 if is_ukrainian(locale) {
1125 user_error(locale, format!("Неприпустимі аргументи: {detail}"))
1126 } else {
1127 user_error(locale, format!("Invalid arguments: {detail}"))
1128 }
1129}
1130
1131fn unknown_parameter_error(locale: &str, key: &str) -> ToolError {
1132 if is_ukrainian(locale) {
1133 user_error(locale, format!("Невідомий параметр: {key}"))
1134 } else {
1135 user_error(locale, format!("Unknown parameter: {key}"))
1136 }
1137}
1138
1139fn user_text(locale: &str, key: &str) -> &'static str {
1140 match (is_ukrainian(locale), key) {
1141 (true, "missing_url") => "Параметр url обов’язковий.",
1142 (true, "invalid_scheme") => "URL має бути http://, https:// або доменним URL.",
1143 (true, "invalid_method") => "Метод має бути GET або HEAD.",
1144 (true, "blocked_url") => "URL заблокований політикою безпеки.",
1145 (true, "timeout") => {
1146 "Сервер не відповів протягом 1 секунди. Спробуйте ще раз або інший URL."
1147 }
1148 (true, "connect_error") => {
1149 "Не вдалося з’єднатися із сервером. Спробуйте ще раз або інший URL."
1150 }
1151 (true, "request_error") => "Запит не вдався. Спробуйте ще раз.",
1152 (true, "fetcher_error") => "Не вдалося обробити відповідь цього URL.",
1153 (true, "save_error") => "Не вдалося зберегти файл. Перевірте шлях призначення.",
1154 (true, "saver_missing") => "save_to_file потребує адаптер FileSaver.",
1155 (false, "missing_url") => "url is required.",
1156 (false, "invalid_scheme") => "URL must be http://, https://, or a bare domain URL.",
1157 (false, "invalid_method") => "Method must be GET or HEAD.",
1158 (false, "blocked_url") => "URL is blocked by security policy.",
1159 (false, "timeout") => {
1160 "Server did not respond within 1 second. Retry or try a different URL."
1161 }
1162 (false, "connect_error") => "Could not connect to server. Retry or try a different URL.",
1163 (false, "request_error") => "Request failed. Retry the tool call.",
1164 (false, "fetcher_error") => "Could not process the response for this URL.",
1165 (false, "save_error") => "Could not save the file. Check the destination path.",
1166 (false, "saver_missing") => "save_to_file requires the FileSaver adapter.",
1167 _ => "Tool execution failed.",
1168 }
1169}
1170
1171fn user_error(locale: &str, message: impl Into<String>) -> ToolError {
1172 let _ = locale;
1173 ToolError::UserFacing(message.into())
1174}
1175
1176fn internal_error(message: impl Into<String>) -> ToolError {
1177 ToolError::Internal(message.into())
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use serde_json::json;
1184 use tower::Service;
1185
1186 #[test]
1187 fn test_tool_builder() {
1188 let tool = Tool::builder()
1189 .locale("uk-UA")
1190 .enable_markdown(false)
1191 .enable_text(true)
1192 .user_agent("TestAgent/1.0")
1193 .allow_prefix("https://allowed.com")
1194 .block_prefix("https://blocked.com")
1195 .max_body_size(1024)
1196 .respect_proxy_env(true)
1197 .build();
1198
1199 assert_eq!(tool.locale(), "uk-UA");
1200 assert_eq!(tool.name(), "web_fetch");
1201 assert_eq!(tool.display_name(), "Веб-завантаження");
1202 assert!(!tool.enable_markdown);
1203 assert!(tool.enable_text);
1204 assert_eq!(tool.user_agent, Some("TestAgent/1.0".to_string()));
1205 assert_eq!(tool.allow_prefixes, vec!["https://allowed.com"]);
1206 assert_eq!(tool.block_prefixes, vec!["https://blocked.com"]);
1207 assert!(tool.dns_policy.block_private);
1208 assert_eq!(tool.max_body_size, Some(1024));
1209 assert!(!tool.enable_save_to_file);
1210 assert!(tool.respect_proxy_env);
1211 assert!(tool.allowed_ports.is_empty());
1212 assert!(tool.blocked_hosts.is_empty());
1213 assert!(!tool.same_host_redirects_only);
1214 }
1215
1216 #[test]
1217 fn test_tool_builder_opt_out_private_ip_blocking() {
1218 let tool = Tool::builder().block_private_ips(false).build();
1219 assert!(!tool.dns_policy.block_private);
1220 }
1221
1222 #[test]
1223 fn test_tool_builder_security_defaults() {
1224 let tool = Tool::builder().build();
1225 assert!(tool.max_body_size.is_none());
1226 assert!(!tool.enable_save_to_file);
1227 assert!(!tool.respect_proxy_env);
1228 assert!(tool.allowed_ports.is_empty());
1229 assert!(tool.blocked_hosts.is_empty());
1230 assert!(!tool.same_host_redirects_only);
1231 }
1232
1233 #[test]
1234 fn test_tool_builder_hardened_profile() {
1235 let tool = Tool::builder().hardened().build();
1236
1237 assert!(tool.dns_policy.block_private);
1238 assert!(!tool.respect_proxy_env);
1239 assert_eq!(tool.allowed_ports, vec![80, 443]);
1240 assert!(tool.blocked_hosts.contains(&"localhost".to_string()));
1241 assert!(tool.blocked_hosts.contains(&".cluster.local".to_string()));
1242 assert!(tool.same_host_redirects_only);
1243 }
1244
1245 #[test]
1246 fn test_tool_builder_preserves_hardened_redirect_policy_when_override_is_unset() {
1247 let tool = Tool::builder()
1248 .hardened()
1249 .same_host_redirects_only_if_set(None)
1250 .build();
1251
1252 assert!(tool.same_host_redirects_only);
1253 }
1254
1255 #[test]
1256 fn test_tool_builder_allows_explicit_redirect_policy_override() {
1257 let tool = Tool::builder()
1258 .hardened()
1259 .same_host_redirects_only_if_set(Some(false))
1260 .build();
1261
1262 assert!(!tool.same_host_redirects_only);
1263 }
1264
1265 #[test]
1266 fn test_tool_metadata() {
1267 let tool = Tool::default();
1268 assert_eq!(tool.name(), "web_fetch");
1269 assert_eq!(tool.display_name(), "Web Fetch");
1270 assert!(!tool.description().is_empty());
1271 assert!(tool.system_prompt().starts_with("web_fetch:"));
1272 assert!(tool.help().contains("## Parameters"));
1273 assert_eq!(tool.locale(), "en-US");
1274 }
1275
1276 #[test]
1277 fn test_tool_llmtxt_matches_help() {
1278 let tool = Tool::builder().enable_save_to_file(true).build();
1279 assert_eq!(tool.llmtxt(), tool.help());
1280 assert!(tool.llmtxt().contains("save_to_file"));
1281 }
1282
1283 #[test]
1284 fn test_tool_schemas() {
1285 let tool = Tool::default();
1286 let input_schema = tool.input_schema();
1287 let output_schema = tool.output_schema();
1288
1289 assert_eq!(input_schema["type"], "object");
1290 assert!(input_schema["properties"]["url"]["description"]
1291 .as_str()
1292 .unwrap()
1293 .contains("bare domain URL"));
1294 assert_eq!(input_schema["properties"]["method"]["default"], "GET");
1295 assert!(input_schema["properties"]["if_none_match"].is_object());
1296 assert!(input_schema["properties"]["if_modified_since"].is_object());
1297 assert!(output_schema["properties"]["url"].is_object());
1298 assert!(output_schema["properties"]["status_code"].is_object());
1299 assert!(output_schema["properties"]["word_count"].is_object());
1300 assert!(output_schema["properties"]["redirect_chain"].is_object());
1301 assert!(output_schema["properties"]["is_paywall"].is_object());
1302 assert!(output_schema["properties"]["etag"].is_object());
1303 }
1304
1305 #[test]
1306 fn test_tool_schema_feature_gating() {
1307 let tool = Tool::builder()
1308 .enable_markdown(false)
1309 .enable_text(false)
1310 .build();
1311
1312 let schema = tool.input_schema();
1313 let props = schema
1314 .get("properties")
1315 .and_then(|p| p.as_object())
1316 .unwrap();
1317
1318 assert!(!props.contains_key("as_markdown"));
1319 assert!(!props.contains_key("as_text"));
1320 }
1321
1322 #[test]
1323 fn test_tool_definition_uses_contract_metadata() {
1324 let definition = Tool::builder()
1325 .enable_save_to_file(true)
1326 .build_tool_definition();
1327 assert_eq!(definition["type"], "function");
1328 assert_eq!(definition["function"]["name"], "web_fetch");
1329 assert_eq!(definition["function"]["parameters"]["type"], "object");
1330 }
1331
1332 #[test]
1333 fn test_execution_rejects_unknown_parameter() {
1334 let err = Tool::default().execution(json!({
1335 "url": "https://example.com",
1336 "bogus": true
1337 }));
1338
1339 assert!(matches!(&err, Err(ToolError::UserFacing(_))));
1340 assert!(err.unwrap_err().to_string().contains("Unknown parameter"));
1341 }
1342
1343 #[test]
1344 fn test_execution_accepts_conditional_fetch_arguments() {
1345 let ok = Tool::default().execution(json!({
1346 "url": "https://example.com",
1347 "if_none_match": "\"abc\"",
1348 "if_modified_since": "Wed, 21 Oct 2015 07:28:00 GMT"
1349 }));
1350 assert!(ok.is_ok());
1351 }
1352
1353 #[test]
1354 fn test_execution_rejects_invalid_url_before_running() {
1355 let err = Tool::default().execution(json!({"url": "ftp://example.com"}));
1356 assert!(matches!(&err, Err(ToolError::UserFacing(_))));
1357 assert!(err
1358 .unwrap_err()
1359 .to_string()
1360 .contains("URL must be http://, https://, or a bare domain URL"));
1361 }
1362
1363 #[test]
1364 fn test_tool_status() {
1365 let status = ToolStatus::new("fetch")
1366 .with_message("Fetching URL")
1367 .with_percent(50.0)
1368 .with_eta(5000);
1369
1370 assert_eq!(status.phase, "fetch");
1371 assert_eq!(status.message, Some("Fetching URL".to_string()));
1372 assert_eq!(status.percent_complete, Some(50.0));
1373 assert_eq!(status.eta_ms, Some(5000));
1374 }
1375
1376 #[tokio::test]
1377 async fn test_build_service_propagates_validation_errors() {
1378 let mut service = Tool::builder().build_service();
1379 let err = service.call(json!(["not-an-object"])).await.unwrap_err();
1380 assert!(err.is_user_facing());
1381 }
1382}