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