diff --git a/crates/owlen-core/src/tools/code_exec.rs b/crates/owlen-core/src/tools/code_exec.rs new file mode 100644 index 0000000..20985a3 --- /dev/null +++ b/crates/owlen-core/src/tools/code_exec.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; +use std::time::Instant; + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::{Tool, ToolResult}; +use crate::sandbox::{SandboxConfig, SandboxedProcess}; + +pub struct CodeExecTool { + allowed_languages: Arc>, +} + +impl CodeExecTool { + pub fn new(allowed_languages: Vec) -> Self { + Self { + allowed_languages: Arc::new(allowed_languages), + } + } +} + +#[async_trait] +impl Tool for CodeExecTool { + fn name(&self) -> &'static str { + "code_exec" + } + + fn description(&self) -> &'static str { + "Execute code snippets within a sandboxed environment" + } + + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "language": { + "type": "string", + "enum": self.allowed_languages.as_slice(), + "description": "Language of the code block" + }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 10000, + "description": "Code to execute" + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Execution timeout in seconds" + } + }, + "required": ["language", "code"], + "additionalProperties": false + }) + } + + async fn execute(&self, args: Value) -> Result { + let start = Instant::now(); + + let language = args + .get("language") + .and_then(Value::as_str) + .context("Missing language parameter")?; + let code = args + .get("code") + .and_then(Value::as_str) + .context("Missing code parameter")?; + let timeout = args.get("timeout").and_then(Value::as_u64).unwrap_or(30); + + if !self.allowed_languages.iter().any(|lang| lang == language) { + return Err(anyhow!("Language '{}' not permitted", language)); + } + + let (command, command_args) = match language { + "python" => ( + "python3".to_string(), + vec!["-c".to_string(), code.to_string()], + ), + "javascript" => ("node".to_string(), vec!["-e".to_string(), code.to_string()]), + "bash" => ("bash".to_string(), vec!["-c".to_string(), code.to_string()]), + "rust" => { + let mut result = + ToolResult::error("Rust execution is not yet supported in the sandbox"); + result.duration = start.elapsed(); + return Ok(result); + } + other => return Err(anyhow!("Unsupported language: {}", other)), + }; + + let sandbox_config = SandboxConfig { + allow_network: false, + timeout_seconds: timeout, + ..Default::default() + }; + + let sandbox_result = tokio::task::spawn_blocking(move || -> Result<_> { + let sandbox = SandboxedProcess::new(sandbox_config)?; + let arg_refs: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect(); + sandbox.execute(&command, &arg_refs) + }) + .await + .context("Sandbox execution task failed")??; + + let mut result = if sandbox_result.exit_code == 0 { + ToolResult::success(json!({ + "stdout": sandbox_result.stdout, + "stderr": sandbox_result.stderr, + "exit_code": sandbox_result.exit_code, + "timed_out": sandbox_result.was_timeout, + })) + } else { + let error_msg = if sandbox_result.was_timeout { + format!( + "Execution timed out after {} seconds (exit code {}): {}", + timeout, sandbox_result.exit_code, sandbox_result.stderr + ) + } else { + format!( + "Execution failed with status {}: {}", + sandbox_result.exit_code, sandbox_result.stderr + ) + }; + let mut err_result = ToolResult::error(&error_msg); + err_result.output = json!({ + "stdout": sandbox_result.stdout, + "stderr": sandbox_result.stderr, + "exit_code": sandbox_result.exit_code, + "timed_out": sandbox_result.was_timeout, + }); + err_result + }; + + result.duration = start.elapsed(); + result + .metadata + .insert("language".to_string(), language.to_string()); + result + .metadata + .insert("timeout_seconds".to_string(), timeout.to_string()); + + Ok(result) + } +} diff --git a/crates/owlen-core/src/tools/mod.rs b/crates/owlen-core/src/tools/mod.rs new file mode 100644 index 0000000..6849be2 --- /dev/null +++ b/crates/owlen-core/src/tools/mod.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; + +pub mod code_exec; +pub mod registry; +pub mod web_search; +pub mod web_search_detailed; + +#[async_trait] +pub trait Tool: Send + Sync { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + fn schema(&self) -> Value; + fn requires_network(&self) -> bool { + false + } + fn requires_filesystem(&self) -> Vec { + Vec::new() + } + + async fn execute(&self, args: Value) -> Result; +} + +#[derive(Debug, Clone)] +pub struct ToolResult { + pub success: bool, + pub output: Value, + pub duration: std::time::Duration, + pub metadata: HashMap, +} + +impl ToolResult { + pub fn success(output: Value) -> Self { + Self { + success: true, + output, + duration: std::time::Duration::from_millis(0), + metadata: HashMap::new(), + } + } + + pub fn error(message: &str) -> Self { + Self { + success: false, + output: serde_json::json!({ "error": message }), + duration: std::time::Duration::from_millis(0), + metadata: HashMap::new(), + } + } +} diff --git a/crates/owlen-core/src/tools/registry.rs b/crates/owlen-core/src/tools/registry.rs new file mode 100644 index 0000000..68a8e5c --- /dev/null +++ b/crates/owlen-core/src/tools/registry.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use serde_json::Value; + +use super::Tool; + +pub struct ToolRegistry { + tools: HashMap>, +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { + tools: HashMap::new(), + } + } + + pub fn register(&mut self, tool: T) + where + T: Tool + 'static, + { + let tool: Arc = Arc::new(tool); + let name = tool.name().to_string(); + self.tools.insert(name, tool); + } + + pub fn get(&self, name: &str) -> Option> { + self.tools.get(name).cloned() + } + + pub fn all(&self) -> Vec> { + self.tools.values().cloned().collect() + } + + pub async fn execute(&self, name: &str, args: Value) -> Result { + let tool = self + .get(name) + .with_context(|| format!("Tool not registered: {}", name))?; + tool.execute(args).await + } + + pub fn tools(&self) -> Vec { + self.tools.keys().cloned().collect() + } +} diff --git a/crates/owlen-core/src/tools/web_search.rs b/crates/owlen-core/src/tools/web_search.rs new file mode 100644 index 0000000..4b653b7 --- /dev/null +++ b/crates/owlen-core/src/tools/web_search.rs @@ -0,0 +1,153 @@ +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::{Tool, ToolResult}; +use crate::consent::ConsentManager; +use crate::credentials::CredentialManager; +use crate::encryption::VaultHandle; + +pub struct WebSearchTool { + consent_manager: Arc>, + _credential_manager: Option>, + browser: duckduckgo::browser::Browser, +} + +impl WebSearchTool { + pub fn new( + consent_manager: Arc>, + credential_manager: Option>, + _vault: Option>>, + ) -> Self { + // Create a reqwest client compatible with duckduckgo crate (v0.11) + let client = reqwest_011::Client::new(); + let browser = duckduckgo::browser::Browser::new(client); + + Self { + consent_manager, + _credential_manager: credential_manager, + browser, + } + } +} + +#[async_trait] +impl Tool for WebSearchTool { + fn name(&self) -> &'static str { + "web_search" + } + + fn description(&self) -> &'static str { + "Search the web for information using DuckDuckGo API" + } + + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Search query" + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 5, + "description": "Maximum number of results" + } + }, + "required": ["query"], + "additionalProperties": false + }) + } + + fn requires_network(&self) -> bool { + true + } + + async fn execute(&self, args: Value) -> Result { + let start = Instant::now(); + + // Check if consent has been granted (non-blocking check) + // Consent should have been granted via TUI dialog before tool execution + { + let consent = self + .consent_manager + .lock() + .expect("Consent manager mutex poisoned"); + + if !consent.has_consent(self.name()) { + return Ok(ToolResult::error( + "Consent not granted for web search. This should have been handled by the TUI.", + )); + } + } + + let query = args + .get("query") + .and_then(Value::as_str) + .context("Missing query parameter")?; + let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize; + + let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0", + ); + + // Detect if this is a news query - use news endpoint for better snippets + let is_news_query = query.to_lowercase().contains("news") + || query.to_lowercase().contains("latest") + || query.to_lowercase().contains("today") + || query.to_lowercase().contains("recent"); + + let mut formatted_results = Vec::new(); + + if is_news_query { + // Use news endpoint which returns excerpts/snippets + let news_results = self + .browser + .news(query, "wt-wt", false, Some(max_results), user_agent) + .await + .context("DuckDuckGo news search failed")?; + + for result in news_results { + formatted_results.push(json!({ + "title": result.title, + "url": result.url, + "snippet": result.body, // news has body/excerpt + "source": result.source, + "date": result.date + })); + } + } else { + // Use lite search for general queries (fast but no snippets) + let search_results = self + .browser + .lite_search(query, "wt-wt", Some(max_results), user_agent) + .await + .context("DuckDuckGo search failed")?; + + for result in search_results { + formatted_results.push(json!({ + "title": result.title, + "url": result.url, + "snippet": result.snippet + })); + } + } + + let mut result = ToolResult::success(json!({ + "query": query, + "results": formatted_results, + "total_found": formatted_results.len() + })); + result.duration = start.elapsed(); + + Ok(result) + } +} diff --git a/crates/owlen-core/src/tools/web_search_detailed.rs b/crates/owlen-core/src/tools/web_search_detailed.rs new file mode 100644 index 0000000..0332fd2 --- /dev/null +++ b/crates/owlen-core/src/tools/web_search_detailed.rs @@ -0,0 +1,130 @@ +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::{Tool, ToolResult}; +use crate::consent::ConsentManager; +use crate::credentials::CredentialManager; +use crate::encryption::VaultHandle; + +pub struct WebSearchDetailedTool { + consent_manager: Arc>, + _credential_manager: Option>, + browser: duckduckgo::browser::Browser, +} + +impl WebSearchDetailedTool { + pub fn new( + consent_manager: Arc>, + credential_manager: Option>, + _vault: Option>>, + ) -> Self { + // Create a reqwest client compatible with duckduckgo crate (v0.11) + let client = reqwest_011::Client::new(); + let browser = duckduckgo::browser::Browser::new(client); + + Self { + consent_manager, + _credential_manager: credential_manager, + browser, + } + } +} + +#[async_trait] +impl Tool for WebSearchDetailedTool { + fn name(&self) -> &'static str { + "web_search_detailed" + } + + fn description(&self) -> &'static str { + "Search for recent articles and web content with detailed snippets and descriptions. \ + Returns results with publication dates, sources, and full text excerpts. \ + Best for finding recent information, articles, and detailed context about topics." + } + + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Search query" + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 5, + "description": "Maximum number of results" + } + }, + "required": ["query"], + "additionalProperties": false + }) + } + + fn requires_network(&self) -> bool { + true + } + + async fn execute(&self, args: Value) -> Result { + let start = Instant::now(); + + // Check if consent has been granted (non-blocking check) + // Consent should have been granted via TUI dialog before tool execution + { + let consent = self + .consent_manager + .lock() + .expect("Consent manager mutex poisoned"); + + if !consent.has_consent(self.name()) { + return Ok(ToolResult::error("Consent not granted for detailed web search. This should have been handled by the TUI.")); + } + } + + let query = args + .get("query") + .and_then(Value::as_str) + .context("Missing query parameter")?; + let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize; + + let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0", + ); + + // Use news endpoint which provides detailed results with full snippets + // Even for non-news queries, this often returns recent articles and content with good descriptions + let news_results = self + .browser + .news(query, "wt-wt", false, Some(max_results), user_agent) + .await + .context("DuckDuckGo detailed search failed")?; + + let mut formatted_results = Vec::new(); + for result in news_results { + formatted_results.push(json!({ + "title": result.title, + "url": result.url, + "snippet": result.body, // news endpoint includes full excerpts + "source": result.source, + "date": result.date + })); + } + + let mut result = ToolResult::success(json!({ + "query": query, + "results": formatted_results, + "total_found": formatted_results.len() + })); + result.duration = start.elapsed(); + + Ok(result) + } +}