//! Browser tools for the agent — enables web browsing with anti-detection. //! //! These tools are registered with the `ToolExecutor` and provide //! LLM-accessible functions for searching the web, browsing pages, //! clicking elements, and extracting content. //! //! Uses the camoufox-browser daemon protocol (Unix socket IPC). use serde_json; use tracing::info; use gyandoot_browser::BrowserClient; use crate::tools::Tool; /// Search macro name strings sent to the camoufox daemon. const MACRO_GOOGLE: &str = "@google_search"; const MACRO_YOUTUBE: &str = "@youtube_search"; const MACRO_AMAZON: &str = "@amazon_search"; const MACRO_REDDIT: &str = "@reddit_search"; const MACRO_WIKIPEDIA: &str = "@wikipedia_search"; const MACRO_TWITTER: &str = "@twitter_search"; const MACRO_YELP: &str = "@yelp_search"; const MACRO_SPOTIFY: &str = "@spotify_search"; const MACRO_LINKEDIN: &str = "@linkedin_search"; const MACRO_INSTAGRAM: &str = "@instagram_search"; const MACRO_TIKTOK: &str = "@tiktok_search"; const MACRO_TWITCH: &str = "@twitch_search"; fn site_to_macro(site: &str) -> &'static str { match site { "google" => MACRO_GOOGLE, "youtube" => MACRO_YOUTUBE, "amazon" => MACRO_AMAZON, "reddit" => MACRO_REDDIT, "wikipedia" => MACRO_WIKIPEDIA, "twitter" | "x" => MACRO_TWITTER, "yelp" => MACRO_YELP, "spotify" => MACRO_SPOTIFY, "linkedin" => MACRO_LINKEDIN, "instagram" => MACRO_INSTAGRAM, "tiktok" => MACRO_TIKTOK, "twitch" => MACRO_TWITCH, _ => MACRO_GOOGLE, } } fn site_name(site: &str) -> &'static str { match site { "google" => "Google", "youtube" => "YouTube", "amazon" => "Amazon", "reddit" => "Reddit", "wikipedia" => "Wikipedia", "twitter" | "x" => "Twitter/X", "yelp" => "Yelp", "spotify" => "Spotify", "linkedin" => "LinkedIn", "instagram" => "Instagram", "tiktok" => "TikTok", "twitch" => "Twitch", _ => "Google", } } /// Tool for searching the web using camoufox daemon macros. pub struct WebSearchTool { client: BrowserClient, } impl WebSearchTool { pub fn new(client: BrowserClient) -> Self { Self { client } } } #[async_trait::async_trait] impl Tool for WebSearchTool { fn name(&self) -> &str { "web_search" } fn description(&self) -> &str { "Search the web using Google, YouTube, Amazon, Reddit, Wikipedia, and more. \ Uses anti-detection browser to bypass bot protection. \ Returns an accessibility snapshot of search results." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "query": { "type": "string", "description": "The search query" }, "site": { "type": "string", "description": "Site to search. Options: google, youtube, amazon, reddit, wikipedia, twitter, yelp, spotify, linkedin, instagram, tiktok, twitch", "default": "google" } }, "required": ["query"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let query = args["query"].as_str().unwrap_or("").to_string(); let site = args["site"].as_str().unwrap_or("google").to_lowercase(); if query.is_empty() { return Err(anyhow::anyhow!("query is required")); } let mname = site_to_macro(&site); let sname = site_name(&site); info!("web_search: searching {} for '{}'", sname, query); // Execute search — daemon auto-launches browser on first call let result = self.client.search(mname, &query).await .map_err(|e| anyhow::anyhow!("Search failed: {}", e))?; // Get snapshot of results let snap = self.client.snapshot(true).await .map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?; let refs = snap.extract_refs(); let response = format!( "## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*", sname, query, result.url, result.title, snap.snapshot, refs, ); Ok(response) } } /// Tool for browsing a specific URL using camoufox daemon browser. pub struct WebBrowseTool { client: BrowserClient, } impl WebBrowseTool { pub fn new(client: BrowserClient) -> Self { Self { client } } } #[async_trait::async_trait] impl Tool for WebBrowseTool { fn name(&self) -> &str { "web_browse" } fn description(&self) -> &str { "Browse a specific URL using an anti-detection browser. \ Returns an accessibility snapshot with element refs for interaction. \ Supports navigation, clicking, typing, and scrolling." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "url": { "type": "string", "description": "The URL to browse" }, "action": { "type": "string", "description": "Action to perform: 'navigate' (default), 'click', 'type', 'scroll', 'back', 'forward', 'refresh'", "default": "navigate" }, "element_ref": { "type": "string", "description": "Element reference (e.g., 'e1', 'e2') — required for click/type actions" }, "text": { "type": "string", "description": "Text to type — required for type action" }, "press_enter": { "type": "boolean", "description": "Press Enter after typing", "default": false }, "direction": { "type": "string", "description": "Scroll direction: 'up' or 'down'", "default": "down" }, "amount": { "type": "integer", "description": "Scroll amount in pixels", "default": 500 } }, "required": ["url"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let url = args["url"].as_str().unwrap_or("").to_string(); let action = args["action"].as_str().unwrap_or("navigate").to_lowercase(); let element_ref = args["element_ref"].as_str().map(|s| s.to_string()); let text = args["text"].as_str().map(|s| s.to_string()); let press_enter = args["press_enter"].as_bool().unwrap_or(false); let direction = args["direction"].as_str().unwrap_or("down").to_string(); let amount = args["amount"].as_u64().unwrap_or(500) as u32; if url.is_empty() { return Err(anyhow::anyhow!("url is required")); } match action.as_str() { "navigate" => { self.client.navigate(&url).await .map_err(|e| anyhow::anyhow!("Navigation failed: {}", e))?; } "click" => { let r#ref = element_ref.as_ref() .ok_or_else(|| anyhow::anyhow!("element_ref is required for click action"))?; self.client.click(r#ref).await .map_err(|e| anyhow::anyhow!("Click failed: {}", e))?; } "type" => { let r#ref = element_ref.as_ref() .ok_or_else(|| anyhow::anyhow!("element_ref is required for type action"))?; let t = text.as_ref() .ok_or_else(|| anyhow::anyhow!("text is required for type action"))?; self.client.type_text(r#ref, t, press_enter).await .map_err(|e| anyhow::anyhow!("Type failed: {}", e))?; } "scroll" => { self.client.scroll(&direction, amount).await .map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?; } "back" => { self.client.go_back().await .map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?; } "forward" => { self.client.go_forward().await .map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?; } "refresh" => { self.client.refresh().await .map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?; } _ => {} } let snap = self.client.snapshot(false).await .map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?; let refs = snap.extract_refs(); let current_url = if snap.url.is_empty() { &url } else { &snap.url }; let response = format!( "## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*", current_url, snap.snapshot, refs, ); Ok(response) } } /// Register all browser tools with the given executor. pub fn register_browser_tools(executor: &mut crate::tools::ToolExecutor, client: BrowserClient) { executor.register(Box::new(WebSearchTool::new(client.clone()))); executor.register(Box::new(WebBrowseTool::new(client))); info!("Registered browser tools: web_search, web_browse"); } #[cfg(test)] mod tests { use super::*; #[test] fn test_web_search_tool_definition() { let client = BrowserClient::new(); let tool = WebSearchTool::new(client); assert_eq!(tool.name(), "web_search"); let params = tool.parameters(); assert!(params["required"].as_array().unwrap().contains(&"query".to_string().into())); } #[test] fn test_web_browse_tool_definition() { let client = BrowserClient::new(); let tool = WebBrowseTool::new(client); assert_eq!(tool.name(), "web_browse"); let params = tool.parameters(); assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into())); } #[test] fn test_site_to_macro() { assert_eq!(site_to_macro("google"), "@google_search"); assert_eq!(site_to_macro("youtube"), "@youtube_search"); assert_eq!(site_to_macro("reddit"), "@reddit_search"); } }