1. /quit and /help commands restored: - /quit: exits the interactive loop - /help: shows formatted help box with available commands & tips - Unknown commands get a friendly error pointing to /help - Slash commands intercepted before LLM processing (no token waste) 2. Zero warnings across entire workspace (13 files fixed): - Fixed ambiguous glob re-exports in gyandoot-core - Added #[allow(dead_code)] on intentional dead fields - Fixed unused imports, variables, and macros - Fixed unused assignments in subagents - cleaned up service.rs, profile, kanban warnings install.sh now produces a completely clean build with zero warnings.
309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
//! 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<String> {
|
|
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<String> {
|
|
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");
|
|
}
|
|
} |