microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use async_trait::async_trait;
use serde_json::json;

use super::{schema_object, Tool, ToolResult};
use crate::internal::tool_runtime::web_search::SearchProviderConfig;
use microclaw_core::llm_types::ToolDefinition;

pub struct WebSearchTool {
    default_timeout_secs: u64,
    provider: SearchProviderConfig,
}

const MIN_TIMEOUT_SECS: u64 = 1;
const MAX_TIMEOUT_SECS: u64 = 60;

impl WebSearchTool {
    pub fn new(default_timeout_secs: u64) -> Self {
        Self {
            default_timeout_secs,
            provider: SearchProviderConfig::default(),
        }
    }

    pub fn with_provider(mut self, provider: SearchProviderConfig) -> Self {
        self.provider = provider;
        self
    }
}

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

    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "web_search".into(),
            description:
                "Search the web (DuckDuckGo by default; SearXNG/Brave/Tavily when configured). \
                 Returns titles, URLs, and snippets."
                    .into(),
            input_schema: schema_object(
                json!({
                    "query": {
                        "type": "string",
                        "description": "The search query"
                    },
                    "timeout_secs": {
                        "type": "integer",
                        "description": "Timeout in seconds (defaults to configured tool timeout budget)"
                    }
                }),
                &["query"],
            ),
        }
    }

    async fn execute(&self, input: serde_json::Value) -> ToolResult {
        let query = match parse_query(&input) {
            Ok(q) => q,
            Err(msg) => return ToolResult::error(msg),
        };
        let timeout_secs = resolve_timeout_secs(&input, self.default_timeout_secs);

        match crate::internal::tool_runtime::web_search::search_with_provider(
            &query,
            &self.provider,
            timeout_secs,
        )
        .await
        {
            Ok(hits) => {
                if hits.is_empty() {
                    ToolResult::success("No results found.".into())
                } else {
                    ToolResult::success(
                        crate::internal::tool_runtime::web_search::format_search_hits(&hits),
                    )
                }
            }
            Err(e) => ToolResult::error(format!("Search failed: {e}")),
        }
    }
}

fn parse_query(input: &serde_json::Value) -> Result<String, String> {
    let query = input
        .get("query")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "Missing required parameter: query".to_string())?;
    let trimmed = query.trim();
    if trimmed.is_empty() {
        return Err("Missing required parameter: query".to_string());
    }
    Ok(trimmed.to_string())
}

fn resolve_timeout_secs(input: &serde_json::Value, default_timeout_secs: u64) -> u64 {
    input
        .get("timeout_secs")
        .and_then(|v| v.as_u64())
        .unwrap_or(default_timeout_secs)
        .clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
}

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

    #[test]
    fn test_web_search_definition() {
        let tool = WebSearchTool::new(15);
        assert_eq!(tool.name(), "web_search");
        let def = tool.definition();
        assert_eq!(def.name, "web_search");
        assert!(def.description.contains("DuckDuckGo"));
        assert!(def.input_schema["properties"]["query"].is_object());
        let required = def.input_schema["required"].as_array().unwrap();
        assert!(required.iter().any(|v| v == "query"));
    }

    #[tokio::test]
    async fn test_web_search_missing_query() {
        let tool = WebSearchTool::new(15);
        let result = tool.execute(json!({})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing required parameter: query"));
    }

    #[tokio::test]
    async fn test_web_search_null_query() {
        let tool = WebSearchTool::new(15);
        let result = tool.execute(json!({"query": null})).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing required parameter: query"));
    }

    #[tokio::test]
    async fn test_web_search_empty_query() {
        let tool = WebSearchTool::new(15);
        let result = tool.execute(json!({"query": "   " })).await;
        assert!(result.is_error);
        assert!(result.content.contains("Missing required parameter: query"));
    }

    #[test]
    fn test_resolve_timeout_secs_clamps_bounds() {
        assert_eq!(resolve_timeout_secs(&json!({"timeout_secs": 0}), 15), 1);
        assert_eq!(resolve_timeout_secs(&json!({"timeout_secs": 1000}), 15), 60);
        assert_eq!(resolve_timeout_secs(&json!({"timeout_secs": 5}), 15), 5);
        assert_eq!(resolve_timeout_secs(&json!({}), 120), 60);
    }
}