Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Batch fix covering multiple modules:
- P2-01: HandRegistry Semaphore-based max_concurrent enforcement
- P2-03: Populate toolCount/metricCount from Hand trait methods
- P2-06: heartbeat_update_config minimum interval validation
- P2-07: ReflectionResult used_fallback marker for rule-based fallback
- P2-08/09: identity_propose_change parameter naming consistency
- P2-10: ClassroomMetadata is_placeholder flag for LLM failure
- P2-11: classroomStore userDidCloseDuringGeneration intent tracking
- P2-12: workflowStore pipeline_create sends actionType
- P2-13/14: PipelineInfo step_count + PipelineStepInfo for proper step mapping
- P2-15: Pipe transform support in context.resolve (8 transforms)
- P2-16: Mustache {{...}} → \${...} auto-normalization
- P2-17: SaaSLogin password placeholder 6→8
- P2-19: serialize_skill_md + update_skill preserve tools field
- P2-22: ToolOutputGuard sensitive patterns from warn→block
- P2-23: Mutex::unwrap() → unwrap_or_else in relay/service.rs
- P3-01/03/07/08/09: Various P3 fixes
- DEFECT_LIST.md: comprehensive status sync (43/51 fixed, 8 remaining)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
6.2 KiB
Rust
194 lines
6.2 KiB
Rust
//! Skill runners for different execution modes
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::Value;
|
|
use std::process::Command;
|
|
use std::time::Instant;
|
|
use tracing::warn;
|
|
use zclaw_types::Result;
|
|
|
|
use super::{Skill, SkillContext, SkillManifest, SkillResult};
|
|
|
|
/// Returns the platform-appropriate Python binary name.
|
|
/// On Windows, the standard installer provides `python.exe`, not `python3.exe`.
|
|
#[cfg(target_os = "windows")]
|
|
fn python_bin() -> &'static str { "python" }
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
fn python_bin() -> &'static str { "python3" }
|
|
|
|
/// Prompt-only skill execution
|
|
pub struct PromptOnlySkill {
|
|
manifest: SkillManifest,
|
|
prompt_template: String,
|
|
}
|
|
|
|
impl PromptOnlySkill {
|
|
pub fn new(manifest: SkillManifest, prompt_template: String) -> Self {
|
|
Self { manifest, prompt_template }
|
|
}
|
|
|
|
fn format_prompt(&self, input: &Value) -> String {
|
|
let mut prompt = self.prompt_template.clone();
|
|
|
|
if let Value::String(s) = input {
|
|
prompt = prompt.replace("{{input}}", s);
|
|
} else {
|
|
prompt = prompt.replace("{{input}}", &serde_json::to_string_pretty(input).unwrap_or_default());
|
|
}
|
|
|
|
prompt
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Skill for PromptOnlySkill {
|
|
fn manifest(&self) -> &SkillManifest {
|
|
&self.manifest
|
|
}
|
|
|
|
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult> {
|
|
let prompt = self.format_prompt(&input);
|
|
|
|
// If an LLM completer is available, generate an AI response
|
|
if let Some(completer) = &context.llm {
|
|
match completer.complete(&prompt).await {
|
|
Ok(response) => return Ok(SkillResult::success(Value::String(response))),
|
|
Err(e) => {
|
|
warn!("[PromptOnlySkill] LLM completion failed: {}, falling back to raw prompt", e);
|
|
// Fall through to return raw prompt
|
|
}
|
|
}
|
|
}
|
|
|
|
// No LLM available — return formatted prompt (backward compatible)
|
|
Ok(SkillResult::success(Value::String(prompt)))
|
|
}
|
|
}
|
|
|
|
/// Python script skill execution
|
|
pub struct PythonSkill {
|
|
manifest: SkillManifest,
|
|
script_path: std::path::PathBuf,
|
|
}
|
|
|
|
impl PythonSkill {
|
|
pub fn new(manifest: SkillManifest, script_path: std::path::PathBuf) -> Self {
|
|
Self { manifest, script_path }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Skill for PythonSkill {
|
|
fn manifest(&self) -> &SkillManifest {
|
|
&self.manifest
|
|
}
|
|
|
|
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult> {
|
|
let start = Instant::now();
|
|
let input_json = serde_json::to_string(&input).unwrap_or_default();
|
|
|
|
// P2-20: Platform-aware Python binary (Windows has no python3)
|
|
let output = Command::new(python_bin())
|
|
.arg(&self.script_path)
|
|
.env("SKILL_INPUT", &input_json)
|
|
.env("AGENT_ID", &context.agent_id)
|
|
.env("SESSION_ID", &context.session_id)
|
|
.output()
|
|
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute Python: {}", e)))?;
|
|
|
|
let duration_ms = start.elapsed().as_millis() as u64;
|
|
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let result = serde_json::from_str(&stdout)
|
|
.map(|v| SkillResult {
|
|
success: true,
|
|
output: v,
|
|
error: None,
|
|
duration_ms: Some(duration_ms),
|
|
tokens_used: None,
|
|
})
|
|
.unwrap_or_else(|_| SkillResult::success(Value::String(stdout.to_string())));
|
|
Ok(result)
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Ok(SkillResult::error(stderr))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shell command skill execution
|
|
pub struct ShellSkill {
|
|
manifest: SkillManifest,
|
|
command: String,
|
|
}
|
|
|
|
impl ShellSkill {
|
|
pub fn new(manifest: SkillManifest, command: String) -> Self {
|
|
Self { manifest, command }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Skill for ShellSkill {
|
|
fn manifest(&self) -> &SkillManifest {
|
|
&self.manifest
|
|
}
|
|
|
|
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult> {
|
|
let start = Instant::now();
|
|
|
|
let mut cmd = self.command.clone();
|
|
if let Value::String(s) = input {
|
|
// Shell-quote the input to prevent command injection
|
|
let quoted = shlex::try_quote(&s)
|
|
.map_err(|_| zclaw_types::ZclawError::ToolError(
|
|
"Input contains null bytes and cannot be safely quoted".to_string()
|
|
))?;
|
|
cmd = cmd.replace("{{input}}", "ed);
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
let output = {
|
|
Command::new("cmd")
|
|
.args(["/C", &cmd])
|
|
.current_dir(context.working_dir.as_ref().unwrap_or(&std::path::PathBuf::from(".")))
|
|
.output()
|
|
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute shell: {}", e)))?
|
|
};
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
let output = {
|
|
Command::new("sh")
|
|
.args(["-c", &cmd])
|
|
.current_dir(context.working_dir.as_ref().unwrap_or(&std::path::PathBuf::from(".")))
|
|
.output()
|
|
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute shell: {}", e)))?
|
|
};
|
|
|
|
// P3-08: Use duration_ms instead of discarding it
|
|
let duration_ms = start.elapsed().as_millis() as u64;
|
|
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
Ok(SkillResult {
|
|
success: true,
|
|
output: Value::String(stdout.to_string()),
|
|
error: None,
|
|
duration_ms: Some(duration_ms),
|
|
tokens_used: None,
|
|
})
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Ok(SkillResult {
|
|
success: false,
|
|
output: Value::Null,
|
|
error: Some(stderr.to_string()),
|
|
duration_ms: Some(duration_ms),
|
|
tokens_used: None,
|
|
})
|
|
}
|
|
}
|
|
}
|