From aa6a9cbd84c2b6abd40c52fa44564e9b9c19f57b Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 25 Mar 2026 08:27:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E5=BC=95=E6=93=8E=E5=92=8C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=9E=84=E5=BB=BA=E5=99=A8=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 统一Hands系统常量到单个源文件 refactor: 更新Hands中文名称和描述 fix: 修复技能市场在连接状态变化时重新加载 fix: 修复身份变更提案的错误处理逻辑 docs: 更新多个功能文档的验证状态和实现位置 docs: 更新Hands系统文档 test: 添加测试文件验证工作区路径 --- Cargo.lock | 58 + Cargo.toml | 2 + crates/zclaw-hands/src/hands/browser.rs | 4 +- crates/zclaw-hands/src/hands/clip.rs | 4 +- crates/zclaw-hands/src/hands/collector.rs | 4 +- crates/zclaw-hands/src/hands/quiz.rs | 4 +- crates/zclaw-hands/src/hands/researcher.rs | 4 +- crates/zclaw-hands/src/hands/slideshow.rs | 4 +- crates/zclaw-hands/src/hands/speech.rs | 4 +- crates/zclaw-hands/src/hands/twitter.rs | 4 +- crates/zclaw-hands/src/hands/whiteboard.rs | 4 +- crates/zclaw-kernel/src/capabilities.rs | 4 +- crates/zclaw-kernel/src/config.rs | 95 +- crates/zclaw-kernel/src/export/html.rs | 1 - crates/zclaw-kernel/src/export/pptx.rs | 4 +- crates/zclaw-kernel/src/generation.rs | 3 +- crates/zclaw-kernel/src/kernel.rs | 99 +- crates/zclaw-kernel/src/lib.rs | 3 + crates/zclaw-pipeline/src/actions/mod.rs | 42 + .../src/actions/orchestration.rs | 61 + crates/zclaw-pipeline/src/executor.rs | 10 + crates/zclaw-pipeline/src/types.rs | 13 + crates/zclaw-runtime/src/driver/gemini.rs | 2 +- crates/zclaw-runtime/src/driver/local.rs | 2 +- crates/zclaw-runtime/src/driver/openai.rs | 10 +- crates/zclaw-runtime/src/loop_runner.rs | 417 ++++--- .../src/tool/builtin/file_write.rs | 2 +- .../src/tool/builtin/shell_exec.rs | 3 +- crates/zclaw-skills/Cargo.toml | 2 + crates/zclaw-skills/src/lib.rs | 2 + crates/zclaw-skills/src/loader.rs | 13 + .../src/orchestration/auto_compose.rs | 380 ++++++ .../zclaw-skills/src/orchestration/context.rs | 255 ++++ .../src/orchestration/executor.rs | 319 +++++ crates/zclaw-skills/src/orchestration/mod.rs | 18 + .../zclaw-skills/src/orchestration/planner.rs | 337 ++++++ .../zclaw-skills/src/orchestration/types.rs | 344 ++++++ .../src/orchestration/validation.rs | 406 +++++++ crates/zclaw-skills/src/registry.rs | 10 +- crates/zclaw-skills/src/skill.rs | 4 + desktop/package.json | 4 + desktop/pnpm-lock.yaml | 216 ++++ .../src-tauri/src/intelligence/heartbeat.rs | 153 ++- .../src-tauri/src/intelligence/identity.rs | 4 +- desktop/src-tauri/src/intelligence/mod.rs | 18 +- desktop/src-tauri/src/kernel_commands.rs | 78 ++ desktop/src-tauri/src/memory/mod.rs | 6 - desktop/src-tauri/src/memory/persistent.rs | 2 +- desktop/src-tauri/src/memory_commands.rs | 2 +- desktop/src/App.tsx | 65 +- .../src/components/IdentityChangeProposal.tsx | 33 +- desktop/src/components/RightPanel.tsx | 26 +- desktop/src/components/SkillMarket.tsx | 12 +- .../WorkflowBuilder/NodePalette.tsx | 92 ++ .../WorkflowBuilder/PropertyPanel.tsx | 295 +++++ .../WorkflowBuilder/WorkflowBuilder.tsx | 324 +++++ .../WorkflowBuilder/WorkflowToolbar.tsx | 166 +++ .../src/components/WorkflowBuilder/index.ts | 21 + .../WorkflowBuilder/nodes/ConditionNode.tsx | 79 ++ .../WorkflowBuilder/nodes/ExportNode.tsx | 72 ++ .../WorkflowBuilder/nodes/HandNode.tsx | 74 ++ .../WorkflowBuilder/nodes/HttpNode.tsx | 81 ++ .../WorkflowBuilder/nodes/InputNode.tsx | 54 + .../WorkflowBuilder/nodes/LlmNode.tsx | 70 ++ .../nodes/OrchestrationNode.tsx | 81 ++ .../WorkflowBuilder/nodes/ParallelNode.tsx | 55 + .../WorkflowBuilder/nodes/SkillNode.tsx | 65 ++ desktop/src/constants/api-urls.ts | 81 ++ desktop/src/constants/hands.ts | 79 ++ desktop/src/constants/index.ts | 9 + desktop/src/constants/models.ts | 112 ++ desktop/src/lib/config-parser.ts | 7 +- desktop/src/lib/intelligence-backend.ts | 6 +- desktop/src/lib/intelligence-client.ts | 40 +- desktop/src/lib/kernel-client.ts | 246 ++++ desktop/src/lib/llm-service.ts | 6 +- desktop/src/lib/skill-adapter.ts | 22 +- desktop/src/lib/skill-discovery.ts | 218 ++-- desktop/src/lib/workflow-builder/index.ts | 11 + desktop/src/lib/workflow-builder/types.ts | 329 ++++++ .../lib/workflow-builder/yaml-converter.ts | 803 +++++++++++++ desktop/src/store/configStore.ts | 136 ++- desktop/src/store/connectionStore.ts | 4 + desktop/src/store/workflowBuilderStore.ts | 456 ++++++++ desktop/src/types/automation.ts | 17 +- .../00-architecture/01-communication-layer.md | 19 +- .../00-architecture/02-state-management.md | 61 +- .../02-intelligence-layer/00-agent-memory.md | 27 +- .../01-identity-evolution.md | 3 + .../03-reflection-engine.md | 13 +- .../04-heartbeat-engine.md | 106 +- .../05-autonomy-manager.md | 5 +- .../00-openviking-integration.md | 4 +- .../04-skills-ecosystem/00-skill-system.md | 100 +- .../01-intelligent-routing.md | 417 +++++++ .../05-hands-system/00-hands-overview.md | 34 +- .../00-backend-integration.md | 329 +++++- docs/features/VERIFICATION_REPORT.md | 321 +++++ docs/knowledge-base/troubleshooting.md | 174 +++ plans/abstract-weaving-crab.md | 714 +++++++++++ plans/abundant-frolicking-ember.md | 308 +++++ plans/cryptic-imagining-peach.md | 644 ++++++++++ plans/fancy-orbiting-meteor.md | 1039 +++++++++++++++++ plans/nifty-inventing-valiant.md | 57 + plans/polymorphic-orbiting-pinwheel.md | 149 +++ skills/README.md | 243 ++++ target/.rustc_info.json | 2 +- target/flycheck0/stderr | 231 +--- target/flycheck0/stdout | 1022 ++++++++-------- test.rs | 12 + 110 files changed, 12384 insertions(+), 1337 deletions(-) create mode 100644 crates/zclaw-pipeline/src/actions/orchestration.rs create mode 100644 crates/zclaw-skills/src/orchestration/auto_compose.rs create mode 100644 crates/zclaw-skills/src/orchestration/context.rs create mode 100644 crates/zclaw-skills/src/orchestration/executor.rs create mode 100644 crates/zclaw-skills/src/orchestration/mod.rs create mode 100644 crates/zclaw-skills/src/orchestration/planner.rs create mode 100644 crates/zclaw-skills/src/orchestration/types.rs create mode 100644 crates/zclaw-skills/src/orchestration/validation.rs create mode 100644 desktop/src/components/WorkflowBuilder/NodePalette.tsx create mode 100644 desktop/src/components/WorkflowBuilder/PropertyPanel.tsx create mode 100644 desktop/src/components/WorkflowBuilder/WorkflowBuilder.tsx create mode 100644 desktop/src/components/WorkflowBuilder/WorkflowToolbar.tsx create mode 100644 desktop/src/components/WorkflowBuilder/index.ts create mode 100644 desktop/src/components/WorkflowBuilder/nodes/ConditionNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/ExportNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/HandNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/HttpNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/InputNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/LlmNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/OrchestrationNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/ParallelNode.tsx create mode 100644 desktop/src/components/WorkflowBuilder/nodes/SkillNode.tsx create mode 100644 desktop/src/constants/api-urls.ts create mode 100644 desktop/src/constants/hands.ts create mode 100644 desktop/src/constants/index.ts create mode 100644 desktop/src/constants/models.ts create mode 100644 desktop/src/lib/workflow-builder/index.ts create mode 100644 desktop/src/lib/workflow-builder/types.ts create mode 100644 desktop/src/lib/workflow-builder/yaml-converter.ts create mode 100644 desktop/src/store/workflowBuilderStore.ts create mode 100644 docs/features/04-skills-ecosystem/01-intelligent-routing.md create mode 100644 docs/features/VERIFICATION_REPORT.md create mode 100644 plans/abstract-weaving-crab.md create mode 100644 plans/abundant-frolicking-ember.md create mode 100644 plans/cryptic-imagining-peach.md create mode 100644 plans/fancy-orbiting-meteor.md create mode 100644 plans/polymorphic-orbiting-pinwheel.md create mode 100644 skills/README.md create mode 100644 test.rs diff --git a/Cargo.lock b/Cargo.lock index 60601ba..4269d34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,6 +935,7 @@ dependencies = [ "zclaw-hands", "zclaw-kernel", "zclaw-memory", + "zclaw-pipeline", "zclaw-runtime", "zclaw-skills", "zclaw-types", @@ -4208,6 +4209,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5254,6 +5268,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5596,6 +5621,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -6875,6 +6906,31 @@ dependencies = [ "zclaw-types", ] +[[package]] +name = "zclaw-pipeline" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tracing", + "uuid", + "zclaw-hands", + "zclaw-kernel", + "zclaw-runtime", + "zclaw-skills", + "zclaw-types", +] + [[package]] name = "zclaw-protocols" version = "0.1.0" @@ -6919,11 +6975,13 @@ name = "zclaw-skills" version = "0.1.0" dependencies = [ "async-trait", + "regex", "serde", "serde_json", "thiserror 2.0.18", "tokio", "tracing", + "uuid", "zclaw-types", ] diff --git a/Cargo.toml b/Cargo.toml index 85fbe9a..e1fbe82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/zclaw-hands", "crates/zclaw-channels", "crates/zclaw-protocols", + "crates/zclaw-pipeline", # Desktop Application "desktop/src-tauri", ] @@ -92,6 +93,7 @@ zclaw-skills = { path = "crates/zclaw-skills" } zclaw-hands = { path = "crates/zclaw-hands" } zclaw-channels = { path = "crates/zclaw-channels" } zclaw-protocols = { path = "crates/zclaw-protocols" } +zclaw-pipeline = { path = "crates/zclaw-pipeline" } [profile.release] lto = true diff --git a/crates/zclaw-hands/src/hands/browser.rs b/crates/zclaw-hands/src/hands/browser.rs index dae9647..a9a59cf 100644 --- a/crates/zclaw-hands/src/hands/browser.rs +++ b/crates/zclaw-hands/src/hands/browser.rs @@ -132,8 +132,8 @@ impl BrowserHand { Self { config: HandConfig { id: "browser".to_string(), - name: "Browser".to_string(), - description: "Web browser automation for navigation, interaction, and scraping".to_string(), + name: "浏览器".to_string(), + description: "网页浏览器自动化,支持导航、交互和数据采集".to_string(), needs_approval: false, dependencies: vec!["webdriver".to_string()], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/clip.rs b/crates/zclaw-hands/src/hands/clip.rs index eb2c186..9231235 100644 --- a/crates/zclaw-hands/src/hands/clip.rs +++ b/crates/zclaw-hands/src/hands/clip.rs @@ -170,8 +170,8 @@ impl ClipHand { Self { config: HandConfig { id: "clip".to_string(), - name: "Clip".to_string(), - description: "Video processing and editing capabilities using FFmpeg".to_string(), + name: "视频剪辑".to_string(), + description: "使用 FFmpeg 进行视频处理和编辑".to_string(), needs_approval: false, dependencies: vec!["ffmpeg".to_string()], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/collector.rs b/crates/zclaw-hands/src/hands/collector.rs index ace14fe..b6c9e45 100644 --- a/crates/zclaw-hands/src/hands/collector.rs +++ b/crates/zclaw-hands/src/hands/collector.rs @@ -113,8 +113,8 @@ impl CollectorHand { Self { config: HandConfig { id: "collector".to_string(), - name: "Collector".to_string(), - description: "Data collection and aggregation from web sources".to_string(), + name: "数据采集器".to_string(), + description: "从网页源收集和聚合数据".to_string(), needs_approval: false, dependencies: vec!["network".to_string()], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/quiz.rs b/crates/zclaw-hands/src/hands/quiz.rs index 0c36033..275c7dd 100644 --- a/crates/zclaw-hands/src/hands/quiz.rs +++ b/crates/zclaw-hands/src/hands/quiz.rs @@ -261,8 +261,8 @@ impl QuizHand { Self { config: HandConfig { id: "quiz".to_string(), - name: "Quiz".to_string(), - description: "Generate and manage quizzes for assessment".to_string(), + name: "测验".to_string(), + description: "生成和管理测验题目,评估答案,提供反馈".to_string(), needs_approval: false, dependencies: vec![], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/researcher.rs b/crates/zclaw-hands/src/hands/researcher.rs index 5a36c0c..1a38853 100644 --- a/crates/zclaw-hands/src/hands/researcher.rs +++ b/crates/zclaw-hands/src/hands/researcher.rs @@ -142,8 +142,8 @@ impl ResearcherHand { Self { config: HandConfig { id: "researcher".to_string(), - name: "Researcher".to_string(), - description: "Deep research and analysis capabilities with web search and content fetching".to_string(), + name: "研究员".to_string(), + description: "深度研究和分析能力,支持网络搜索和内容获取".to_string(), needs_approval: false, dependencies: vec!["network".to_string()], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/slideshow.rs b/crates/zclaw-hands/src/hands/slideshow.rs index 030816e..325cbc1 100644 --- a/crates/zclaw-hands/src/hands/slideshow.rs +++ b/crates/zclaw-hands/src/hands/slideshow.rs @@ -156,8 +156,8 @@ impl SlideshowHand { Self { config: HandConfig { id: "slideshow".to_string(), - name: "Slideshow".to_string(), - description: "Control presentation slides and highlights".to_string(), + name: "幻灯片".to_string(), + description: "控制演示文稿的播放、导航和标注".to_string(), needs_approval: false, dependencies: vec![], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/speech.rs b/crates/zclaw-hands/src/hands/speech.rs index 9f53ffd..ea9e553 100644 --- a/crates/zclaw-hands/src/hands/speech.rs +++ b/crates/zclaw-hands/src/hands/speech.rs @@ -149,8 +149,8 @@ impl SpeechHand { Self { config: HandConfig { id: "speech".to_string(), - name: "Speech".to_string(), - description: "Text-to-speech synthesis for voice output".to_string(), + name: "语音合成".to_string(), + description: "文本转语音合成输出".to_string(), needs_approval: false, dependencies: vec![], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/twitter.rs b/crates/zclaw-hands/src/hands/twitter.rs index 4f37c45..93b14e6 100644 --- a/crates/zclaw-hands/src/hands/twitter.rs +++ b/crates/zclaw-hands/src/hands/twitter.rs @@ -205,8 +205,8 @@ impl TwitterHand { Self { config: HandConfig { id: "twitter".to_string(), - name: "Twitter".to_string(), - description: "Twitter/X automation capabilities for posting, searching, and managing content".to_string(), + name: "Twitter 自动化".to_string(), + description: "Twitter/X 自动化能力,发布、搜索和管理内容".to_string(), needs_approval: true, // Twitter actions need approval dependencies: vec!["twitter_api_key".to_string()], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-hands/src/hands/whiteboard.rs b/crates/zclaw-hands/src/hands/whiteboard.rs index 6733bb0..5e4483f 100644 --- a/crates/zclaw-hands/src/hands/whiteboard.rs +++ b/crates/zclaw-hands/src/hands/whiteboard.rs @@ -180,8 +180,8 @@ impl WhiteboardHand { Self { config: HandConfig { id: "whiteboard".to_string(), - name: "Whiteboard".to_string(), - description: "Draw and annotate on a virtual whiteboard".to_string(), + name: "白板".to_string(), + description: "在虚拟白板上绘制和标注".to_string(), needs_approval: false, dependencies: vec![], input_schema: Some(serde_json::json!({ diff --git a/crates/zclaw-kernel/src/capabilities.rs b/crates/zclaw-kernel/src/capabilities.rs index fd52254..b3aa2bf 100644 --- a/crates/zclaw-kernel/src/capabilities.rs +++ b/crates/zclaw-kernel/src/capabilities.rs @@ -1,7 +1,7 @@ //! Capability manager use dashmap::DashMap; -use zclaw_types::{AgentId, Capability, CapabilitySet, Result, ZclawError}; +use zclaw_types::{AgentId, Capability, CapabilitySet, Result}; /// Manages capabilities for all agents pub struct CapabilityManager { @@ -53,7 +53,7 @@ impl CapabilityManager { } /// Validate capabilities don't exceed parent's - pub fn validate(&self, capabilities: &[Capability]) -> Result<()> { + pub fn validate(&self, _capabilities: &[Capability]) -> Result<()> { // TODO: Implement capability validation Ok(()) } diff --git a/crates/zclaw-kernel/src/config.rs b/crates/zclaw-kernel/src/config.rs index 35320d1..fa9f761 100644 --- a/crates/zclaw-kernel/src/config.rs +++ b/crates/zclaw-kernel/src/config.rs @@ -157,11 +157,98 @@ impl Default for KernelConfig { } } -/// Default skills directory (./skills relative to cwd) +/// Default skills directory +/// +/// Discovery order: +/// 1. ZCLAW_SKILLS_DIR environment variable (if set) +/// 2. Compile-time known workspace path (CARGO_WORKSPACE_DIR or relative from manifest dir) +/// 3. Current working directory/skills (for development) +/// 4. Executable directory and multiple levels up (for packaged apps) fn default_skills_dir() -> Option { - std::env::current_dir() + // 1. Check environment variable override + if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") { + let path = std::path::PathBuf::from(&dir); + eprintln!("[default_skills_dir] ZCLAW_SKILLS_DIR env: {} (exists: {})", path.display(), path.exists()); + if path.exists() { + return Some(path); + } + // Even if it doesn't exist, respect the env var + return Some(path); + } + + // 2. Try compile-time known paths (works for cargo build/test) + // CARGO_MANIFEST_DIR is the crate directory (crates/zclaw-kernel) + // We need to go up to find the workspace root + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + eprintln!("[default_skills_dir] CARGO_MANIFEST_DIR: {}", manifest_dir.display()); + + // Go up from crates/zclaw-kernel to workspace root + if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) { + let workspace_skills = workspace_root.join("skills"); + eprintln!("[default_skills_dir] Workspace skills: {} (exists: {})", workspace_skills.display(), workspace_skills.exists()); + if workspace_skills.exists() { + return Some(workspace_skills); + } + } + + // 3. Try current working directory first (for development) + if let Ok(cwd) = std::env::current_dir() { + let cwd_skills = cwd.join("skills"); + eprintln!("[default_skills_dir] Checking cwd: {} (exists: {})", cwd_skills.display(), cwd_skills.exists()); + if cwd_skills.exists() { + return Some(cwd_skills); + } + + // Also try going up from cwd (might be in desktop/src-tauri) + let mut current = cwd.as_path(); + for i in 0..6 { + if let Some(parent) = current.parent() { + let parent_skills = parent.join("skills"); + eprintln!("[default_skills_dir] CWD Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists()); + if parent_skills.exists() { + return Some(parent_skills); + } + current = parent; + } else { + break; + } + } + } + + // 4. Try executable's directory and multiple levels up + if let Ok(exe) = std::env::current_exe() { + eprintln!("[default_skills_dir] Current exe: {}", exe.display()); + if let Some(exe_dir) = exe.parent().map(|p| p.to_path_buf()) { + // Same directory as exe + let exe_skills = exe_dir.join("skills"); + eprintln!("[default_skills_dir] Checking exe dir: {} (exists: {})", exe_skills.display(), exe_skills.exists()); + if exe_skills.exists() { + return Some(exe_skills); + } + + // Go up multiple levels to handle Tauri dev builds + let mut current = exe_dir.as_path(); + for i in 0..6 { + if let Some(parent) = current.parent() { + let parent_skills = parent.join("skills"); + eprintln!("[default_skills_dir] EXE Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists()); + if parent_skills.exists() { + return Some(parent_skills); + } + current = parent; + } else { + break; + } + } + } + } + + // 5. Fallback to current working directory/skills (may not exist) + let fallback = std::env::current_dir() .ok() - .map(|cwd| cwd.join("skills")) + .map(|cwd| cwd.join("skills")); + eprintln!("[default_skills_dir] Fallback to: {:?}", fallback); + fallback } impl KernelConfig { @@ -334,7 +421,7 @@ impl KernelConfig { Self { database_url: default_database_url(), llm, - skills_dir: None, + skills_dir: default_skills_dir(), } } } diff --git a/crates/zclaw-kernel/src/export/html.rs b/crates/zclaw-kernel/src/export/html.rs index 025e5e4..bca2c4a 100644 --- a/crates/zclaw-kernel/src/export/html.rs +++ b/crates/zclaw-kernel/src/export/html.rs @@ -10,7 +10,6 @@ use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction}; use super::{ExportOptions, ExportResult, Exporter, sanitize_filename}; use zclaw_types::Result; -use zclaw_types::ZclawError; /// HTML exporter pub struct HtmlExporter { diff --git a/crates/zclaw-kernel/src/export/pptx.rs b/crates/zclaw-kernel/src/export/pptx.rs index 93c7b36..c0879ca 100644 --- a/crates/zclaw-kernel/src/export/pptx.rs +++ b/crates/zclaw-kernel/src/export/pptx.rs @@ -10,7 +10,7 @@ //! without external dependencies. For more advanced features, consider using //! a dedicated library like `pptx-rs` or `office` crate. -use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction}; +use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneAction}; use super::{ExportOptions, ExportResult, Exporter, sanitize_filename}; use zclaw_types::{Result, ZclawError}; use std::collections::HashMap; @@ -211,7 +211,7 @@ impl PptxExporter { /// Generate title slide XML fn generate_title_slide(&self, classroom: &Classroom) -> String { - let objectives = classroom.objectives.iter() + let _objectives = classroom.objectives.iter() .map(|o| format!("- {}", o)) .collect::>() .join("\n"); diff --git a/crates/zclaw-kernel/src/generation.rs b/crates/zclaw-kernel/src/generation.rs index d2ed9d6..ae53e0c 100644 --- a/crates/zclaw-kernel/src/generation.rs +++ b/crates/zclaw-kernel/src/generation.rs @@ -9,9 +9,8 @@ use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; use futures::future::join_all; -use zclaw_types::{AgentId, Result, ZclawError}; +use zclaw_types::Result; use zclaw_runtime::{LlmDriver, CompletionRequest, CompletionResponse, ContentBlock}; -use zclaw_hands::{WhiteboardAction, SpeechAction, QuizAction}; /// Generation stage #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/zclaw-kernel/src/kernel.rs b/crates/zclaw-kernel/src/kernel.rs index 608d26f..5d162cd 100644 --- a/crates/zclaw-kernel/src/kernel.rs +++ b/crates/zclaw-kernel/src/kernel.rs @@ -132,38 +132,103 @@ impl Kernel { .map(|p| p.clone()) .unwrap_or_else(|| "You are a helpful AI assistant.".to_string()); - // Inject skill information + // Inject skill information with categories if !skills.is_empty() { prompt.push_str("\n\n## Available Skills\n\n"); - prompt.push_str("You have access to the following skills that can help with specific tasks. "); - prompt.push_str("Use the `execute_skill` tool with the skill_id to invoke them:\n\n"); + prompt.push_str("You have access to specialized skills. Analyze user intent and autonomously call `execute_skill` with the appropriate skill_id.\n\n"); - for skill in skills { - prompt.push_str(&format!( - "- **{}**: {}", - skill.id.as_str(), - skill.description - )); + // Group skills by category based on their ID patterns + let categories = self.categorize_skills(&skills); - // Add trigger words if available - if !skill.triggers.is_empty() { + for (category, category_skills) in categories { + prompt.push_str(&format!("### {}\n", category)); + for skill in category_skills { prompt.push_str(&format!( - " (Triggers: {})", - skill.triggers.join(", ") + "- **{}**: {}", + skill.id.as_str(), + skill.description )); + prompt.push('\n'); } prompt.push('\n'); } - prompt.push_str("\n### When to use skills:\n"); - prompt.push_str("- When the user's request matches a skill's trigger words\n"); - prompt.push_str("- When you need specialized expertise for a task\n"); - prompt.push_str("- When the task would benefit from a structured workflow\n"); + prompt.push_str("### When to use skills:\n"); + prompt.push_str("- **IMPORTANT**: You should autonomously decide when to use skills based on your understanding of the user's intent.\n"); + prompt.push_str("- Do not wait for explicit skill names - recognize the need and act.\n"); + prompt.push_str("- Match user's request to the most appropriate skill's domain.\n"); + prompt.push_str("- If multiple skills could apply, choose the most specialized one.\n\n"); + prompt.push_str("### Example:\n"); + prompt.push_str("User: \"分析腾讯财报\" → Intent: Financial analysis → Call: execute_skill(\"finance-tracker\", {...})\n"); } prompt } + /// Categorize skills into logical groups + /// + /// Priority: + /// 1. Use skill's `category` field if defined in SKILL.md + /// 2. Fall back to pattern matching for backward compatibility + fn categorize_skills<'a>(&self, skills: &'a [zclaw_skills::SkillManifest]) -> Vec<(String, Vec<&'a zclaw_skills::SkillManifest>)> { + let mut categories: std::collections::HashMap> = std::collections::HashMap::new(); + + // Fallback category patterns for skills without explicit category + let fallback_patterns = [ + ("开发工程", vec!["senior-developer", "frontend-developer", "backend-architect", "ai-engineer", "devops-automator", "rapid-prototyper", "lsp-index-engineer"]), + ("测试质量", vec!["api-tester", "evidence-collector", "reality-checker", "performance-benchmarker", "test-results-analyzer", "accessibility-auditor", "code-review"]), + ("安全合规", vec!["security-engineer", "legal-compliance-checker", "agentic-identity-trust"]), + ("数据分析", vec!["analytics-reporter", "finance-tracker", "data-analysis", "sales-data-extraction-agent", "data-consolidation-agent", "report-distribution-agent"]), + ("项目管理", vec!["senior-pm", "project-shepherd", "sprint-prioritizer", "experiment-tracker", "feedback-synthesizer", "trend-researcher", "agents-orchestrator"]), + ("设计UX", vec!["ui-designer", "ux-architect", "ux-researcher", "visual-storyteller", "image-prompt-engineer", "whimsy-injector", "brand-guardian"]), + ("内容营销", vec!["content-creator", "chinese-writing", "executive-summary-generator", "social-media-strategist"]), + ("社交平台", vec!["twitter-engager", "instagram-curator", "tiktok-strategist", "reddit-community-builder", "zhihu-strategist", "xiaohongshu-specialist", "wechat-official-account", "growth-hacker", "app-store-optimizer"]), + ("运营支持", vec!["studio-operations", "studio-producer", "support-responder", "workflow-optimizer", "infrastructure-maintainer", "tool-evaluator"]), + ("XR/空间计算", vec!["visionos-spatial-engineer", "macos-spatial-metal-engineer", "xr-immersive-developer", "xr-interface-architect", "xr-cockpit-interaction-specialist", "terminal-integration-specialist"]), + ("基础工具", vec!["web-search", "file-operations", "shell-command", "git", "translation", "feishu-docs"]), + ]; + + // Categorize each skill + for skill in skills { + // Priority 1: Use skill's explicit category + if let Some(ref category) = skill.category { + if !category.is_empty() { + categories.entry(category.clone()).or_default().push(skill); + continue; + } + } + + // Priority 2: Fallback to pattern matching + let skill_id = skill.id.as_str(); + let mut categorized = false; + + for (category, patterns) in &fallback_patterns { + if patterns.iter().any(|p| skill_id.contains(p) || *p == skill_id) { + categories.entry(category.to_string()).or_default().push(skill); + categorized = true; + break; + } + } + + // Put uncategorized skills in "其他" + if !categorized { + categories.entry("其他".to_string()).or_default().push(skill); + } + } + + // Convert to ordered vector + let mut result: Vec<(String, Vec<_>)> = categories.into_iter().collect(); + result.sort_by(|a, b| { + // Sort by predefined order + let order = ["开发工程", "测试质量", "安全合规", "数据分析", "项目管理", "设计UX", "内容营销", "社交平台", "运营支持", "XR/空间计算", "基础工具", "其他"]; + let a_idx = order.iter().position(|&x| x == a.0).unwrap_or(99); + let b_idx = order.iter().position(|&x| x == b.0).unwrap_or(99); + a_idx.cmp(&b_idx) + }); + + result + } + /// Spawn a new agent pub async fn spawn_agent(&self, config: AgentConfig) -> Result { let id = config.id; diff --git a/crates/zclaw-kernel/src/lib.rs b/crates/zclaw-kernel/src/lib.rs index e1c3619..6a13573 100644 --- a/crates/zclaw-kernel/src/lib.rs +++ b/crates/zclaw-kernel/src/lib.rs @@ -19,3 +19,6 @@ pub use config::*; pub use director::*; pub use generation::*; pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom}; + +// Re-export hands types for convenience +pub use zclaw_hands::{HandRegistry, HandContext, HandResult, HandConfig, Hand, HandStatus}; diff --git a/crates/zclaw-pipeline/src/actions/mod.rs b/crates/zclaw-pipeline/src/actions/mod.rs index 5aab153..323a2cc 100644 --- a/crates/zclaw-pipeline/src/actions/mod.rs +++ b/crates/zclaw-pipeline/src/actions/mod.rs @@ -9,6 +9,7 @@ mod export; mod http; mod skill; mod hand; +mod orchestration; pub use llm::*; pub use parallel::*; @@ -17,6 +18,7 @@ pub use export::*; pub use http::*; pub use skill::*; pub use hand::*; +pub use orchestration::*; use std::collections::HashMap; use std::sync::Arc; @@ -57,6 +59,9 @@ pub enum ActionError { #[error("Invalid input: {0}")] InvalidInput(String), + + #[error("Orchestration error: {0}")] + Orchestration(String), } /// Action registry - holds references to all action executors @@ -70,6 +75,9 @@ pub struct ActionRegistry { /// Hand registry (injected from kernel) hand_registry: Option>, + /// Orchestration driver (injected from kernel) + orchestration_driver: Option>, + /// Template directory template_dir: Option, } @@ -81,6 +89,7 @@ impl ActionRegistry { llm_driver: None, skill_registry: None, hand_registry: None, + orchestration_driver: None, template_dir: None, } } @@ -103,6 +112,12 @@ impl ActionRegistry { self } + /// Set orchestration driver + pub fn with_orchestration_driver(mut self, driver: Arc) -> Self { + self.orchestration_driver = Some(driver); + self + } + /// Set template directory pub fn with_template_dir(mut self, dir: std::path::PathBuf) -> Self { self.template_dir = Some(dir); @@ -166,6 +181,22 @@ impl ActionRegistry { } } + /// Execute a skill orchestration + pub async fn execute_orchestration( + &self, + graph_id: Option<&str>, + graph: Option<&Value>, + input: HashMap, + ) -> Result { + if let Some(driver) = &self.orchestration_driver { + driver.execute(graph_id, graph, input) + .await + .map_err(ActionError::Orchestration) + } else { + Err(ActionError::Orchestration("Orchestration driver not configured".to_string())) + } + } + /// Render classroom pub async fn render_classroom(&self, data: &Value) -> Result { // This will integrate with the classroom renderer @@ -377,3 +408,14 @@ pub trait HandActionDriver: Send + Sync { params: HashMap, ) -> Result; } + +/// Orchestration action driver trait +#[async_trait] +pub trait OrchestrationActionDriver: Send + Sync { + async fn execute( + &self, + graph_id: Option<&str>, + graph: Option<&Value>, + input: HashMap, + ) -> Result; +} diff --git a/crates/zclaw-pipeline/src/actions/orchestration.rs b/crates/zclaw-pipeline/src/actions/orchestration.rs new file mode 100644 index 0000000..1dd3f53 --- /dev/null +++ b/crates/zclaw-pipeline/src/actions/orchestration.rs @@ -0,0 +1,61 @@ +//! Skill orchestration action +//! +//! Executes skill graphs (DAGs) with data passing and parallel execution. + +use std::collections::HashMap; +use std::sync::Arc; +use serde_json::Value; +use async_trait::async_trait; + +use super::OrchestrationActionDriver; + +/// Orchestration driver that uses the skill orchestration engine +pub struct SkillOrchestrationDriver { + /// Skill registry for executing skills + skill_registry: Arc, +} + +impl SkillOrchestrationDriver { + /// Create a new orchestration driver + pub fn new(skill_registry: Arc) -> Self { + Self { skill_registry } + } +} + +#[async_trait] +impl OrchestrationActionDriver for SkillOrchestrationDriver { + async fn execute( + &self, + graph_id: Option<&str>, + graph: Option<&Value>, + input: HashMap, + ) -> Result { + use zclaw_skills::orchestration::{SkillGraph, DefaultExecutor, SkillGraphExecutor}; + + // Load or parse the graph + let skill_graph = if let Some(graph_value) = graph { + // Parse inline graph definition + serde_json::from_value::(graph_value.clone()) + .map_err(|e| format!("Failed to parse graph: {}", e))? + } else if let Some(id) = graph_id { + // Load graph from registry (TODO: implement graph storage) + return Err(format!("Graph loading by ID not yet implemented: {}", id)); + } else { + return Err("Either graph_id or graph must be provided".to_string()); + }; + + // Create executor + let executor = DefaultExecutor::new(self.skill_registry.clone()); + + // Create skill context with default values + let context = zclaw_skills::SkillContext::default(); + + // Execute the graph + let result = executor.execute(&skill_graph, input, &context) + .await + .map_err(|e| format!("Orchestration execution failed: {}", e))?; + + // Return the output + Ok(result.output) + } +} diff --git a/crates/zclaw-pipeline/src/executor.rs b/crates/zclaw-pipeline/src/executor.rs index 941d99e..d6d1668 100644 --- a/crates/zclaw-pipeline/src/executor.rs +++ b/crates/zclaw-pipeline/src/executor.rs @@ -281,6 +281,16 @@ impl PipelineExecutor { tokio::time::sleep(tokio::time::Duration::from_millis(*ms)).await; Ok(Value::Null) } + + Action::SkillOrchestration { graph_id, graph, input } => { + let resolved_input = context.resolve_map(input)?; + self.action_registry.execute_orchestration( + graph_id.as_deref(), + graph.as_ref(), + resolved_input, + ).await + .map_err(|e| ExecuteError::Action(e.to_string())) + } } }.boxed() } diff --git a/crates/zclaw-pipeline/src/types.rs b/crates/zclaw-pipeline/src/types.rs index 1a98d33..be4dbb8 100644 --- a/crates/zclaw-pipeline/src/types.rs +++ b/crates/zclaw-pipeline/src/types.rs @@ -326,6 +326,19 @@ pub enum Action { /// Duration in milliseconds ms: u64, }, + + /// Skill orchestration - execute multiple skills in a DAG + SkillOrchestration { + /// Graph ID (reference to a pre-defined graph) or inline definition + graph_id: Option, + + /// Inline graph definition (alternative to graph_id) + graph: Option, + + /// Input variables + #[serde(default)] + input: HashMap, + }, } fn default_http_method() -> String { diff --git a/crates/zclaw-runtime/src/driver/gemini.rs b/crates/zclaw-runtime/src/driver/gemini.rs index 9003ee6..1d5fe79 100644 --- a/crates/zclaw-runtime/src/driver/gemini.rs +++ b/crates/zclaw-runtime/src/driver/gemini.rs @@ -1,7 +1,7 @@ //! Google Gemini driver implementation use async_trait::async_trait; -use futures::{Stream, StreamExt}; +use futures::Stream; use secrecy::{ExposeSecret, SecretString}; use reqwest::Client; use std::pin::Pin; diff --git a/crates/zclaw-runtime/src/driver/local.rs b/crates/zclaw-runtime/src/driver/local.rs index d7234c3..d03d1c6 100644 --- a/crates/zclaw-runtime/src/driver/local.rs +++ b/crates/zclaw-runtime/src/driver/local.rs @@ -1,7 +1,7 @@ //! Local LLM driver (Ollama, LM Studio, vLLM, etc.) use async_trait::async_trait; -use futures::{Stream, StreamExt}; +use futures::Stream; use reqwest::Client; use std::pin::Pin; use zclaw_types::{Result, ZclawError}; diff --git a/crates/zclaw-runtime/src/driver/openai.rs b/crates/zclaw-runtime/src/driver/openai.rs index 84d5b53..dbdb861 100644 --- a/crates/zclaw-runtime/src/driver/openai.rs +++ b/crates/zclaw-runtime/src/driver/openai.rs @@ -499,7 +499,15 @@ impl OpenAiDriver { eprintln!("[OpenAiDriver:stream_from_complete] Got response with {} choices", api_response.choices.len()); if let Some(choice) = api_response.choices.first() { eprintln!("[OpenAiDriver:stream_from_complete] First choice: content={:?}, tool_calls={:?}, finish_reason={:?}", - choice.message.content.as_ref().map(|c| if c.len() > 100 { &c[..100] } else { c.as_str() }), + choice.message.content.as_ref().map(|c| { + if c.len() > 100 { + // 使用 floor_char_boundary 确保不在多字节字符中间截断 + let end = c.floor_char_boundary(100); + &c[..end] + } else { + c.as_str() + } + }), choice.message.tool_calls.as_ref().map(|tc| tc.len()), choice.finish_reason); } diff --git a/crates/zclaw-runtime/src/loop_runner.rs b/crates/zclaw-runtime/src/loop_runner.rs index 6099c42..f38f53b 100644 --- a/crates/zclaw-runtime/src/loop_runner.rs +++ b/crates/zclaw-runtime/src/loop_runner.rs @@ -94,78 +94,110 @@ impl AgentLoop { } /// Run the agent loop with a single message + /// Implements complete agent loop: LLM → Tool Call → Tool Result → LLM → Final Response pub async fn run(&self, session_id: SessionId, input: String) -> Result { // Add user message to session let user_message = Message::user(input); self.memory.append_message(&session_id, &user_message).await?; // Get all messages for context - let messages = self.memory.get_messages(&session_id).await?; + let mut messages = self.memory.get_messages(&session_id).await?; - // Build completion request with configured model - let request = CompletionRequest { - model: self.model.clone(), - system: self.system_prompt.clone(), - messages, - tools: self.tools.definitions(), - max_tokens: Some(self.max_tokens), - temperature: Some(self.temperature), - stop: Vec::new(), - stream: false, - }; + let max_iterations = 10; + let mut iterations = 0; + let mut total_input_tokens = 0u32; + let mut total_output_tokens = 0u32; - // Call LLM - let response = self.driver.complete(request).await?; - - // Create tool context - let tool_context = self.create_tool_context(session_id.clone()); - - // 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)); - } + loop { + iterations += 1; + if iterations > max_iterations { + // Save the state before returning + let error_msg = "达到最大迭代次数,请简化请求"; + self.memory.append_message(&session_id, &Message::assistant(error_msg)).await?; + return Ok(AgentLoopResult { + response: error_msg.to_string(), + input_tokens: total_input_tokens, + output_tokens: total_output_tokens, + iterations, + }); } + + // Build completion request + let request = CompletionRequest { + model: self.model.clone(), + system: self.system_prompt.clone(), + messages: messages.clone(), + tools: self.tools.definitions(), + max_tokens: Some(self.max_tokens), + temperature: Some(self.temperature), + stop: Vec::new(), + stream: false, + }; + + // Call LLM + let response = self.driver.complete(request).await?; + total_input_tokens += response.input_tokens; + total_output_tokens += response.output_tokens; + + // Extract tool calls from response + let tool_calls: Vec<(String, String, serde_json::Value)> = response.content.iter() + .filter_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => Some((id.clone(), name.clone(), input.clone())), + _ => None, + }) + .collect(); + + // If no tool calls, we have the final response + if tool_calls.is_empty() { + // Extract text content + let text = response.content.iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.clone()), + ContentBlock::Thinking { thinking } => Some(format!("[思考] {}", thinking)), + _ => None, + }) + .collect::>() + .join("\n"); + + // Save final assistant message + self.memory.append_message(&session_id, &Message::assistant(&text)).await?; + + return Ok(AgentLoopResult { + response: text, + input_tokens: total_input_tokens, + output_tokens: total_output_tokens, + iterations, + }); + } + + // There are tool calls - add assistant message with tool calls to history + for (id, name, input) in &tool_calls { + messages.push(Message::tool_use(id, zclaw_types::ToolId::new(name), input.clone())); + } + + // Create tool context and execute all tools + let tool_context = self.create_tool_context(session_id.clone()); + for (id, name, input) in tool_calls { + let tool_result = match self.execute_tool(&name, input, &tool_context).await { + Ok(result) => result, + Err(e) => serde_json::json!({ "error": e.to_string() }), + }; + + // Add tool result to messages + messages.push(Message::tool_result( + id, + zclaw_types::ToolId::new(&name), + tool_result, + false, // is_error - we include errors in the result itself + )); + } + + // Continue the loop - LLM will process tool results and generate final response } - - // 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: 1, - }) } /// Run the agent loop with streaming + /// Implements complete agent loop with multi-turn tool calling support pub async fn run_streaming( &self, session_id: SessionId, @@ -180,18 +212,6 @@ impl AgentLoop { // Get all messages for context let messages = self.memory.get_messages(&session_id).await?; - // Build completion request - let request = CompletionRequest { - model: self.model.clone(), - system: self.system_prompt.clone(), - messages, - tools: self.tools.definitions(), - max_tokens: Some(self.max_tokens), - temperature: Some(self.temperature), - stop: Vec::new(), - stream: true, - }; - // Clone necessary data for the async task let session_id_clone = session_id.clone(); let memory = self.memory.clone(); @@ -199,116 +219,170 @@ impl AgentLoop { let tools = self.tools.clone(); let skill_executor = self.skill_executor.clone(); let agent_id = self.agent_id.clone(); + let system_prompt = self.system_prompt.clone(); + let model = self.model.clone(); + let max_tokens = self.max_tokens; + let temperature = self.temperature; 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 messages = messages; + let max_iterations = 10; + let mut iteration = 0; + let mut total_input_tokens = 0u32; + let mut total_output_tokens = 0u32; - let mut stream = driver.stream(request); - - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - // Track response and tokens - match &chunk { - StreamChunk::TextDelta { delta } => { - full_response.push_str(delta); - let _ = tx.send(LoopEvent::Delta(delta.clone())).await; - } - StreamChunk::ThinkingDelta { delta } => { - let _ = tx.send(LoopEvent::Delta(format!("[思考] {}", delta))).await; - } - 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 { 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 { 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; - output_tokens = *ot; - } - StreamChunk::Error { message } => { - let _ = tx.send(LoopEvent::Error(message.clone())).await; - } - } - } - Err(e) => { - let _ = tx.send(LoopEvent::Error(e.to_string())).await; - } + 'outer: loop { + iteration += 1; + if iteration > max_iterations { + let _ = tx.send(LoopEvent::Error("达到最大迭代次数".to_string())).await; + break; } - } - // 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(), + // Notify iteration start + let _ = tx.send(LoopEvent::IterationStart { + iteration, + max_iterations, + }).await; + + // Build completion request + let request = CompletionRequest { + model: model.clone(), + system: system_prompt.clone(), + messages: messages.clone(), + tools: tools.definitions(), + max_tokens: Some(max_tokens), + temperature: Some(temperature), + stop: Vec::new(), + stream: true, }; - // 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 + let mut stream = driver.stream(request); + let mut pending_tool_calls: Vec<(String, String, serde_json::Value)> = Vec::new(); + let mut iteration_text = String::new(); + + // Process stream chunks + tracing::debug!("[AgentLoop] Starting to process stream chunks"); + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + match &chunk { + StreamChunk::TextDelta { delta } => { + iteration_text.push_str(delta); + let _ = tx.send(LoopEvent::Delta(delta.clone())).await; + } + StreamChunk::ThinkingDelta { delta } => { + let _ = tx.send(LoopEvent::Delta(format!("[思考] {}", delta))).await; + } + StreamChunk::ToolUseStart { id, name } => { + tracing::debug!("[AgentLoop] ToolUseStart: id={}, name={}", id, name); + pending_tool_calls.push((id.clone(), name.clone(), serde_json::Value::Null)); + } + StreamChunk::ToolUseDelta { id, delta } => { + // Accumulate tool input delta (internal processing, not sent to user) + if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) { + // Try to accumulate JSON string + match &mut tool.2 { + serde_json::Value::String(s) => s.push_str(delta), + serde_json::Value::Null => tool.2 = serde_json::Value::String(delta.clone()), + _ => {} + } + } + } + StreamChunk::ToolUseEnd { id, input } => { + tracing::debug!("[AgentLoop] ToolUseEnd: id={}, input={:?}", id, input); + // Update with final parsed input and emit ToolStart event + if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) { + tool.2 = input.clone(); + let _ = tx.send(LoopEvent::ToolStart { name: tool.1.clone(), input: input.clone() }).await; + } + } + StreamChunk::Complete { input_tokens: it, output_tokens: ot, .. } => { + tracing::debug!("[AgentLoop] Stream complete: input_tokens={}, output_tokens={}", it, ot); + total_input_tokens += *it; + total_output_tokens += *ot; + } + StreamChunk::Error { message } => { + tracing::error!("[AgentLoop] Stream error: {}", message); + let _ = tx.send(LoopEvent::Error(message.clone())).await; + } + } } 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 + tracing::error!("[AgentLoop] Chunk error: {}", e); + let _ = tx.send(LoopEvent::Error(e.to_string())).await; } } - } 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 - }; + } + tracing::debug!("[AgentLoop] Stream ended, pending_tool_calls count: {}", pending_tool_calls.len()); - full_response.push_str(&format!("\n[工具 {} 结果]: {}", name, serde_json::to_string(&result).unwrap_or_default())); + // If no tool calls, we have the final response + if pending_tool_calls.is_empty() { + tracing::debug!("[AgentLoop] No tool calls, returning final response"); + // Save final assistant message + let _ = memory.append_message(&session_id_clone, &Message::assistant(&iteration_text)).await; + + let _ = tx.send(LoopEvent::Complete(AgentLoopResult { + response: iteration_text, + input_tokens: total_input_tokens, + output_tokens: total_output_tokens, + iterations: iteration, + })).await; + break 'outer; + } + + tracing::debug!("[AgentLoop] Processing {} tool calls", pending_tool_calls.len()); + + // There are tool calls - add to message history + for (id, name, input) in &pending_tool_calls { + tracing::debug!("[AgentLoop] Adding tool_use to history: id={}, name={}, input={:?}", id, name, input); + messages.push(Message::tool_use(id, zclaw_types::ToolId::new(name), input.clone())); + } + + // Execute tools + for (id, name, input) in pending_tool_calls { + tracing::debug!("[AgentLoop] Executing tool: name={}, input={:?}", name, input); + 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(), + }; + + let (result, is_error) = if let Some(tool) = tools.get(&name) { + tracing::debug!("[AgentLoop] Tool '{}' found, executing...", name); + match tool.execute(input.clone(), &tool_context).await { + Ok(output) => { + tracing::debug!("[AgentLoop] Tool '{}' executed successfully: {:?}", name, output); + let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await; + (output, false) + } + Err(e) => { + tracing::error!("[AgentLoop] Tool '{}' execution failed: {}", name, e); + let error_output = serde_json::json!({ "error": e.to_string() }); + let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; + (error_output, true) + } + } + } else { + tracing::error!("[AgentLoop] Tool '{}' not found in registry", name); + let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) }); + let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; + (error_output, true) + }; + + // Add tool result to message history + tracing::debug!("[AgentLoop] Adding tool_result to history: id={}, name={}, is_error={}", id, name, is_error); + messages.push(Message::tool_result( + id, + zclaw_types::ToolId::new(&name), + result, + is_error, + )); + } + + tracing::debug!("[AgentLoop] Continuing to next iteration for LLM to process tool results"); + // Continue loop - next iteration will call LLM with tool results } - - // Save assistant message to memory - let assistant_message = Message::assistant(full_response.clone()); - let _ = memory.append_message(&session_id_clone, &assistant_message).await; - - // Send completion event - let _ = tx.send(LoopEvent::Complete(AgentLoopResult { - response: full_response, - input_tokens, - output_tokens, - iterations: 1, - })).await; }); Ok(rx) @@ -327,9 +401,16 @@ pub struct AgentLoopResult { /// Events emitted during streaming #[derive(Debug, Clone)] pub enum LoopEvent { + /// Text delta from LLM Delta(String), + /// Tool execution started ToolStart { name: String, input: serde_json::Value }, + /// Tool execution completed ToolEnd { name: String, output: serde_json::Value }, + /// New iteration started (multi-turn tool calling) + IterationStart { iteration: usize, max_iterations: usize }, + /// Loop completed with final result Complete(AgentLoopResult), + /// Error occurred Error(String), } diff --git a/crates/zclaw-runtime/src/tool/builtin/file_write.rs b/crates/zclaw-runtime/src/tool/builtin/file_write.rs index 48fd202..f3d7807 100644 --- a/crates/zclaw-runtime/src/tool/builtin/file_write.rs +++ b/crates/zclaw-runtime/src/tool/builtin/file_write.rs @@ -42,7 +42,7 @@ impl Tool for FileWriteTool { } async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let path = input["path"].as_str() + let _path = input["path"].as_str() .ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?; let content = input["content"].as_str() .ok_or_else(|| ZclawError::InvalidInput("Missing 'content' parameter".into()))?; diff --git a/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs b/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs index 7802a0c..807d911 100644 --- a/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs +++ b/crates/zclaw-runtime/src/tool/builtin/shell_exec.rs @@ -1,10 +1,9 @@ //! Shell execution tool with security controls use async_trait::async_trait; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashSet; -use std::io::{Read, Write}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use zclaw_types::{Result, ZclawError}; diff --git a/crates/zclaw-skills/Cargo.toml b/crates/zclaw-skills/Cargo.toml index 08cdf2c..d00847c 100644 --- a/crates/zclaw-skills/Cargo.toml +++ b/crates/zclaw-skills/Cargo.toml @@ -16,3 +16,5 @@ serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +regex = { workspace = true } +uuid = { workspace = true } diff --git a/crates/zclaw-skills/src/lib.rs b/crates/zclaw-skills/src/lib.rs index a602b02..fb169ce 100644 --- a/crates/zclaw-skills/src/lib.rs +++ b/crates/zclaw-skills/src/lib.rs @@ -7,6 +7,8 @@ mod runner; mod loader; mod registry; +pub mod orchestration; + pub use skill::*; pub use runner::*; pub use loader::*; diff --git a/crates/zclaw-skills/src/loader.rs b/crates/zclaw-skills/src/loader.rs index 39b79e4..ad3c467 100644 --- a/crates/zclaw-skills/src/loader.rs +++ b/crates/zclaw-skills/src/loader.rs @@ -42,6 +42,7 @@ pub fn parse_skill_md(content: &str) -> Result { let mut capabilities = Vec::new(); let mut tags = Vec::new(); let mut triggers = Vec::new(); + let mut category: Option = None; let mut in_triggers_list = false; // Parse frontmatter if present @@ -62,6 +63,12 @@ pub fn parse_skill_md(content: &str) -> Result { in_triggers_list = false; } + // Parse category field + if let Some(cat) = line.strip_prefix("category:") { + category = Some(cat.trim().trim_matches('"').to_string()); + continue; + } + if let Some((key, value)) = line.split_once(':') { let key = key.trim(); let value = value.trim().trim_matches('"'); @@ -158,6 +165,7 @@ pub fn parse_skill_md(content: &str) -> Result { input_schema: None, output_schema: None, tags, + category, triggers, enabled: true, }) @@ -181,6 +189,7 @@ pub fn parse_skill_toml(content: &str) -> Result { let mut mode = "prompt_only".to_string(); let mut capabilities = Vec::new(); let mut tags = Vec::new(); + let mut category: Option = None; let mut triggers = Vec::new(); for line in content.lines() { @@ -219,6 +228,9 @@ pub fn parse_skill_toml(content: &str) -> Result { .filter(|s| !s.is_empty()) .collect(); } + "category" => { + category = Some(value.to_string()); + } _ => {} } } @@ -245,6 +257,7 @@ pub fn parse_skill_toml(content: &str) -> Result { input_schema: None, output_schema: None, tags, + category, triggers, enabled: true, }) diff --git a/crates/zclaw-skills/src/orchestration/auto_compose.rs b/crates/zclaw-skills/src/orchestration/auto_compose.rs new file mode 100644 index 0000000..165fe83 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/auto_compose.rs @@ -0,0 +1,380 @@ +//! 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 { + // 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> { + 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 { + 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> = 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 = 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 = 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::>() + .join(" → ")), + description: format!("Automatically composed from skills: {}", + skill_ids.iter() + .map(|id| id.to_string()) + .collect::>() + .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> { + 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, + input_types: &HashSet, + ) -> 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>, + /// Input types for each skill + input_types: HashMap>, + /// Capabilities for each skill + capabilities: HashMap>, +} + +/// 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, + /// Fixed edges between slots + pub edges: Vec, +} + +/// 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, + /// Expected input schema + pub input_schema: Option, + /// Expected output schema + pub output_schema: Option, +} + +/// 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, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_types() { + let composer = AutoComposer { + registry: unsafe { &*(&SkillRegistry::new() as *const _) }, + }; + + 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")); + } +} diff --git a/crates/zclaw-skills/src/orchestration/context.rs b/crates/zclaw-skills/src/orchestration/context.rs new file mode 100644 index 0000000..cdac966 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/context.rs @@ -0,0 +1,255 @@ +//! Orchestration context +//! +//! Manages execution state, data resolution, and expression evaluation +//! during skill graph execution. + +use std::collections::HashMap; +use serde_json::Value; +use regex::Regex; + +use super::{SkillGraph, DataExpression}; + +/// Orchestration execution context +#[derive(Debug, Clone)] +pub struct OrchestrationContext { + /// Graph being executed + pub graph_id: String, + /// Input values + pub inputs: HashMap, + /// Outputs from completed nodes: node_id -> output + pub node_outputs: HashMap, + /// Custom variables + pub variables: HashMap, + /// Expression parser regex + expr_regex: Regex, +} + +impl OrchestrationContext { + /// Create a new execution context + pub fn new(graph: &SkillGraph, inputs: HashMap) -> Self { + Self { + graph_id: graph.id.clone(), + inputs, + node_outputs: HashMap::new(), + variables: HashMap::new(), + expr_regex: Regex::new(r"\$\{([^}]+)\}").unwrap(), + } + } + + /// Set a node's output + pub fn set_node_output(&mut self, node_id: &str, output: Value) { + self.node_outputs.insert(node_id.to_string(), output); + } + + /// Set a variable + pub fn set_variable(&mut self, name: &str, value: Value) { + self.variables.insert(name.to_string(), value); + } + + /// Get a variable + pub fn get_variable(&self, name: &str) -> Option<&Value> { + self.variables.get(name) + } + + /// Resolve all input mappings for a node + pub fn resolve_node_input( + &self, + node: &super::SkillNode, + ) -> Value { + let mut input = serde_json::Map::new(); + + for (field, expr_str) in &node.input_mappings { + if let Some(value) = self.resolve_expression(expr_str) { + input.insert(field.clone(), value); + } + } + + Value::Object(input) + } + + /// Resolve an expression to a value + pub fn resolve_expression(&self, expr: &str) -> Option { + let expr = expr.trim(); + + // Parse expression type + if let Some(parsed) = DataExpression::parse(expr) { + match parsed { + DataExpression::InputRef { field } => { + self.inputs.get(&field).cloned() + } + DataExpression::NodeOutputRef { node_id, field } => { + self.get_node_field(&node_id, &field) + } + DataExpression::Literal { value } => { + Some(value) + } + DataExpression::Expression { template } => { + self.evaluate_template(&template) + } + } + } else { + // Return as string literal + Some(Value::String(expr.to_string())) + } + } + + /// Get a field from a node's output + pub fn get_node_field(&self, node_id: &str, field: &str) -> Option { + let output = self.node_outputs.get(node_id)?; + + if field.is_empty() { + return Some(output.clone()); + } + + // Navigate nested fields + let parts: Vec<&str> = field.split('.').collect(); + let mut current = output; + + for part in parts { + match current { + Value::Object(map) => { + current = map.get(part)?; + } + Value::Array(arr) => { + if let Ok(idx) = part.parse::() { + current = arr.get(idx)?; + } else { + return None; + } + } + _ => return None, + } + } + + Some(current.clone()) + } + + /// Evaluate a template expression with variable substitution + pub fn evaluate_template(&self, template: &str) -> Option { + let result = self.expr_regex.replace_all(template, |caps: ®ex::Captures| { + let expr = &caps[1]; + if let Some(value) = self.resolve_expression(&format!("${{{}}}", expr)) { + value.as_str().unwrap_or(&value.to_string()).to_string() + } else { + caps[0].to_string() // Keep original if not resolved + } + }); + + Some(Value::String(result.to_string())) + } + + /// Evaluate a condition expression + pub fn evaluate_condition(&self, condition: &str) -> Option { + // Simple condition evaluation + // Supports: ${var} == "value", ${var} != "value", ${var} exists + + let condition = condition.trim(); + + // Check for equality + if let Some((left, right)) = condition.split_once("==") { + let left = self.resolve_expression(left.trim())?; + let right = self.resolve_expression(right.trim())?; + return Some(left == right); + } + + // Check for inequality + if let Some((left, right)) = condition.split_once("!=") { + let left = self.resolve_expression(left.trim())?; + let right = self.resolve_expression(right.trim())?; + return Some(left != right); + } + + // Check for existence + if condition.ends_with(" exists") { + let expr = condition.replace(" exists", ""); + let expr = expr.trim(); + return Some(self.resolve_expression(expr).is_some()); + } + + // Try to resolve as boolean + if let Some(value) = self.resolve_expression(condition) { + if let Some(b) = value.as_bool() { + return Some(b); + } + } + + None + } + + /// Build the final output using output mapping + pub fn build_output(&self, mapping: &HashMap) -> Value { + let mut output = serde_json::Map::new(); + + for (field, expr) in mapping { + if let Some(value) = self.resolve_expression(expr) { + output.insert(field.clone(), value); + } + } + + Value::Object(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_context() -> OrchestrationContext { + let graph = SkillGraph { + id: "test".to_string(), + name: "Test".to_string(), + description: String::new(), + nodes: vec![], + edges: vec![], + input_schema: None, + output_mapping: HashMap::new(), + on_error: Default::default(), + timeout_secs: 300, + }; + + let mut inputs = HashMap::new(); + inputs.insert("topic".to_string(), serde_json::json!("AI research")); + + let mut ctx = OrchestrationContext::new(&graph, inputs); + ctx.set_node_output("research", serde_json::json!({ + "content": "AI is transforming industries", + "sources": ["source1", "source2"] + })); + ctx + } + + #[test] + fn test_resolve_input_ref() { + let ctx = make_context(); + let value = ctx.resolve_expression("${inputs.topic}").unwrap(); + assert_eq!(value.as_str().unwrap(), "AI research"); + } + + #[test] + fn test_resolve_node_output_ref() { + let ctx = make_context(); + let value = ctx.resolve_expression("${nodes.research.output.content}").unwrap(); + assert_eq!(value.as_str().unwrap(), "AI is transforming industries"); + } + + #[test] + fn test_evaluate_condition_equality() { + let ctx = make_context(); + let result = ctx.evaluate_condition("${inputs.topic} == \"AI research\"").unwrap(); + assert!(result); + } + + #[test] + fn test_build_output() { + let ctx = make_context(); + let mapping = vec![ + ("summary".to_string(), "${nodes.research.output.content}".to_string()), + ].into_iter().collect(); + + let output = ctx.build_output(&mapping); + assert_eq!( + output.get("summary").unwrap().as_str().unwrap(), + "AI is transforming industries" + ); + } +} diff --git a/crates/zclaw-skills/src/orchestration/executor.rs b/crates/zclaw-skills/src/orchestration/executor.rs new file mode 100644 index 0000000..36009d3 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/executor.rs @@ -0,0 +1,319 @@ +//! Orchestration executor +//! +//! Executes skill graphs with parallel execution, data passing, +//! error handling, and progress tracking. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use serde_json::Value; +use zclaw_types::Result; + +use crate::{SkillRegistry, SkillContext}; +use super::{ + SkillGraph, OrchestrationPlan, OrchestrationResult, NodeResult, + OrchestrationProgress, ErrorStrategy, OrchestrationContext, + planner::OrchestrationPlanner, +}; + +/// Skill graph executor trait +#[async_trait::async_trait] +pub trait SkillGraphExecutor: Send + Sync { + /// Execute a skill graph with given inputs + async fn execute( + &self, + graph: &SkillGraph, + inputs: HashMap, + context: &SkillContext, + ) -> Result; + + /// Execute with progress callback + async fn execute_with_progress( + &self, + graph: &SkillGraph, + inputs: HashMap, + context: &SkillContext, + progress_fn: F, + ) -> Result + where + F: Fn(OrchestrationProgress) + Send + Sync; + + /// Execute a pre-built plan + async fn execute_plan( + &self, + plan: &OrchestrationPlan, + inputs: HashMap, + context: &SkillContext, + ) -> Result; +} + +/// Default executor implementation +pub struct DefaultExecutor { + /// Skill registry for executing skills + registry: Arc, + /// Cancellation tokens + cancellations: RwLock>, +} + +impl DefaultExecutor { + pub fn new(registry: Arc) -> Self { + Self { + registry, + cancellations: RwLock::new(HashMap::new()), + } + } + + /// Cancel an ongoing orchestration + pub async fn cancel(&self, graph_id: &str) { + let mut cancellations = self.cancellations.write().await; + cancellations.insert(graph_id.to_string(), true); + } + + /// Check if cancelled + async fn is_cancelled(&self, graph_id: &str) -> bool { + let cancellations = self.cancellations.read().await; + cancellations.get(graph_id).copied().unwrap_or(false) + } + + /// Execute a single node + async fn execute_node( + &self, + node: &super::SkillNode, + orch_context: &OrchestrationContext, + skill_context: &SkillContext, + ) -> Result { + let start = Instant::now(); + let node_id = node.id.clone(); + + // Check condition + if let Some(when) = &node.when { + if !orch_context.evaluate_condition(when).unwrap_or(false) { + return Ok(NodeResult { + node_id, + success: true, + output: Value::Null, + error: None, + duration_ms: 0, + retries: 0, + skipped: true, + }); + } + } + + // Resolve input mappings + let input = orch_context.resolve_node_input(node); + + // Execute with retry + let max_attempts = node.retry.as_ref() + .map(|r| r.max_attempts) + .unwrap_or(1); + let delay_ms = node.retry.as_ref() + .map(|r| r.delay_ms) + .unwrap_or(1000); + + let mut last_error = None; + let mut attempts = 0; + + for attempt in 0..max_attempts { + attempts = attempt + 1; + + // Apply timeout if specified + let result = if let Some(timeout_secs) = node.timeout_secs { + tokio::time::timeout( + Duration::from_secs(timeout_secs), + self.registry.execute(&node.skill_id, skill_context, input.clone()) + ).await + .map_err(|_| zclaw_types::ZclawError::Timeout(format!( + "Node {} timed out after {}s", + node.id, timeout_secs + )))? + } else { + self.registry.execute(&node.skill_id, skill_context, input.clone()).await + }; + + match result { + Ok(skill_result) if skill_result.success => { + return Ok(NodeResult { + node_id, + success: true, + output: skill_result.output, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + retries: attempt, + skipped: false, + }); + } + Ok(skill_result) => { + last_error = skill_result.error; + } + Err(e) => { + last_error = Some(e.to_string()); + } + } + + // Delay before retry (except last attempt) + if attempt < max_attempts - 1 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + } + + // All retries failed + Ok(NodeResult { + node_id, + success: false, + output: Value::Null, + error: last_error, + duration_ms: start.elapsed().as_millis() as u64, + retries: attempts - 1, + skipped: false, + }) + } +} + +#[async_trait::async_trait] +impl SkillGraphExecutor for DefaultExecutor { + async fn execute( + &self, + graph: &SkillGraph, + inputs: HashMap, + context: &SkillContext, + ) -> Result { + // Build plan first + let plan = super::DefaultPlanner::new().plan(graph)?; + self.execute_plan(&plan, inputs, context).await + } + + async fn execute_with_progress( + &self, + graph: &SkillGraph, + inputs: HashMap, + context: &SkillContext, + progress_fn: F, + ) -> Result + where + F: Fn(OrchestrationProgress) + Send + Sync, + { + let plan = super::DefaultPlanner::new().plan(graph)?; + + let start = Instant::now(); + let mut orch_context = OrchestrationContext::new(graph, inputs); + let mut node_results: HashMap = HashMap::new(); + let mut progress = OrchestrationProgress::new(&graph.id, graph.nodes.len()); + + // Execute parallel groups + for group in &plan.parallel_groups { + if self.is_cancelled(&graph.id).await { + return Ok(OrchestrationResult { + success: false, + output: Value::Null, + node_results, + duration_ms: start.elapsed().as_millis() as u64, + error: Some("Cancelled".to_string()), + }); + } + + // Execute nodes in parallel within the group + for node_id in group { + if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) { + progress.current_node = Some(node_id.clone()); + progress_fn(progress.clone()); + + let result = self.execute_node(node, &orch_context, context).await + .unwrap_or_else(|e| NodeResult { + node_id: node_id.clone(), + success: false, + output: Value::Null, + error: Some(e.to_string()), + duration_ms: 0, + retries: 0, + skipped: false, + }); + node_results.insert(node_id.clone(), result); + } + } + + // Update context with node outputs + for node_id in group { + if let Some(result) = node_results.get(node_id) { + if result.success { + orch_context.set_node_output(node_id, result.output.clone()); + progress.completed_nodes.push(node_id.clone()); + } else { + progress.failed_nodes.push(node_id.clone()); + + // Handle error based on strategy + match graph.on_error { + ErrorStrategy::Stop => { + // Clone error before moving node_results + let error = result.error.clone(); + return Ok(OrchestrationResult { + success: false, + output: Value::Null, + node_results, + duration_ms: start.elapsed().as_millis() as u64, + error, + }); + } + ErrorStrategy::Continue => { + // Continue to next group + } + ErrorStrategy::Retry => { + // Already handled in execute_node + } + } + } + } + } + + // Update progress + progress.progress_percent = ((progress.completed_nodes.len() + progress.failed_nodes.len()) + * 100 / graph.nodes.len()) as u8; + progress.status = format!("Completed group with {} nodes", group.len()); + progress_fn(progress.clone()); + } + + // Build final output + let output = orch_context.build_output(&graph.output_mapping); + + let success = progress.failed_nodes.is_empty(); + + Ok(OrchestrationResult { + success, + output, + node_results, + duration_ms: start.elapsed().as_millis() as u64, + error: if success { None } else { Some("Some nodes failed".to_string()) }, + }) + } + + async fn execute_plan( + &self, + plan: &OrchestrationPlan, + inputs: HashMap, + context: &SkillContext, + ) -> Result { + self.execute_with_progress(&plan.graph, inputs, context, |_| {}).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_node_result_success() { + let result = NodeResult { + node_id: "test".to_string(), + success: true, + output: serde_json::json!({"data": "value"}), + error: None, + duration_ms: 100, + retries: 0, + skipped: false, + }; + + assert!(result.success); + assert_eq!(result.node_id, "test"); + } +} diff --git a/crates/zclaw-skills/src/orchestration/mod.rs b/crates/zclaw-skills/src/orchestration/mod.rs new file mode 100644 index 0000000..5baa1d8 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/mod.rs @@ -0,0 +1,18 @@ +//! Skill Orchestration Engine +//! +//! Automatically compose multiple Skills into execution graphs (DAGs) +//! with data passing, error handling, and dependency resolution. + +mod types; +mod validation; +mod planner; +mod executor; +mod context; +mod auto_compose; + +pub use types::*; +pub use validation::*; +pub use planner::*; +pub use executor::*; +pub use context::*; +pub use auto_compose::*; diff --git a/crates/zclaw-skills/src/orchestration/planner.rs b/crates/zclaw-skills/src/orchestration/planner.rs new file mode 100644 index 0000000..f4d9311 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/planner.rs @@ -0,0 +1,337 @@ +//! Orchestration planner +//! +//! Generates execution plans from skill graphs, including +//! topological sorting and parallel group identification. + +use zclaw_types::{Result, SkillId}; +use crate::registry::SkillRegistry; + +use super::{ + SkillGraph, OrchestrationPlan, ValidationError, + topological_sort, identify_parallel_groups, build_dependency_map, + validate_graph, +}; + +/// Orchestration planner trait +#[async_trait::async_trait] +pub trait OrchestrationPlanner: Send + Sync { + /// Validate a skill graph + async fn validate( + &self, + graph: &SkillGraph, + registry: &SkillRegistry, + ) -> Vec; + + /// Build an execution plan from a skill graph + fn plan(&self, graph: &SkillGraph) -> Result; + + /// Auto-compose skills based on input/output schema matching + async fn auto_compose( + &self, + skill_ids: &[SkillId], + registry: &SkillRegistry, + ) -> Result; +} + +/// Default orchestration planner implementation +pub struct DefaultPlanner { + /// Maximum parallel workers + max_workers: usize, +} + +impl DefaultPlanner { + pub fn new() -> Self { + Self { max_workers: 4 } + } + + pub fn with_max_workers(mut self, max_workers: usize) -> Self { + self.max_workers = max_workers; + self + } +} + +impl Default for DefaultPlanner { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl OrchestrationPlanner for DefaultPlanner { + async fn validate( + &self, + graph: &SkillGraph, + registry: &SkillRegistry, + ) -> Vec { + validate_graph(graph, registry).await + } + + fn plan(&self, graph: &SkillGraph) -> Result { + // Get topological order + let execution_order = topological_sort(graph).map_err(|errs| { + zclaw_types::ZclawError::InvalidInput( + errs.iter() + .map(|e| e.message.clone()) + .collect::>() + .join("; ") + ) + })?; + + // Identify parallel groups + let parallel_groups = identify_parallel_groups(graph); + + // Build dependency map + let dependencies = build_dependency_map(graph); + + // Limit parallel group size + let parallel_groups: Vec> = parallel_groups + .into_iter() + .map(|group| { + if group.len() > self.max_workers { + // Split into smaller groups + group.into_iter() + .collect::>() + .chunks(self.max_workers) + .flat_map(|c| c.to_vec()) + .collect() + } else { + group + } + }) + .collect(); + + Ok(OrchestrationPlan { + graph: graph.clone(), + execution_order, + parallel_groups, + dependencies, + }) + } + + async fn auto_compose( + &self, + skill_ids: &[SkillId], + registry: &SkillRegistry, + ) -> Result { + use super::auto_compose::AutoComposer; + let composer = AutoComposer::new(registry); + composer.compose(skill_ids).await + } +} + +/// Plan builder for fluent API +pub struct PlanBuilder { + graph: SkillGraph, +} + +impl PlanBuilder { + /// Create a new plan builder + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + graph: SkillGraph { + id: id.into(), + name: name.into(), + description: String::new(), + nodes: Vec::new(), + edges: Vec::new(), + input_schema: None, + output_mapping: std::collections::HashMap::new(), + on_error: Default::default(), + timeout_secs: 300, + }, + } + } + + /// Add description + pub fn description(mut self, desc: impl Into) -> Self { + self.graph.description = desc.into(); + self + } + + /// Add a node + pub fn node(mut self, node: super::SkillNode) -> Self { + self.graph.nodes.push(node); + self + } + + /// Add an edge + pub fn edge(mut self, from: impl Into, to: impl Into) -> Self { + self.graph.edges.push(super::SkillEdge { + from_node: from.into(), + to_node: to.into(), + field_mapping: std::collections::HashMap::new(), + condition: None, + }); + self + } + + /// Add edge with field mapping + pub fn edge_with_mapping( + mut self, + from: impl Into, + to: impl Into, + mapping: std::collections::HashMap, + ) -> Self { + self.graph.edges.push(super::SkillEdge { + from_node: from.into(), + to_node: to.into(), + field_mapping: mapping, + condition: None, + }); + self + } + + /// Set input schema + pub fn input_schema(mut self, schema: serde_json::Value) -> Self { + self.graph.input_schema = Some(schema); + self + } + + /// Add output mapping + pub fn output(mut self, name: impl Into, expression: impl Into) -> Self { + self.graph.output_mapping.insert(name.into(), expression.into()); + self + } + + /// Set error strategy + pub fn on_error(mut self, strategy: super::ErrorStrategy) -> Self { + self.graph.on_error = strategy; + self + } + + /// Set timeout + pub fn timeout_secs(mut self, secs: u64) -> Self { + self.graph.timeout_secs = secs; + self + } + + /// Build the graph + pub fn build(self) -> SkillGraph { + self.graph + } + + /// Build and validate + pub async fn build_and_validate( + self, + registry: &SkillRegistry, + ) -> std::result::Result> { + let graph = self.graph; + let errors = validate_graph(&graph, registry).await; + if errors.is_empty() { + Ok(graph) + } else { + Err(errors) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_test_graph() -> SkillGraph { + use super::super::{SkillNode, SkillEdge}; + + SkillGraph { + id: "test".to_string(), + name: "Test".to_string(), + description: String::new(), + nodes: vec![ + SkillNode { + id: "research".to_string(), + skill_id: "web-researcher".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }, + SkillNode { + id: "summarize".to_string(), + skill_id: "text-summarizer".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }, + SkillNode { + id: "translate".to_string(), + skill_id: "translator".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }, + ], + edges: vec![ + SkillEdge { + from_node: "research".to_string(), + to_node: "summarize".to_string(), + field_mapping: HashMap::new(), + condition: None, + }, + SkillEdge { + from_node: "summarize".to_string(), + to_node: "translate".to_string(), + field_mapping: HashMap::new(), + condition: None, + }, + ], + input_schema: None, + output_mapping: HashMap::new(), + on_error: Default::default(), + timeout_secs: 300, + } + } + + #[test] + fn test_planner_plan() { + let planner = DefaultPlanner::new(); + let graph = make_test_graph(); + let plan = planner.plan(&graph).unwrap(); + + assert_eq!(plan.execution_order, vec!["research", "summarize", "translate"]); + assert_eq!(plan.parallel_groups.len(), 3); + } + + #[test] + fn test_plan_builder() { + let graph = PlanBuilder::new("my-graph", "My Graph") + .description("Test graph") + .node(super::super::SkillNode { + id: "a".to_string(), + skill_id: "skill-a".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }) + .node(super::super::SkillNode { + id: "b".to_string(), + skill_id: "skill-b".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }) + .edge("a", "b") + .output("result", "${nodes.b.output}") + .timeout_secs(600) + .build(); + + assert_eq!(graph.id, "my-graph"); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.timeout_secs, 600); + } +} diff --git a/crates/zclaw-skills/src/orchestration/types.rs b/crates/zclaw-skills/src/orchestration/types.rs new file mode 100644 index 0000000..464f0c3 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/types.rs @@ -0,0 +1,344 @@ +//! Orchestration types and data structures + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use zclaw_types::SkillId; + +/// Skill orchestration graph (DAG) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillGraph { + /// Unique graph identifier + pub id: String, + /// Human-readable name + pub name: String, + /// Description of what this orchestration does + #[serde(default)] + pub description: String, + /// DAG nodes representing skills + pub nodes: Vec, + /// Edges representing data flow + #[serde(default)] + pub edges: Vec, + /// Global input schema (JSON Schema) + #[serde(default)] + pub input_schema: Option, + /// Global output mapping: output_field -> expression + #[serde(default)] + pub output_mapping: HashMap, + /// Error handling strategy + #[serde(default)] + pub on_error: ErrorStrategy, + /// Timeout for entire orchestration in seconds + #[serde(default = "default_timeout")] + pub timeout_secs: u64, +} + +fn default_timeout() -> u64 { 300 } + +/// A skill node in the orchestration graph +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillNode { + /// Unique node identifier within the graph + pub id: String, + /// Skill to execute + pub skill_id: SkillId, + /// Human-readable description + #[serde(default)] + pub description: String, + /// Input mappings: skill_input_field -> expression string + /// Expression format: ${inputs.field}, ${nodes.node_id.output.field}, or literal + #[serde(default)] + pub input_mappings: HashMap, + /// Retry configuration + #[serde(default)] + pub retry: Option, + /// Timeout for this node in seconds + #[serde(default)] + pub timeout_secs: Option, + /// Condition for execution (expression that must evaluate to true) + #[serde(default)] + pub when: Option, + /// Whether to skip this node on error + #[serde(default)] + pub skip_on_error: bool, +} + +/// Data flow edge between nodes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillEdge { + /// Source node ID + pub from_node: String, + /// Target node ID + pub to_node: String, + /// Field mapping: to_node_input -> from_node_output_field + /// If empty, all output is passed + #[serde(default)] + pub field_mapping: HashMap, + /// Optional condition for this edge + #[serde(default)] + pub condition: Option, +} + +/// Expression for data resolution +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DataExpression { + /// Reference to graph input: ${inputs.field_name} + InputRef { + field: String, + }, + /// Reference to node output: ${nodes.node_id.output.field} + NodeOutputRef { + node_id: String, + field: String, + }, + /// Static literal value + Literal { + value: Value, + }, + /// Computed expression (e.g., string interpolation) + Expression { + template: String, + }, +} + +impl DataExpression { + /// Parse from string expression like "${inputs.topic}" or "${nodes.research.output.content}" + pub fn parse(expr: &str) -> Option { + let expr = expr.trim(); + + // Check for expression pattern ${...} + if expr.starts_with("${") && expr.ends_with("}") { + let inner = &expr[2..expr.len()-1]; + + // Parse inputs.field + if let Some(field) = inner.strip_prefix("inputs.") { + return Some(DataExpression::InputRef { + field: field.to_string(), + }); + } + + // Parse nodes.node_id.output.field or nodes.node_id.output + if let Some(rest) = inner.strip_prefix("nodes.") { + let parts: Vec<&str> = rest.split('.').collect(); + if parts.len() >= 2 { + let node_id = parts[0].to_string(); + // Skip "output" if present + let field = if parts.len() > 2 && parts[1] == "output" { + parts[2..].join(".") + } else if parts[1] == "output" { + String::new() + } else { + parts[1..].join(".") + }; + return Some(DataExpression::NodeOutputRef { node_id, field }); + } + } + } + + // Try to parse as JSON literal + if let Ok(value) = serde_json::from_str::(expr) { + return Some(DataExpression::Literal { value }); + } + + // Treat as expression template + Some(DataExpression::Expression { + template: expr.to_string(), + }) + } + + /// Convert to string representation + pub fn to_expr_string(&self) -> String { + match self { + DataExpression::InputRef { field } => format!("${{inputs.{}}}", field), + DataExpression::NodeOutputRef { node_id, field } => { + if field.is_empty() { + format!("${{nodes.{}.output}}", node_id) + } else { + format!("${{nodes.{}.output.{}}}", node_id, field) + } + } + DataExpression::Literal { value } => value.to_string(), + DataExpression::Expression { template } => template.clone(), + } + } +} + +/// Retry configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RetryConfig { + /// Maximum retry attempts + #[serde(default = "default_max_attempts")] + pub max_attempts: u32, + /// Delay between retries in milliseconds + #[serde(default = "default_delay_ms")] + pub delay_ms: u64, + /// Exponential backoff multiplier + #[serde(default)] + pub backoff: Option, +} + +fn default_max_attempts() -> u32 { 3 } +fn default_delay_ms() -> u64 { 1000 } + +/// Error handling strategy +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ErrorStrategy { + /// Stop execution on first error + #[default] + Stop, + /// Continue with remaining nodes + Continue, + /// Retry failed nodes + Retry, +} + +/// Orchestration execution plan +#[derive(Debug, Clone)] +pub struct OrchestrationPlan { + /// Original graph + pub graph: SkillGraph, + /// Topologically sorted execution order + pub execution_order: Vec, + /// Parallel groups (nodes that can run concurrently) + pub parallel_groups: Vec>, + /// Dependency map: node_id -> list of dependency node_ids + pub dependencies: HashMap>, +} + +/// Orchestration execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrchestrationResult { + /// Whether the entire orchestration succeeded + pub success: bool, + /// Final output after applying output_mapping + pub output: Value, + /// Individual node results + pub node_results: HashMap, + /// Total execution time in milliseconds + pub duration_ms: u64, + /// Error message if orchestration failed + #[serde(default)] + pub error: Option, +} + +/// Result of a single node execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeResult { + /// Node ID + pub node_id: String, + /// Whether this node succeeded + pub success: bool, + /// Output from this node + pub output: Value, + /// Error message if failed + #[serde(default)] + pub error: Option, + /// Execution time in milliseconds + pub duration_ms: u64, + /// Number of retries attempted + #[serde(default)] + pub retries: u32, + /// Whether this node was skipped + #[serde(default)] + pub skipped: bool, +} + +/// Validation error +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationError { + /// Error code + pub code: String, + /// Error message + pub message: String, + /// Location of the error (node ID, edge, etc.) + #[serde(default)] + pub location: Option, +} + +impl ValidationError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + location: None, + } + } + + pub fn with_location(mut self, location: impl Into) -> Self { + self.location = Some(location.into()); + self + } +} + +/// Progress update during orchestration execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrchestrationProgress { + /// Graph ID + pub graph_id: String, + /// Currently executing node + pub current_node: Option, + /// Completed nodes + pub completed_nodes: Vec, + /// Failed nodes + pub failed_nodes: Vec, + /// Total nodes count + pub total_nodes: usize, + /// Progress percentage (0-100) + pub progress_percent: u8, + /// Status message + pub status: String, +} + +impl OrchestrationProgress { + pub fn new(graph_id: &str, total_nodes: usize) -> Self { + Self { + graph_id: graph_id.to_string(), + current_node: None, + completed_nodes: Vec::new(), + failed_nodes: Vec::new(), + total_nodes, + progress_percent: 0, + status: "Starting".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_input_ref() { + let expr = DataExpression::parse("${inputs.topic}").unwrap(); + match expr { + DataExpression::InputRef { field } => assert_eq!(field, "topic"), + _ => panic!("Expected InputRef"), + } + } + + #[test] + fn test_parse_node_output_ref() { + let expr = DataExpression::parse("${nodes.research.output.content}").unwrap(); + match expr { + DataExpression::NodeOutputRef { node_id, field } => { + assert_eq!(node_id, "research"); + assert_eq!(field, "content"); + } + _ => panic!("Expected NodeOutputRef"), + } + } + + #[test] + fn test_parse_literal() { + let expr = DataExpression::parse("\"hello world\"").unwrap(); + match expr { + DataExpression::Literal { value } => { + assert_eq!(value.as_str().unwrap(), "hello world"); + } + _ => panic!("Expected Literal"), + } + } +} diff --git a/crates/zclaw-skills/src/orchestration/validation.rs b/crates/zclaw-skills/src/orchestration/validation.rs new file mode 100644 index 0000000..e51fbd5 --- /dev/null +++ b/crates/zclaw-skills/src/orchestration/validation.rs @@ -0,0 +1,406 @@ +//! Orchestration graph validation +//! +//! Validates skill graphs for correctness, including cycle detection, +//! missing node references, and schema compatibility. + +use std::collections::{HashMap, HashSet, VecDeque}; +use crate::registry::SkillRegistry; +use super::{SkillGraph, ValidationError, DataExpression}; + +/// Validate a skill graph +pub async fn validate_graph( + graph: &SkillGraph, + registry: &SkillRegistry, +) -> Vec { + let mut errors = Vec::new(); + + // 1. Check for empty graph + if graph.nodes.is_empty() { + errors.push(ValidationError::new( + "EMPTY_GRAPH", + "Skill graph has no nodes", + )); + return errors; + } + + // 2. Check for duplicate node IDs + let mut seen_ids = HashSet::new(); + for node in &graph.nodes { + if !seen_ids.insert(&node.id) { + errors.push(ValidationError::new( + "DUPLICATE_NODE_ID", + format!("Duplicate node ID: {}", node.id), + ).with_location(&node.id)); + } + } + + // 3. Check for missing skills + for node in &graph.nodes { + if registry.get_manifest(&node.skill_id).await.is_none() { + errors.push(ValidationError::new( + "MISSING_SKILL", + format!("Skill not found: {}", node.skill_id), + ).with_location(&node.id)); + } + } + + // 4. Check for cycle (circular dependencies) + if let Some(cycle) = detect_cycle(graph) { + errors.push(ValidationError::new( + "CYCLE_DETECTED", + format!("Circular dependency detected: {}", cycle.join(" -> ")), + )); + } + + // 5. Check edge references + let node_ids: HashSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect(); + for edge in &graph.edges { + if !node_ids.contains(edge.from_node.as_str()) { + errors.push(ValidationError::new( + "MISSING_SOURCE_NODE", + format!("Edge references non-existent source node: {}", edge.from_node), + )); + } + if !node_ids.contains(edge.to_node.as_str()) { + errors.push(ValidationError::new( + "MISSING_TARGET_NODE", + format!("Edge references non-existent target node: {}", edge.to_node), + )); + } + } + + // 6. Check for isolated nodes (no incoming or outgoing edges) + let mut connected_nodes = HashSet::new(); + for edge in &graph.edges { + connected_nodes.insert(&edge.from_node); + connected_nodes.insert(&edge.to_node); + } + for node in &graph.nodes { + if !connected_nodes.contains(&node.id) && graph.nodes.len() > 1 { + errors.push(ValidationError::new( + "ISOLATED_NODE", + format!("Node {} is not connected to any other nodes", node.id), + ).with_location(&node.id)); + } + } + + // 7. Validate data expressions + for node in &graph.nodes { + for (_field, expr_str) in &node.input_mappings { + // Parse the expression + if let Some(expr) = DataExpression::parse(expr_str) { + match &expr { + DataExpression::NodeOutputRef { node_id, .. } => { + if !node_ids.contains(node_id.as_str()) { + errors.push(ValidationError::new( + "INVALID_EXPRESSION", + format!("Expression references non-existent node: {}", node_id), + ).with_location(&node.id)); + } + } + _ => {} + } + } + } + } + + // 8. Check for multiple start nodes (nodes with no incoming edges) + let start_nodes = find_start_nodes(graph); + if start_nodes.len() > 1 { + // This is actually allowed for parallel execution + // Just log as info, not error + } + + errors +} + +/// Detect cycle in the skill graph using DFS +pub fn detect_cycle(graph: &SkillGraph) -> Option> { + let mut visited = HashSet::new(); + let mut rec_stack = HashSet::new(); + let mut path = Vec::new(); + + // Build adjacency list + let mut adj: HashMap<&str, Vec<&str>> = HashMap::new(); + for edge in &graph.edges { + adj.entry(&edge.from_node).or_default().push(&edge.to_node); + } + + for node in &graph.nodes { + if let Some(cycle) = dfs_cycle(&node.id, &adj, &mut visited, &mut rec_stack, &mut path) { + return Some(cycle); + } + } + + None +} + +fn dfs_cycle<'a>( + node: &'a str, + adj: &HashMap<&'a str, Vec<&'a str>>, + visited: &mut HashSet<&'a str>, + rec_stack: &mut HashSet<&'a str>, + path: &mut Vec, +) -> Option> { + if rec_stack.contains(node) { + // Found cycle, return the cycle path + let cycle_start = path.iter().position(|n| n == node)?; + return Some(path[cycle_start..].to_vec()); + } + + if visited.contains(node) { + return None; + } + + visited.insert(node); + rec_stack.insert(node); + path.push(node.to_string()); + + if let Some(neighbors) = adj.get(node) { + for neighbor in neighbors { + if let Some(cycle) = dfs_cycle(neighbor, adj, visited, rec_stack, path) { + return Some(cycle); + } + } + } + + path.pop(); + rec_stack.remove(node); + None +} + +/// Find start nodes (nodes with no incoming edges) +pub fn find_start_nodes(graph: &SkillGraph) -> Vec<&str> { + let mut has_incoming = HashSet::new(); + for edge in &graph.edges { + has_incoming.insert(edge.to_node.as_str()); + } + + graph.nodes + .iter() + .filter(|n| !has_incoming.contains(n.id.as_str())) + .map(|n| n.id.as_str()) + .collect() +} + +/// Find end nodes (nodes with no outgoing edges) +pub fn find_end_nodes(graph: &SkillGraph) -> Vec<&str> { + let mut has_outgoing = HashSet::new(); + for edge in &graph.edges { + has_outgoing.insert(edge.from_node.as_str()); + } + + graph.nodes + .iter() + .filter(|n| !has_outgoing.contains(n.id.as_str())) + .map(|n| n.id.as_str()) + .collect() +} + +/// Topological sort of the graph +pub fn topological_sort(graph: &SkillGraph) -> Result, Vec> { + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adj: HashMap<&str, Vec<&str>> = HashMap::new(); + + // Initialize in-degree for all nodes + for node in &graph.nodes { + in_degree.insert(&node.id, 0); + } + + // Build adjacency list and calculate in-degrees + for edge in &graph.edges { + adj.entry(&edge.from_node).or_default().push(&edge.to_node); + *in_degree.entry(&edge.to_node).or_insert(0) += 1; + } + + // Queue nodes with no incoming edges + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&node, _)| node) + .collect(); + + let mut result = Vec::new(); + + while let Some(node) = queue.pop_front() { + result.push(node.to_string()); + + if let Some(neighbors) = adj.get(node) { + for neighbor in neighbors { + if let Some(deg) = in_degree.get_mut(neighbor) { + *deg -= 1; + if *deg == 0 { + queue.push_back(neighbor); + } + } + } + } + } + + // Check if topological sort is possible (no cycles) + if result.len() != graph.nodes.len() { + return Err(vec![ValidationError::new( + "TOPOLOGICAL_SORT_FAILED", + "Graph contains a cycle, topological sort not possible", + )]); + } + + Ok(result) +} + +/// Identify parallel groups (nodes that can run concurrently) +pub fn identify_parallel_groups(graph: &SkillGraph) -> Vec> { + let mut groups = Vec::new(); + let mut completed: HashSet = HashSet::new(); + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adj: HashMap<&str, Vec<&str>> = HashMap::new(); + + // Initialize + for node in &graph.nodes { + in_degree.insert(&node.id, 0); + } + + for edge in &graph.edges { + adj.entry(&edge.from_node).or_default().push(&edge.to_node); + *in_degree.entry(&edge.to_node).or_insert(0) += 1; + } + + // Process in levels + while completed.len() < graph.nodes.len() { + // Find all nodes with in-degree 0 that are not yet completed + let current_group: Vec = in_degree + .iter() + .filter(|(node, °)| deg == 0 && !completed.contains(&node.to_string())) + .map(|(node, _)| node.to_string()) + .collect(); + + if current_group.is_empty() { + break; // Should not happen in a valid DAG + } + + // Add to completed and update in-degrees + for node in ¤t_group { + completed.insert(node.clone()); + if let Some(neighbors) = adj.get(node.as_str()) { + for neighbor in neighbors { + if let Some(deg) = in_degree.get_mut(neighbor) { + *deg -= 1; + } + } + } + } + + groups.push(current_group); + } + + groups +} + +/// Build dependency map +pub fn build_dependency_map(graph: &SkillGraph) -> HashMap> { + let mut deps: HashMap> = HashMap::new(); + + for node in &graph.nodes { + deps.entry(node.id.clone()).or_default(); + } + + for edge in &graph.edges { + deps.entry(edge.to_node.clone()) + .or_default() + .push(edge.from_node.clone()); + } + + deps +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_simple_graph() -> SkillGraph { + SkillGraph { + id: "test".to_string(), + name: "Test Graph".to_string(), + description: String::new(), + nodes: vec![ + SkillNode { + id: "a".to_string(), + skill_id: "skill-a".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }, + SkillNode { + id: "b".to_string(), + skill_id: "skill-b".into(), + description: String::new(), + input_mappings: HashMap::new(), + retry: None, + timeout_secs: None, + when: None, + skip_on_error: false, + }, + ], + edges: vec![SkillEdge { + from_node: "a".to_string(), + to_node: "b".to_string(), + field_mapping: HashMap::new(), + condition: None, + }], + input_schema: None, + output_mapping: HashMap::new(), + on_error: Default::default(), + timeout_secs: 300, + } + } + + #[test] + fn test_topological_sort() { + let graph = make_simple_graph(); + let result = topological_sort(&graph).unwrap(); + assert_eq!(result, vec!["a", "b"]); + } + + #[test] + fn test_detect_no_cycle() { + let graph = make_simple_graph(); + assert!(detect_cycle(&graph).is_none()); + } + + #[test] + fn test_detect_cycle() { + let mut graph = make_simple_graph(); + // Add cycle: b -> a + graph.edges.push(SkillEdge { + from_node: "b".to_string(), + to_node: "a".to_string(), + field_mapping: HashMap::new(), + condition: None, + }); + assert!(detect_cycle(&graph).is_some()); + } + + #[test] + fn test_find_start_nodes() { + let graph = make_simple_graph(); + let starts = find_start_nodes(&graph); + assert_eq!(starts, vec!["a"]); + } + + #[test] + fn test_find_end_nodes() { + let graph = make_simple_graph(); + let ends = find_end_nodes(&graph); + assert_eq!(ends, vec!["b"]); + } + + #[test] + fn test_identify_parallel_groups() { + let graph = make_simple_graph(); + let groups = identify_parallel_groups(&graph); + assert_eq!(groups, vec![vec!["a"], vec!["b"]]); + } +} diff --git a/crates/zclaw-skills/src/registry.rs b/crates/zclaw-skills/src/registry.rs index 4dac9e3..b094e11 100644 --- a/crates/zclaw-skills/src/registry.rs +++ b/crates/zclaw-skills/src/registry.rs @@ -44,14 +44,14 @@ impl SkillRegistry { // Scan for skills let skill_paths = loader::discover_skills(&dir)?; for skill_path in skill_paths { - self.load_skill_from_dir(&skill_path)?; + self.load_skill_from_dir(&skill_path).await?; } Ok(()) } /// Load a skill from directory - fn load_skill_from_dir(&self, dir: &PathBuf) -> Result<()> { + async fn load_skill_from_dir(&self, dir: &PathBuf) -> Result<()> { let md_path = dir.join("SKILL.md"); let toml_path = dir.join("skill.toml"); @@ -82,9 +82,9 @@ impl SkillRegistry { } }; - // Register - let mut skills = self.skills.blocking_write(); - let mut manifests = self.manifests.blocking_write(); + // Register (use async write instead of blocking_write) + let mut skills = self.skills.write().await; + let mut manifests = self.manifests.write().await; skills.insert(manifest.id.clone(), skill); manifests.insert(manifest.id.clone(), manifest); diff --git a/crates/zclaw-skills/src/skill.rs b/crates/zclaw-skills/src/skill.rs index f56aa8a..06f5614 100644 --- a/crates/zclaw-skills/src/skill.rs +++ b/crates/zclaw-skills/src/skill.rs @@ -32,6 +32,10 @@ pub struct SkillManifest { /// Tags for categorization #[serde(default)] pub tags: Vec, + /// Category for skill grouping (e.g., "开发工程", "数据分析") + /// If not specified, will be auto-detected from skill ID + #[serde(default)] + pub category: Option, /// Trigger words for skill activation #[serde(default)] pub triggers: Vec, diff --git a/desktop/package.json b/desktop/package.json index 37030bc..f1e67fe 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -31,10 +31,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "@xstate/react": "^6.1.0", + "@xyflow/react": "^12.10.1", "clsx": "^2.1.1", + "dagre": "^0.8.5", "date-fns": "^4.1.0", "framer-motion": "^12.36.0", "lucide-react": "^0.577.0", @@ -55,6 +58,7 @@ "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", + "@types/js-yaml": "^4.0.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/react-window": "^2.0.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 4ee559e..08f174d 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 '@tauri-apps/api': specifier: ^2 version: 2.10.1 @@ -17,9 +20,15 @@ importers: '@xstate/react': specifier: ^6.1.0 version: 6.1.0(@types/react@19.2.14)(react@19.2.4)(xstate@5.28.0) + '@xyflow/react': + specifier: ^12.10.1 + version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) clsx: specifier: ^2.1.1 version: 2.1.1 + dagre: + specifier: ^0.8.5 + version: 0.8.5 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -75,6 +84,9 @@ importers: '@testing-library/react': specifier: 16.1.0 version: 16.1.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/react': specifier: ^19.1.8 version: 19.2.14 @@ -248,6 +260,12 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -930,9 +948,30 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1004,6 +1043,15 @@ packages: xstate: optional: true + '@xyflow/react@12.10.1': + resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.75': + resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1096,6 +1144,9 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1128,6 +1179,47 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1304,6 +1396,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1982,6 +2077,21 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -2153,6 +2263,12 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2589,8 +2705,31 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.8': {} + '@types/js-yaml@4.0.9': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -2692,6 +2831,29 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@xyflow/system': 0.0.75 + classcat: 5.0.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.75': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + agent-base@7.1.4: {} ansi-regex@5.0.1: {} @@ -2771,6 +2933,8 @@ snapshots: check-error@2.1.3: {} + classcat@5.0.5: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -2800,6 +2964,47 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + dagre@0.8.5: + dependencies: + graphlib: 2.1.8 + lodash: 4.17.23 + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -2995,6 +3200,10 @@ snapshots: graceful-fs@4.2.11: {} + graphlib@2.1.8: + dependencies: + lodash: 4.17.23 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3573,6 +3782,13 @@ snapshots: yallist@3.1.1: {} + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 diff --git a/desktop/src-tauri/src/intelligence/heartbeat.rs b/desktop/src-tauri/src/intelligence/heartbeat.rs index 187be1b..5695c25 100644 --- a/desktop/src-tauri/src/intelligence/heartbeat.rs +++ b/desktop/src-tauri/src/intelligence/heartbeat.rs @@ -7,7 +7,7 @@ //! Phase 2 of Intelligence Layer Migration. //! Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.1 -use chrono::{DateTime, Local, Timelike}; +use chrono::{Local, Timelike}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -342,6 +342,10 @@ static CORRECTION_COUNTERS: OnceLock>> = OnceLo /// Key: agent_id, Value: (task_count, total_memories, storage_bytes) static MEMORY_STATS_CACHE: OnceLock>> = OnceLock::new(); +/// Global last interaction timestamps +/// Key: agent_id, Value: last interaction timestamp (RFC3339) +static LAST_INTERACTION: OnceLock>> = OnceLock::new(); + /// Cached memory stats for an agent #[derive(Clone, Debug, Default)] pub struct MemoryStatsCache { @@ -359,6 +363,18 @@ fn get_memory_stats_cache() -> &'static RwLock &'static RwLock> { + LAST_INTERACTION.get_or_init(|| RwLock::new(StdHashMap::new())) +} + +/// Record an interaction for an agent (call from frontend when user sends message) +pub fn record_interaction(agent_id: &str) { + let map = get_last_interaction_map(); + if let Ok(mut map) = map.write() { + map.insert(agent_id.to_string(), chrono::Utc::now().to_rfc3339()); + } +} + /// Update memory stats cache for an agent /// Call this from frontend via Tauri command after fetching memory stats pub fn update_memory_stats_cache(agent_id: &str, task_count: usize, total_entries: usize, storage_size_bytes: usize) { @@ -433,10 +449,10 @@ fn check_correction_patterns(agent_id: &str) -> Vec { /// Check for pending task memories /// Uses cached memory stats to detect task backlog fn check_pending_tasks(agent_id: &str) -> Option { - if let Some(stats) = get_cached_memory_stats(agent_id) { - // Alert if there are 5+ pending tasks - if stats.task_count >= 5 { - return Some(HeartbeatAlert { + match get_cached_memory_stats(agent_id) { + Some(stats) if stats.task_count >= 5 => { + // Alert if there are 5+ pending tasks + Some(HeartbeatAlert { title: "待办任务积压".to_string(), content: format!("当前有 {} 个待办任务未完成,建议处理或重新评估优先级", stats.task_count), urgency: if stats.task_count >= 10 { @@ -446,51 +462,102 @@ fn check_pending_tasks(agent_id: &str) -> Option { }, source: "pending-tasks".to_string(), timestamp: chrono::Utc::now().to_rfc3339(), - }); + }) + }, + Some(_) => None, // Stats available but no alert needed + None => { + // Cache is empty - warn about missing sync + tracing::warn!("[Heartbeat] Memory stats cache is empty for agent {}, waiting for frontend sync", agent_id); + Some(HeartbeatAlert { + title: "记忆统计未同步".to_string(), + content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(), + urgency: Urgency::Low, + source: "pending-tasks".to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }) } } - None } /// Check memory storage health /// Uses cached memory stats to detect storage issues fn check_memory_health(agent_id: &str) -> Option { - if let Some(stats) = get_cached_memory_stats(agent_id) { - // Alert if storage is very large (> 50MB) - if stats.storage_size_bytes > 50 * 1024 * 1024 { - return Some(HeartbeatAlert { - title: "记忆存储过大".to_string(), - content: format!( - "记忆存储已达 {:.1}MB,建议清理低重要性记忆或归档旧记忆", - stats.storage_size_bytes as f64 / (1024.0 * 1024.0) - ), - urgency: Urgency::Medium, - source: "memory-health".to_string(), - timestamp: chrono::Utc::now().to_rfc3339(), - }); - } + match get_cached_memory_stats(agent_id) { + Some(stats) => { + // Alert if storage is very large (> 50MB) + if stats.storage_size_bytes > 50 * 1024 * 1024 { + return Some(HeartbeatAlert { + title: "记忆存储过大".to_string(), + content: format!( + "记忆存储已达 {:.1}MB,建议清理低重要性记忆或归档旧记忆", + stats.storage_size_bytes as f64 / (1024.0 * 1024.0) + ), + urgency: Urgency::Medium, + source: "memory-health".to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }); + } - // Alert if too many memories (> 1000) - if stats.total_entries > 1000 { - return Some(HeartbeatAlert { - title: "记忆条目过多".to_string(), - content: format!( - "当前有 {} 条记忆,可能影响检索效率,建议清理或归档", - stats.total_entries - ), - urgency: Urgency::Low, - source: "memory-health".to_string(), - timestamp: chrono::Utc::now().to_rfc3339(), - }); + // Alert if too many memories (> 1000) + if stats.total_entries > 1000 { + return Some(HeartbeatAlert { + title: "记忆条目过多".to_string(), + content: format!( + "当前有 {} 条记忆,可能影响检索效率,建议清理或归档", + stats.total_entries + ), + urgency: Urgency::Low, + source: "memory-health".to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }); + } + None + }, + None => { + // Cache is empty - skip check (already reported in check_pending_tasks) + None } } - None } -/// Check if user has been idle (placeholder) -fn check_idle_greeting(_agent_id: &str) -> Option { - // In full implementation, this would check last interaction time - None +/// Check if user has been idle and might benefit from a greeting +fn check_idle_greeting(agent_id: &str) -> Option { + let map = get_last_interaction_map(); + + // Try to get the last interaction time + let last_interaction = { + let read_result = map.read(); + match read_result { + Ok(map) => map.get(agent_id).cloned(), + Err(_) => return None, // Skip if lock fails + } + }; + + // If no interaction recorded yet, skip + let last_interaction = last_interaction?; + + // Parse the timestamp and convert to UTC for comparison + let last_time = chrono::DateTime::parse_from_rfc3339(&last_interaction) + .ok()? + .with_timezone(&chrono::Utc); + let now = chrono::Utc::now(); + let idle_hours = (now - last_time).num_hours(); + + // Alert if idle for more than 24 hours + if idle_hours >= 24 { + Some(HeartbeatAlert { + title: "用户长时间未互动".to_string(), + content: format!( + "距离上次互动已过去 {} 小时,可以考虑主动问候或检查用户是否需要帮助", + idle_hours + ), + urgency: Urgency::Low, + source: "idle-greeting".to_string(), + timestamp: now.to_rfc3339(), + }) + } else { + None + } } /// Check for personality improvement opportunities @@ -665,6 +732,16 @@ pub async fn heartbeat_record_correction( Ok(()) } +/// Record a user interaction for idle greeting detection +/// Call this from frontend whenever user sends a message +#[tauri::command] +pub async fn heartbeat_record_interaction( + agent_id: String, +) -> Result<(), String> { + record_interaction(&agent_id); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/intelligence/identity.rs b/desktop/src-tauri/src/intelligence/identity.rs index d67e4b5..2e9f1cd 100644 --- a/desktop/src-tauri/src/intelligence/identity.rs +++ b/desktop/src-tauri/src/intelligence/identity.rs @@ -10,12 +10,12 @@ //! Phase 3 of Intelligence Layer Migration. //! Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.3 -use chrono::{DateTime, Utc}; +use chrono::Utc; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; -use tracing::{error, info, warn}; +use tracing::{error, warn}; // === Types === diff --git a/desktop/src-tauri/src/intelligence/mod.rs b/desktop/src-tauri/src/intelligence/mod.rs index 3ee8f48..bdf3bfa 100644 --- a/desktop/src-tauri/src/intelligence/mod.rs +++ b/desktop/src-tauri/src/intelligence/mod.rs @@ -29,24 +29,10 @@ pub mod reflection; pub mod identity; // Re-export main types for convenience -pub use heartbeat::{ - HeartbeatConfig, HeartbeatEngine, HeartbeatEngineState, - HeartbeatAlert, HeartbeatResult, HeartbeatStatus, - Urgency, NotifyChannel, ProactivityLevel, -}; -pub use compactor::{ - CompactionConfig, ContextCompactor, CompactableMessage, - CompactionResult, CompactionCheck, CompactionUrgency, - estimate_tokens, estimate_messages_tokens, -}; +pub use heartbeat::HeartbeatEngineState; pub use reflection::{ - ReflectionConfig, ReflectionEngine, ReflectionEngineState, - ReflectionResult, ReflectionState, ReflectionResult as ReflectionOutput, - PatternObservation, ImprovementSuggestion, IdentityChangeProposal as ReflectionIdentityChangeProposal, - Sentiment, Priority, MemoryEntryForAnalysis, + ReflectionEngine, ReflectionEngineState, }; pub use identity::{ AgentIdentityManager, IdentityManagerState, - IdentityFiles, IdentityChangeProposal, IdentitySnapshot, - IdentityFile, ProposalStatus, }; diff --git a/desktop/src-tauri/src/kernel_commands.rs b/desktop/src-tauri/src/kernel_commands.rs index 9fa6798..f8de70a 100644 --- a/desktop/src-tauri/src/kernel_commands.rs +++ b/desktop/src-tauri/src/kernel_commands.rs @@ -174,6 +174,13 @@ pub async fn kernel_init( zclaw_kernel::config::KernelConfig::default() }; + // Debug: print skills directory + if let Some(ref skills_dir) = config.skills_dir { + println!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists()); + } else { + println!("[kernel_init] No skills directory configured"); + } + let base_url = config.llm.base_url.clone(); let model = config.llm.model.clone(); @@ -353,6 +360,8 @@ pub enum StreamChatEvent { ToolStart { name: String, input: serde_json::Value }, /// Tool use completed ToolEnd { name: String, output: serde_json::Value }, + /// New iteration started (multi-turn tool calling) + IterationStart { iteration: usize, max_iterations: usize }, /// Stream completed Complete { input_tokens: u32, output_tokens: u32 }, /// Error occurred @@ -406,24 +415,38 @@ pub async fn agent_chat_stream( tokio::spawn(async move { use zclaw_runtime::LoopEvent; + println!("[agent_chat_stream] Starting to process stream events for session: {}", session_id); + while let Some(event) = rx.recv().await { + println!("[agent_chat_stream] Received event: {:?}", event); + let stream_event = match event { LoopEvent::Delta(delta) => { + println!("[agent_chat_stream] Delta: {} bytes", delta.len()); StreamChatEvent::Delta { delta } } LoopEvent::ToolStart { name, input } => { + println!("[agent_chat_stream] ToolStart: {} input={:?}", name, input); StreamChatEvent::ToolStart { name, input } } LoopEvent::ToolEnd { name, output } => { + println!("[agent_chat_stream] ToolEnd: {} output={:?}", name, output); StreamChatEvent::ToolEnd { name, output } } + LoopEvent::IterationStart { iteration, max_iterations } => { + println!("[agent_chat_stream] IterationStart: {}/{}", iteration, max_iterations); + StreamChatEvent::IterationStart { iteration, max_iterations } + } LoopEvent::Complete(result) => { + println!("[agent_chat_stream] Complete: input_tokens={}, output_tokens={}", + result.input_tokens, result.output_tokens); StreamChatEvent::Complete { input_tokens: result.input_tokens, output_tokens: result.output_tokens, } } LoopEvent::Error(message) => { + println!("[agent_chat_stream] Error: {}", message); StreamChatEvent::Error { message } } }; @@ -434,6 +457,8 @@ pub async fn agent_chat_stream( "event": stream_event })); } + + println!("[agent_chat_stream] Stream ended for session: {}", session_id); }); Ok(()) @@ -460,6 +485,8 @@ pub struct SkillInfoResponse { pub tags: Vec, pub mode: String, pub enabled: bool, + pub triggers: Vec, + pub category: Option, } impl From for SkillInfoResponse { @@ -473,6 +500,8 @@ impl From for SkillInfoResponse { tags: manifest.tags, mode: format!("{:?}", manifest.mode), enabled: manifest.enabled, + triggers: manifest.triggers, + category: manifest.category, } } } @@ -491,6 +520,10 @@ pub async fn skill_list( .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; let skills = kernel.list_skills().await; + println!("[skill_list] Found {} skills", skills.len()); + for skill in &skills { + println!("[skill_list] - {} ({})", skill.name, skill.id); + } Ok(skills.into_iter().map(SkillInfoResponse::from).collect()) } @@ -603,22 +636,67 @@ pub struct HandInfoResponse { pub id: String, pub name: String, pub description: String, + pub status: String, + pub requirements_met: bool, pub needs_approval: bool, pub dependencies: Vec, pub tags: Vec, pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default)] + pub tool_count: u32, + #[serde(default)] + pub metric_count: u32, } impl From for HandInfoResponse { fn from(config: zclaw_hands::HandConfig) -> Self { + // Determine status based on enabled and dependencies + let status = if !config.enabled { + "unavailable".to_string() + } else if config.needs_approval { + "needs_approval".to_string() + } else { + "idle".to_string() + }; + + // Extract category from tags if present + let category = config.tags.iter().find(|t| { + ["research", "automation", "browser", "data", "media", "communication"].contains(&t.as_str()) + }).cloned(); + + // Map tags to icon + let icon = if config.tags.contains(&"browser".to_string()) { + Some("globe".to_string()) + } else if config.tags.contains(&"research".to_string()) { + Some("search".to_string()) + } else if config.tags.contains(&"media".to_string()) { + Some("video".to_string()) + } else if config.tags.contains(&"data".to_string()) { + Some("database".to_string()) + } else if config.tags.contains(&"communication".to_string()) { + Some("message-circle".to_string()) + } else { + Some("zap".to_string()) + }; + Self { id: config.id, name: config.name, description: config.description, + status, + requirements_met: config.enabled && config.dependencies.is_empty(), needs_approval: config.needs_approval, dependencies: config.dependencies, tags: config.tags, enabled: config.enabled, + category, + icon, + tool_count: 0, + metric_count: 0, } } } diff --git a/desktop/src-tauri/src/memory/mod.rs b/desktop/src-tauri/src/memory/mod.rs index 4e72a91..677a6c5 100644 --- a/desktop/src-tauri/src/memory/mod.rs +++ b/desktop/src-tauri/src/memory/mod.rs @@ -13,13 +13,7 @@ pub mod persistent; pub mod crypto; // Re-export main types for convenience -pub use extractor::{SessionExtractor, ExtractedMemory, ExtractionConfig}; -pub use context_builder::{ContextBuilder, EnhancedContext, ContextLevel}; pub use persistent::{ PersistentMemory, PersistentMemoryStore, MemorySearchQuery, MemoryStats, generate_memory_id, }; -pub use crypto::{ - CryptoError, KEY_SIZE, MEMORY_ENCRYPTION_KEY_NAME, - derive_key, generate_key, encrypt, decrypt, -}; diff --git a/desktop/src-tauri/src/memory/persistent.rs b/desktop/src-tauri/src/memory/persistent.rs index ba9feab..13b8300 100644 --- a/desktop/src-tauri/src/memory/persistent.rs +++ b/desktop/src-tauri/src/memory/persistent.rs @@ -15,7 +15,7 @@ use tokio::sync::Mutex; use uuid::Uuid; use tauri::Manager; use sqlx::{SqliteConnection, Connection, Row, sqlite::SqliteRow}; -use chrono::{DateTime, Utc}; +use chrono::Utc; /// Memory entry stored in SQLite #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/desktop/src-tauri/src/memory_commands.rs b/desktop/src-tauri/src/memory_commands.rs index 1e0eeac..2f0d66f 100644 --- a/desktop/src-tauri/src/memory_commands.rs +++ b/desktop/src-tauri/src/memory_commands.rs @@ -6,7 +6,7 @@ use crate::memory::{PersistentMemory, PersistentMemoryStore, MemorySearchQuery, MemoryStats, generate_memory_id}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, State}; use tokio::sync::Mutex; use chrono::Utc; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index e17e292..4a49276 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -211,6 +211,28 @@ function App() { await intelligenceClient.heartbeat.start(defaultAgentId); console.log('[App] Heartbeat engine started for self-evolution'); + + // Set up periodic memory stats sync (every 5 minutes) + const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000; + const statsSyncInterval = setInterval(async () => { + try { + const stats = await intelligenceClient.memory.stats(); + const taskCount = stats.byType?.['task'] || 0; + await intelligenceClient.heartbeat.updateMemoryStats( + defaultAgentId, + taskCount, + stats.totalEntries, + stats.storageSizeBytes + ); + console.log('[App] Memory stats synced (periodic)'); + } catch (err) { + console.warn('[App] Periodic memory stats sync failed:', err); + } + }, MEMORY_STATS_SYNC_INTERVAL); + + // Store interval for cleanup + // @ts-expect-error - Global cleanup reference + window.__ZCLAW_STATS_SYNC_INTERVAL__ = statsSyncInterval; } catch (err) { console.warn('[App] Failed to start heartbeat engine:', err); // Non-critical, continue without heartbeat @@ -229,6 +251,12 @@ function App() { return () => { mounted = false; + // Clean up periodic stats sync interval + // @ts-expect-error - Global cleanup reference + if (window.__ZCLAW_STATS_SYNC_INTERVAL__) { + // @ts-expect-error - Global cleanup reference + clearInterval(window.__ZCLAW_STATS_SYNC_INTERVAL__); + } }; }, [connect, onboardingNeeded, onboardingLoading]); @@ -282,8 +310,41 @@ function App() { return ( { - // Skip onboarding and mark as completed with default values + onClose={async () => { + // Skip onboarding but still create a default agent with default personality + try { + const { getGatewayClient } = await import('./lib/gateway-client'); + const client = getGatewayClient(); + if (client) { + // Create default agent with versatile assistant personality + const defaultAgent = await client.createClone({ + name: '全能助手', + role: '全能型 AI 助手', + nickname: '小龙', + emoji: '🦞', + personality: 'friendly', + scenarios: ['coding', 'writing', 'research', 'product', 'data'], + userName: 'User', + userRole: 'user', + communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念', + }); + + if (defaultAgent?.clone) { + setCurrentAgent({ + id: defaultAgent.clone.id, + name: defaultAgent.clone.name, + icon: defaultAgent.clone.emoji || '🦞', + color: 'bg-gradient-to-br from-orange-500 to-red-500', + lastMessage: defaultAgent.clone.role || '全能型 AI 助手', + time: '', + }); + } + } + } catch (err) { + console.warn('[App] Failed to create default agent on skip:', err); + } + + // Mark onboarding as completed markCompleted({ userName: 'User', userRole: 'user', diff --git a/desktop/src/components/IdentityChangeProposal.tsx b/desktop/src/components/IdentityChangeProposal.tsx index c726e1a..9480242 100644 --- a/desktop/src/components/IdentityChangeProposal.tsx +++ b/desktop/src/components/IdentityChangeProposal.tsx @@ -30,6 +30,30 @@ import { import { useChatStore } from '../store/chatStore'; import { Button, Badge } from './ui'; +// === Error Parsing Utility === + +type ProposalOperation = 'approval' | 'rejection' | 'restore'; + +function parseProposalError(err: unknown, operation: ProposalOperation): string { + const errorMessage = err instanceof Error ? err.message : String(err); + + if (errorMessage.includes('not found') || errorMessage.includes('不存在')) { + return '提案不存在或已被处理,请刷新页面'; + } + if (errorMessage.includes('not pending') || errorMessage.includes('已处理')) { + return '该提案已被处理,请刷新页面'; + } + if (errorMessage.includes('network') || errorMessage.includes('fetch') || errorMessage.includes('网络')) { + return '网络连接失败,请检查网络后重试'; + } + if (errorMessage.includes('timeout') || errorMessage.includes('超时')) { + return '操作超时,请重试'; + } + + const operationName = operation === 'approval' ? '审批' : operation === 'rejection' ? '拒绝' : '恢复'; + return `${operationName}失败: ${errorMessage}`; +} + // === Diff View Component === function DiffView({ @@ -331,8 +355,7 @@ export function IdentityChangeProposalPanel() { setSnapshots(agentSnapshots); } catch (err) { console.error('[IdentityChangeProposal] Failed to approve:', err); - const message = err instanceof Error ? err.message : '审批失败,请重试'; - setError(`审批失败: ${message}`); + setError(parseProposalError(err, 'approval')); } finally { setProcessingId(null); } @@ -349,8 +372,7 @@ export function IdentityChangeProposalPanel() { setProposals(pendingProposals); } catch (err) { console.error('[IdentityChangeProposal] Failed to reject:', err); - const message = err instanceof Error ? err.message : '拒绝失败,请重试'; - setError(`拒绝失败: ${message}`); + setError(parseProposalError(err, 'rejection')); } finally { setProcessingId(null); } @@ -367,8 +389,7 @@ export function IdentityChangeProposalPanel() { setSnapshots(agentSnapshots); } catch (err) { console.error('[IdentityChangeProposal] Failed to restore:', err); - const message = err instanceof Error ? err.message : '恢复失败,请重试'; - setError(`恢复失败: ${message}`); + setError(parseProposalError(err, 'restore')); } finally { setProcessingId(null); } diff --git a/desktop/src/components/RightPanel.tsx b/desktop/src/components/RightPanel.tsx index 482c6a3..5974325 100644 --- a/desktop/src/components/RightPanel.tsx +++ b/desktop/src/components/RightPanel.tsx @@ -112,7 +112,7 @@ export function RightPanel() { () => clones.find((clone) => clone.id === currentAgent?.id), [clones, currentAgent?.id] ); - const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'research']; + const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'writing', 'research', 'product', 'data']; const bootstrapFiles = selectedClone?.bootstrapFiles || []; const gatewayUrl = quickConfig.gatewayUrl || getStoredGatewayUrl(); @@ -172,8 +172,8 @@ export function RightPanel() { const assistantMsgCount = messages.filter(m => m.role === 'assistant').length; const toolCallCount = messages.filter(m => m.role === 'tool').length; const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'; - const userNameDisplay = selectedClone?.userName || quickConfig.userName || '未设置'; - const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置'; + const userNameDisplay = selectedClone?.userName || quickConfig.userName || 'User'; + const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || 'User'; const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区'; // Extract code blocks from all messages (both from codeBlocks property and content parsing) @@ -342,23 +342,27 @@ export function RightPanel() { >
-
+
{selectedClone?.emoji ? ( {selectedClone.emoji} ) : ( - {(selectedClone?.nickname || currentAgent?.name || 'Z').slice(0, 1)} + 🦞 )}
- {selectedClone?.name || currentAgent?.name || 'ZCLAW'} - {selectedClone?.personality && ( + {selectedClone?.name || currentAgent?.name || '全能助手'} + {selectedClone?.personality ? ( {getPersonalityById(selectedClone.personality)?.label || selectedClone.personality} + ) : ( + + 友好亲切 + )}
-
{selectedClone?.role || 'AI coworker'}
+
{selectedClone?.role || '全能型 AI 助手'}
{selectedClone ? ( @@ -410,10 +414,10 @@ export function RightPanel() {
) : (
- - + + - +
)} diff --git a/desktop/src/components/SkillMarket.tsx b/desktop/src/components/SkillMarket.tsx index 68756df..e22bb68 100644 --- a/desktop/src/components/SkillMarket.tsx +++ b/desktop/src/components/SkillMarket.tsx @@ -25,6 +25,7 @@ import { RefreshCw, } from 'lucide-react'; import { useConfigStore } from '../store/configStore'; +import { useConnectionStore } from '../store/connectionStore'; import { adaptSkillsCatalog, type SkillDisplay, @@ -250,6 +251,9 @@ export function SkillMarket({ const loadSkillsCatalog = useConfigStore((s) => s.loadSkillsCatalog); const updateSkill = useConfigStore((s) => s.updateSkill); + // Watch connection state to reload skills when connected + const connectionState = useConnectionStore((s) => s.connectionState); + const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [expandedSkillId, setExpandedSkillId] = useState(null); @@ -258,10 +262,12 @@ export function SkillMarket({ // Adapt skills to display format const skills = useMemo(() => adaptSkillsCatalog(skillsCatalog), [skillsCatalog]); - // Load skills on mount + // Load skills on mount and when connection state changes to 'connected' useEffect(() => { - loadSkillsCatalog(); - }, [loadSkillsCatalog]); + if (connectionState === 'connected') { + loadSkillsCatalog(); + } + }, [loadSkillsCatalog, connectionState]); // Filter skills const filteredSkills = useMemo(() => { diff --git a/desktop/src/components/WorkflowBuilder/NodePalette.tsx b/desktop/src/components/WorkflowBuilder/NodePalette.tsx new file mode 100644 index 0000000..dd34c81 --- /dev/null +++ b/desktop/src/components/WorkflowBuilder/NodePalette.tsx @@ -0,0 +1,92 @@ +/** + * Node Palette Component + * + * Draggable palette of available node types. + */ + +import React, { DragEvent } from 'react'; +import type { NodePaletteItem, NodeCategory } from '../../lib/workflow-builder/types'; + +interface NodePaletteProps { + categories: Record; + onDragStart: (type: string) => void; + onDragEnd: () => void; +} + +const categoryLabels: Record = { + input: { label: 'Input', color: 'emerald' }, + ai: { label: 'AI & Skills', color: 'violet' }, + action: { label: 'Actions', color: 'amber' }, + control: { label: 'Control Flow', color: 'orange' }, + output: { label: 'Output', color: 'blue' }, +}; + +export function NodePalette({ categories, onDragStart, onDragEnd }: NodePaletteProps) { + const handleDragStart = (event: DragEvent, type: string) => { + event.dataTransfer.setData('application/reactflow', type); + event.dataTransfer.effectAllowed = 'move'; + onDragStart(type); + }; + + const handleDragEnd = () => { + onDragEnd(); + }; + + return ( +
+
+

Nodes

+

Drag nodes to canvas

+
+ +
+ {(Object.keys(categories) as NodeCategory[]).map((category) => { + const items = categories[category]; + if (items.length === 0) return null; + + const { label, color } = categoryLabels[category]; + + return ( +
+

+ {label} +

+ +
+ {items.map((item) => ( +
handleDragStart(e, item.type)} + onDragEnd={handleDragEnd} + className={` + flex items-center gap-3 px-3 py-2 rounded-lg + bg-gray-50 hover:bg-gray-100 cursor-grab + border border-transparent hover:border-gray-200 + transition-all duration-150 + active:cursor-grabbing + `} + > + {item.icon} +
+
+ {item.label} +
+
+ {item.description} +
+
+
+ ))} +
+
+ ); + })} +
+
+ ); +} + +export default NodePalette; diff --git a/desktop/src/components/WorkflowBuilder/PropertyPanel.tsx b/desktop/src/components/WorkflowBuilder/PropertyPanel.tsx new file mode 100644 index 0000000..4d21bde --- /dev/null +++ b/desktop/src/components/WorkflowBuilder/PropertyPanel.tsx @@ -0,0 +1,295 @@ +/** + * Property Panel Component + * + * Panel for editing node properties. + */ + +import React, { useState, useEffect } from 'react'; +import type { WorkflowNodeData } from '../../lib/workflow-builder/types'; + +interface PropertyPanelProps { + nodeId: string; + nodeData: WorkflowNodeData | undefined; + onUpdate: (data: Partial) => void; + onDelete: () => void; + onClose: () => void; +} + +export function PropertyPanel({ + nodeId, + nodeData, + onUpdate, + onDelete, + onClose, +}: PropertyPanelProps) { + const [localData, setLocalData] = useState>({}); + + useEffect(() => { + if (nodeData) { + setLocalData(nodeData); + } + }, [nodeData]); + + if (!nodeData) return null; + + const handleChange = (field: string, value: unknown) => { + const updated = { ...localData, [field]: value }; + setLocalData(updated); + onUpdate({ [field]: value } as Partial); + }; + + return ( +
+ {/* Header */} +
+

Properties

+ +
+ + {/* Content */} +
+ {/* Common Fields */} +
+ + handleChange('label', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Type-specific Fields */} + {renderTypeSpecificFields(nodeData.type, localData, handleChange)} + + {/* Delete Button */} +
+ +
+
+
+ ); +} + +function renderTypeSpecificFields( + type: string, + data: Partial, + onChange: (field: string, value: unknown) => void +) { + switch (type) { + case 'input': + return ( + <> +
+ + onChange('variableName', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono" + /> +
+
+ +