1pub mod tool;
2pub mod capabilities;
3pub mod runtime;
4pub mod compiler;
5
6use std::path::{Path, PathBuf};
7use std::collections::HashMap;
8use std::sync::Arc;
9use dashmap::DashMap;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use tracing::{info, warn};
15
16use crate::error::{Error, Result};
17use crate::skills::tool::{Tool, ToolDefinition};
18use crate::agent::context::ContextInjector;
19use crate::agent::message::Message;
20#[cfg(feature = "trading")]
21use crate::trading::risk::RiskManager;
22#[cfg(feature = "trading")]
23use crate::trading::strategy::{Action, ActionExecutor};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SkillMetadata {
28 pub name: String,
30 pub description: String,
32 pub homepage: Option<String>,
34 pub parameters: Option<Value>,
36 pub interface: Option<String>,
38 pub script: Option<String>,
40 pub runtime: Option<String>,
42 #[serde(default)]
44 pub metadata: Value,
45 #[serde(default = "default_skill_kind")]
47 pub kind: String,
48 pub usage_guidelines: Option<String>,
50}
51
52fn default_skill_kind() -> String {
53 "tool".to_string()
54}
55
56#[derive(Debug, Clone)]
58pub struct SkillExecutionConfig {
59 pub timeout_secs: u64,
61 pub max_output_bytes: usize,
63 pub allow_network: bool,
65 pub env_vars: HashMap<String, String>,
67}
68
69impl Default for SkillExecutionConfig {
70 fn default() -> Self {
71 Self {
72 timeout_secs: 30,
73 max_output_bytes: 1024 * 1024, allow_network: false,
75 env_vars: HashMap::new(),
76 }
77 }
78}
79
80pub struct DynamicSkill {
82 metadata: SkillMetadata,
83 instructions: String,
84 base_dir: PathBuf,
85 #[cfg(feature = "trading")]
86 risk_manager: Option<Arc<RiskManager>>,
87 #[cfg(feature = "trading")]
88 executor: Option<Arc<dyn ActionExecutor>>,
89 execution_config: SkillExecutionConfig,
90 wasm_runtime: Arc<crate::skills::runtime::WasmRuntime>,
91}
92
93impl DynamicSkill {
94 pub fn new(metadata: SkillMetadata, instructions: String, base_dir: PathBuf) -> Self {
96 Self {
97 metadata,
98 instructions,
99 base_dir,
100 #[cfg(feature = "trading")]
101 risk_manager: None,
102 #[cfg(feature = "trading")]
103 executor: None,
104 execution_config: SkillExecutionConfig::default(),
105 wasm_runtime: Arc::new(crate::skills::runtime::WasmRuntime::new().expect("Failed to init WasmRuntime")),
106 }
107 }
108
109 #[cfg(feature = "trading")]
111 pub fn with_risk_manager(mut self, risk_manager: Arc<RiskManager>) -> Self {
112 self.risk_manager = Some(risk_manager);
113 self
114 }
115
116 #[cfg(feature = "trading")]
118 pub fn with_executor(mut self, executor: Arc<dyn ActionExecutor>) -> Self {
119 self.executor = Some(executor);
120 self
121 }
122
123 pub fn with_execution_config(mut self, config: SkillExecutionConfig) -> Self {
125 self.execution_config = config;
126 self
127 }
128
129 pub fn metadata(&self) -> &SkillMetadata {
131 &self.metadata
132 }
133}
134
135#[cfg(feature = "trading")]
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct Proposal {
138 pub from_token: String,
139 pub to_token: String,
140 pub amount_usd: rust_decimal::Decimal,
141 pub amount: String,
143 pub expected_slippage: Option<rust_decimal::Decimal>,
144}
145
146#[async_trait]
147impl Tool for DynamicSkill {
148 fn name(&self) -> String {
149 self.metadata.name.clone()
150 }
151
152 async fn definition(&self) -> ToolDefinition {
153 ToolDefinition {
154 name: self.metadata.name.clone(),
155 description: self.metadata.description.clone(),
156 parameters: self.metadata.parameters.clone().unwrap_or(json!({})),
157 parameters_ts: self.metadata.interface.clone(),
158 is_binary: self.metadata.runtime.as_deref() == Some("wasm"),
159 is_verified: false, usage_guidelines: self.metadata.usage_guidelines.clone(),
161 }
162 }
163
164
165
166 async fn call(&self, arguments: &str) -> anyhow::Result<String> {
167 let runtime_type = self.metadata.runtime.as_deref().unwrap_or("python3");
168
169 let interpreter = match runtime_type {
170 "python" | "python3" => "python3",
171 "bash" | "sh" => "bash",
172 "node" | "js" => "node",
173 "wasm" => "wasm",
174 lang => lang
175 };
176
177 if interpreter == "wasm" {
178 let wasm_file = self.metadata.script.as_ref().ok_or_else(|| {
179 Error::tool_execution(self.name(), "No wasm file defined for this skill".to_string())
180 })?;
181 let wasm_path = self.base_dir.join("scripts").join(wasm_file);
182 info!(tool = %self.name(), "Executing Wasm skill");
183 return self.wasm_runtime.call(&wasm_path, arguments).map_err(|e| e.into());
184 }
185
186 let script_file = self.metadata.script.as_ref().ok_or_else(|| {
187 Error::tool_execution(self.name(), "No script defined for this skill".to_string())
188 })?;
189
190 let script_rel_path = Path::new("scripts").join(script_file);
191 let script_full_path = self.base_dir.join(&script_rel_path);
192
193 if !script_full_path.exists() {
194 return Err(Error::tool_execution(
195 self.name(),
196 format!("Script not found at {:?}", script_full_path),
197 ).into());
198 }
199
200 info!(tool = %self.name(), "Executing dynamic skill (Runtime: {})", runtime_type);
201
202 let has_bwrap = which::which("bwrap").is_ok();
203 let unsafe_override = std::env::var("AAGT_UNSAFE_SKILL_EXEC").map(|v| v == "true").unwrap_or(false);
204 let timeout = std::time::Duration::from_secs(self.execution_config.timeout_secs);
205
206 if !has_bwrap && !unsafe_override {
207 return Err(Error::tool_execution(
208 self.name(),
209 "Security Error: 'bwrap' (Bubblewrap) sandbox is not installed on the system. Cannot execute skill securely. \
210 To bypass this for testing, set AAGT_UNSAFE_SKILL_EXEC=true."
211 ).into());
212 }
213
214 let output = if !has_bwrap && unsafe_override {
215 warn!(tool = %self.name(), "UNSAFE EXECUTION: Running skill without Bubblewrap sandbox override.");
216 let mut cmd = tokio::process::Command::new(interpreter);
217 cmd.arg(&script_full_path);
218 cmd.arg(arguments);
219 cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
220 for (key, value) in &self.execution_config.env_vars { cmd.env(key, value); }
221
222 let child = cmd.spawn()
223 .map_err(|e| Error::ToolExecution {
224 tool_name: self.name(),
225 message: format!("Failed to spawn process: {}", e)
226 })?;
227
228 tokio::time::timeout(timeout, child.wait_with_output())
229 .await
230 .map_err(|_| Error::ToolExecution {
231 tool_name: self.name(),
232 message: "Execution timed out".to_string()
233 })?
234 .map_err(|e| Error::ToolExecution {
235 tool_name: self.name(),
236 message: format!("Process failed: {}", e)
237 })?
238 } else {
239 let mut cmd = tokio::process::Command::new("bwrap");
240 cmd.arg("--ro-bind").arg("/").arg("/");
241 cmd.arg("--dev").arg("/dev");
242 cmd.arg("--proc").arg("/proc");
243 cmd.arg("--tmpfs").arg("/tmp");
244 if let Ok(cwd) = std::env::current_dir() {
245 cmd.arg("--bind").arg(&cwd).arg(&cwd);
246 }
247 if !self.execution_config.allow_network {
248 cmd.arg("--unshare-net");
249 }
250 cmd.arg(interpreter).arg(&script_full_path).arg(arguments);
251 cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
252 for (key, value) in &self.execution_config.env_vars { cmd.env(key, value); }
253
254 let child = cmd.spawn()
255 .map_err(|e| Error::ToolExecution {
256 tool_name: self.name(),
257 message: format!("Failed to spawn process: {}", e)
258 })?;
259
260 tokio::time::timeout(timeout, child.wait_with_output())
261 .await
262 .map_err(|_| Error::ToolExecution {
263 tool_name: self.name(),
264 message: "Execution timed out".to_string()
265 })?
266 .map_err(|e| Error::ToolExecution {
267 tool_name: self.name(),
268 message: format!("Process failed: {}", e)
269 })?
270 };
271
272 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
273 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
274
275 if !output.status.success() {
276 return Err(Error::ToolExecution {
277 tool_name: self.name(),
278 message: format!("Script error (exit code {}): {}\nStderr: {}",
279 output.status.code().unwrap_or(-1), stdout, stderr)
280 }.into());
281 }
282
283 #[cfg(feature = "trading")]
285 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout) {
286 if value.get("type").and_then(|t| t.as_str()) == Some("proposal") {
287 if let Some(proposal_data) = value.get("data") {
288 let proposal: Proposal = serde_json::from_value(proposal_data.clone())
289 .map_err(|e| Error::tool_execution(self.name(), format!("Malformed proposal: {}", e)))?;
290
291 info!("Skill {} generated a transaction proposal: {:?}", self.name(), proposal);
292
293 if let Some(ref rm) = self.risk_manager {
294 let context = crate::trading::risk::TradeContext {
295 user_id: "default_user".to_string(),
296 from_token: proposal.from_token.clone(),
297 to_token: proposal.to_token.clone(),
298 amount_usd: proposal.amount_usd,
299 expected_slippage: proposal.expected_slippage.unwrap_or(rust_decimal_macros::dec!(1.0)),
300 liquidity_usd: None,
301 is_flagged: false,
302 };
303
304 rm.check_and_reserve(&context).await
305 .map_err(|e| Error::tool_execution(self.name(), format!("Risk Check Denied: {}", e)))?;
306
307 if let Some(ref executor) = self.executor {
308 let action = Action::Swap {
309 from_token: proposal.from_token,
310 to_token: proposal.to_token,
311 amount: proposal.amount,
312 };
313 let pipeline_ctx = crate::trading::pipeline::Context::new(format!("Skill execution: {}", self.name()));
314 let result = match executor.execute(&action, &pipeline_ctx).await {
315 Ok(res) => res,
316 Err(e) => {
317 warn!("Skill execution failed, rolling back risk reservation: {}", e);
318 rm.rollback_trade(&context.user_id, context.amount_usd).await;
319 return Err(Error::tool_execution(self.name(), format!("Execution Failed (Rolled Back): {}", e)).into());
320 }
321 };
322 rm.commit_trade(&context.user_id, context.amount_usd).await?;
323 return Ok(format!("SUCCESS: Trade executed: {}", result));
324 } else {
325 rm.commit_trade(&context.user_id, context.amount_usd).await?;
326 return Ok(format!("SIMULATION SUCCESS: Trade approved but NO EXECUTOR configured. Proposal: {:?}", proposal));
327 }
328 } else {
329 return Err(Error::tool_execution(self.name(), "RiskManager not configured, cannot execute risky proposal".to_string()).into());
330 }
331 }
332 }
333 }
334
335 Ok(stdout)
336 }
337}
338
339pub struct SkillLoader {
341 pub skills: DashMap<String, Arc<DynamicSkill>>,
342 pub base_path: PathBuf,
343 #[cfg(feature = "trading")]
344 risk_manager: Option<Arc<RiskManager>>,
345 #[cfg(feature = "trading")]
346 executor: Option<Arc<dyn ActionExecutor>>,
347}
348
349impl SkillLoader {
350 pub fn new(base_path: impl Into<PathBuf>) -> Self {
352 Self {
353 skills: DashMap::new(),
354 base_path: base_path.into(),
355 #[cfg(feature = "trading")]
356 risk_manager: None,
357 #[cfg(feature = "trading")]
358 executor: None,
359 }
360 }
361
362 #[cfg(feature = "trading")]
364 pub fn with_risk_manager(mut self, risk_manager: Arc<RiskManager>) -> Self {
365 self.risk_manager = Some(risk_manager);
366 self
367 }
368
369 #[cfg(feature = "trading")]
371 pub fn with_executor(mut self, executor: Arc<dyn ActionExecutor>) -> Self {
372 self.executor = Some(executor);
373 self
374 }
375
376 pub async fn load_all(&self) -> Result<()> {
378 if !self.base_path.exists() {
379 return Ok(());
380 }
381
382 let mut entries = tokio::fs::read_dir(&self.base_path).await?;
383 while let Some(entry) = entries.next_entry().await? {
384 let path = entry.path();
385 if path.is_dir() {
386 if let Ok(skill) = self.load_skill(&path).await {
387 #[cfg(feature = "trading")]
388 let mut skill = skill;
389 #[cfg(feature = "trading")]
390 {
391 if let Some(ref rm) = self.risk_manager {
392 skill = skill.with_risk_manager(Arc::clone(rm));
393 }
394 if let Some(ref exec) = self.executor {
395 skill = skill.with_executor(Arc::clone(exec));
396 }
397 }
398 info!("Loaded dynamic skill: {}", skill.name());
399 self.skills.insert(skill.name(), Arc::new(skill));
400 }
401 }
402 }
403 Ok(())
404 }
405
406 pub async fn load_skill(&self, path: &Path) -> Result<DynamicSkill> {
407 let manifest_path = path.join("SKILL.md");
408 if !manifest_path.exists() {
409 return Err(Error::Internal("No SKILL.md found".to_string()));
410 }
411
412 let content = tokio::fs::read_to_string(&manifest_path).await?;
413
414 let start_delimiter = "---\n";
416 let end_delimiter = "\n---";
417
418 let yaml_str;
424 let instructions;
425
426 if content.starts_with(start_delimiter) || content.starts_with("---\r\n") {
428 if let Some(end_idx) = content[4..].find(end_delimiter) {
430 let actual_end_idx = end_idx + 4; yaml_str = &content[4..actual_end_idx]; let rest_start = actual_end_idx + 4;
437 if rest_start < content.len() {
438 instructions = content[rest_start..].trim().to_string();
439 } else {
440 instructions = String::new();
441 }
442 } else {
443 return Err(Error::Internal("SKILL.md frontmatter unclosed (missing closing ---)".to_string()));
444 }
445 } else {
446 return Err(Error::Internal("SKILL.md must start with ---".to_string()));
447 }
448
449 let mut metadata: SkillMetadata = serde_yaml_ng::from_str(yaml_str)
450 .map_err(|e| Error::Internal(format!("Failed to parse Skill YAML: {}", e)))?;
451
452 if metadata.script.is_none() || metadata.runtime.is_none() {
455 if metadata.script.is_none() {
458 let scripts_dir = path.join("scripts");
459 if scripts_dir.exists() {
460 if let Ok(mut entries) = std::fs::read_dir(scripts_dir) {
461 if let Some(Ok(first_entry)) = entries.next() {
462 let filename = first_entry.file_name().to_string_lossy().to_string();
463 metadata.script = Some(filename.clone());
464
465 if metadata.runtime.is_none() {
467 if filename.ends_with(".py") {
468 metadata.runtime = Some("python3".into());
469 } else if filename.ends_with(".js") {
470 metadata.runtime = Some("node".into());
471 } else if filename.ends_with(".sh") {
472 metadata.runtime = Some("bash".into());
473 }
474 }
475 }
476 }
477 }
478 }
479
480 if metadata.runtime.is_none() {
482 if instructions.contains("python3") {
483 metadata.runtime = Some("python3".into());
484 } else if instructions.contains("node") {
485 metadata.runtime = Some("node".into());
486 } else if instructions.contains("bash") || instructions.contains("sh ") {
487 metadata.runtime = Some("bash".into());
488 }
489 }
490 }
491
492 Ok(DynamicSkill::new(metadata, instructions, path.to_path_buf()))
493 }
494}
495
496#[async_trait::async_trait]
497impl ContextInjector for SkillLoader {
498 async fn inject(&self) -> Result<Vec<Message>> {
499 if self.skills.is_empty() {
500 return Ok(Vec::new());
501 }
502
503 let mut content = String::from("## Available Skills\n\n");
504 content.push_str("You have the following skills available via `read_skill_manual`:\n\n");
505
506 for skill_ref in self.skills.iter() {
507 let skill = skill_ref.value();
508 content.push_str(&format!("- **{}**: {}\n", skill.name(), skill.metadata.description));
509 }
510
511 content.push_str("\nUse `read_skill_manual(skill_name)` to see full instructions for any skill.\n");
512
513 Ok(vec![Message::system(content)])
514 }
515}
516
517pub struct ReadSkillDoc {
519 loader: Arc<SkillLoader>,
520}
521
522impl ReadSkillDoc {
523 pub fn new(loader: Arc<SkillLoader>) -> Self {
524 Self { loader }
525 }
526}
527
528#[async_trait]
529impl Tool for ReadSkillDoc {
530 fn name(&self) -> String {
531 "read_skill_manual".to_string()
532 }
533
534 async fn definition(&self) -> ToolDefinition {
535 ToolDefinition {
536 name: self.name(),
537 description: "Read the full SKILL.md manual for a specific skill to understand its parameters and usage examples.".to_string(),
538 parameters: json!({
539 "type": "object",
540 "properties": {
541 "skill_name": {
542 "type": "string",
543 "description": "The name of the skill to read documentation for"
544 }
545 },
546 "required": ["skill_name"]
547 }),
548 parameters_ts: Some("interface ReadSkillArgs {\n skill_name: string; // The name of the skill to read manual for\n}".to_string()),
549 is_binary: false,
550 is_verified: true,
551 usage_guidelines: None,
552 }
553 }
554
555 async fn call(&self, arguments: &str) -> anyhow::Result<String> {
556 #[derive(Deserialize)]
557 struct Args {
558 skill_name: String,
559 }
560 let args: Args = serde_json::from_str(arguments)?;
561
562 if let Some(skill) = self.loader.skills.get(&args.skill_name) {
563 Ok(format!("# Skill: {}\n\n{}", skill.name(), skill.instructions))
564 } else {
565 Err(anyhow::anyhow!("Skill '{}' not found in registry", args.skill_name))
566 }
567 }
568}
569pub struct ClawHubTool {
571 loader: Arc<SkillLoader>,
572}
573
574impl ClawHubTool {
575 pub fn new(loader: Arc<SkillLoader>) -> Self {
576 Self { loader }
577 }
578}
579
580#[async_trait]
581impl Tool for ClawHubTool {
582 fn name(&self) -> String {
583 "clawhub_manager".to_string()
584 }
585
586 async fn definition(&self) -> ToolDefinition {
587 ToolDefinition {
588 name: self.name(),
589 description: "Search and install new skills from the ClawHub.ai registry. Supports 'search' to find skills and 'install' to add them to your environment.".to_string(),
590 parameters: json!({
591 "type": "object",
592 "properties": {
593 "action": {
594 "type": "string",
595 "enum": ["search", "install"],
596 "description": "The action to perform"
597 },
598 "query": {
599 "type": "string",
600 "description": "Search query or skill slug to install"
601 },
602 "manager": {
603 "type": "string",
604 "enum": ["npm", "pnpm", "bun"],
605 "description": "The package manager to use (default: npm)"
606 }
607 },
608 "required": ["action", "query"]
609 }),
610 parameters_ts: Some("interface ClawHubArgs {\n action: 'search' | 'install';\n query: string; // Search query or skill slug\n manager?: 'npm' | 'pnpm' | 'bun'; // Package manager (default: npm)\n}".to_string()),
611 is_binary: false,
612 is_verified: true,
613 usage_guidelines: None,
614 }
615 }
616
617 async fn call(&self, arguments: &str) -> anyhow::Result<String> {
618 #[derive(Deserialize)]
619 struct Args {
620 action: String,
621 query: String,
622 manager: Option<String>,
623 }
624 let args: Args = serde_json::from_str(arguments)?;
625
626 let manager = args.manager.as_deref().unwrap_or("npm");
627 let (cmd, base_args) = match manager {
628 "pnpm" => ("pnpm", vec!["dlx", "clawhub@latest"]),
629 "bun" => ("bunx", vec!["clawhub@latest"]),
630 _ => ("npx", vec!["clawhub@latest"]),
631 };
632
633 match args.action.as_str() {
634 "search" => {
635 info!("Searching ClawHub registry for: {} (via {})", args.query, manager);
636 let output = tokio::process::Command::new(cmd)
637 .args(&base_args)
638 .arg("search")
639 .arg(&args.query)
640 .output()
641 .await?;
642
643 Ok(String::from_utf8_lossy(&output.stdout).to_string())
644 }
645 "install" => {
646 info!("Installing skill from ClawHub: {} (via {})", args.query, manager);
647 let output = tokio::process::Command::new(cmd)
648 .args(&base_args)
649 .arg("install")
650 .arg(&args.query)
651 .output()
652 .await?;
653
654 if output.status.success() {
655 info!("Skill {} installed successfully, refreshing registry...", args.query);
657 self.loader.load_all().await?;
658 Ok(format!("Successfully installed '{}'. It is now available for use.", args.query))
659 } else {
660 let err = String::from_utf8_lossy(&output.stderr);
661 Err(anyhow::anyhow!("Failed to install skill: {}", err))
662 }
663 }
664 _ => Err(anyhow::anyhow!("Unknown action: {}", args.action)),
665 }
666 }
667}