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