1use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::sync::Arc;
22use thiserror::Error;
23use tracing::{debug, info, instrument, warn};
24
25use crate::sandbox::Sandbox;
27
28#[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#[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 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 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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ToolDefinition {
121 pub name: String,
123 pub description: String,
125 pub parameters: JsonSchema,
127 #[serde(default)]
129 pub requires_approval: bool,
130 #[serde(default)]
132 pub category: ToolCategory,
133}
134
135impl ToolDefinition {
136 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ToolCall {
172 pub name: String,
174 pub arguments: serde_json::Value,
176 #[serde(default)]
178 pub id: Option<String>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ToolResult {
184 pub tool_name: String,
186 pub success: bool,
188 pub output: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub error: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub exit_code: Option<i32>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub duration_ms: Option<u64>,
199}
200
201#[async_trait::async_trait]
205pub trait ToolImpl: Send + Sync {
206 async fn execute(&self, args: serde_json::Value) -> ToolResultValue<ToolResult>;
208
209 fn definition(&self) -> &ToolDefinition;
211
212 fn name(&self) -> &str {
214 &self.definition().name
215 }
216}
217
218#[derive(Clone)]
222pub struct ToolRegistry {
223 tools: HashMap<String, Arc<dyn ToolImpl>>,
224}
225
226impl ToolRegistry {
227 pub fn new() -> Self {
229 Self {
230 tools: HashMap::new(),
231 }
232 }
233
234 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 pub fn get(&self, name: &str) -> Option<&Arc<dyn ToolImpl>> {
243 self.tools.get(name)
244 }
245
246 #[allow(dead_code)]
248 pub fn has(&self, name: &str) -> bool {
249 self.tools.contains_key(name)
250 }
251
252 #[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 #[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 #[allow(dead_code)]
272 pub fn len(&self) -> usize {
273 self.tools.len()
274 }
275
276 #[allow(dead_code)]
278 pub fn is_empty(&self) -> bool {
279 self.tools.is_empty()
280 }
281
282 #[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 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 #[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 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
389pub 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 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 let result = run_shell_command(command, timeout_secs, Some(workdir)).await?;
489
490 Ok(result)
491 }
492}
493
494pub 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
583pub 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 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
713pub 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 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 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
858pub 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 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 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 let mut results = Vec::new();
1040 let mut pos = 0;
1041 let result_class = "result__a";
1042
1043 while results.len() < max_results {
1044 let link_start = match body[pos..].find(result_class) {
1046 Some(i) => pos + i,
1047 None => break,
1048 };
1049
1050 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 let url = extract_href(a_tag).unwrap_or_default();
1064 let title = a_tag.rsplit('>').next().unwrap_or("").trim().to_string();
1066
1067 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#[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 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 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 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 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
1218pub 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 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 #[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 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 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 async fn navigate(&self, url: &str) -> ToolResultValue<String> {
1564 let ws_url = self.get_ws_url().await?;
1565
1566 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 let target_id = ws_url.rsplit('/').next().unwrap_or("").to_string();
1577
1578 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 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 async fn click(&self, selector: &str) -> ToolResultValue<String> {
1615 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 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 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 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 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 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 async fn get_page_text(&self) -> ToolResultValue<String> {
1737 self.evaluate("document.body.innerText || document.body.textContent || ''")
1738 .await
1739 }
1740
1741 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 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 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 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 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
1819async 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
1852fn 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 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 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 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 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 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 if bytes[i] == b'&' {
1948 let remaining = len - i;
1949 let entity = if remaining > 5 && &html[i..i + 6] == " " {
1950 i += 6;
1951 " "
1952 } else if remaining > 3 && &html[i..i + 4] == "<" {
1953 i += 4;
1954 "<"
1955 } else if remaining > 3 && &html[i..i + 4] == ">" {
1956 i += 4;
1957 ">"
1958 } else if remaining > 4 && &html[i..i + 5] == "&" {
1959 i += 5;
1960 "&"
1961 } else if remaining > 5 && &html[i..i + 6] == """ {
1962 i += 6;
1963 "\""
1964 } else if remaining > 3 && &html[i..i + 4] == "'" {
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 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 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
2009fn 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 output
2026 .replace("&", "&")
2027 .replace("<", "<")
2028 .replace(">", ">")
2029 .replace(""", "\"")
2030 .replace("'", "'")
2031 .replace(" ", " ")
2032}
2033
2034fn 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 if href.starts_with("//") {
2043 return Some(format!("https:{}", href));
2044 }
2045 if href.starts_with("/") {
2046 return None; }
2048
2049 Some(href.to_string())
2050}
2051
2052fn 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#[allow(dead_code)]
2093pub struct ToolCallDetector {
2094 patterns: Vec<DetectorPattern>,
2095}
2096
2097#[allow(dead_code)]
2099struct DetectorPattern {
2100 regex: regex_lite::Regex,
2102 tool_name: Option<String>,
2104 arg_key: Option<String>,
2106 arg_group: usize,
2108}
2109
2110#[allow(dead_code)]
2111impl ToolCallDetector {
2112 pub fn new() -> Self {
2114 let patterns = vec![
2116 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, arg_key: None,
2124 arg_group: 2,
2125 },
2126 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 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 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 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 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 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 let arguments = match &pattern.arg_key {
2200 Some(key) => {
2201 serde_json::json!({ key: arg_value })
2202 }
2203 None => {
2204 serde_json::from_str(&arg_value).unwrap_or_else(
2206 |_| serde_json::json!({ "command": arg_value, "input": arg_value }),
2207 )
2208 }
2209 };
2210
2211 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 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
2244async 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 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#[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 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 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 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 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 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 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 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 #[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 #[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 & bar < baz > 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 & bar < 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 #[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 #[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}