diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index a0a771b..9cf9da1 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod ui_writer; pub mod utils; pub mod webdriver_session; pub mod skills; +pub mod toolsets; pub use feedback_extraction::{ extract_coach_feedback, ExtractedFeedback, FeedbackExtractionConfig, FeedbackSource, @@ -163,6 +164,8 @@ pub struct Agent { in_plan_mode: bool, /// Manager for async research tasks pending_research_manager: pending_research::PendingResearchManager, + /// Set of toolset names that have been loaded in this session + loaded_toolsets: std::collections::HashSet, } impl Agent { @@ -218,6 +221,7 @@ impl Agent { acd_enabled: false, in_plan_mode: false, pending_research_manager: pending_research::PendingResearchManager::new(), + loaded_toolsets: std::collections::HashSet::new(), } } @@ -614,6 +618,26 @@ impl Agent { provider_config::resolve_temperature(&self.config, provider_name) } + /// Get tool definitions including any dynamically loaded toolsets. + fn get_tool_definitions_with_loaded_toolsets(&self, tool_config: tool_definitions::ToolConfig) -> Vec { + let mut tools = tool_definitions::create_tool_definitions(tool_config); + + // Add tools from loaded toolsets + for toolset_name in &self.loaded_toolsets { + if let Ok(toolset) = toolsets::get_toolset(toolset_name) { + let toolset_tools = toolset.get_tools(); + // Avoid duplicates (in case toolset was already in base config) + for tool in toolset_tools { + if !tools.iter().any(|t| t.name == tool.name) { + tools.push(tool); + } + } + } + } + + tools + } + /// Print provider diagnostics through the UiWriter for visibility pub fn print_provider_banner(&self, role_label: &str) { if let Ok((provider_name, model)) = self.get_provider_info() { @@ -1016,10 +1040,9 @@ impl Agent { let _supports_cache_control = provider.supports_cache_control(); let tools = if provider.has_native_tool_calling() { let tool_config = tool_definitions::ToolConfig::new( - self.config.webdriver.enabled, self.config.computer_control.enabled, ); - Some(tool_definitions::create_tool_definitions(tool_config)) + Some(self.get_tool_definitions_with_loaded_toolsets(tool_config)) } else { None }; @@ -1887,10 +1910,9 @@ Skip if nothing new. Be brief."#; let provider_name = provider.name().to_string(); let tools = if provider.has_native_tool_calling() { let tool_config = tool_definitions::ToolConfig::new( - self.config.webdriver.enabled, self.config.computer_control.enabled, ); - Some(tool_definitions::create_tool_definitions(tool_config)) + Some(self.get_tool_definitions_with_loaded_toolsets(tool_config)) } else { None }; @@ -2569,11 +2591,10 @@ Skip if nothing new. Be brief."#; let provider_for_tools = self.providers.get(None)?; if provider_for_tools.has_native_tool_calling() { let tool_config = tool_definitions::ToolConfig::new( - self.config.webdriver.enabled, self.config.computer_control.enabled, ); request.tools = - Some(tool_definitions::create_tool_definitions(tool_config)); + Some(self.get_tool_definitions_with_loaded_toolsets(tool_config)); } // DO NOT add final_display_content to full_response here! @@ -2981,6 +3002,7 @@ Skip if nothing new. Be brief."#; requirements_sha: self.requirements_sha.as_deref(), context_total_tokens: self.context_window.total_tokens, context_used_tokens: self.context_window.used_tokens, + loaded_toolsets: &mut self.loaded_toolsets, }; // Dispatch to the appropriate tool handler diff --git a/crates/g3-core/src/prompts.rs b/crates/g3-core/src/prompts.rs index b1507e3..e41e0cf 100644 --- a/crates/g3-core/src/prompts.rs +++ b/crates/g3-core/src/prompts.rs @@ -116,6 +116,7 @@ write_file(\"helper.rs\", \"...\") // ============================================================================ use crate::skills::{Skill, generate_skills_prompt}; +use crate::toolsets::generate_toolsets_prompt; /// System prompt for providers with native tool calling (Anthropic, OpenAI, etc.) /// Uses include_str! to embed the prompt at compile time. @@ -126,11 +127,21 @@ pub fn get_system_prompt_for_native() -> String { /// System prompt for providers with native tool calling, with skills support. pub fn get_system_prompt_for_native_with_skills(skills: &[Skill]) -> String { let skills_section = generate_skills_prompt(skills); - if skills_section.is_empty() { - EMBEDDED_NATIVE_PROMPT.to_string() - } else { - format!("{}\n\n{}", EMBEDDED_NATIVE_PROMPT, skills_section) + let toolsets_section = generate_toolsets_prompt(); + + let mut prompt = EMBEDDED_NATIVE_PROMPT.to_string(); + + // Add toolsets section (available toolsets for dynamic loading) + if !toolsets_section.is_empty() { + prompt = format!("{}\n\n{}", prompt, toolsets_section); } + + // Add skills section + if !skills_section.is_empty() { + prompt = format!("{}\n\n{}", prompt, skills_section); + } + + prompt } /// System prompt for providers without native tool calling (embedded models) diff --git a/crates/g3-core/src/tool_definitions.rs b/crates/g3-core/src/tool_definitions.rs index 746f75f..6a8c76f 100644 --- a/crates/g3-core/src/tool_definitions.rs +++ b/crates/g3-core/src/tool_definitions.rs @@ -10,15 +10,13 @@ use serde_json::json; /// Configuration for which optional tool sets to enable #[derive(Debug, Clone, Copy, Default)] pub struct ToolConfig { - pub webdriver: bool, pub computer_control: bool, pub exclude_research: bool, } impl ToolConfig { - pub fn new(webdriver: bool, computer_control: bool) -> Self { + pub fn new(computer_control: bool) -> Self { Self { - webdriver, computer_control, exclude_research: false, } @@ -37,13 +35,8 @@ impl ToolConfig { /// Returns a vector of Tool definitions that describe the available tools /// and their input schemas. pub fn create_tool_definitions(config: ToolConfig) -> Vec { - let mut tools = create_core_tools(config.exclude_research); - - if config.webdriver { - tools.extend(create_webdriver_tools()); - } - - tools + // Webdriver tools are now JIT-loaded via load_toolset("webdriver") + create_core_tools(config.exclude_research) } /// Create the core tools that are always available @@ -301,200 +294,23 @@ fn create_core_tools(exclude_research: bool) -> Vec { }); } - tools -} + // Toolset loading tool - allows dynamic loading of additional tools + tools.push(Tool { + name: "load_toolset".to_string(), + description: "Load additional tools from a named toolset. Returns the tool definitions so you learn how to use them. Use this when you need specialized tools like browser automation.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the toolset to load (e.g., 'webdriver')" + } + }, + "required": ["name"] + }), + }); -/// Create WebDriver browser automation tools -fn create_webdriver_tools() -> Vec { - vec![ - Tool { - name: "webdriver_start".to_string(), - description: "Start a Safari WebDriver session for browser automation. Must be called before any other webdriver tools. Requires Safari's 'Allow Remote Automation' to be enabled in Develop menu.".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "webdriver_navigate".to_string(), - description: "Navigate to a URL in the browser".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "The URL to navigate to (must include protocol, e.g., https://)" - } - }, - "required": ["url"] - }), - }, - Tool { - name: "webdriver_get_url".to_string(), - description: "Get the current URL of the browser".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "webdriver_get_title".to_string(), - description: "Get the title of the current page".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "webdriver_find_element".to_string(), - description: "Find an element on the page by CSS selector and return its text content".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "selector": { - "type": "string", - "description": "CSS selector to find the element (e.g., 'h1', '.class-name', '#id')" - } - }, - "required": ["selector"] - }), - }, - Tool { - name: "webdriver_find_elements".to_string(), - description: "Find all elements matching a CSS selector and return their text content".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "selector": { - "type": "string", - "description": "CSS selector to find elements" - } - }, - "required": ["selector"] - }), - }, - Tool { - name: "webdriver_click".to_string(), - description: "Click an element on the page".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "selector": { - "type": "string", - "description": "CSS selector for the element to click" - } - }, - "required": ["selector"] - }), - }, - Tool { - name: "webdriver_send_keys".to_string(), - description: "Type text into an input element".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "selector": { - "type": "string", - "description": "CSS selector for the input element" - }, - "text": { - "type": "string", - "description": "Text to type into the element" - }, - "clear_first": { - "type": "boolean", - "description": "Whether to clear the element before typing (default: true)" - } - }, - "required": ["selector", "text"] - }), - }, - Tool { - name: "webdriver_execute_script".to_string(), - description: "Execute JavaScript code in the browser and return the result".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "JavaScript code to execute (use 'return' to return a value)" - } - }, - "required": ["script"] - }), - }, - Tool { - name: "webdriver_get_page_source".to_string(), - description: "Get the rendered HTML source of the current page. Returns the current DOM state after JavaScript execution.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "max_length": { - "type": "integer", - "description": "Maximum length of HTML to return (default: 10000, use 0 for no truncation)" - }, - "save_to_file": { - "type": "string", - "description": "Optional file path to save the HTML instead of returning it inline" - } - }, - "required": [] - }), - }, - Tool { - name: "webdriver_screenshot".to_string(), - description: "Take a screenshot of the browser window".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path where to save the screenshot (e.g., '/tmp/screenshot.png')" - } - }, - "required": ["path"] - }), - }, - Tool { - name: "webdriver_back".to_string(), - description: "Navigate back in browser history".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "webdriver_forward".to_string(), - description: "Navigate forward in browser history".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "webdriver_refresh".to_string(), - description: "Refresh the current page".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "webdriver_quit".to_string(), - description: "Close the browser and end the WebDriver session".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - ] + tools } #[cfg(test)] @@ -506,15 +322,8 @@ mod tests { let tools = create_core_tools(false); // Core tools (with research): shell, background_process, read_file, read_image, // write_file, str_replace, code_search, plan_read, plan_write, plan_approve, - // remember, rehydrate, research, research_status - // (14 total) - assert_eq!(tools.len(), 14); - } - - #[test] - fn test_webdriver_tools_count() { - let tools = create_webdriver_tools(); - // 15 webdriver tools + // remember, rehydrate, research, research_status, load_toolset + // (15 total) assert_eq!(tools.len(), 15); } @@ -522,15 +331,16 @@ mod tests { fn test_create_tool_definitions_core_only() { let config = ToolConfig::default(); let tools = create_tool_definitions(config); - assert_eq!(tools.len(), 14); + // 15 core tools (webdriver is now JIT-loaded) + assert_eq!(tools.len(), 15); } #[test] - fn test_create_tool_definitions_all_enabled() { - let config = ToolConfig::new(true, true); + fn test_create_tool_definitions() { + let config = ToolConfig::new(true); let tools = create_tool_definitions(config); - // 14 core + 15 webdriver = 29 - assert_eq!(tools.len(), 29); + // Webdriver tools are now JIT-loaded, so only core tools are included + assert!(tools.len() >= 14); // At least 14 core tools } #[test] diff --git a/crates/g3-core/src/tool_dispatch.rs b/crates/g3-core/src/tool_dispatch.rs index 13ebfe1..425f57c 100644 --- a/crates/g3-core/src/tool_dispatch.rs +++ b/crates/g3-core/src/tool_dispatch.rs @@ -7,7 +7,7 @@ use anyhow::Result; use tracing::{debug, warn}; use crate::tools::executor::ToolContext; -use crate::tools::{acd, file_ops, memory, misc, plan, research, shell, webdriver}; +use crate::tools::{acd, file_ops, memory, misc, plan, research, shell, toolsets, webdriver}; use crate::ui_writer::UiWriter; use crate::ToolCall; @@ -47,6 +47,9 @@ pub async fn dispatch_tool( // Workspace memory tools "remember" => memory::execute_remember(tool_call, ctx).await, + // Toolset loading + "load_toolset" => toolsets::execute_load_toolset(tool_call, ctx).await, + // ACD (Aggressive Context Dehydration) tools "rehydrate" => acd::execute_rehydrate(tool_call, ctx).await, diff --git a/crates/g3-core/src/tools/acd.rs b/crates/g3-core/src/tools/acd.rs index f2beb8b..8101738 100644 --- a/crates/g3-core/src/tools/acd.rs +++ b/crates/g3-core/src/tools/acd.rs @@ -137,6 +137,7 @@ mod tests { pending_images: Vec, pending_research_manager: PendingResearchManager, config: g3_config::Config, + loaded_toolsets: std::collections::HashSet, } impl TestContext { @@ -150,6 +151,7 @@ mod tests { pending_images: Vec::new(), pending_research_manager: PendingResearchManager::new(), config: g3_config::Config::default(), + loaded_toolsets: std::collections::HashSet::new(), } } } @@ -173,6 +175,7 @@ mod tests { requirements_sha: None, context_total_tokens: 100000, context_used_tokens: 10000, + loaded_toolsets: &mut test_ctx.loaded_toolsets, }; let tool_call = ToolCall { @@ -204,6 +207,7 @@ mod tests { requirements_sha: None, context_total_tokens: 100000, context_used_tokens: 10000, + loaded_toolsets: &mut test_ctx.loaded_toolsets, }; let tool_call = ToolCall { @@ -235,6 +239,7 @@ mod tests { requirements_sha: None, context_total_tokens: 100000, context_used_tokens: 10000, + loaded_toolsets: &mut test_ctx.loaded_toolsets, }; let tool_call = ToolCall { diff --git a/crates/g3-core/src/tools/executor.rs b/crates/g3-core/src/tools/executor.rs index 5f0555a..fa7b636 100644 --- a/crates/g3-core/src/tools/executor.rs +++ b/crates/g3-core/src/tools/executor.rs @@ -1,6 +1,7 @@ //! Tool executor trait and context for tool execution. use anyhow::Result; +use std::collections::HashSet; use std::sync::Arc; use tokio::sync::RwLock; @@ -29,6 +30,8 @@ pub struct ToolContext<'a, W: UiWriter> { pub requirements_sha: Option<&'a str>, pub context_total_tokens: u32, pub context_used_tokens: u32, + /// Set of toolset names that have been loaded in this session + pub loaded_toolsets: &'a mut HashSet, } impl<'a, W: UiWriter> ToolContext<'a, W> { diff --git a/crates/g3-core/src/tools/mod.rs b/crates/g3-core/src/tools/mod.rs index 1f49f81..39d347b 100644 --- a/crates/g3-core/src/tools/mod.rs +++ b/crates/g3-core/src/tools/mod.rs @@ -19,6 +19,7 @@ pub mod misc; pub mod plan; pub mod research; pub mod shell; +pub mod toolsets; pub mod webdriver; pub use executor::ToolExecutor; diff --git a/crates/g3-core/src/tools/toolsets.rs b/crates/g3-core/src/tools/toolsets.rs new file mode 100644 index 0000000..2b7ffac --- /dev/null +++ b/crates/g3-core/src/tools/toolsets.rs @@ -0,0 +1,110 @@ +//! Toolset loading tool implementation. +//! +//! This module provides the `load_toolset` tool which allows the agent to +//! dynamically load additional tool definitions at runtime. + +use anyhow::Result; +use tracing::debug; + +use crate::toolsets; +use crate::tools::executor::ToolContext; +use crate::ui_writer::UiWriter; +use crate::ToolCall; + +/// Execute the load_toolset tool. +/// +/// This tool loads a named toolset and returns the tool definitions so the +/// agent learns how to use the newly available tools. +/// +/// The tool definitions are returned as formatted text describing each tool, +/// its purpose, and its parameters. +pub async fn execute_load_toolset( + tool_call: &ToolCall, + ctx: &mut ToolContext<'_, W>, +) -> Result { + let toolset_name = tool_call + .args + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + debug!("Loading toolset: {}", toolset_name); + + // Get the toolset from the registry + let toolset = match toolsets::get_toolset(toolset_name) { + Ok(ts) => ts, + Err(err) => { + return Ok(format!("❌ {}", err)); + } + }; + + // Check if already loaded (idempotent) + if ctx.loaded_toolsets.contains(&toolset_name.to_string()) { + return Ok(format!( + "✅ Toolset '{}' is already loaded. You can use its tools.", + toolset_name + )); + } + + // Get the tool definitions + let tools = toolset.get_tools(); + + // Mark as loaded + ctx.loaded_toolsets.insert(toolset_name.to_string()); + + // Format the tool definitions for the agent + let mut output = String::new(); + output.push_str(&format!( + "✅ Loaded toolset '{}' with {} tools:\n\n", + toolset_name, + tools.len() + )); + + for tool in &tools { + output.push_str(&format!("## {}", tool.name)); + output.push_str("\n\n"); + output.push_str(&tool.description); + output.push_str("\n\n"); + + // Format the input schema in a readable way + if let Some(props) = tool.input_schema.get("properties") { + if let Some(obj) = props.as_object() { + if !obj.is_empty() { + output.push_str("**Parameters:**\n"); + for (param_name, param_schema) in obj { + let param_type = param_schema + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("any"); + let param_desc = param_schema + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + + // Check if required + let required = tool.input_schema + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().any(|v| v.as_str() == Some(param_name))) + .unwrap_or(false); + + let req_marker = if required { " (required)" } else { " (optional)" }; + + output.push_str(&format!( + "- `{}` ({}){}: {}\n", + param_name, param_type, req_marker, param_desc + )); + } + output.push_str("\n"); + } + } + } + } + + output.push_str(&format!( + "---\nYou can now use these {} tools. They are available for the rest of this session.", + tools.len() + )); + + Ok(output) +} diff --git a/crates/g3-core/src/toolsets.rs b/crates/g3-core/src/toolsets.rs new file mode 100644 index 0000000..dc923f6 --- /dev/null +++ b/crates/g3-core/src/toolsets.rs @@ -0,0 +1,394 @@ +//! Toolsets module - registry of dynamically loadable tool collections. +//! +//! Toolsets are groups of related tools that can be loaded on-demand via the +//! `load_toolset` tool. This keeps the default tool set lean while allowing +//! access to specialized tools when needed. +//! +//! The agent sees a concise registry in the system prompt and can load +//! toolsets as needed. The tool definitions are returned so the agent +//! learns how to call the newly available tools. + +use g3_providers::Tool; +use serde_json::json; + +/// A toolset that can be dynamically loaded. +#[derive(Debug, Clone)] +pub struct Toolset { + /// Unique identifier for the toolset (e.g., "webdriver") + pub name: &'static str, + /// Brief description of what the toolset provides + pub description: &'static str, + /// Function that returns the tool definitions for this toolset + tool_definitions_fn: fn() -> Vec, +} + +impl Toolset { + /// Get the tool definitions for this toolset. + pub fn get_tools(&self) -> Vec { + (self.tool_definitions_fn)() + } +} + +/// Registry of all available toolsets. +/// Add new toolsets here as they are created. +const TOOLSET_REGISTRY: &[Toolset] = &[ + Toolset { + name: "webdriver", + description: "Browser automation via Safari WebDriver. Start sessions, navigate, find elements, click, type, execute JavaScript, take screenshots.", + tool_definitions_fn: create_webdriver_tools, + }, +]; + +/// Get a toolset by name. +/// +/// Returns `Ok(Toolset)` if found, or `Err` with a helpful message listing +/// available toolsets if not found. +pub fn get_toolset(name: &str) -> Result<&'static Toolset, String> { + let name = name.trim(); + + if name.is_empty() { + return Err(format!( + "Toolset name cannot be empty. Available toolsets: {}", + list_toolset_names().join(", ") + )); + } + + TOOLSET_REGISTRY + .iter() + .find(|t| t.name == name) + .ok_or_else(|| { + format!( + "Unknown toolset '{}'. Available toolsets: {}", + name, + list_toolset_names().join(", ") + ) + }) +} + +/// List all available toolset names. +pub fn list_toolset_names() -> Vec<&'static str> { + TOOLSET_REGISTRY.iter().map(|t| t.name).collect() +} + +/// Get all available toolsets. +pub fn get_all_toolsets() -> &'static [Toolset] { + TOOLSET_REGISTRY +} + +/// Generate the prompt section describing available toolsets. +/// +/// This is injected into the system prompt so the agent knows what +/// toolsets are available to load. +pub fn generate_toolsets_prompt() -> String { + if TOOLSET_REGISTRY.is_empty() { + return String::new(); + } + + let mut prompt = String::new(); + prompt.push_str("# Available Toolsets\n\n"); + prompt.push_str("You can dynamically load additional tools using `load_toolset`. "); + prompt.push_str("The tool will return the full definitions so you learn how to use them.\n\n"); + prompt.push_str("\n"); + + for toolset in TOOLSET_REGISTRY { + prompt.push_str(" \n"); + prompt.push_str(&format!(" {}\n", escape_xml(toolset.name))); + prompt.push_str(&format!(" {}\n", escape_xml(toolset.description))); + prompt.push_str(" \n"); + } + + prompt.push_str("\n"); + prompt +} + +/// Escape special XML characters. +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +// ============================================================================= +// TOOLSET DEFINITIONS +// ============================================================================= + +/// Create WebDriver browser automation tools. +/// +/// These tools enable browser automation via Safari WebDriver. +fn create_webdriver_tools() -> Vec { + vec![ + Tool { + name: "webdriver_start".to_string(), + description: "Start a Safari WebDriver session for browser automation. Must be called before any other webdriver tools. Requires Safari's 'Allow Remote Automation' to be enabled in Develop menu.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "webdriver_navigate".to_string(), + description: "Navigate to a URL in the browser".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to (must include protocol, e.g., https://)" + } + }, + "required": ["url"] + }), + }, + Tool { + name: "webdriver_get_url".to_string(), + description: "Get the current URL of the browser".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "webdriver_get_title".to_string(), + description: "Get the title of the current page".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "webdriver_find_element".to_string(), + description: "Find an element on the page by CSS selector and return its text content".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "CSS selector to find the element (e.g., 'h1', '.class-name', '#id')" + } + }, + "required": ["selector"] + }), + }, + Tool { + name: "webdriver_find_elements".to_string(), + description: "Find all elements matching a CSS selector and return their text content".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "CSS selector to find elements" + } + }, + "required": ["selector"] + }), + }, + Tool { + name: "webdriver_click".to_string(), + description: "Click an element on the page".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "CSS selector for the element to click" + } + }, + "required": ["selector"] + }), + }, + Tool { + name: "webdriver_send_keys".to_string(), + description: "Type text into an input element".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "CSS selector for the input element" + }, + "text": { + "type": "string", + "description": "Text to type into the element" + }, + "clear_first": { + "type": "boolean", + "description": "Whether to clear the element before typing (default: true)" + } + }, + "required": ["selector", "text"] + }), + }, + Tool { + name: "webdriver_execute_script".to_string(), + description: "Execute JavaScript code in the browser and return the result".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "JavaScript code to execute (use 'return' to return a value)" + } + }, + "required": ["script"] + }), + }, + Tool { + name: "webdriver_get_page_source".to_string(), + description: "Get the rendered HTML source of the current page. Returns the current DOM state after JavaScript execution.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "max_length": { + "type": "integer", + "description": "Maximum length of HTML to return (default: 10000, use 0 for no truncation)" + }, + "save_to_file": { + "type": "string", + "description": "Optional file path to save the HTML instead of returning it inline" + } + }, + "required": [] + }), + }, + Tool { + name: "webdriver_screenshot".to_string(), + description: "Take a screenshot of the browser window".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path where to save the screenshot (e.g., '/tmp/screenshot.png')" + } + }, + "required": ["path"] + }), + }, + Tool { + name: "webdriver_back".to_string(), + description: "Navigate back in browser history".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "webdriver_forward".to_string(), + description: "Navigate forward in browser history".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "webdriver_refresh".to_string(), + description: "Refresh the current page".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + Tool { + name: "webdriver_quit".to_string(), + description: "Close the browser and end the WebDriver session".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_toolset_webdriver() { + let toolset = get_toolset("webdriver").unwrap(); + assert_eq!(toolset.name, "webdriver"); + assert!(!toolset.description.is_empty()); + + let tools = toolset.get_tools(); + assert!(!tools.is_empty()); + assert!(tools.iter().any(|t| t.name == "webdriver_start")); + } + + #[test] + fn test_get_toolset_unknown() { + let result = get_toolset("nonexistent"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Unknown toolset")); + assert!(err.contains("webdriver")); // Should list available toolsets + } + + #[test] + fn test_get_toolset_empty_name() { + let result = get_toolset(""); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("cannot be empty")); + } + + #[test] + fn test_get_toolset_whitespace_trimmed() { + let toolset = get_toolset(" webdriver ").unwrap(); + assert_eq!(toolset.name, "webdriver"); + } + + #[test] + fn test_list_toolset_names() { + let names = list_toolset_names(); + assert!(names.contains(&"webdriver")); + } + + #[test] + fn test_generate_toolsets_prompt() { + let prompt = generate_toolsets_prompt(); + assert!(prompt.contains("")); + assert!(prompt.contains("")); + assert!(prompt.contains("webdriver")); + assert!(prompt.contains("load_toolset")); + } + + #[test] + fn test_xml_escaping() { + // The current toolsets don't have special chars, but test the function + assert_eq!(escape_xml(""), "<test>"); + assert_eq!(escape_xml("a & b"), "a & b"); + } + + #[test] + fn test_webdriver_tools_complete() { + let tools = create_webdriver_tools(); + let tool_names: Vec<_> = tools.iter().map(|t| t.name.as_str()).collect(); + + // Verify all expected webdriver tools are present + assert!(tool_names.contains(&"webdriver_start")); + assert!(tool_names.contains(&"webdriver_navigate")); + assert!(tool_names.contains(&"webdriver_get_url")); + assert!(tool_names.contains(&"webdriver_get_title")); + assert!(tool_names.contains(&"webdriver_find_element")); + assert!(tool_names.contains(&"webdriver_find_elements")); + assert!(tool_names.contains(&"webdriver_click")); + assert!(tool_names.contains(&"webdriver_send_keys")); + assert!(tool_names.contains(&"webdriver_execute_script")); + assert!(tool_names.contains(&"webdriver_get_page_source")); + assert!(tool_names.contains(&"webdriver_screenshot")); + assert!(tool_names.contains(&"webdriver_back")); + assert!(tool_names.contains(&"webdriver_forward")); + assert!(tool_names.contains(&"webdriver_refresh")); + assert!(tool_names.contains(&"webdriver_quit")); + } +}