use std::path::PathBuf;
use std::process::Stdio;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::cli::error::{CliError, Result};
use crate::cli::llm::{CompletionResult, ToolCall, ToolDef, ToolParam};
use crate::tui::AppEvent;
use super::{Skill, SkillContext, SkillInput, SkillOutput};
pub const DEFAULT_MAX_TOOL_ROUNDS: usize = 10;
#[derive(Debug, Clone, Deserialize)]
pub struct SkillToolDef {
pub name: String,
pub description: String,
pub command: Vec<String>,
#[serde(default)]
pub parameters: Vec<SkillToolParam>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SkillToolParam {
pub name: String,
pub description: String,
#[serde(default = "default_true")]
pub required: bool,
}
fn default_true() -> bool {
true
}
pub struct ScriptSkill {
pub name: String,
pub description: String,
pub capabilities: Vec<String>,
pub skill_dir: PathBuf,
pub system_prompt: String,
pub tools: Vec<SkillToolDef>,
pub max_tool_rounds: usize,
}
impl ScriptSkill {
async fn run_tool(&self, tool_def: &SkillToolDef, call: &ToolCall, job_id: &str, ctx: &SkillContext) -> Result<String> {
let mut args = tool_def.command.clone();
let mut first_required = true;
for param in &tool_def.parameters {
if let Some(val) = call.arguments.get(¶m.name) {
let s = match val {
Value::String(s) => s.clone(),
other => other.to_string(),
};
if first_required && param.required {
args.push(s);
first_required = false;
} else {
args.push(format!("--{}", param.name));
args.push(s);
}
}
}
if let Some(ref tx) = ctx.event_tx {
let _ = tx.send(AppEvent::ToolStarted {
job_id: job_id.to_string(),
tool_name: call.name.clone(),
});
}
let child = tokio::process::Command::new(&args[0])
.args(&args[1..])
.current_dir(&self.skill_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CliError::Other(format!("failed to spawn tool '{}': {}", call.name, e)))?;
let output = child
.wait_with_output()
.await
.map_err(|e| CliError::Other(format!("tool '{}' failed: {}", call.name, e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if let Some(ref tx) = ctx.event_tx {
let _ = tx.send(AppEvent::ToolFailed {
job_id: job_id.to_string(),
tool_name: call.name.clone(),
error: stderr.trim().to_string(),
});
}
return Ok(format!("Error: tool exited with {}: {}", output.status, stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if let Some(ref tx) = ctx.event_tx {
let _ = tx.send(AppEvent::ToolCompleted {
job_id: job_id.to_string(),
tool_name: call.name.clone(),
output_len: stdout.len(),
});
}
Ok(stdout)
}
fn find_tool(&self, name: &str) -> Option<&SkillToolDef> {
self.tools.iter().find(|t| t.name == name)
}
fn llm_tools(&self) -> Vec<ToolDef> {
self.tools
.iter()
.map(|t| ToolDef {
name: t.name.clone(),
description: t.description.clone(),
parameters: t
.parameters
.iter()
.map(|p| ToolParam {
name: p.name.clone(),
description: p.description.clone(),
required: p.required,
})
.collect(),
})
.collect()
}
}
#[async_trait]
impl Skill for ScriptSkill {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn capabilities(&self) -> &[String] {
&self.capabilities
}
async fn execute(&self, input: SkillInput, ctx: &SkillContext) -> Result<SkillOutput> {
let llm = ctx
.llm
.as_ref()
.ok_or_else(|| CliError::Llm("no LLM configured for skill".into()))?;
let tools = self.llm_tools();
let job_id = &input.job_id;
if tools.is_empty() {
if let Some(ref tx) = ctx.event_tx {
let _ = tx.send(AppEvent::LlmRound {
job_id: job_id.clone(),
round: 1,
max_rounds: 1,
});
}
let result = llm.complete(&self.system_prompt, &input.data).await?;
return Ok(SkillOutput {
data: result,
output_mime: None,
});
}
let mut messages: Vec<Value> = vec![json!({
"role": "user",
"content": input.data,
})];
let max_rounds = self.max_tool_rounds;
for round in 0..max_rounds {
if let Some(ref tx) = ctx.event_tx {
let _ = tx.send(AppEvent::LlmRound {
job_id: job_id.clone(),
round: round + 1,
max_rounds,
});
}
let result = llm
.complete_with_tools(&self.system_prompt, &messages, &tools)
.await?;
match result {
CompletionResult::Text(text) => {
return Ok(SkillOutput {
data: text,
output_mime: None,
});
}
CompletionResult::ToolUse {
calls,
assistant_message,
} => {
messages.push(assistant_message);
for call in &calls {
let tool_result = match self.find_tool(&call.name) {
Some(tool_def) => self.run_tool(tool_def, call, job_id, ctx).await?,
None => format!("Error: unknown tool '{}'", call.name),
};
messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": call.id,
"content": tool_result,
}]
}));
}
}
}
}
Err(CliError::Other(format!(
"skill '{}' exceeded max tool rounds ({})",
self.name, max_rounds
)))
}
}