feat(skill-execution): implement execute_skill tool with full execution chain
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
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
- Add ExecuteSkillTool for LLM to call skills during conversation - Implement SkillExecutor trait in Kernel for skill execution - Update AgentLoop to support tool execution with skill_executor - Add default skills_dir configuration in KernelConfig - Connect frontend skillMarketStore to backend skill_list command - Update technical documentation with Skill system architecture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,12 @@
|
||||
//! - No provider prefix or alias mapping
|
||||
//! - Simple, unified configuration structure
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use secrecy::SecretString;
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
use zclaw_runtime::{LlmDriver, AnthropicDriver, OpenAiDriver, GeminiDriver, LocalDriver};
|
||||
use zclaw_types::Result;
|
||||
use zclaw_runtime::{LlmDriver, AnthropicDriver, OpenAiDriver};
|
||||
|
||||
/// API protocol type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -119,6 +120,10 @@ pub struct KernelConfig {
|
||||
/// LLM configuration
|
||||
#[serde(flatten)]
|
||||
pub llm: LlmConfig,
|
||||
|
||||
/// Skills directory path (optional, defaults to ./skills)
|
||||
#[serde(default)]
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn default_database_url() -> String {
|
||||
@@ -147,10 +152,18 @@ impl Default for KernelConfig {
|
||||
max_tokens: default_max_tokens(),
|
||||
temperature: default_temperature(),
|
||||
},
|
||||
skills_dir: default_skills_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default skills directory (./skills relative to cwd)
|
||||
fn default_skills_dir() -> Option<std::path::PathBuf> {
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|cwd| cwd.join("skills"))
|
||||
}
|
||||
|
||||
impl KernelConfig {
|
||||
/// Load configuration from file
|
||||
pub async fn load() -> Result<Self> {
|
||||
@@ -321,6 +334,7 @@ impl KernelConfig {
|
||||
Self {
|
||||
database_url: default_database_url(),
|
||||
llm,
|
||||
skills_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use zclaw_types::{AgentId, SessionId, Message, Result};
|
||||
|
||||
use crate::driver::{LlmDriver, CompletionRequest, ContentBlock};
|
||||
use crate::stream::StreamChunk;
|
||||
use crate::tool::ToolRegistry;
|
||||
use crate::tool::{ToolRegistry, ToolContext, SkillExecutor};
|
||||
use crate::loop_guard::LoopGuard;
|
||||
use zclaw_memory::MemoryStore;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct AgentLoop {
|
||||
system_prompt: Option<String>,
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
skill_executor: Option<Arc<dyn SkillExecutor>>,
|
||||
}
|
||||
|
||||
impl AgentLoop {
|
||||
@@ -41,9 +42,16 @@ impl AgentLoop {
|
||||
system_prompt: None,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
skill_executor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the skill executor for tool execution
|
||||
pub fn with_skill_executor(mut self, executor: Arc<dyn SkillExecutor>) -> Self {
|
||||
self.skill_executor = Some(executor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the model to use
|
||||
pub fn with_model(mut self, model: impl Into<String>) -> Self {
|
||||
self.model = model.into();
|
||||
@@ -68,6 +76,23 @@ impl AgentLoop {
|
||||
self
|
||||
}
|
||||
|
||||
/// Create tool context for tool execution
|
||||
fn create_tool_context(&self, session_id: SessionId) -> ToolContext {
|
||||
ToolContext {
|
||||
agent_id: self.agent_id.clone(),
|
||||
working_directory: None,
|
||||
session_id: Some(session_id.to_string()),
|
||||
skill_executor: self.skill_executor.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a tool with the given input
|
||||
async fn execute_tool(&self, tool_name: &str, input: serde_json::Value, context: &ToolContext) -> Result<serde_json::Value> {
|
||||
let tool = self.tools.get(tool_name)
|
||||
.ok_or_else(|| zclaw_types::ZclawError::ToolError(format!("Unknown tool: {}", tool_name)))?;
|
||||
tool.execute(input, context).await
|
||||
}
|
||||
|
||||
/// Run the agent loop with a single message
|
||||
pub async fn run(&self, session_id: SessionId, input: String) -> Result<AgentLoopResult> {
|
||||
// Add user message to session
|
||||
@@ -92,27 +117,51 @@ impl AgentLoop {
|
||||
// Call LLM
|
||||
let response = self.driver.complete(request).await?;
|
||||
|
||||
// Extract text content from response
|
||||
let response_text = response.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.clone()),
|
||||
ContentBlock::Thinking { thinking } => Some(format!("[思考] {}", thinking)),
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
Some(format!("[工具调用] {}({})", name, serde_json::to_string(input).unwrap_or_default()))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// Create tool context
|
||||
let tool_context = self.create_tool_context(session_id.clone());
|
||||
|
||||
// Process response and handle tool calls
|
||||
let iterations = 0;
|
||||
// Process response and execute tools
|
||||
let mut response_parts = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
|
||||
for block in &response.content {
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
response_parts.push(text.clone());
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
response_parts.push(format!("[思考] {}", thinking));
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
// Execute the tool
|
||||
let tool_result = match self.execute_tool(name, input.clone(), &tool_context).await {
|
||||
Ok(result) => {
|
||||
response_parts.push(format!("[工具执行成功] {}", name));
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
response_parts.push(format!("[工具执行失败] {}: {}", name, e));
|
||||
serde_json::json!({ "error": e.to_string() })
|
||||
}
|
||||
};
|
||||
tool_results.push((id.clone(), name.clone(), tool_result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were tool calls, we might need to continue the conversation
|
||||
// For now, just include tool results in the response
|
||||
for (id, name, result) in tool_results {
|
||||
response_parts.push(format!("[工具结果 {}]: {}", name, serde_json::to_string(&result).unwrap_or_default()));
|
||||
}
|
||||
|
||||
let response_text = response_parts.join("\n");
|
||||
|
||||
Ok(AgentLoopResult {
|
||||
response: response_text,
|
||||
input_tokens: response.input_tokens,
|
||||
output_tokens: response.output_tokens,
|
||||
iterations,
|
||||
iterations: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,11 +196,15 @@ impl AgentLoop {
|
||||
let session_id_clone = session_id.clone();
|
||||
let memory = self.memory.clone();
|
||||
let driver = self.driver.clone();
|
||||
let tools = self.tools.clone();
|
||||
let skill_executor = self.skill_executor.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut full_response = String::new();
|
||||
let mut input_tokens = 0u32;
|
||||
let mut output_tokens = 0u32;
|
||||
let mut pending_tool_calls: Vec<(String, String, serde_json::Value)> = Vec::new();
|
||||
|
||||
let mut stream = driver.stream(request);
|
||||
|
||||
@@ -167,21 +220,27 @@ impl AgentLoop {
|
||||
StreamChunk::ThinkingDelta { delta } => {
|
||||
let _ = tx.send(LoopEvent::Delta(format!("[思考] {}", delta))).await;
|
||||
}
|
||||
StreamChunk::ToolUseStart { name, .. } => {
|
||||
StreamChunk::ToolUseStart { id, name } => {
|
||||
pending_tool_calls.push((id.clone(), name.clone(), serde_json::Value::Null));
|
||||
let _ = tx.send(LoopEvent::ToolStart {
|
||||
name: name.clone(),
|
||||
input: serde_json::Value::Null,
|
||||
}).await;
|
||||
}
|
||||
StreamChunk::ToolUseDelta { delta, .. } => {
|
||||
// Accumulate tool input deltas
|
||||
StreamChunk::ToolUseDelta { id, delta } => {
|
||||
// Update the pending tool call's input
|
||||
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
|
||||
// For simplicity, just store the delta as the input
|
||||
// In a real implementation, you'd accumulate and parse JSON
|
||||
tool.2 = serde_json::Value::String(delta.clone());
|
||||
}
|
||||
let _ = tx.send(LoopEvent::Delta(format!("[工具参数] {}", delta))).await;
|
||||
}
|
||||
StreamChunk::ToolUseEnd { input, .. } => {
|
||||
let _ = tx.send(LoopEvent::ToolEnd {
|
||||
name: String::new(),
|
||||
output: input.clone(),
|
||||
}).await;
|
||||
StreamChunk::ToolUseEnd { id, input } => {
|
||||
// Update the tool call with final input
|
||||
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
|
||||
tool.2 = input.clone();
|
||||
}
|
||||
}
|
||||
StreamChunk::Complete { input_tokens: it, output_tokens: ot, .. } => {
|
||||
input_tokens = *it;
|
||||
@@ -198,6 +257,47 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
// Execute pending tool calls
|
||||
for (_id, name, input) in pending_tool_calls {
|
||||
// Create tool context
|
||||
let tool_context = ToolContext {
|
||||
agent_id: agent_id.clone(),
|
||||
working_directory: None,
|
||||
session_id: Some(session_id_clone.to_string()),
|
||||
skill_executor: skill_executor.clone(),
|
||||
};
|
||||
|
||||
// Execute the tool
|
||||
let result = if let Some(tool) = tools.get(&name) {
|
||||
match tool.execute(input.clone(), &tool_context).await {
|
||||
Ok(output) => {
|
||||
let _ = tx.send(LoopEvent::ToolEnd {
|
||||
name: name.clone(),
|
||||
output: output.clone(),
|
||||
}).await;
|
||||
output
|
||||
}
|
||||
Err(e) => {
|
||||
let error_output: serde_json::Value = serde_json::json!({ "error": e.to_string() });
|
||||
let _ = tx.send(LoopEvent::ToolEnd {
|
||||
name: name.clone(),
|
||||
output: error_output.clone(),
|
||||
}).await;
|
||||
error_output
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error_output: serde_json::Value = serde_json::json!({ "error": format!("Unknown tool: {}", name) });
|
||||
let _ = tx.send(LoopEvent::ToolEnd {
|
||||
name: name.clone(),
|
||||
output: error_output.clone(),
|
||||
}).await;
|
||||
error_output
|
||||
};
|
||||
|
||||
full_response.push_str(&format!("\n[工具 {} 结果]: {}", name, serde_json::to_string(&result).unwrap_or_default()));
|
||||
}
|
||||
|
||||
// Save assistant message to memory
|
||||
let assistant_message = Message::assistant(full_response.clone());
|
||||
let _ = memory.append_message(&session_id_clone, &assistant_message).await;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Tool system for agent capabilities
|
||||
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use zclaw_types::{AgentId, Result};
|
||||
@@ -22,16 +23,54 @@ pub trait Tool: Send + Sync {
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value>;
|
||||
}
|
||||
|
||||
/// Skill executor trait for runtime skill execution
|
||||
/// This allows tools to execute skills without direct dependency on zclaw-skills
|
||||
#[async_trait]
|
||||
pub trait SkillExecutor: Send + Sync {
|
||||
/// Execute a skill by ID
|
||||
async fn execute_skill(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
input: Value,
|
||||
) -> Result<Value>;
|
||||
}
|
||||
|
||||
/// Context provided to tool execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolContext {
|
||||
pub agent_id: AgentId,
|
||||
pub working_directory: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub skill_executor: Option<Arc<dyn SkillExecutor>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ToolContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ToolContext")
|
||||
.field("agent_id", &self.agent_id)
|
||||
.field("working_directory", &self.working_directory)
|
||||
.field("session_id", &self.session_id)
|
||||
.field("skill_executor", &self.skill_executor.as_ref().map(|_| "SkillExecutor"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ToolContext {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
agent_id: self.agent_id.clone(),
|
||||
working_directory: self.working_directory.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
skill_executor: self.skill_executor.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool registry for managing available tools
|
||||
#[derive(Clone)]
|
||||
pub struct ToolRegistry {
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
tools: Vec<Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
@@ -40,11 +79,11 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
pub fn register(&mut self, tool: Box<dyn Tool>) {
|
||||
self.tools.push(tool);
|
||||
self.tools.push(Arc::from(tool));
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
|
||||
self.tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
|
||||
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
||||
self.tools.iter().find(|t| t.name() == name).cloned()
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<&dyn Tool> {
|
||||
|
||||
@@ -4,13 +4,15 @@ mod file_read;
|
||||
mod file_write;
|
||||
mod shell_exec;
|
||||
mod web_fetch;
|
||||
mod execute_skill;
|
||||
|
||||
pub use file_read::FileReadTool;
|
||||
pub use file_write::FileWriteTool;
|
||||
pub use shell_exec::ShellExecTool;
|
||||
pub use web_fetch::WebFetchTool;
|
||||
pub use execute_skill::ExecuteSkillTool;
|
||||
|
||||
use crate::tool::{ToolRegistry, Tool};
|
||||
use crate::tool::ToolRegistry;
|
||||
|
||||
/// Register all built-in tools
|
||||
pub fn register_builtin_tools(registry: &mut ToolRegistry) {
|
||||
@@ -18,4 +20,5 @@ pub fn register_builtin_tools(registry: &mut ToolRegistry) {
|
||||
registry.register(Box::new(FileWriteTool::new()));
|
||||
registry.register(Box::new(ShellExecTool::new()));
|
||||
registry.register(Box::new(WebFetchTool::new()));
|
||||
registry.register(Box::new(ExecuteSkillTool::new()));
|
||||
}
|
||||
|
||||
72
crates/zclaw-runtime/src/tool/builtin/execute_skill.rs
Normal file
72
crates/zclaw-runtime/src/tool/builtin/execute_skill.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Execute skill tool
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use crate::tool::{Tool, ToolContext};
|
||||
|
||||
pub struct ExecuteSkillTool;
|
||||
|
||||
impl ExecuteSkillTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ExecuteSkillTool {
|
||||
fn name(&self) -> &str {
|
||||
"execute_skill"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute a skill by its ID. Skills are predefined capabilities that can be invoked with structured input."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the skill to execute"
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
"description": "The input parameters for the skill",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": ["skill_id"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
||||
let skill_id = input["skill_id"].as_str()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'skill_id' parameter".into()))?;
|
||||
|
||||
let skill_input = input.get("input").cloned().unwrap_or(json!({}));
|
||||
|
||||
// Get skill executor from context
|
||||
let executor = context.skill_executor.as_ref()
|
||||
.ok_or_else(|| ZclawError::ToolError("Skill executor not available".into()))?;
|
||||
|
||||
// Get session_id from context or use empty string
|
||||
let session_id = context.session_id.as_deref().unwrap_or("");
|
||||
|
||||
// Execute the skill
|
||||
executor.execute_skill(
|
||||
skill_id,
|
||||
&context.agent_id.to_string(),
|
||||
session_id,
|
||||
skill_input,
|
||||
).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExecuteSkillTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user