Files
zclaw_openfang/crates/zclaw-skills/src/orchestration/auto_compose.rs
iven 978dc5cdd8
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
fix(安全): 修复HTML导出中的XSS漏洞并清理调试日志
refactor(日志): 替换console.log为tracing日志系统
style(代码): 移除未使用的代码和依赖项

feat(测试): 添加端到端测试文档和CI工作流
docs(变更日志): 更新CHANGELOG.md记录0.1.0版本变更

perf(构建): 更新依赖版本并优化CI流程
2026-03-26 19:49:03 +08:00

382 lines
12 KiB
Rust

//! Auto-compose skills
//!
//! Automatically compose skills into execution graphs based on
//! input/output schema matching and semantic compatibility.
use std::collections::{HashMap, HashSet};
use serde_json::Value;
use zclaw_types::{Result, SkillId};
use crate::registry::SkillRegistry;
use crate::SkillManifest;
use super::{SkillGraph, SkillNode, SkillEdge};
/// Auto-composer for automatic skill graph generation
pub struct AutoComposer<'a> {
registry: &'a SkillRegistry,
}
impl<'a> AutoComposer<'a> {
pub fn new(registry: &'a SkillRegistry) -> Self {
Self { registry }
}
/// Compose multiple skills into an execution graph
pub async fn compose(&self, skill_ids: &[SkillId]) -> Result<SkillGraph> {
// 1. Load all skill manifests
let manifests = self.load_manifests(skill_ids).await?;
// 2. Analyze input/output schemas
let analysis = self.analyze_skills(&manifests);
// 3. Build dependency graph based on schema matching
let edges = self.infer_edges(&manifests, &analysis);
// 4. Create the skill graph
let graph = self.build_graph(skill_ids, &manifests, edges);
Ok(graph)
}
/// Load manifests for all skills
async fn load_manifests(&self, skill_ids: &[SkillId]) -> Result<Vec<SkillManifest>> {
let mut manifests = Vec::new();
for id in skill_ids {
if let Some(manifest) = self.registry.get_manifest(id).await {
manifests.push(manifest);
} else {
return Err(zclaw_types::ZclawError::NotFound(
format!("Skill not found: {}", id)
));
}
}
Ok(manifests)
}
/// Analyze skills for compatibility
fn analyze_skills(&self, manifests: &[SkillManifest]) -> SkillAnalysis {
let mut analysis = SkillAnalysis::default();
for manifest in manifests {
// Extract output types from schema
if let Some(schema) = &manifest.output_schema {
let types = self.extract_types_from_schema(schema);
analysis.output_types.insert(manifest.id.clone(), types);
}
// Extract input types from schema
if let Some(schema) = &manifest.input_schema {
let types = self.extract_types_from_schema(schema);
analysis.input_types.insert(manifest.id.clone(), types);
}
// Extract capabilities
analysis.capabilities.insert(
manifest.id.clone(),
manifest.capabilities.clone(),
);
}
analysis
}
/// Extract type names from JSON schema
fn extract_types_from_schema(&self, schema: &Value) -> HashSet<String> {
let mut types = HashSet::new();
if let Some(obj) = schema.as_object() {
// Get type field
if let Some(type_val) = obj.get("type") {
if let Some(type_str) = type_val.as_str() {
types.insert(type_str.to_string());
} else if let Some(type_arr) = type_val.as_array() {
for t in type_arr {
if let Some(s) = t.as_str() {
types.insert(s.to_string());
}
}
}
}
// Get properties
if let Some(props) = obj.get("properties") {
if let Some(props_obj) = props.as_object() {
for (name, prop) in props_obj {
types.insert(name.clone());
if let Some(prop_obj) = prop.as_object() {
if let Some(type_str) = prop_obj.get("type").and_then(|t| t.as_str()) {
types.insert(format!("{}:{}", name, type_str));
}
}
}
}
}
}
types
}
/// Infer edges based on schema matching
fn infer_edges(
&self,
manifests: &[SkillManifest],
analysis: &SkillAnalysis,
) -> Vec<(String, String)> {
let mut edges = Vec::new();
let mut used_outputs: HashMap<String, HashSet<String>> = HashMap::new();
// Try to match outputs to inputs
for (i, source) in manifests.iter().enumerate() {
let source_outputs = analysis.output_types.get(&source.id).cloned().unwrap_or_default();
for (j, target) in manifests.iter().enumerate() {
if i == j {
continue;
}
let target_inputs = analysis.input_types.get(&target.id).cloned().unwrap_or_default();
// Check for matching types
let matches: Vec<_> = source_outputs
.intersection(&target_inputs)
.filter(|t| !t.starts_with("object") && !t.starts_with("array"))
.collect();
if !matches.is_empty() {
// Check if this output hasn't been used yet
let used = used_outputs.entry(source.id.to_string()).or_default();
let new_matches: Vec<_> = matches
.into_iter()
.filter(|m| !used.contains(*m))
.collect();
if !new_matches.is_empty() {
edges.push((source.id.to_string(), target.id.to_string()));
for m in new_matches {
used.insert(m.clone());
}
}
}
}
}
// If no edges found, create a linear chain
if edges.is_empty() && manifests.len() > 1 {
for i in 0..manifests.len() - 1 {
edges.push((
manifests[i].id.to_string(),
manifests[i + 1].id.to_string(),
));
}
}
edges
}
/// Build the final skill graph
fn build_graph(
&self,
skill_ids: &[SkillId],
manifests: &[SkillManifest],
edges: Vec<(String, String)>,
) -> SkillGraph {
let nodes: Vec<SkillNode> = manifests
.iter()
.map(|m| SkillNode {
id: m.id.to_string(),
skill_id: m.id.clone(),
description: m.description.clone(),
input_mappings: HashMap::new(),
retry: None,
timeout_secs: None,
when: None,
skip_on_error: false,
})
.collect();
let edges: Vec<SkillEdge> = edges
.into_iter()
.map(|(from, to)| SkillEdge {
from_node: from,
to_node: to,
field_mapping: HashMap::new(),
condition: None,
})
.collect();
let graph_id = format!("auto-{}", uuid::Uuid::new_v4());
SkillGraph {
id: graph_id,
name: format!("Auto-composed: {}", skill_ids.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join("")),
description: format!("Automatically composed from skills: {}",
skill_ids.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ")),
nodes,
edges,
input_schema: None,
output_mapping: HashMap::new(),
on_error: Default::default(),
timeout_secs: 300,
}
}
/// Suggest skills that can be composed with a given skill
pub async fn suggest_compatible_skills(
&self,
skill_id: &SkillId,
) -> Result<Vec<(SkillId, CompatibilityScore)>> {
let manifest = self.registry.get_manifest(skill_id).await
.ok_or_else(|| zclaw_types::ZclawError::NotFound(
format!("Skill not found: {}", skill_id)
))?;
let all_skills = self.registry.list().await;
let mut suggestions = Vec::new();
let output_types = manifest.output_schema
.as_ref()
.map(|s| self.extract_types_from_schema(s))
.unwrap_or_default();
for other in all_skills {
if other.id == *skill_id {
continue;
}
let input_types = other.input_schema
.as_ref()
.map(|s| self.extract_types_from_schema(s))
.unwrap_or_default();
// Calculate compatibility score
let score = self.calculate_compatibility(&output_types, &input_types);
if score > 0.0 {
suggestions.push((other.id.clone(), CompatibilityScore {
skill_id: other.id.clone(),
score,
reason: format!("Output types match {} input types",
other.name),
}));
}
}
// Sort by score descending
suggestions.sort_by(|a, b| b.1.score.partial_cmp(&a.1.score).unwrap());
Ok(suggestions)
}
/// Calculate compatibility score between output and input types
fn calculate_compatibility(
&self,
output_types: &HashSet<String>,
input_types: &HashSet<String>,
) -> f32 {
if output_types.is_empty() || input_types.is_empty() {
return 0.0;
}
let intersection = output_types.intersection(input_types).count();
let union = output_types.union(input_types).count();
if union == 0 {
0.0
} else {
intersection as f32 / union as f32
}
}
}
/// Skill analysis result
#[derive(Debug, Default)]
struct SkillAnalysis {
/// Output types for each skill
output_types: HashMap<SkillId, HashSet<String>>,
/// Input types for each skill
input_types: HashMap<SkillId, HashSet<String>>,
/// Capabilities for each skill
capabilities: HashMap<SkillId, Vec<String>>,
}
/// Compatibility score for skill composition
#[derive(Debug, Clone)]
pub struct CompatibilityScore {
/// Skill ID
pub skill_id: SkillId,
/// Compatibility score (0.0 - 1.0)
pub score: f32,
/// Reason for the score
pub reason: String,
}
/// Skill composition template
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CompositionTemplate {
/// Template name
pub name: String,
/// Template description
pub description: String,
/// Skill slots to fill
pub slots: Vec<CompositionSlot>,
/// Fixed edges between slots
pub edges: Vec<TemplateEdge>,
}
/// Slot in a composition template
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CompositionSlot {
/// Slot identifier
pub id: String,
/// Required capabilities
pub required_capabilities: Vec<String>,
/// Expected input schema
pub input_schema: Option<Value>,
/// Expected output schema
pub output_schema: Option<Value>,
}
/// Edge in a composition template
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TemplateEdge {
/// Source slot
pub from: String,
/// Target slot
pub to: String,
/// Field mappings
#[serde(default)]
pub mapping: HashMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_types() {
let registry: &'static SkillRegistry = Box::leak(Box::new(SkillRegistry::new()));
let composer = AutoComposer {
registry,
};
let schema = serde_json::json!({
"type": "object",
"properties": {
"content": { "type": "string" },
"count": { "type": "number" }
}
});
let types = composer.extract_types_from_schema(&schema);
assert!(types.contains("object"));
assert!(types.contains("content"));
assert!(types.contains("count"));
}
}