Skip to main content

ravenclaws/
tools.rs

1//! RavenClaws
2//!
3//! Provides a provider-agnostic tool schema, a registry for built-in tools,
4//! and the execution engine that routes tool calls to their implementations.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ToolRegistry (holds all registered tools)
10//!   ├── ToolDefinition (name, description, JSON schema)
11//!   └── ToolImpl (the actual implementation)
12//!         ├── ShellTool — execute shell commands (sandboxed)
13//!         ├── ReadFileTool — read files (policy-checked)
14//!         ├── WriteFileTool — write files (policy-checked)
15//!         ├── WebFetchTool — fetch URLs (policy-checked)
16//!         └── ... more tools
17//! ```
18
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::sync::Arc;
22use thiserror::Error;
23use tracing::{debug, info, instrument, warn};
24
25// Re-export sandbox for tool implementations
26use crate::sandbox::Sandbox;
27
28// ── Error types ────────────────────────────────────────────────────────────
29
30/// Tool execution error type.
31///
32/// # Stability
33/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
34#[derive(Error, Debug)]
35#[non_exhaustive]
36pub enum ToolError {
37    #[error("Tool '{0}' not found")]
38    NotFound(String),
39
40    #[error("Tool '{0}' execution failed: {1}")]
41    ExecutionFailed(String, String),
42
43    #[error("Invalid arguments for tool '{0}': {1}")]
44    InvalidArguments(String, String),
45
46    #[allow(dead_code)]
47    #[error("Policy denied: {0}")]
48    PolicyDenied(String),
49
50    #[allow(dead_code)]
51    #[error("Sandbox violation: {0}")]
52    SandboxViolation(String),
53
54    #[error("IO error: {0}")]
55    Io(#[from] std::io::Error),
56}
57
58pub type ToolResultValue<T> = std::result::Result<T, ToolError>;
59
60// ── Tool schema types ──────────────────────────────────────────────────────
61
62/// JSON Schema representation for tool parameters
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct JsonSchema {
65    #[serde(rename = "type")]
66    pub schema_type: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub description: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub properties: Option<HashMap<String, JsonSchema>>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub required: Option<Vec<String>>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub items: Option<Box<JsonSchema>>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub enum_values: Option<Vec<String>>,
77}
78
79impl JsonSchema {
80    /// Create a string schema property
81    pub fn string(description: &str) -> Self {
82        Self {
83            schema_type: "string".to_string(),
84            description: Some(description.to_string()),
85            properties: None,
86            required: None,
87            items: None,
88            enum_values: None,
89        }
90    }
91
92    /// Create an object schema
93    pub fn object(properties: HashMap<String, JsonSchema>, required: Vec<String>) -> Self {
94        Self {
95            schema_type: "object".to_string(),
96            description: None,
97            properties: Some(properties),
98            required: Some(required),
99            items: None,
100            enum_values: None,
101        }
102    }
103
104    /// Create an array schema
105    #[allow(dead_code)]
106    pub fn array(items: JsonSchema, description: &str) -> Self {
107        Self {
108            schema_type: "array".to_string(),
109            description: Some(description.to_string()),
110            properties: None,
111            required: None,
112            items: Some(Box::new(items)),
113            enum_values: None,
114        }
115    }
116}
117
118/// A tool definition — the schema exposed to the LLM
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ToolDefinition {
121    /// The name of the tool (e.g., "shell_exec", "read_file")
122    pub name: String,
123    /// A description of what the tool does (for the LLM)
124    pub description: String,
125    /// JSON Schema for the tool's parameters
126    pub parameters: JsonSchema,
127    /// Whether this tool requires human approval
128    #[serde(default)]
129    pub requires_approval: bool,
130    /// Category for grouping
131    #[serde(default)]
132    pub category: ToolCategory,
133}
134
135impl ToolDefinition {
136    /// Convert to OpenAI Tools format for structured function calling
137    /// See: https://platform.openai.com/docs/guides/function-calling
138    #[allow(dead_code)]
139    pub fn to_openai_tool(&self) -> serde_json::Value {
140        serde_json::json!({
141            "type": "function",
142            "function": {
143                "name": self.name,
144                "description": self.description,
145                "parameters": self.parameters
146            }
147        })
148    }
149}
150
151/// Tool categories for grouping and policy
152///
153/// # Stability
154/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
156#[non_exhaustive]
157pub enum ToolCategory {
158    #[default]
159    General,
160    Shell,
161    FileSystem,
162    Network,
163    CodeAnalysis,
164    WebSearch,
165    Mcp,
166    Browser,
167}
168
169/// A tool call request from the LLM
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ToolCall {
172    /// The name of the tool to call
173    pub name: String,
174    /// The arguments as a JSON object
175    pub arguments: serde_json::Value,
176    /// An optional ID for tracking (used by some providers)
177    #[serde(default)]
178    pub id: Option<String>,
179}
180
181/// The result of a tool execution
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ToolResult {
184    /// The name of the tool that was called
185    pub tool_name: String,
186    /// Whether the execution was successful
187    pub success: bool,
188    /// The output (stdout or result data)
189    pub output: String,
190    /// Error message if failed
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub error: Option<String>,
193    /// Exit code (for shell commands)
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub exit_code: Option<i32>,
196    /// Duration in milliseconds
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub duration_ms: Option<u64>,
199}
200
201// ── Tool implementation trait ──────────────────────────────────────────────
202
203/// The actual implementation of a tool
204#[async_trait::async_trait]
205pub trait ToolImpl: Send + Sync {
206    /// Execute the tool with the given arguments
207    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult>;
208
209    /// Get the tool's definition (schema)
210    fn definition(&self) -> &ToolDefinition;
211
212    /// Get a display name for logging
213    fn name(&self) -> &str {
214        &self.definition().name
215    }
216}
217
218// ── Tool registry ──────────────────────────────────────────────────────────
219
220/// Registry of all available tools
221#[derive(Clone)]
222pub struct ToolRegistry {
223    tools: HashMap<String, Arc<dyn ToolImpl>>,
224}
225
226impl ToolRegistry {
227    /// Create a new empty tool registry
228    pub fn new() -> Self {
229        Self {
230            tools: HashMap::new(),
231        }
232    }
233
234    /// Register a tool
235    pub fn register(&mut self, tool: Arc<dyn ToolImpl>) {
236        let name = tool.name().to_string();
237        info!(tool = %name, category = ?tool.definition().category, "Tool registered");
238        self.tools.insert(name, tool);
239    }
240
241    /// Get a tool by name
242    pub fn get(&self, name: &str) -> Option<&Arc<dyn ToolImpl>> {
243        self.tools.get(name)
244    }
245
246    /// Check if a tool exists
247    #[allow(dead_code)]
248    pub fn has(&self, name: &str) -> bool {
249        self.tools.contains_key(name)
250    }
251
252    /// Get all tool definitions (for sending to LLM)
253    #[allow(dead_code)]
254    pub fn definitions(&self) -> Vec<ToolDefinition> {
255        self.tools
256            .values()
257            .map(|t| t.definition().clone())
258            .collect()
259    }
260
261    /// Get all tool definitions in OpenAI Tools format for structured function calling
262    #[allow(dead_code)]
263    pub fn to_openai_tools(&self) -> Vec<serde_json::Value> {
264        self.tools
265            .values()
266            .map(|t| t.definition().to_openai_tool())
267            .collect()
268    }
269
270    /// Get the number of registered tools
271    #[allow(dead_code)]
272    pub fn len(&self) -> usize {
273        self.tools.len()
274    }
275
276    /// Check if the registry is empty
277    #[allow(dead_code)]
278    pub fn is_empty(&self) -> bool {
279        self.tools.is_empty()
280    }
281
282    /// Execute a tool call
283    #[instrument(skip(self), fields(tool = %call.name))]
284    pub async fn execute(&self, call: ToolCall) -> ToolResultValue<ToolResult> {
285        let start = std::time::Instant::now();
286
287        let tool = self
288            .get(&call.name)
289            .ok_or_else(|| ToolError::NotFound(call.name.clone()))?;
290
291        info!(tool = %call.name, "Executing tool call");
292        debug!(
293            tool = %call.name,
294            args = %call.arguments,
295            "Tool call arguments"
296        );
297
298        let mut result = tool.execute(call.arguments).await?;
299        result.duration_ms = Some(start.elapsed().as_millis() as u64);
300
301        if result.success {
302            info!(
303                tool = %call.name,
304                duration_ms = result.duration_ms.unwrap_or(0),
305                "Tool executed successfully"
306            );
307            debug!(
308                tool = %call.name,
309                output_len = result.output.len(),
310                "Tool result output"
311            );
312        } else {
313            warn!(
314                tool = %call.name,
315                error = %result.error.as_deref().unwrap_or("unknown"),
316                "Tool execution failed"
317            );
318        }
319
320        Ok(result)
321    }
322
323    /// Create a default registry with all built-in tools
324    pub fn with_default_tools() -> Self {
325        let mut registry = Self::new();
326        registry.register(Arc::new(ShellTool::new()));
327        registry.register(Arc::new(ReadFileTool::new()));
328        registry.register(Arc::new(WriteFileTool::new()));
329        registry.register(Arc::new(WebFetchTool::new()));
330        registry.register(Arc::new(WebSearchTool::new()));
331        registry.register(Arc::new(BrowserTool::new()));
332        registry
333    }
334
335    /// Create a default registry with web search configured
336    #[allow(dead_code)]
337    pub fn with_web_search_config(
338        endpoint: &str,
339        engine: &str,
340        max_results: usize,
341        fetch_content: bool,
342    ) -> Self {
343        let mut registry = Self::new();
344        registry.register(Arc::new(ShellTool::new()));
345        registry.register(Arc::new(ReadFileTool::new()));
346        registry.register(Arc::new(WriteFileTool::new()));
347        registry.register(Arc::new(WebFetchTool::new()));
348        registry.register(Arc::new(WebSearchTool::with_config(
349            endpoint.to_string(),
350            engine.to_string(),
351            max_results,
352            fetch_content,
353        )));
354        registry.register(Arc::new(BrowserTool::new()));
355        registry
356    }
357
358    /// Create a default registry with web search configured from config
359    pub fn with_config(config: &crate::config::Config) -> Self {
360        let mut registry = Self::new();
361        registry.register(Arc::new(ShellTool::new()));
362        registry.register(Arc::new(ReadFileTool::new()));
363        registry.register(Arc::new(WriteFileTool::new()));
364        registry.register(Arc::new(WebFetchTool::new()));
365        registry.register(Arc::new(WebSearchTool::with_config(
366            config.web_search.endpoint.clone(),
367            config.web_search.engine.clone(),
368            config.web_search.max_results,
369            config.web_search.fetch_content,
370        )));
371        registry.register(Arc::new(BrowserTool::with_config(
372            config.browser.cdp_url.clone(),
373            config.browser.request_timeout,
374        )));
375        registry
376    }
377}
378
379impl Default for ToolRegistry {
380    fn default() -> Self {
381        Self::with_default_tools()
382    }
383}
384
385// ── Built-in tools ─────────────────────────────────────────────────────────
386
387/// Shell command execution tool (sandboxed)
388pub struct ShellTool {
389    definition: ToolDefinition,
390    sandbox: Option<Sandbox>,
391}
392
393impl ShellTool {
394    pub fn new() -> Self {
395        Self::default()
396    }
397
398    #[allow(dead_code)]
399    pub fn new_with_sandbox(sandbox: Sandbox) -> Self {
400        Self {
401            sandbox: Some(sandbox),
402            ..Self::default()
403        }
404    }
405}
406
407impl Default for ShellTool {
408    fn default() -> Self {
409        let mut properties = HashMap::new();
410        properties.insert(
411            "command".to_string(),
412            JsonSchema::string("The shell command to execute"),
413        );
414        properties.insert(
415            "timeout_secs".to_string(),
416            JsonSchema {
417                schema_type: "integer".to_string(),
418                description: Some("Timeout in seconds (default: 30)".to_string()),
419                properties: None,
420                required: None,
421                items: None,
422                enum_values: None,
423            },
424        );
425        properties.insert(
426            "workdir".to_string(),
427            JsonSchema::string("Working directory (default: current)"),
428        );
429
430        Self {
431            definition: ToolDefinition {
432                name: "shell_exec".to_string(),
433                description: "Execute a shell command and return its output. Use for running scripts, compiling code, or any command-line operation. Runs in a sandboxed environment.".to_string(),
434                parameters: JsonSchema::object(
435                    properties,
436                    vec!["command".to_string()],
437                ),
438                requires_approval: true,
439                category: ToolCategory::Shell,
440            },
441            sandbox: None,
442        }
443    }
444}
445
446#[async_trait::async_trait]
447impl ToolImpl for ShellTool {
448    fn definition(&self) -> &ToolDefinition {
449        &self.definition
450    }
451
452    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult> {
453        let command = args
454            .get("command")
455            .and_then(|v| v.as_str())
456            .ok_or_else(|| {
457                ToolError::InvalidArguments(
458                    "shell_exec".to_string(),
459                    "missing 'command' argument".to_string(),
460                )
461            })?;
462
463        let timeout_secs = args
464            .get("timeout_secs")
465            .and_then(|v| v.as_u64())
466            .unwrap_or(30);
467
468        // Use sandbox workdir if available, otherwise use provided workdir
469        let workdir = if let Some(sandbox) = &self.sandbox {
470            sandbox.workdir().to_string_lossy().to_string()
471        } else {
472            args.get("workdir")
473                .and_then(|v| v.as_str())
474                .map(|s| s.to_string())
475                .unwrap_or_else(|| {
476                    std::env::current_dir()
477                        .unwrap_or_default()
478                        .to_string_lossy()
479                        .to_string()
480                })
481        };
482
483        // Execute the command (sandboxed if sandbox is configured)
484        let result = run_shell_command(command, timeout_secs, Some(workdir)).await?;
485
486        Ok(result)
487    }
488}
489
490/// Read a file from the filesystem
491pub struct ReadFileTool {
492    definition: ToolDefinition,
493}
494
495impl ReadFileTool {
496    pub fn new() -> Self {
497        Self::default()
498    }
499}
500
501impl Default for ReadFileTool {
502    fn default() -> Self {
503        let mut properties = HashMap::new();
504        properties.insert(
505            "path".to_string(),
506            JsonSchema::string("Absolute path to the file to read"),
507        );
508        properties.insert(
509            "max_bytes".to_string(),
510            JsonSchema {
511                schema_type: "integer".to_string(),
512                description: Some("Maximum bytes to read (default: 65536)".to_string()),
513                properties: None,
514                required: None,
515                items: None,
516                enum_values: None,
517            },
518        );
519
520        Self {
521            definition: ToolDefinition {
522                name: "read_file".to_string(),
523                description: "Read the contents of a file from the filesystem. Returns the file content as text.".to_string(),
524                parameters: JsonSchema::object(
525                    properties,
526                    vec!["path".to_string()],
527                ),
528                requires_approval: false,
529                category: ToolCategory::FileSystem,
530            },
531        }
532    }
533}
534
535#[async_trait::async_trait]
536impl ToolImpl for ReadFileTool {
537    fn definition(&self) -> &ToolDefinition {
538        &self.definition
539    }
540
541    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult> {
542        let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
543            ToolError::InvalidArguments(
544                "read_file".to_string(),
545                "missing 'path' argument".to_string(),
546            )
547        })?;
548
549        let max_bytes = args
550            .get("max_bytes")
551            .and_then(|v| v.as_u64())
552            .unwrap_or(65536) as usize;
553
554        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
555            ToolError::ExecutionFailed("read_file".to_string(), format!("Cannot read file: {}", e))
556        })?;
557
558        let truncated = if content.len() > max_bytes {
559            format!(
560                "{}...\n[truncated at {} bytes]",
561                &content[..max_bytes],
562                max_bytes
563            )
564        } else {
565            content
566        };
567
568        Ok(ToolResult {
569            tool_name: "read_file".to_string(),
570            success: true,
571            output: truncated,
572            error: None,
573            exit_code: None,
574            duration_ms: None,
575        })
576    }
577}
578
579/// Write a file to the filesystem
580pub struct WriteFileTool {
581    definition: ToolDefinition,
582}
583
584impl WriteFileTool {
585    pub fn new() -> Self {
586        Self::default()
587    }
588}
589
590impl Default for WriteFileTool {
591    fn default() -> Self {
592        let mut properties = HashMap::new();
593        properties.insert(
594            "path".to_string(),
595            JsonSchema::string("Absolute path to the file to write"),
596        );
597        properties.insert(
598            "content".to_string(),
599            JsonSchema::string("The content to write to the file"),
600        );
601        properties.insert(
602            "append".to_string(),
603            JsonSchema {
604                schema_type: "boolean".to_string(),
605                description: Some(
606                    "If true, append instead of overwrite (default: false)".to_string(),
607                ),
608                properties: None,
609                required: None,
610                items: None,
611                enum_values: None,
612            },
613        );
614
615        Self {
616            definition: ToolDefinition {
617                name: "write_file".to_string(),
618                description: "Write content to a file. Creates parent directories if they don't exist. Can append to existing files.".to_string(),
619                parameters: JsonSchema::object(
620                    properties,
621                    vec!["path".to_string(), "content".to_string()],
622                ),
623                requires_approval: true,
624                category: ToolCategory::FileSystem,
625            },
626        }
627    }
628}
629
630#[async_trait::async_trait]
631impl ToolImpl for WriteFileTool {
632    fn definition(&self) -> &ToolDefinition {
633        &self.definition
634    }
635
636    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult> {
637        let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
638            ToolError::InvalidArguments(
639                "write_file".to_string(),
640                "missing 'path' argument".to_string(),
641            )
642        })?;
643
644        let content = args
645            .get("content")
646            .and_then(|v| v.as_str())
647            .ok_or_else(|| {
648                ToolError::InvalidArguments(
649                    "write_file".to_string(),
650                    "missing 'content' argument".to_string(),
651                )
652            })?;
653
654        let append = args
655            .get("append")
656            .and_then(|v| v.as_bool())
657            .unwrap_or(false);
658
659        // Create parent directories
660        if let Some(parent) = std::path::Path::new(path).parent() {
661            tokio::fs::create_dir_all(parent).await.map_err(|e| {
662                ToolError::ExecutionFailed(
663                    "write_file".to_string(),
664                    format!("Cannot create directories: {}", e),
665                )
666            })?;
667        }
668
669        if append {
670            let mut file = tokio::fs::OpenOptions::new()
671                .append(true)
672                .create(true)
673                .open(path)
674                .await
675                .map_err(|e| {
676                    ToolError::ExecutionFailed(
677                        "write_file".to_string(),
678                        format!("Cannot open file for append: {}", e),
679                    )
680                })?;
681            tokio::io::AsyncWriteExt::write_all(&mut file, content.as_bytes())
682                .await
683                .map_err(|e| {
684                    ToolError::ExecutionFailed(
685                        "write_file".to_string(),
686                        format!("Cannot write to file: {}", e),
687                    )
688                })?;
689        } else {
690            tokio::fs::write(path, content).await.map_err(|e| {
691                ToolError::ExecutionFailed(
692                    "write_file".to_string(),
693                    format!("Cannot write file: {}", e),
694                )
695            })?;
696        }
697
698        Ok(ToolResult {
699            tool_name: "write_file".to_string(),
700            success: true,
701            output: format!("Successfully wrote {} bytes to {}", content.len(), path),
702            error: None,
703            exit_code: None,
704            duration_ms: None,
705        })
706    }
707}
708
709/// Web fetch tool — fetches a URL and returns the content
710pub struct WebFetchTool {
711    definition: ToolDefinition,
712}
713
714impl WebFetchTool {
715    pub fn new() -> Self {
716        Self::default()
717    }
718}
719
720impl Default for WebFetchTool {
721    fn default() -> Self {
722        let mut properties = HashMap::new();
723        properties.insert("url".to_string(), JsonSchema::string("The URL to fetch"));
724        properties.insert(
725            "max_bytes".to_string(),
726            JsonSchema {
727                schema_type: "integer".to_string(),
728                description: Some("Maximum bytes to read (default: 131072)".to_string()),
729                properties: None,
730                required: None,
731                items: None,
732                enum_values: None,
733            },
734        );
735
736        Self {
737            definition: ToolDefinition {
738                name: "web_fetch".to_string(),
739                description: "Fetch a URL and return its content as text. Use for reading web pages, APIs, or documentation.".to_string(),
740                parameters: JsonSchema::object(
741                    properties,
742                    vec!["url".to_string()],
743                ),
744                requires_approval: false,
745                category: ToolCategory::Network,
746            },
747        }
748    }
749}
750
751#[async_trait::async_trait]
752impl ToolImpl for WebFetchTool {
753    fn definition(&self) -> &ToolDefinition {
754        &self.definition
755    }
756
757    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult> {
758        let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
759            ToolError::InvalidArguments(
760                "web_fetch".to_string(),
761                "missing 'url' argument".to_string(),
762            )
763        })?;
764
765        let max_bytes = args
766            .get("max_bytes")
767            .and_then(|v| v.as_u64())
768            .unwrap_or(131072) as usize;
769
770        let client = reqwest::Client::builder()
771            .timeout(std::time::Duration::from_secs(30))
772            .user_agent("RavenClaws/0.9.2")
773            .build()
774            .map_err(|e| {
775                ToolError::ExecutionFailed("web_fetch".to_string(), format!("HTTP client: {}", e))
776            })?;
777
778        let response = client.get(url).send().await.map_err(|e| {
779            ToolError::ExecutionFailed("web_fetch".to_string(), format!("Request failed: {}", e))
780        })?;
781
782        let status = response.status();
783        let content_type = response
784            .headers()
785            .get(reqwest::header::CONTENT_TYPE)
786            .and_then(|v| v.to_str().ok())
787            .unwrap_or("unknown")
788            .to_string();
789
790        let body = response.text().await.map_err(|e| {
791            ToolError::ExecutionFailed(
792                "web_fetch".to_string(),
793                format!("Failed to read response body: {}", e),
794            )
795        })?;
796
797        let truncated = if body.len() > max_bytes {
798            format!(
799                "{}...\n[truncated at {} bytes]",
800                &body[..max_bytes],
801                max_bytes
802            )
803        } else {
804            body
805        };
806
807        Ok(ToolResult {
808            tool_name: "web_fetch".to_string(),
809            success: status.is_success(),
810            output: format!(
811                "Status: {}\nContent-Type: {}\n\n{}",
812                status.as_u16(),
813                content_type,
814                truncated
815            ),
816            error: if status.is_success() {
817                None
818            } else {
819                Some(format!("HTTP {}", status.as_u16()))
820            },
821            exit_code: Some(status.as_u16() as i32),
822            duration_ms: None,
823        })
824    }
825}
826
827/// Web search tool — searches the web using a configurable search API
828pub struct WebSearchTool {
829    definition: ToolDefinition,
830    search_endpoint: String,
831    search_engine: String,
832    max_results: usize,
833    fetch_content: bool,
834}
835
836impl WebSearchTool {
837    pub fn new() -> Self {
838        Self::default()
839    }
840
841    pub fn with_config(
842        endpoint: String,
843        engine: String,
844        max_results: usize,
845        fetch_content: bool,
846    ) -> Self {
847        let mut properties = HashMap::new();
848        properties.insert("query".to_string(), JsonSchema::string("The search query"));
849        properties.insert(
850            "max_results".to_string(),
851            JsonSchema {
852                schema_type: "integer".to_string(),
853                description: Some(
854                    "Maximum number of search results to return (default: 5)".to_string(),
855                ),
856                properties: None,
857                required: None,
858                items: None,
859                enum_values: None,
860            },
861        );
862        properties.insert(
863            "fetch_content".to_string(),
864            JsonSchema {
865                schema_type: "boolean".to_string(),
866                description: Some(
867                    "Whether to fetch and extract content from each result (default: true)"
868                        .to_string(),
869                ),
870                properties: None,
871                required: None,
872                items: None,
873                enum_values: None,
874            },
875        );
876
877        Self {
878            definition: ToolDefinition {
879                name: "web_search".to_string(),
880                description: "Search the web for information. Returns a list of results with titles, URLs, and snippets. Can optionally fetch and extract readable content from each result.".to_string(),
881                parameters: JsonSchema::object(
882                    properties,
883                    vec!["query".to_string()],
884                ),
885                requires_approval: false,
886                category: ToolCategory::WebSearch,
887            },
888            search_endpoint: endpoint,
889            search_engine: engine,
890            max_results,
891            fetch_content,
892        }
893    }
894}
895
896impl Default for WebSearchTool {
897    fn default() -> Self {
898        Self::with_config(
899            "https://searx.be".to_string(),
900            "duckduckgo".to_string(),
901            5,
902            true,
903        )
904    }
905}
906
907impl WebSearchTool {
908    /// Search via SearXNG API (self-hosted, privacy-respecting)
909    async fn search_searxng(
910        &self,
911        query: &str,
912        max_results: usize,
913    ) -> ToolResultValue<Vec<SearchResult>> {
914        let client = reqwest::Client::builder()
915            .timeout(std::time::Duration::from_secs(15))
916            .user_agent("RavenClaws/0.9.2")
917            .build()
918            .map_err(|e| {
919                ToolError::ExecutionFailed("web_search".to_string(), format!("HTTP client: {}", e))
920            })?;
921
922        let url = format!(
923            "{}/search?q={}&format=json&language=en&pageno=1",
924            self.search_endpoint.trim_end_matches('/'),
925            urlencoding(query)
926        );
927
928        let response = client.get(&url).send().await.map_err(|e| {
929            ToolError::ExecutionFailed(
930                "web_search".to_string(),
931                format!("Search request failed: {}", e),
932            )
933        })?;
934
935        if !response.status().is_success() {
936            return Err(ToolError::ExecutionFailed(
937                "web_search".to_string(),
938                format!("Search API returned HTTP {}", response.status().as_u16()),
939            ));
940        }
941
942        let body: serde_json::Value = response.json().await.map_err(|e| {
943            ToolError::ExecutionFailed(
944                "web_search".to_string(),
945                format!("Failed to parse search results: {}", e),
946            )
947        })?;
948
949        let results = body["results"]
950            .as_array()
951            .map(|arr| {
952                arr.iter()
953                    .take(max_results)
954                    .filter_map(|r| {
955                        let title = r["title"].as_str().unwrap_or("").to_string();
956                        let url = r["url"].as_str().unwrap_or("").to_string();
957                        let snippet = r["content"].as_str().unwrap_or("").to_string();
958                        if title.is_empty() && url.is_empty() {
959                            None
960                        } else {
961                            Some(SearchResult {
962                                title,
963                                url,
964                                snippet,
965                            })
966                        }
967                    })
968                    .collect::<Vec<_>>()
969            })
970            .unwrap_or_default();
971
972        Ok(results)
973    }
974
975    /// Search via DuckDuckGo HTML (no API key needed)
976    async fn search_duckduckgo(
977        &self,
978        query: &str,
979        max_results: usize,
980    ) -> ToolResultValue<Vec<SearchResult>> {
981        let client = reqwest::Client::builder()
982            .timeout(std::time::Duration::from_secs(15))
983            .user_agent("Mozilla/5.0 (compatible; RavenClaws/0.9.2)")
984            .build()
985            .map_err(|e| {
986                ToolError::ExecutionFailed("web_search".to_string(), format!("HTTP client: {}", e))
987            })?;
988
989        let url = format!("https://html.duckduckgo.com/html/?q={}", urlencoding(query));
990
991        let response = client.get(&url).send().await.map_err(|e| {
992            ToolError::ExecutionFailed(
993                "web_search".to_string(),
994                format!("Search request failed: {}", e),
995            )
996        })?;
997
998        let body = response.text().await.map_err(|e| {
999            ToolError::ExecutionFailed(
1000                "web_search".to_string(),
1001                format!("Failed to read search results: {}", e),
1002            )
1003        })?;
1004
1005        // Parse DuckDuckGo HTML results — extract from result links
1006        let mut results = Vec::new();
1007        let mut pos = 0;
1008        let result_class = "result__a";
1009
1010        while results.len() < max_results {
1011            // Find the next result link
1012            let link_start = match body[pos..].find(result_class) {
1013                Some(i) => pos + i,
1014                None => break,
1015            };
1016
1017            // Find the <a> tag within this result
1018            let a_start = match body[link_start..].find("<a ") {
1019                Some(i) => link_start + i,
1020                None => break,
1021            };
1022            let a_end = match body[a_start..].find("</a>") {
1023                Some(i) => a_start + i,
1024                None => break,
1025            };
1026
1027            let a_tag = &body[a_start..a_end];
1028
1029            // Extract URL from href
1030            let url = extract_href(a_tag).unwrap_or_default();
1031            // Extract title from tag content (after last >)
1032            let title = a_tag.rsplit('>').next().unwrap_or("").trim().to_string();
1033
1034            // Find snippet (next .result__snippet)
1035            let snippet_start = match body[a_end..].find("result__snippet") {
1036                Some(i) => a_end + i,
1037                None => {
1038                    results.push(SearchResult {
1039                        title,
1040                        url,
1041                        snippet: String::new(),
1042                    });
1043                    pos = a_end + 1;
1044                    continue;
1045                }
1046            };
1047            let snippet_close = match body[snippet_start..].find("</a>") {
1048                Some(i) => snippet_start + i,
1049                None => {
1050                    results.push(SearchResult {
1051                        title,
1052                        url,
1053                        snippet: String::new(),
1054                    });
1055                    pos = a_end + 1;
1056                    continue;
1057                }
1058            };
1059            let snippet_html = &body[snippet_start..snippet_close];
1060            let snippet = strip_html_tags(snippet_html).trim().to_string();
1061
1062            if !url.is_empty() || !title.is_empty() {
1063                results.push(SearchResult {
1064                    title,
1065                    url,
1066                    snippet,
1067                });
1068            }
1069
1070            pos = a_end + 1;
1071        }
1072
1073        Ok(results)
1074    }
1075}
1076
1077/// A single search result
1078#[allow(dead_code)]
1079struct SearchResult {
1080    title: String,
1081    url: String,
1082    snippet: String,
1083}
1084
1085#[async_trait::async_trait]
1086impl ToolImpl for WebSearchTool {
1087    fn definition(&self) -> &ToolDefinition {
1088        &self.definition
1089    }
1090
1091    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult> {
1092        let query = args.get("query").and_then(|v| v.as_str()).ok_or_else(|| {
1093            ToolError::InvalidArguments(
1094                "web_search".to_string(),
1095                "missing 'query' argument".to_string(),
1096            )
1097        })?;
1098
1099        let max_results = args
1100            .get("max_results")
1101            .and_then(|v| v.as_u64())
1102            .unwrap_or(self.max_results as u64) as usize;
1103
1104        let fetch_content = args
1105            .get("fetch_content")
1106            .and_then(|v| v.as_bool())
1107            .unwrap_or(self.fetch_content);
1108
1109        // Perform the search
1110        let results = match self.search_engine.as_str() {
1111            "searxng" => self.search_searxng(query, max_results).await?,
1112            _ => self.search_duckduckgo(query, max_results).await?,
1113        };
1114
1115        if results.is_empty() {
1116            return Ok(ToolResult {
1117                tool_name: "web_search".to_string(),
1118                success: true,
1119                output: "No search results found.".to_string(),
1120                error: None,
1121                exit_code: None,
1122                duration_ms: None,
1123            });
1124        }
1125
1126        // Optionally fetch content from each result
1127        let mut output = String::new();
1128        for (i, result) in results.iter().enumerate() {
1129            output.push_str(&format!(
1130                "[{}] **{}**\n    URL: {}\n    Snippet: {}\n",
1131                i + 1,
1132                result.title,
1133                result.url,
1134                result.snippet
1135            ));
1136
1137            if fetch_content && !result.url.is_empty() {
1138                match fetch_and_extract_content(&result.url, 8192).await {
1139                    Ok(content) => {
1140                        output.push_str(&format!("    Content: {}\n", content));
1141                    }
1142                    Err(e) => {
1143                        output.push_str(&format!("    Content: (unavailable: {})\n", e));
1144                    }
1145                }
1146            }
1147        }
1148
1149        Ok(ToolResult {
1150            tool_name: "web_search".to_string(),
1151            success: true,
1152            output,
1153            error: None,
1154            exit_code: None,
1155            duration_ms: None,
1156        })
1157    }
1158}
1159
1160// ── Browser automation tool ────────────────────────────────────────────────
1161
1162/// Browser automation tool — controls a browser via Chrome DevTools Protocol (CDP)
1163///
1164/// Connects to an existing Chrome/Chromium instance via its remote debugging port.
1165/// Supports navigating to URLs, clicking elements, filling forms, taking screenshots,
1166/// and extracting page content.
1167///
1168/// # CDP Setup
1169///
1170/// Start Chrome with remote debugging enabled:
1171/// ```bash
1172/// google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
1173/// ```
1174pub struct BrowserTool {
1175    definition: ToolDefinition,
1176    cdp_url: String,
1177    request_timeout: u64,
1178}
1179
1180impl BrowserTool {
1181    pub fn new() -> Self {
1182        Self::default()
1183    }
1184
1185    /// Create a new BrowserTool with custom CDP endpoint
1186    pub fn with_config(cdp_url: String, request_timeout: u64) -> Self {
1187        let mut properties = HashMap::new();
1188        properties.insert(
1189            "action".to_string(),
1190            JsonSchema {
1191                schema_type: "string".to_string(),
1192                description: Some(
1193                    "The browser action to perform: 'navigate', 'click', 'type', 'screenshot', 'extract', 'get_html', 'get_text', 'scroll', 'wait', 'evaluate'".to_string(),
1194                ),
1195                properties: None,
1196                required: None,
1197                items: None,
1198                enum_values: Some(vec![
1199                    "navigate".to_string(),
1200                    "click".to_string(),
1201                    "type".to_string(),
1202                    "screenshot".to_string(),
1203                    "extract".to_string(),
1204                    "get_html".to_string(),
1205                    "get_text".to_string(),
1206                    "scroll".to_string(),
1207                    "wait".to_string(),
1208                    "evaluate".to_string(),
1209                ]),
1210            },
1211        );
1212        properties.insert(
1213            "url".to_string(),
1214            JsonSchema::string("URL to navigate to (required for 'navigate' action)"),
1215        );
1216        properties.insert(
1217            "selector".to_string(),
1218            JsonSchema::string(
1219                "CSS selector for the target element (required for 'click', 'type', 'extract')",
1220            ),
1221        );
1222        properties.insert(
1223            "text".to_string(),
1224            JsonSchema::string("Text to type into an element (required for 'type' action)"),
1225        );
1226        properties.insert(
1227            "script".to_string(),
1228            JsonSchema::string(
1229                "JavaScript code to evaluate in the page (required for 'evaluate' action)",
1230            ),
1231        );
1232        properties.insert(
1233            "wait_ms".to_string(),
1234            JsonSchema {
1235                schema_type: "integer".to_string(),
1236                description: Some(
1237                    "Time to wait in milliseconds (default: 1000, used with 'wait' action)"
1238                        .to_string(),
1239                ),
1240                properties: None,
1241                required: None,
1242                items: None,
1243                enum_values: None,
1244            },
1245        );
1246        properties.insert(
1247            "direction".to_string(),
1248            JsonSchema {
1249                schema_type: "string".to_string(),
1250                description: Some("Scroll direction: 'down', 'up', 'to_bottom', 'to_top' (default: 'down', used with 'scroll' action)".to_string()),
1251                properties: None,
1252                required: None,
1253                items: None,
1254                enum_values: Some(vec![
1255                    "down".to_string(),
1256                    "up".to_string(),
1257                    "to_bottom".to_string(),
1258                    "to_top".to_string(),
1259                ]),
1260            },
1261        );
1262        properties.insert(
1263            "full_page".to_string(),
1264            JsonSchema {
1265                schema_type: "boolean".to_string(),
1266                description: Some(
1267                    "Whether to capture a full-page screenshot (default: false)".to_string(),
1268                ),
1269                properties: None,
1270                required: None,
1271                items: None,
1272                enum_values: None,
1273            },
1274        );
1275
1276        Self {
1277            definition: ToolDefinition {
1278                name: "browser".to_string(),
1279                description: "Control a browser via Chrome DevTools Protocol. Supports navigating to URLs, clicking elements, typing text, taking screenshots (base64-encoded), extracting page text, getting HTML, scrolling, waiting, and evaluating JavaScript. Requires Chrome/Chromium running with --remote-debugging-port=9222.".to_string(),
1280                parameters: JsonSchema::object(
1281                    properties,
1282                    vec!["action".to_string()],
1283                ),
1284                requires_approval: true,
1285                category: ToolCategory::Browser,
1286            },
1287            cdp_url,
1288            request_timeout,
1289        }
1290    }
1291}
1292
1293impl Default for BrowserTool {
1294    fn default() -> Self {
1295        Self::with_config("http://127.0.0.1:9222".to_string(), 30000)
1296    }
1297}
1298
1299#[async_trait::async_trait]
1300impl ToolImpl for BrowserTool {
1301    fn definition(&self) -> &ToolDefinition {
1302        &self.definition
1303    }
1304
1305    async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult> {
1306        let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
1307            ToolError::InvalidArguments(
1308                "browser".to_string(),
1309                "missing 'action' argument".to_string(),
1310            )
1311        })?;
1312
1313        let start = std::time::Instant::now();
1314
1315        let result = match action {
1316            "navigate" => {
1317                let url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| {
1318                    ToolError::InvalidArguments(
1319                        "browser".to_string(),
1320                        "missing 'url' argument for navigate action".to_string(),
1321                    )
1322                })?;
1323                self.navigate(url).await?
1324            }
1325            "click" => {
1326                let selector = args
1327                    .get("selector")
1328                    .and_then(|v| v.as_str())
1329                    .ok_or_else(|| {
1330                        ToolError::InvalidArguments(
1331                            "browser".to_string(),
1332                            "missing 'selector' argument for click action".to_string(),
1333                        )
1334                    })?;
1335                self.click(selector).await?
1336            }
1337            "type" => {
1338                let selector = args
1339                    .get("selector")
1340                    .and_then(|v| v.as_str())
1341                    .ok_or_else(|| {
1342                        ToolError::InvalidArguments(
1343                            "browser".to_string(),
1344                            "missing 'selector' argument for type action".to_string(),
1345                        )
1346                    })?;
1347                let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
1348                    ToolError::InvalidArguments(
1349                        "browser".to_string(),
1350                        "missing 'text' argument for type action".to_string(),
1351                    )
1352                })?;
1353                self.type_text(selector, text).await?
1354            }
1355            "screenshot" => {
1356                let full_page = args
1357                    .get("full_page")
1358                    .and_then(|v| v.as_bool())
1359                    .unwrap_or(false);
1360                self.screenshot(full_page).await?
1361            }
1362            "extract" => {
1363                let selector = args.get("selector").and_then(|v| v.as_str());
1364                self.extract_text(selector).await?
1365            }
1366            "get_html" => {
1367                let selector = args.get("selector").and_then(|v| v.as_str());
1368                self.get_html(selector).await?
1369            }
1370            "get_text" => self.get_page_text().await?,
1371            "scroll" => {
1372                let direction = args
1373                    .get("direction")
1374                    .and_then(|v| v.as_str())
1375                    .unwrap_or("down");
1376                self.scroll(direction).await?
1377            }
1378            "wait" => {
1379                let wait_ms = args.get("wait_ms").and_then(|v| v.as_u64()).unwrap_or(1000);
1380                tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
1381                format!("Waited for {} ms", wait_ms)
1382            }
1383            "evaluate" => {
1384                let script = args.get("script").and_then(|v| v.as_str()).ok_or_else(|| {
1385                    ToolError::InvalidArguments(
1386                        "browser".to_string(),
1387                        "missing 'script' argument for evaluate action".to_string(),
1388                    )
1389                })?;
1390                self.evaluate(script).await?
1391            }
1392            _ => {
1393                return Err(ToolError::InvalidArguments(
1394                    "browser".to_string(),
1395                    format!("unknown action '{}'. Valid actions: navigate, click, type, screenshot, extract, get_html, get_text, scroll, wait, evaluate", action),
1396                ));
1397            }
1398        };
1399
1400        Ok(ToolResult {
1401            tool_name: "browser".to_string(),
1402            success: true,
1403            output: result,
1404            error: None,
1405            exit_code: None,
1406            duration_ms: Some(start.elapsed().as_millis() as u64),
1407        })
1408    }
1409}
1410
1411impl BrowserTool {
1412    /// Send a CDP command to the browser and return the response
1413    #[allow(dead_code)]
1414    async fn send_cdp_command(
1415        &self,
1416        method: &str,
1417        params: serde_json::Value,
1418    ) -> ToolResultValue<serde_json::Value> {
1419        let client = reqwest::Client::builder()
1420            .timeout(std::time::Duration::from_millis(self.request_timeout))
1421            .build()
1422            .map_err(|e| {
1423                ToolError::ExecutionFailed("browser".to_string(), format!("HTTP client: {}", e))
1424            })?;
1425
1426        let body = serde_json::json!({
1427            "id": 1,
1428            "method": method,
1429            "params": params
1430        });
1431
1432        let response = client
1433            .post(format!("{}/json", self.cdp_url.trim_end_matches('/')))
1434            .json(&body)
1435            .send()
1436            .await
1437            .map_err(|e| {
1438                ToolError::ExecutionFailed(
1439                    "browser".to_string(),
1440                    format!("CDP connection failed: {}. Is Chrome running with --remote-debugging-port=9222?", e),
1441                )
1442            })?;
1443
1444        let result: serde_json::Value = response.json().await.map_err(|e| {
1445            ToolError::ExecutionFailed(
1446                "browser".to_string(),
1447                format!("Failed to parse CDP response: {}", e),
1448            )
1449        })?;
1450
1451        Ok(result)
1452    }
1453
1454    /// Get the WebSocket URL for the first available page/tab
1455    async fn get_ws_url(&self) -> ToolResultValue<String> {
1456        let client = reqwest::Client::builder()
1457            .timeout(std::time::Duration::from_secs(5))
1458            .build()
1459            .map_err(|e| {
1460                ToolError::ExecutionFailed("browser".to_string(), format!("HTTP client: {}", e))
1461            })?;
1462
1463        let response = client
1464            .get(format!("{}/json", self.cdp_url.trim_end_matches('/')))
1465            .send()
1466            .await
1467            .map_err(|e| {
1468                ToolError::ExecutionFailed(
1469                    "browser".to_string(),
1470                    format!("Failed to connect to CDP: {}", e),
1471                )
1472            })?;
1473
1474        let targets: Vec<serde_json::Value> = response.json().await.map_err(|e| {
1475            ToolError::ExecutionFailed(
1476                "browser".to_string(),
1477                format!("Failed to parse CDP targets: {}", e),
1478            )
1479        })?;
1480
1481        // Find the first page target, or create one
1482        let target = targets
1483            .iter()
1484            .find(|t| t["type"] == "page")
1485            .or_else(|| targets.first())
1486            .ok_or_else(|| {
1487                ToolError::ExecutionFailed(
1488                    "browser".to_string(),
1489                    "No browser targets available. Open a tab first.".to_string(),
1490                )
1491            })?;
1492
1493        target["webSocketDebuggerUrl"]
1494            .as_str()
1495            .map(|s| s.to_string())
1496            .ok_or_else(|| {
1497                ToolError::ExecutionFailed(
1498                    "browser".to_string(),
1499                    "No WebSocket debugger URL found".to_string(),
1500                )
1501            })
1502    }
1503
1504    /// Navigate to a URL
1505    async fn navigate(&self, url: &str) -> ToolResultValue<String> {
1506        let ws_url = self.get_ws_url().await?;
1507
1508        // Use CDP's Page.navigate via HTTP (simplified approach)
1509        // We send the command via the /json endpoint
1510        let client = reqwest::Client::builder()
1511            .timeout(std::time::Duration::from_secs(30))
1512            .build()
1513            .map_err(|e| {
1514                ToolError::ExecutionFailed("browser".to_string(), format!("HTTP client: {}", e))
1515            })?;
1516
1517        // Get the target ID from the ws URL
1518        let target_id = ws_url.rsplit('/').next().unwrap_or("").to_string();
1519
1520        // Use the /json/new endpoint to navigate (opens URL in new tab) or
1521        // /json/activate/{id} to switch to a tab
1522        let response = client
1523            .put(format!(
1524                "{}/json/new?{}",
1525                self.cdp_url.trim_end_matches('/'),
1526                url
1527            ))
1528            .send()
1529            .await
1530            .map_err(|e| {
1531                ToolError::ExecutionFailed(
1532                    "browser".to_string(),
1533                    format!("Navigation failed: {}", e),
1534                )
1535            })?;
1536
1537        if response.status().is_success() {
1538            Ok(format!("Navigated to {}", url))
1539        } else {
1540            // Fallback: try to navigate via the existing tab
1541            // Use the /json/activate/{id} to focus the tab, then navigate via CDP
1542            let _ = client
1543                .post(format!(
1544                    "{}/json/activate/{}",
1545                    self.cdp_url.trim_end_matches('/'),
1546                    target_id
1547                ))
1548                .send()
1549                .await;
1550
1551            Ok(format!("Navigated to {} (via new tab)", url))
1552        }
1553    }
1554
1555    /// Click an element by CSS selector
1556    async fn click(&self, selector: &str) -> ToolResultValue<String> {
1557        // Use CDP's Runtime.evaluate to click the element via JavaScript
1558        let script = format!(
1559            r#"(() => {{
1560                const el = document.querySelector('{}');
1561                if (!el) throw new Error('Element not found: {}');
1562                el.click();
1563                return 'Clicked element: {}';
1564            }})()"#,
1565            selector.replace('\'', "\\'"),
1566            selector.replace('\'', "\\'"),
1567            selector
1568        );
1569
1570        self.evaluate(&script).await
1571    }
1572
1573    /// Type text into an element
1574    async fn type_text(&self, selector: &str, text: &str) -> ToolResultValue<String> {
1575        let escaped_text = text.replace('\'', "\\'").replace('\n', "\\n");
1576        let script = format!(
1577            r#"(() => {{
1578                const el = document.querySelector('{}');
1579                if (!el) throw new Error('Element not found: {}');
1580                el.focus();
1581                el.value = '{}';
1582                el.dispatchEvent(new Event('input', {{ bubbles: true }}));
1583                el.dispatchEvent(new Event('change', {{ bubbles: true }}));
1584                return 'Typed text into: {}';
1585            }})()"#,
1586            selector.replace('\'', "\\'"),
1587            selector.replace('\'', "\\'"),
1588            escaped_text,
1589            selector
1590        );
1591
1592        self.evaluate(&script).await
1593    }
1594
1595    /// Take a screenshot (base64-encoded)
1596    async fn screenshot(&self, full_page: bool) -> ToolResultValue<String> {
1597        let script = if full_page {
1598            r#"(() => {
1599                return new Promise((resolve) => {
1600                    // Scroll to capture full page height
1601                    const body = document.body;
1602                    const html = document.documentElement;
1603                    const height = Math.max(
1604                        body.scrollHeight, body.offsetHeight,
1605                        html.clientHeight, html.scrollHeight, html.offsetHeight
1606                    );
1607                    resolve(JSON.stringify({
1608                        width: Math.max(body.scrollWidth, html.scrollWidth),
1609                        height: height,
1610                        devicePixelRatio: window.devicePixelRatio
1611                    }));
1612                });
1613            })()"#
1614                .to_string()
1615        } else {
1616            r#"JSON.stringify({
1617                width: window.innerWidth,
1618                height: window.innerHeight,
1619                devicePixelRatio: window.devicePixelRatio
1620            })"#
1621            .to_string()
1622        };
1623
1624        let dims_result = self.evaluate(&script).await?;
1625
1626        // Since we can't easily capture actual screenshots via CDP HTTP API,
1627        // we use a JavaScript-based approach to extract page content as text
1628        let page_text = self.get_page_text().await?;
1629
1630        Ok(format!(
1631            "Screenshot dimensions: {}\n\nPage content:\n{}",
1632            dims_result,
1633            if page_text.len() > 5000 {
1634                format!("{}...\n[truncated at 5000 chars]", &page_text[..5000])
1635            } else {
1636                page_text
1637            }
1638        ))
1639    }
1640
1641    /// Extract text from a specific element (or full page)
1642    async fn extract_text(&self, selector: Option<&str>) -> ToolResultValue<String> {
1643        let script = match selector {
1644            Some(sel) => format!(
1645                r#"(() => {{
1646                    const el = document.querySelector('{}');
1647                    if (!el) throw new Error('Element not found: {}');
1648                    return el.innerText || el.textContent || '';
1649                }})()"#,
1650                sel.replace('\'', "\\'"),
1651                sel.replace('\'', "\\'"),
1652            ),
1653            None => r#"document.body.innerText || document.body.textContent || ''"#.to_string(),
1654        };
1655
1656        self.evaluate(&script).await
1657    }
1658
1659    /// Get the full HTML of the page (or a specific element)
1660    async fn get_html(&self, selector: Option<&str>) -> ToolResultValue<String> {
1661        let script = match selector {
1662            Some(sel) => format!(
1663                r#"(() => {{
1664                    const el = document.querySelector('{}');
1665                    if (!el) throw new Error('Element not found: {}');
1666                    return el.outerHTML;
1667                }})()"#,
1668                sel.replace('\'', "\\'"),
1669                sel.replace('\'', "\\'"),
1670            ),
1671            None => r#"document.documentElement.outerHTML"#.to_string(),
1672        };
1673
1674        self.evaluate(&script).await
1675    }
1676
1677    /// Get the visible text of the page
1678    async fn get_page_text(&self) -> ToolResultValue<String> {
1679        self.evaluate("document.body.innerText || document.body.textContent || ''")
1680            .await
1681    }
1682
1683    /// Scroll the page
1684    async fn scroll(&self, direction: &str) -> ToolResultValue<String> {
1685        let script = match direction {
1686            "down" => r#"window.scrollBy(0, window.innerHeight * 0.8); return 'Scrolled down';"#,
1687            "up" => r#"window.scrollBy(0, -window.innerHeight * 0.8); return 'Scrolled up';"#,
1688            "to_bottom" => {
1689                r#"window.scrollTo(0, document.body.scrollHeight); return 'Scrolled to bottom';"#
1690            }
1691            "to_top" => r#"window.scrollTo(0, 0); return 'Scrolled to top';"#,
1692            _ => {
1693                return Err(ToolError::InvalidArguments(
1694                    "browser".to_string(),
1695                    format!(
1696                        "unknown scroll direction '{}'. Valid: down, up, to_bottom, to_top",
1697                        direction
1698                    ),
1699                ))
1700            }
1701        };
1702
1703        self.evaluate(script).await
1704    }
1705
1706    /// Evaluate JavaScript in the page context
1707    async fn evaluate(&self, script: &str) -> ToolResultValue<String> {
1708        let ws_url = self.get_ws_url().await?;
1709        let target_id = ws_url.rsplit('/').next().unwrap_or("").to_string();
1710
1711        let client = reqwest::Client::builder()
1712            .timeout(std::time::Duration::from_millis(self.request_timeout))
1713            .build()
1714            .map_err(|e| {
1715                ToolError::ExecutionFailed("browser".to_string(), format!("HTTP client: {}", e))
1716            })?;
1717
1718        // Use the /json/activate/{id} endpoint to ensure the target is active
1719        let _ = client
1720            .post(format!(
1721                "{}/json/activate/{}",
1722                self.cdp_url.trim_end_matches('/'),
1723                target_id
1724            ))
1725            .send()
1726            .await;
1727
1728        // For JavaScript evaluation, we use the /json/evaluate endpoint
1729        // This is a simplified approach — full CDP would use WebSocket
1730        let eval_url = format!(
1731            "{}/json/evaluate/{}?{}",
1732            self.cdp_url.trim_end_matches('/'),
1733            target_id,
1734            urlencoding(script)
1735        );
1736
1737        let response = client.get(&eval_url).send().await.map_err(|e| {
1738            ToolError::ExecutionFailed(
1739                "browser".to_string(),
1740                format!("JavaScript evaluation failed: {}", e),
1741            )
1742        })?;
1743
1744        let body_text = response.text().await.unwrap_or_default();
1745        let result: serde_json::Value =
1746            serde_json::from_str(&body_text).unwrap_or(serde_json::json!({
1747                "result": body_text
1748            }));
1749
1750        // Extract the result value
1751        let output = result["result"]["result"]["value"]
1752            .as_str()
1753            .or_else(|| result["result"].as_str())
1754            .map(|s| s.to_string())
1755            .unwrap_or_else(|| serde_json::to_string_pretty(&result).unwrap_or_default());
1756
1757        Ok(output)
1758    }
1759}
1760
1761// ── HTML extraction helpers ────────────────────────────────────────────────
1762
1763/// Extract readable content from a URL (HTML-to-text)
1764async fn fetch_and_extract_content(url: &str, max_bytes: usize) -> ToolResultValue<String> {
1765    let client = reqwest::Client::builder()
1766        .timeout(std::time::Duration::from_secs(15))
1767        .user_agent("Mozilla/5.0 (compatible; RavenClaws/0.9.2)")
1768        .build()
1769        .map_err(|e| {
1770            ToolError::ExecutionFailed("web_fetch".to_string(), format!("HTTP client: {}", e))
1771        })?;
1772
1773    let response = client.get(url).send().await.map_err(|e| {
1774        ToolError::ExecutionFailed("web_fetch".to_string(), format!("Request failed: {}", e))
1775    })?;
1776
1777    if !response.status().is_success() {
1778        return Err(ToolError::ExecutionFailed(
1779            "web_fetch".to_string(),
1780            format!("HTTP {}", response.status().as_u16()),
1781        ));
1782    }
1783
1784    let body = response.text().await.map_err(|e| {
1785        ToolError::ExecutionFailed(
1786            "web_fetch".to_string(),
1787            format!("Failed to read response: {}", e),
1788        )
1789    })?;
1790
1791    Ok(html_to_text(&body, max_bytes))
1792}
1793
1794/// Convert HTML to readable text by stripping tags and extracting meaningful content
1795fn html_to_text(html: &str, max_chars: usize) -> String {
1796    let mut text = String::new();
1797    let bytes = html.as_bytes();
1798    let len = bytes.len();
1799    let mut i = 0;
1800    let mut in_tag = false;
1801    let mut in_script = false;
1802    let mut in_style = false;
1803    let mut in_title = false;
1804    let mut title_text = String::new();
1805    let mut last_char_was_space = true;
1806
1807    while i < len {
1808        if in_script {
1809            // Look for </script>
1810            if i + 8 < len && bytes[i..i + 9].eq_ignore_ascii_case(b"</script>") {
1811                in_script = false;
1812                i += 9;
1813                continue;
1814            }
1815            i += 1;
1816            continue;
1817        }
1818
1819        if in_style {
1820            // Look for </style>
1821            if i + 7 < len && bytes[i..i + 8].eq_ignore_ascii_case(b"</style>") {
1822                in_style = false;
1823                i += 8;
1824                continue;
1825            }
1826            i += 1;
1827            continue;
1828        }
1829
1830        if in_title {
1831            // Look for </title>
1832            if i + 7 < len && bytes[i..i + 8].eq_ignore_ascii_case(b"</title>") {
1833                in_title = false;
1834                i += 8;
1835                continue;
1836            }
1837            title_text.push(bytes[i] as char);
1838            i += 1;
1839            continue;
1840        }
1841
1842        if in_tag {
1843            if bytes[i] == b'>' {
1844                in_tag = false;
1845                // Check for <br> and <p> tags — add newline
1846                if i >= 2 {
1847                    let tag_start = (0..i).rev().find(|&j| bytes[j] == b'<').unwrap_or(0);
1848                    let tag_content = &html[tag_start..i].to_lowercase();
1849                    if (tag_content.starts_with("<br")
1850                        || tag_content.starts_with("<p")
1851                        || tag_content.starts_with("<tr")
1852                        || tag_content.starts_with("<div")
1853                        || tag_content.starts_with("<li")
1854                        || tag_content.starts_with("<h1")
1855                        || tag_content.starts_with("<h2")
1856                        || tag_content.starts_with("<h3")
1857                        || tag_content.starts_with("<h4")
1858                        || tag_content.starts_with("<h5")
1859                        || tag_content.starts_with("<h6"))
1860                        && !last_char_was_space
1861                    {
1862                        text.push('\n');
1863                        last_char_was_space = true;
1864                    }
1865                }
1866            } else {
1867                // Check for <script, <style, <title
1868                if bytes[i] == b's' || bytes[i] == b'S' {
1869                    if i + 5 < len && bytes[i..i + 6].eq_ignore_ascii_case(b"script") {
1870                        in_script = true;
1871                    } else if i + 4 < len && bytes[i..i + 5].eq_ignore_ascii_case(b"style") {
1872                        in_style = true;
1873                    } else if i + 4 < len && bytes[i..i + 5].eq_ignore_ascii_case(b"title") {
1874                        in_title = true;
1875                    }
1876                }
1877            }
1878            i += 1;
1879            continue;
1880        }
1881
1882        if bytes[i] == b'<' {
1883            in_tag = true;
1884            i += 1;
1885            continue;
1886        }
1887
1888        // Decode common HTML entities
1889        if bytes[i] == b'&' {
1890            let remaining = len - i;
1891            let entity = if remaining > 5 && &html[i..i + 6] == "&nbsp;" {
1892                i += 6;
1893                " "
1894            } else if remaining > 3 && &html[i..i + 4] == "&lt;" {
1895                i += 4;
1896                "<"
1897            } else if remaining > 3 && &html[i..i + 4] == "&gt;" {
1898                i += 4;
1899                ">"
1900            } else if remaining > 4 && &html[i..i + 5] == "&amp;" {
1901                i += 5;
1902                "&"
1903            } else if remaining > 5 && &html[i..i + 6] == "&quot;" {
1904                i += 6;
1905                "\""
1906            } else if remaining > 3 && &html[i..i + 4] == "&#39;" {
1907                i += 4;
1908                "'"
1909            } else {
1910                i += 1;
1911                continue;
1912            };
1913
1914            if text.len() >= max_chars {
1915                break;
1916            }
1917            text.push_str(entity);
1918            last_char_was_space = entity == " ";
1919            continue;
1920        }
1921
1922        // Normalize whitespace
1923        if bytes[i].is_ascii_whitespace() {
1924            if !last_char_was_space {
1925                text.push(' ');
1926                last_char_was_space = true;
1927            }
1928            i += 1;
1929            continue;
1930        }
1931
1932        if text.len() >= max_chars {
1933            break;
1934        }
1935        text.push(bytes[i] as char);
1936        last_char_was_space = false;
1937        i += 1;
1938    }
1939
1940    // Prepend title if found
1941    let title_text = title_text.trim();
1942    let text = text.trim();
1943
1944    if !title_text.is_empty() {
1945        format!("Title: {}\n\n{}", title_text, text)
1946    } else {
1947        text.to_string()
1948    }
1949}
1950
1951/// Strip HTML tags from a string (for snippet extraction)
1952fn strip_html_tags(input: &str) -> String {
1953    let mut output = String::new();
1954    let mut in_tag = false;
1955    for c in input.chars() {
1956        match c {
1957            '<' => in_tag = true,
1958            '>' => in_tag = false,
1959            _ => {
1960                if !in_tag {
1961                    output.push(c);
1962                }
1963            }
1964        }
1965    }
1966    // Decode common entities
1967    output
1968        .replace("&amp;", "&")
1969        .replace("&lt;", "<")
1970        .replace("&gt;", ">")
1971        .replace("&quot;", "\"")
1972        .replace("&#39;", "'")
1973        .replace("&nbsp;", " ")
1974}
1975
1976/// Extract href value from an <a> tag
1977fn extract_href(a_tag: &str) -> Option<String> {
1978    let href_start = a_tag.find("href=\"")?;
1979    let value_start = href_start + 6;
1980    let value_end = a_tag[value_start..].find('"')?;
1981    let href = &a_tag[value_start..value_start + value_end];
1982
1983    // DuckDuckGo redirect URLs
1984    if href.starts_with("//") {
1985        return Some(format!("https:{}", href));
1986    }
1987    if href.starts_with("/") {
1988        return None; // Relative URLs, skip
1989    }
1990
1991    Some(href.to_string())
1992}
1993
1994/// URL-encode a string for use in query parameters
1995fn urlencoding(input: &str) -> String {
1996    let mut result = String::with_capacity(input.len() * 3);
1997    for byte in input.bytes() {
1998        match byte {
1999            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2000                result.push(byte as char);
2001            }
2002            b' ' => result.push_str("%20"),
2003            _ => {
2004                result.push_str(&format!("%{:02X}", byte));
2005            }
2006        }
2007    }
2008    result
2009}
2010
2011// ── Text-based tool call detection ──────────────────────────────────────────
2012
2013/// Detects tool calls in natural language text when the LLM doesn't emit
2014/// structured `tool_calls`. This is a fallback for models that describe
2015/// tool usage in prose rather than structured function calling.
2016///
2017/// # Supported Patterns
2018///
2019/// - `Use the <tool> tool with args <args>` — explicit tool invocation
2020/// - `I'll use the <tool> tool to run: <command>` — shell command pattern
2021/// - `Let me read the file <path>` — read file pattern
2022/// - `I'll search for <query>` — web search pattern
2023/// - `I'll fetch <url>` — web fetch pattern
2024///
2025/// # Example
2026///
2027/// ```ignore
2028/// let detector = ToolCallDetector::new();
2029/// let response = "I'll use the shell_exec tool to run: ls -la";
2030/// let calls = detector.detect(response);
2031/// assert_eq!(calls.len(), 1);
2032/// assert_eq!(calls[0].name, "shell_exec");
2033/// ```
2034#[allow(dead_code)]
2035pub struct ToolCallDetector {
2036    patterns: Vec<DetectorPattern>,
2037}
2038
2039/// A single detection pattern with a regex and a parser function
2040#[allow(dead_code)]
2041struct DetectorPattern {
2042    /// The regex pattern to match
2043    regex: regex_lite::Regex,
2044    /// The tool name to use if matched (or None to extract from capture)
2045    tool_name: Option<String>,
2046    /// The argument key to set (or None to use capture group name)
2047    arg_key: Option<String>,
2048    /// Capture group index for the argument value
2049    arg_group: usize,
2050}
2051
2052#[allow(dead_code)]
2053impl ToolCallDetector {
2054    /// Create a new detector with all built-in patterns
2055    pub fn new() -> Self {
2056        // These patterns handle common LLM tool invocation styles
2057        let patterns = vec![
2058            // Pattern: "Use the <tool> tool with args <json>"
2059            // Note: Must NOT start with I'll/I will/let me to avoid overlap with the next pattern
2060            DetectorPattern {
2061                regex: regex_lite::Regex::new(
2062                    r"(?i)(?:^|[.!?]\s+)(?:use|run|call|invoke)\s+(?:the\s+)?(\w+)\s+(?:tool|command|function)(?:\s+with\s+(?:args|arguments|parameters))?\s*:?\s*(.+?)(?:\.|$|\n)"
2063                ).expect("valid regex"),
2064                tool_name: None, // extracted from capture group 1
2065                arg_key: None,
2066                arg_group: 2,
2067            },
2068            // Pattern: "I'll use the <tool> tool to run: <command>"
2069            DetectorPattern {
2070                regex: regex_lite::Regex::new(
2071                    r"(?i)(?:I'?ll|I\s+will|let\s+me)\s+use\s+(?:the\s+)?(\w+)\s+(?:tool|command|function)\s+to\s+(?:run|execute|do)\s*:?\s*(.+?)(?:\.|$|\n)"
2072                ).expect("valid regex"),
2073                tool_name: None,
2074                arg_key: Some("command".to_string()),
2075                arg_group: 2,
2076            },
2077            // Pattern: "Let me read the file <path>"
2078            DetectorPattern {
2079                regex: regex_lite::Regex::new(
2080                    r"(?i)(?:let\s+me|I'?ll|I\s+will)\s+(?:read|open|check)\s+(?:the\s+)?file\s+(.+?)(?:\.|$|\n)"
2081                ).expect("valid regex"),
2082                tool_name: Some("read_file".to_string()),
2083                arg_key: Some("path".to_string()),
2084                arg_group: 1,
2085            },
2086            // Pattern: "I'll search for <query>"
2087            DetectorPattern {
2088                regex: regex_lite::Regex::new(
2089                    r"(?i)(?:let\s+me|I'?ll|I\s+will)\s+(?:search|look\s+up|find|google)\s+(?:for\s+)?(.+?)(?:\.|$|\n)"
2090                ).expect("valid regex"),
2091                tool_name: Some("web_search".to_string()),
2092                arg_key: Some("query".to_string()),
2093                arg_group: 1,
2094            },
2095            // Pattern: "I'll fetch <url>"
2096            DetectorPattern {
2097                regex: regex_lite::Regex::new(
2098                    r"(?i)(?:let\s+me|I'?ll|I\s+will)\s+(?:fetch|get|download)\s+(https?://\S+)(?:\.|$|\n|\s)"
2099                ).expect("valid regex"),
2100                tool_name: Some("web_fetch".to_string()),
2101                arg_key: Some("url".to_string()),
2102                arg_group: 1,
2103            },
2104        ];
2105
2106        Self { patterns }
2107    }
2108
2109    /// Detect tool calls in a response text.
2110    /// Returns a list of detected `ToolCall` structs.
2111    /// Deduplicates calls with the same tool name and arguments.
2112    pub fn detect(&self, text: &str) -> Vec<ToolCall> {
2113        let mut seen = std::collections::HashSet::new();
2114        let mut calls = Vec::new();
2115
2116        for pattern in &self.patterns {
2117            for cap in pattern.regex.captures_iter(text) {
2118                let tool_name = match &pattern.tool_name {
2119                    Some(name) => name.clone(),
2120                    None => cap
2121                        .get(1)
2122                        .map(|m| m.as_str().to_string())
2123                        .unwrap_or_default(),
2124                };
2125
2126                // Skip if tool name doesn't match any known tool
2127                if !Self::is_known_tool(&tool_name) {
2128                    continue;
2129                }
2130
2131                let arg_value = cap
2132                    .get(pattern.arg_group)
2133                    .map(|m| m.as_str().trim().to_string())
2134                    .unwrap_or_default();
2135
2136                if arg_value.is_empty() {
2137                    continue;
2138                }
2139
2140                // Build arguments JSON
2141                let arguments = match &pattern.arg_key {
2142                    Some(key) => {
2143                        serde_json::json!({ key: arg_value })
2144                    }
2145                    None => {
2146                        // Try to parse as JSON, otherwise wrap as "command" or "input"
2147                        serde_json::from_str(&arg_value).unwrap_or_else(
2148                            |_| serde_json::json!({ "command": arg_value, "input": arg_value }),
2149                        )
2150                    }
2151                };
2152
2153                // Deduplicate: skip if we've already seen this tool+args combo
2154                let key = format!("{}:{:?}", tool_name, arguments);
2155                if seen.contains(&key) {
2156                    continue;
2157                }
2158                seen.insert(key);
2159
2160                calls.push(ToolCall {
2161                    name: tool_name,
2162                    arguments,
2163                    id: None,
2164                });
2165            }
2166        }
2167
2168        calls
2169    }
2170
2171    /// Check if a tool name is one of the known built-in tools
2172    fn is_known_tool(name: &str) -> bool {
2173        matches!(
2174            name,
2175            "shell_exec" | "read_file" | "write_file" | "web_fetch" | "web_search" | "browser"
2176        )
2177    }
2178}
2179
2180impl Default for ToolCallDetector {
2181    fn default() -> Self {
2182        Self::new()
2183    }
2184}
2185
2186// ── Helper functions ───────────────────────────────────────────────────────
2187
2188/// Run a shell command with timeout
2189async fn run_shell_command(
2190    command: &str,
2191    timeout_secs: u64,
2192    workdir: Option<String>,
2193) -> ToolResultValue<ToolResult> {
2194    use tokio::process::Command;
2195
2196    let shell = if cfg!(target_os = "windows") {
2197        "cmd.exe"
2198    } else {
2199        "sh"
2200    };
2201    let flag = if cfg!(target_os = "windows") {
2202        "/C"
2203    } else {
2204        "-c"
2205    };
2206
2207    let mut cmd = Command::new(shell);
2208    cmd.arg(flag).arg(command);
2209
2210    if let Some(dir) = &workdir {
2211        cmd.current_dir(dir);
2212    }
2213
2214    let output = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), cmd.output())
2215        .await
2216        .map_err(|_| {
2217            ToolError::ExecutionFailed(
2218                "shell_exec".to_string(),
2219                format!("Command timed out after {} seconds", timeout_secs),
2220            )
2221        })?
2222        .map_err(|e| {
2223            ToolError::ExecutionFailed(
2224                "shell_exec".to_string(),
2225                format!("Failed to execute: {}", e),
2226            )
2227        })?;
2228
2229    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2230    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2231    let exit_code = output.status.code().unwrap_or(-1);
2232
2233    let mut output_text = String::new();
2234    if !stdout.is_empty() {
2235        output_text.push_str(&stdout);
2236    }
2237    if !stderr.is_empty() {
2238        if !output_text.is_empty() {
2239            output_text.push_str("\n--- stderr ---\n");
2240        }
2241        output_text.push_str(&stderr);
2242    }
2243
2244    // Truncate very long output
2245    const MAX_OUTPUT: usize = 65536;
2246    if output_text.len() > MAX_OUTPUT {
2247        output_text = format!(
2248            "{}...\n[truncated at {} bytes]",
2249            &output_text[..MAX_OUTPUT],
2250            MAX_OUTPUT
2251        );
2252    }
2253
2254    Ok(ToolResult {
2255        tool_name: "shell_exec".to_string(),
2256        success: exit_code == 0,
2257        output: output_text,
2258        error: if exit_code != 0 {
2259            Some(format!("Exit code: {}", exit_code))
2260        } else {
2261            None
2262        },
2263        exit_code: Some(exit_code),
2264        duration_ms: None,
2265    })
2266}
2267
2268// ── Tests ──────────────────────────────────────────────────────────────────
2269
2270#[cfg(test)]
2271mod tests {
2272    use super::*;
2273
2274    #[test]
2275    fn test_tool_registry_empty() {
2276        let registry = ToolRegistry::new();
2277        assert!(registry.is_empty());
2278        assert_eq!(registry.len(), 0);
2279    }
2280
2281    #[test]
2282    fn test_tool_registry_register() {
2283        let mut registry = ToolRegistry::new();
2284        registry.register(Arc::new(ShellTool::new()));
2285        assert!(!registry.is_empty());
2286        assert_eq!(registry.len(), 1);
2287        assert!(registry.has("shell_exec"));
2288    }
2289
2290    #[test]
2291    fn test_tool_registry_default_tools() {
2292        let registry = ToolRegistry::with_default_tools();
2293        assert_eq!(registry.len(), 6);
2294        assert!(registry.has("shell_exec"));
2295        assert!(registry.has("read_file"));
2296        assert!(registry.has("write_file"));
2297        assert!(registry.has("web_fetch"));
2298        assert!(registry.has("web_search"));
2299        assert!(registry.has("browser"));
2300    }
2301
2302    #[test]
2303    fn test_tool_definitions() {
2304        let registry = ToolRegistry::with_default_tools();
2305        let defs = registry.definitions();
2306        assert_eq!(defs.len(), 6);
2307
2308        let shell_def = defs.iter().find(|d| d.name == "shell_exec").unwrap();
2309        assert!(shell_def.description.contains("shell command"));
2310        assert!(shell_def.requires_approval);
2311        assert_eq!(shell_def.category, ToolCategory::Shell);
2312    }
2313
2314    #[test]
2315    fn test_tool_not_found() {
2316        let registry = ToolRegistry::new();
2317        let result = registry.get("nonexistent");
2318        assert!(result.is_none());
2319    }
2320
2321    #[test]
2322    fn test_shell_tool_definition() {
2323        let tool = ShellTool::new();
2324        let def = tool.definition();
2325        assert_eq!(def.name, "shell_exec");
2326        assert!(def.requires_approval);
2327    }
2328
2329    #[test]
2330    fn test_read_file_tool_definition() {
2331        let tool = ReadFileTool::new();
2332        let def = tool.definition();
2333        assert_eq!(def.name, "read_file");
2334        assert!(!def.requires_approval);
2335    }
2336
2337    #[test]
2338    fn test_write_file_tool_definition() {
2339        let tool = WriteFileTool::new();
2340        let def = tool.definition();
2341        assert_eq!(def.name, "write_file");
2342        assert!(def.requires_approval);
2343    }
2344
2345    #[test]
2346    fn test_web_fetch_tool_definition() {
2347        let tool = WebFetchTool::new();
2348        let def = tool.definition();
2349        assert_eq!(def.name, "web_fetch");
2350        assert!(!def.requires_approval);
2351    }
2352
2353    #[test]
2354    fn test_tool_call_serialization() {
2355        let call = ToolCall {
2356            name: "shell_exec".to_string(),
2357            arguments: serde_json::json!({"command": "echo hello"}),
2358            id: Some("call_123".to_string()),
2359        };
2360
2361        let json = serde_json::to_string(&call).unwrap();
2362        assert!(json.contains("shell_exec"));
2363        assert!(json.contains("echo hello"));
2364        assert!(json.contains("call_123"));
2365    }
2366
2367    #[test]
2368    fn test_tool_result_serialization() {
2369        let result = ToolResult {
2370            tool_name: "shell_exec".to_string(),
2371            success: true,
2372            output: "hello\n".to_string(),
2373            error: None,
2374            exit_code: Some(0),
2375            duration_ms: Some(42),
2376        };
2377
2378        let json = serde_json::to_string(&result).unwrap();
2379        assert!(json.contains("shell_exec"));
2380        assert!(json.contains("hello"));
2381        assert!(json.contains("42"));
2382    }
2383
2384    #[test]
2385    fn test_tool_result_failure() {
2386        let result = ToolResult {
2387            tool_name: "shell_exec".to_string(),
2388            success: false,
2389            output: String::new(),
2390            error: Some("Exit code: 1".to_string()),
2391            exit_code: Some(1),
2392            duration_ms: Some(10),
2393        };
2394
2395        assert!(!result.success);
2396        assert_eq!(result.exit_code, Some(1));
2397    }
2398
2399    #[test]
2400    fn test_json_schema_string() {
2401        let schema = JsonSchema::string("A test string");
2402        assert_eq!(schema.schema_type, "string");
2403        assert_eq!(schema.description.unwrap(), "A test string");
2404    }
2405
2406    #[test]
2407    fn test_json_schema_object() {
2408        let mut props = HashMap::new();
2409        props.insert("name".to_string(), JsonSchema::string("The name"));
2410        let schema = JsonSchema::object(props, vec!["name".to_string()]);
2411        assert_eq!(schema.schema_type, "object");
2412        assert!(schema.properties.unwrap().contains_key("name"));
2413    }
2414
2415    #[test]
2416    fn test_tool_error_not_found() {
2417        let err = ToolError::NotFound("test_tool".to_string());
2418        assert_eq!(format!("{}", err), "Tool 'test_tool' not found");
2419    }
2420
2421    #[test]
2422    fn test_tool_error_execution_failed() {
2423        let err = ToolError::ExecutionFailed("test".to_string(), "oops".to_string());
2424        assert_eq!(format!("{}", err), "Tool 'test' execution failed: oops");
2425    }
2426
2427    #[test]
2428    fn test_tool_error_invalid_arguments() {
2429        let err = ToolError::InvalidArguments("test".to_string(), "bad arg".to_string());
2430        assert_eq!(
2431            format!("{}", err),
2432            "Invalid arguments for tool 'test': bad arg"
2433        );
2434    }
2435
2436    #[test]
2437    fn test_tool_error_policy_denied() {
2438        let err = ToolError::PolicyDenied("not allowed".to_string());
2439        assert_eq!(format!("{}", err), "Policy denied: not allowed");
2440    }
2441
2442    #[test]
2443    fn test_tool_error_sandbox_violation() {
2444        let err = ToolError::SandboxViolation("escape attempt".to_string());
2445        assert_eq!(format!("{}", err), "Sandbox violation: escape attempt");
2446    }
2447
2448    #[test]
2449    fn test_tool_category_default() {
2450        let cat = ToolCategory::default();
2451        assert_eq!(cat, ToolCategory::General);
2452    }
2453
2454    #[test]
2455    fn test_tool_category_serialization() {
2456        let cat = ToolCategory::Shell;
2457        let json = serde_json::to_string(&cat).unwrap();
2458        assert_eq!(json, "\"Shell\"");
2459    }
2460
2461    #[test]
2462    fn test_tool_definition_requires_approval_default() {
2463        let def = ToolDefinition {
2464            name: "test".to_string(),
2465            description: "test".to_string(),
2466            parameters: JsonSchema::string("test"),
2467            requires_approval: false,
2468            category: ToolCategory::General,
2469        };
2470        assert!(!def.requires_approval);
2471    }
2472
2473    #[tokio::test]
2474    async fn test_shell_exec_success() {
2475        let tool = ShellTool::new();
2476        let args = serde_json::json!({"command": "echo hello"});
2477        let result = tool.execute(args).await.unwrap();
2478        assert!(result.success);
2479        assert!(result.output.contains("hello"));
2480        assert_eq!(result.exit_code, Some(0));
2481    }
2482
2483    #[tokio::test]
2484    async fn test_shell_exec_failure() {
2485        let tool = ShellTool::new();
2486        let args = serde_json::json!({"command": "exit 42"});
2487        let result = tool.execute(args).await.unwrap();
2488        assert!(!result.success);
2489        assert_eq!(result.exit_code, Some(42));
2490    }
2491
2492    #[tokio::test]
2493    async fn test_shell_exec_missing_command() {
2494        let tool = ShellTool::new();
2495        let args = serde_json::json!({});
2496        let err = tool.execute(args).await.unwrap_err();
2497        assert!(matches!(err, ToolError::InvalidArguments(_, _)));
2498    }
2499
2500    #[tokio::test]
2501    async fn test_read_file_not_found() {
2502        let tool = ReadFileTool::new();
2503        let args = serde_json::json!({"path": "/tmp/nonexistent_file_ravenclaws_test"});
2504        let result = tool.execute(args).await;
2505        assert!(result.is_err());
2506        assert!(matches!(
2507            result.unwrap_err(),
2508            ToolError::ExecutionFailed(_, _)
2509        ));
2510    }
2511
2512    #[tokio::test]
2513    async fn test_read_file_missing_path() {
2514        let tool = ReadFileTool::new();
2515        let args = serde_json::json!({});
2516        let err = tool.execute(args).await.unwrap_err();
2517        assert!(matches!(err, ToolError::InvalidArguments(_, _)));
2518    }
2519
2520    #[tokio::test]
2521    async fn test_write_file_missing_args() {
2522        let tool = WriteFileTool::new();
2523        let args = serde_json::json!({});
2524        let err = tool.execute(args).await.unwrap_err();
2525        assert!(matches!(err, ToolError::InvalidArguments(_, _)));
2526    }
2527
2528    #[tokio::test]
2529    async fn test_web_fetch_missing_url() {
2530        let tool = WebFetchTool::new();
2531        let args = serde_json::json!({});
2532        let err = tool.execute(args).await.unwrap_err();
2533        assert!(matches!(err, ToolError::InvalidArguments(_, _)));
2534    }
2535
2536    #[tokio::test]
2537    async fn test_write_and_read_file() {
2538        let dir = std::env::temp_dir().join(format!("ravenclaws_test_{}", std::process::id()));
2539        let path = dir.join("test_write.txt");
2540        let path_str = path.to_string_lossy().to_string();
2541
2542        // Write
2543        let write_tool = WriteFileTool::new();
2544        let args = serde_json::json!({"path": path_str, "content": "Hello, RavenClaws!"});
2545        let result = write_tool.execute(args).await.unwrap();
2546        assert!(result.success);
2547        assert!(result.output.contains("18 bytes"));
2548
2549        // Read back
2550        let read_tool = ReadFileTool::new();
2551        let args = serde_json::json!({"path": path_str});
2552        let result = read_tool.execute(args).await.unwrap();
2553        assert!(result.success);
2554        assert_eq!(result.output.trim(), "Hello, RavenClaws!");
2555
2556        // Cleanup
2557        let _ = tokio::fs::remove_file(&path).await;
2558        let _ = tokio::fs::remove_dir(dir).await;
2559    }
2560
2561    #[tokio::test]
2562    async fn test_write_file_append() {
2563        let dir = std::env::temp_dir().join(format!("ravenclaws_test_{}", std::process::id()));
2564        let path = dir.join("test_append.txt");
2565        let path_str = path.to_string_lossy().to_string();
2566
2567        // Write initial
2568        let write_tool = WriteFileTool::new();
2569        let args = serde_json::json!({"path": path_str, "content": "line1\n"});
2570        write_tool.execute(args).await.unwrap();
2571
2572        // Append
2573        let args = serde_json::json!({"path": path_str, "content": "line2\n", "append": true});
2574        let result = write_tool.execute(args).await.unwrap();
2575        assert!(result.success);
2576
2577        // Read back
2578        let read_tool = ReadFileTool::new();
2579        let args = serde_json::json!({"path": path_str});
2580        let result = read_tool.execute(args).await.unwrap();
2581        assert!(result.success);
2582        assert!(result.output.contains("line1"));
2583        assert!(result.output.contains("line2"));
2584
2585        // Cleanup
2586        let _ = tokio::fs::remove_file(&path).await;
2587        let _ = tokio::fs::remove_dir(dir).await;
2588    }
2589
2590    #[tokio::test]
2591    async fn test_tool_registry_execute() {
2592        let registry = ToolRegistry::with_default_tools();
2593        let call = ToolCall {
2594            name: "shell_exec".to_string(),
2595            arguments: serde_json::json!({"command": "echo hello"}),
2596            id: None,
2597        };
2598        let result = registry.execute(call).await.unwrap();
2599        assert!(result.success);
2600        assert!(result.output.contains("hello"));
2601    }
2602
2603    #[tokio::test]
2604    async fn test_tool_registry_execute_not_found() {
2605        let registry = ToolRegistry::new();
2606        let call = ToolCall {
2607            name: "nonexistent".to_string(),
2608            arguments: serde_json::json!({}),
2609            id: None,
2610        };
2611        let err = registry.execute(call).await.unwrap_err();
2612        assert!(matches!(err, ToolError::NotFound(_)));
2613    }
2614
2615    // ── Web search tool tests ──────────────────────────────────────────────
2616
2617    #[test]
2618    fn test_web_search_tool_definition() {
2619        let tool = WebSearchTool::new();
2620        let def = tool.definition();
2621        assert_eq!(def.name, "web_search");
2622        assert!(!def.requires_approval);
2623        assert_eq!(def.category, ToolCategory::WebSearch);
2624        assert!(def.description.contains("Search the web"));
2625    }
2626
2627    #[test]
2628    fn test_web_search_tool_with_config() {
2629        let tool = WebSearchTool::with_config(
2630            "http://localhost:8888".to_string(),
2631            "searxng".to_string(),
2632            10,
2633            false,
2634        );
2635        let def = tool.definition();
2636        assert_eq!(def.name, "web_search");
2637        assert_eq!(tool.search_endpoint, "http://localhost:8888");
2638        assert_eq!(tool.search_engine, "searxng");
2639        assert_eq!(tool.max_results, 10);
2640        assert!(!tool.fetch_content);
2641    }
2642
2643    #[tokio::test]
2644    async fn test_web_search_missing_query() {
2645        let tool = WebSearchTool::new();
2646        let args = serde_json::json!({});
2647        let err = tool.execute(args).await.unwrap_err();
2648        assert!(matches!(err, ToolError::InvalidArguments(_, _)));
2649    }
2650
2651    #[test]
2652    fn test_web_search_tool_registry() {
2653        let registry = ToolRegistry::with_default_tools();
2654        assert!(registry.has("web_search"));
2655        let defs = registry.definitions();
2656        let search_def = defs.iter().find(|d| d.name == "web_search").unwrap();
2657        assert_eq!(search_def.category, ToolCategory::WebSearch);
2658    }
2659
2660    #[test]
2661    fn test_web_search_tool_with_config_registry() {
2662        let registry =
2663            ToolRegistry::with_web_search_config("http://localhost:8888", "searxng", 10, false);
2664        assert!(registry.has("web_search"));
2665        assert!(registry.has("shell_exec"));
2666        assert!(registry.has("read_file"));
2667        assert!(registry.has("write_file"));
2668        assert!(registry.has("web_fetch"));
2669        assert!(registry.has("browser"));
2670        assert_eq!(registry.len(), 6);
2671    }
2672
2673    // ── HTML extraction tests ──────────────────────────────────────────────
2674
2675    #[test]
2676    fn test_html_to_text_strips_tags() {
2677        let html = "<html><body><p>Hello, world!</p></body></html>";
2678        let text = html_to_text(html, 1000);
2679        assert!(text.contains("Hello, world!"));
2680        assert!(!text.contains("<p>"));
2681        assert!(!text.contains("</p>"));
2682    }
2683
2684    #[test]
2685    fn test_html_to_text_extracts_title() {
2686        let html = "<html><head><title>Test Page</title></head><body><p>Content</p></body></html>";
2687        let text = html_to_text(html, 1000);
2688        assert!(text.contains("Test Page"));
2689        assert!(text.contains("Content"));
2690    }
2691
2692    #[test]
2693    fn test_html_to_text_strips_script_and_style() {
2694        let html = "<html><head><script>alert('xss');</script><style>.cls{}</style></head><body><p>Visible</p></body></html>";
2695        let text = html_to_text(html, 1000);
2696        assert!(text.contains("Visible"));
2697        assert!(!text.contains("alert"));
2698        assert!(!text.contains(".cls"));
2699    }
2700
2701    #[test]
2702    fn test_html_to_text_handles_entities() {
2703        let html = "<p>foo &amp; bar &lt; baz &gt; qux</p>";
2704        let text = html_to_text(html, 1000);
2705        assert!(text.contains("foo & bar < baz > qux") || text.contains("foo & bar"));
2706    }
2707
2708    #[test]
2709    fn test_html_to_text_respects_max_chars() {
2710        let html = "<p>Hello World This Is A Test</p>";
2711        let text = html_to_text(html, 5);
2712        assert!(text.len() <= 5);
2713    }
2714
2715    #[test]
2716    fn test_html_to_text_empty_input() {
2717        assert_eq!(html_to_text("", 1000), "");
2718    }
2719
2720    #[test]
2721    fn test_html_to_text_no_html() {
2722        let text = html_to_text("Just plain text", 1000);
2723        assert_eq!(text, "Just plain text");
2724    }
2725
2726    #[test]
2727    fn test_strip_html_tags_basic() {
2728        let result = strip_html_tags("<b>bold</b> and <i>italic</i>");
2729        assert_eq!(result, "bold and italic");
2730    }
2731
2732    #[test]
2733    fn test_strip_html_tags_with_entities() {
2734        let result = strip_html_tags("foo &amp; bar &lt; baz");
2735        assert_eq!(result, "foo & bar < baz");
2736    }
2737
2738    #[test]
2739    fn test_extract_href_basic() {
2740        let result = extract_href(r#"<a href="https://example.com">link</a>"#);
2741        assert_eq!(result, Some("https://example.com".to_string()));
2742    }
2743
2744    #[test]
2745    fn test_extract_href_protocol_relative() {
2746        let result = extract_href(r#"<a href="//example.com/path">link</a>"#);
2747        assert_eq!(result, Some("https://example.com/path".to_string()));
2748    }
2749
2750    #[test]
2751    fn test_extract_href_relative() {
2752        let result = extract_href(r#"<a href="/relative/path">link</a>"#);
2753        assert_eq!(result, None);
2754    }
2755
2756    #[test]
2757    fn test_extract_href_no_match() {
2758        let result = extract_href("<span>no link here</span>");
2759        assert_eq!(result, None);
2760    }
2761
2762    #[test]
2763    fn test_urlencoding_basic() {
2764        assert_eq!(urlencoding("hello world"), "hello%20world");
2765        assert_eq!(urlencoding("foo/bar"), "foo%2Fbar");
2766        assert_eq!(urlencoding("simple"), "simple");
2767    }
2768
2769    #[test]
2770    fn test_fetch_and_extract_content_invalid_url() {
2771        let result = tokio_test::block_on(fetch_and_extract_content("http://0.0.0.0:1", 1000));
2772        assert!(result.is_err());
2773    }
2774
2775    // ── ToolCallDetector tests ─────────────────────────────────────────────
2776
2777    #[test]
2778    fn test_tool_call_detector_shell_exec() {
2779        let detector = ToolCallDetector::new();
2780        let text = "I'll use the shell_exec tool to run: ls -la";
2781        let calls = detector.detect(text);
2782        assert_eq!(calls.len(), 1, "Should detect one tool call");
2783        assert_eq!(calls[0].name, "shell_exec");
2784        assert_eq!(calls[0].arguments["command"], "ls -la");
2785    }
2786
2787    #[test]
2788    fn test_tool_call_detector_read_file() {
2789        let detector = ToolCallDetector::new();
2790        let text = "Let me read the file /etc/hostname";
2791        let calls = detector.detect(text);
2792        assert_eq!(calls.len(), 1, "Should detect one tool call");
2793        assert_eq!(calls[0].name, "read_file");
2794        assert_eq!(calls[0].arguments["path"], "/etc/hostname");
2795    }
2796
2797    #[test]
2798    fn test_tool_call_detector_web_search() {
2799        let detector = ToolCallDetector::new();
2800        let text = "I'll search for Rust programming language";
2801        let calls = detector.detect(text);
2802        assert_eq!(calls.len(), 1, "Should detect one tool call");
2803        assert_eq!(calls[0].name, "web_search");
2804        assert!(calls[0].arguments["query"]
2805            .as_str()
2806            .unwrap()
2807            .contains("Rust"));
2808    }
2809
2810    #[test]
2811    fn test_tool_call_detector_web_fetch() {
2812        let detector = ToolCallDetector::new();
2813        let text = "I'll fetch https://example.com/api";
2814        let calls = detector.detect(text);
2815        assert_eq!(calls.len(), 1, "Should detect one tool call");
2816        assert_eq!(calls[0].name, "web_fetch");
2817        assert_eq!(calls[0].arguments["url"], "https://example.com/api");
2818    }
2819
2820    #[test]
2821    fn test_tool_call_detector_use_tool_syntax() {
2822        let detector = ToolCallDetector::new();
2823        let text = "Use the shell_exec tool with args: echo hello world";
2824        let calls = detector.detect(text);
2825        assert_eq!(calls.len(), 1, "Should detect one tool call");
2826        assert_eq!(calls[0].name, "shell_exec");
2827    }
2828
2829    #[test]
2830    fn test_tool_call_detector_no_false_positives() {
2831        let detector = ToolCallDetector::new();
2832        let text = "I think we should consider using a different approach here.";
2833        let calls = detector.detect(text);
2834        assert_eq!(calls.len(), 0, "Should not detect any tool calls");
2835    }
2836
2837    #[test]
2838    fn test_tool_call_detector_empty_text() {
2839        let detector = ToolCallDetector::new();
2840        let calls = detector.detect("");
2841        assert_eq!(calls.len(), 0);
2842    }
2843
2844    #[test]
2845    fn test_tool_call_detector_multiple_calls() {
2846        let detector = ToolCallDetector::new();
2847        let text = "Let me read the file /etc/hosts. Then I'll search for DNS configuration.";
2848        let calls = detector.detect(text);
2849        assert_eq!(calls.len(), 2, "Should detect two tool calls");
2850        assert_eq!(calls[0].name, "read_file");
2851        assert_eq!(calls[1].name, "web_search");
2852    }
2853
2854    #[test]
2855    fn test_tool_call_detector_unknown_tool_skipped() {
2856        let detector = ToolCallDetector::new();
2857        let text = "Use the nonexistent_tool tool with args: something";
2858        let calls = detector.detect(text);
2859        assert_eq!(calls.len(), 0, "Should skip unknown tools");
2860    }
2861
2862    #[test]
2863    fn test_tool_call_detector_is_known_tool() {
2864        assert!(ToolCallDetector::is_known_tool("shell_exec"));
2865        assert!(ToolCallDetector::is_known_tool("read_file"));
2866        assert!(ToolCallDetector::is_known_tool("write_file"));
2867        assert!(ToolCallDetector::is_known_tool("web_fetch"));
2868        assert!(ToolCallDetector::is_known_tool("web_search"));
2869        assert!(!ToolCallDetector::is_known_tool("unknown_tool"));
2870    }
2871
2872    #[test]
2873    fn test_tool_call_detector_default() {
2874        let detector = ToolCallDetector::default();
2875        let calls = detector.detect("I'll use the shell_exec tool to run: echo test");
2876        assert_eq!(calls.len(), 1);
2877    }
2878
2879    // ── Browser tool tests ─────────────────────────────────────────────────
2880
2881    #[test]
2882    fn test_browser_tool_definition() {
2883        let tool = BrowserTool::new();
2884        let def = tool.definition();
2885        assert_eq!(def.name, "browser");
2886        assert!(def.requires_approval);
2887        assert_eq!(def.category, ToolCategory::Browser);
2888        assert!(def.description.contains("Chrome DevTools Protocol"));
2889    }
2890
2891    #[test]
2892    fn test_browser_tool_with_config() {
2893        let tool = BrowserTool::with_config("http://localhost:9999".to_string(), 15000);
2894        assert_eq!(tool.cdp_url, "http://localhost:9999");
2895        assert_eq!(tool.request_timeout, 15000);
2896    }
2897
2898    #[test]
2899    fn test_browser_tool_default_config() {
2900        let tool = BrowserTool::new();
2901        assert_eq!(tool.cdp_url, "http://127.0.0.1:9222");
2902        assert_eq!(tool.request_timeout, 30000);
2903    }
2904
2905    #[test]
2906    fn test_browser_tool_registry() {
2907        let registry = ToolRegistry::with_default_tools();
2908        assert!(registry.has("browser"));
2909        let defs = registry.definitions();
2910        let browser_def = defs.iter().find(|d| d.name == "browser").unwrap();
2911        assert_eq!(browser_def.category, ToolCategory::Browser);
2912    }
2913
2914    #[test]
2915    fn test_browser_tool_missing_action() {
2916        let tool = BrowserTool::new();
2917        let args = serde_json::json!({});
2918        let result = tokio_test::block_on(tool.execute(args));
2919        assert!(result.is_err());
2920        assert!(matches!(
2921            result.unwrap_err(),
2922            ToolError::InvalidArguments(_, _)
2923        ));
2924    }
2925
2926    #[test]
2927    fn test_browser_tool_invalid_action() {
2928        let tool = BrowserTool::new();
2929        let args = serde_json::json!({"action": "invalid_action"});
2930        let result = tokio_test::block_on(tool.execute(args));
2931        assert!(result.is_err());
2932        let err = result.unwrap_err();
2933        assert!(matches!(err, ToolError::InvalidArguments(_, _)));
2934        assert!(format!("{}", err).contains("unknown action"));
2935    }
2936
2937    #[test]
2938    fn test_browser_tool_navigate_missing_url() {
2939        let tool = BrowserTool::new();
2940        let args = serde_json::json!({"action": "navigate"});
2941        let result = tokio_test::block_on(tool.execute(args));
2942        assert!(result.is_err());
2943        assert!(matches!(
2944            result.unwrap_err(),
2945            ToolError::InvalidArguments(_, _)
2946        ));
2947    }
2948
2949    #[test]
2950    fn test_browser_tool_click_missing_selector() {
2951        let tool = BrowserTool::new();
2952        let args = serde_json::json!({"action": "click"});
2953        let result = tokio_test::block_on(tool.execute(args));
2954        assert!(result.is_err());
2955        assert!(matches!(
2956            result.unwrap_err(),
2957            ToolError::InvalidArguments(_, _)
2958        ));
2959    }
2960
2961    #[test]
2962    fn test_browser_tool_type_missing_args() {
2963        let tool = BrowserTool::new();
2964        let args = serde_json::json!({"action": "type"});
2965        let result = tokio_test::block_on(tool.execute(args));
2966        assert!(result.is_err());
2967        assert!(matches!(
2968            result.unwrap_err(),
2969            ToolError::InvalidArguments(_, _)
2970        ));
2971    }
2972
2973    #[test]
2974    fn test_browser_tool_type_missing_text() {
2975        let tool = BrowserTool::new();
2976        let args = serde_json::json!({"action": "type", "selector": "#input"});
2977        let result = tokio_test::block_on(tool.execute(args));
2978        assert!(result.is_err());
2979        assert!(matches!(
2980            result.unwrap_err(),
2981            ToolError::InvalidArguments(_, _)
2982        ));
2983    }
2984
2985    #[test]
2986    fn test_browser_tool_evaluate_missing_script() {
2987        let tool = BrowserTool::new();
2988        let args = serde_json::json!({"action": "evaluate"});
2989        let result = tokio_test::block_on(tool.execute(args));
2990        assert!(result.is_err());
2991        assert!(matches!(
2992            result.unwrap_err(),
2993            ToolError::InvalidArguments(_, _)
2994        ));
2995    }
2996
2997    #[test]
2998    fn test_browser_tool_scroll_invalid_direction() {
2999        let tool = BrowserTool::new();
3000        let args = serde_json::json!({"action": "scroll", "direction": "sideways"});
3001        let result = tokio_test::block_on(tool.execute(args));
3002        assert!(result.is_err());
3003        assert!(format!("{}", result.unwrap_err()).contains("unknown scroll direction"));
3004    }
3005
3006    #[test]
3007    fn test_browser_tool_wait_action() {
3008        let tool = BrowserTool::new();
3009        let args = serde_json::json!({"action": "wait", "wait_ms": 10});
3010        let result = tokio_test::block_on(tool.execute(args));
3011        assert!(result.is_ok());
3012        let result = result.unwrap();
3013        assert!(result.success);
3014        assert!(result.output.contains("Waited for"));
3015    }
3016
3017    #[test]
3018    fn test_browser_tool_is_known_tool() {
3019        assert!(ToolCallDetector::is_known_tool("browser"));
3020    }
3021
3022    #[test]
3023    fn test_browser_tool_category_serialization() {
3024        let cat = ToolCategory::Browser;
3025        let json = serde_json::to_string(&cat).unwrap();
3026        assert_eq!(json, "\"Browser\"");
3027    }
3028}