microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use crate::internal::tool_runtime::web_content_validation::WebContentValidationConfig;
use crate::internal::tool_runtime::web_fetch::WebFetchUrlValidationConfig;
use async_trait::async_trait;
use serde_json::json;

use super::{schema_object, Tool, ToolResult};
use microclaw_core::llm_types::ToolDefinition;

pub struct WebFetchTool {
    default_timeout_secs: u64,
    validation: WebContentValidationConfig,
    url_validation: WebFetchUrlValidationConfig,
}

impl WebFetchTool {
    pub fn new(
        default_timeout_secs: u64,
        validation: WebContentValidationConfig,
        url_validation: WebFetchUrlValidationConfig,
    ) -> Self {
        Self {
            default_timeout_secs,
            validation,
            url_validation,
        }
    }
}

#[async_trait]
impl Tool for WebFetchTool {
    fn name(&self) -> &str {
        "web_fetch"
    }

    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "web_fetch".into(),
            description:
                "Fetch a URL and return its text content (HTML parsed, scripts/styles removed). Max 20KB."
                    .into(),
            input_schema: schema_object(
                json!({
                    "url": {
                        "type": "string",
                        "description": "The URL to fetch"
                    },
                    "timeout_secs": {
                        "type": "integer",
                        "description": "Timeout in seconds (defaults to configured tool timeout budget)"
                    }
                }),
                &["url"],
            ),
        }
    }

    async fn execute(&self, input: serde_json::Value) -> ToolResult {
        let url = match input.get("url").and_then(|v| v.as_str()) {
            Some(u) => u,
            None => return ToolResult::error("Missing required parameter: url".into()),
        };
        let timeout_secs = input
            .get("timeout_secs")
            .and_then(|v| v.as_u64())
            .unwrap_or(self.default_timeout_secs);

        match crate::internal::tool_runtime::web_fetch::fetch_url_with_timeout_and_validation(
            url,
            timeout_secs,
            self.validation,
            self.url_validation.clone(),
        )
        .await
        {
            Ok(text) => ToolResult::success(text),
            Err(e) => ToolResult::error(format!("Failed to fetch URL: {e}")),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_web_fetch_definition() {
        let tool = WebFetchTool::new(
            15,
            WebContentValidationConfig::default(),
            WebFetchUrlValidationConfig::default(),
        );
        assert_eq!(tool.name(), "web_fetch");
        let def = tool.definition();
        assert_eq!(def.name, "web_fetch");
        assert!(def.description.contains("20KB"));
        assert!(def.input_schema["properties"]["url"].is_object());
        let required = def.input_schema["required"].as_array().unwrap();
        assert!(required.iter().any(|v| v == "url"));
    }

    #[tokio::test]
    async fn test_web_fetch_missing_url() {
        let tool = WebFetchTool::new(
            15,
            WebContentValidationConfig::default(),
            WebFetchUrlValidationConfig::default(),
        );
        let result = tool.execute(json!({})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing required parameter: url"));
    }

    #[tokio::test]
    async fn test_web_fetch_null_url() {
        let tool = WebFetchTool::new(
            15,
            WebContentValidationConfig::default(),
            WebFetchUrlValidationConfig::default(),
        );
        let result = tool.execute(json!({"url": null})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing required parameter: url"));
    }

    #[tokio::test]
    async fn test_web_fetch_invalid_url() {
        let tool = WebFetchTool::new(
            1,
            WebContentValidationConfig::default(),
            WebFetchUrlValidationConfig::default(),
        );
        let result = tool
            .execute(json!({"url": "https://this-domain-does-not-exist-12345.example"}))
            .await;
        assert!(result.is_error);
        assert!(result.content.contains("Failed to fetch URL"));
    }

    #[tokio::test]
    async fn test_web_fetch_blocks_disallowed_scheme_before_request() {
        let tool = WebFetchTool::new(
            15,
            WebContentValidationConfig::default(),
            WebFetchUrlValidationConfig::default(),
        );
        let result = tool.execute(json!({"url": "ftp://example.com"})).await;
        assert!(result.is_error);
        assert!(result.content.contains("not allowed"));
    }

    #[tokio::test]
    async fn test_web_fetch_blocks_denylisted_host_before_request() {
        let tool = WebFetchTool::new(
            15,
            WebContentValidationConfig::default(),
            WebFetchUrlValidationConfig {
                denylist_hosts: vec!["example.com".to_string()],
                ..WebFetchUrlValidationConfig::default()
            },
        );
        let result = tool.execute(json!({"url": "https://example.com"})).await;
        assert!(result.is_error);
        assert!(result.content.contains("denylisted"));
    }
}