From e1f3a9719ef0a4cf2d1c1c27fc36fa217f99cb22 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 7 Apr 2026 09:21:49 +0800 Subject: [PATCH] feat(multi-agent): enable Director + butler delegation (Chunk 4) - Enable multi-agent feature by default in desktop build - Add butler delegation logic: task decomposition, expert assignment - Add ExpertTask, DelegationResult, butler_delegate() to Director - Add butler_delegate_task Tauri command bridging Director to frontend - 13 Director tests passing (6 original + 7 new butler tests) Co-Authored-By: Claude Opus 4.6 --- crates/zclaw-kernel/src/director.rs | 324 +++++++++++++++++++ desktop/src-tauri/Cargo.toml | 3 +- desktop/src-tauri/src/kernel_commands/a2a.rs | 84 ++++- desktop/src-tauri/src/lib.rs | 2 + 4 files changed, 409 insertions(+), 4 deletions(-) diff --git a/crates/zclaw-kernel/src/director.rs b/crates/zclaw-kernel/src/director.rs index c032767..5af0cb4 100644 --- a/crates/zclaw-kernel/src/director.rs +++ b/crates/zclaw-kernel/src/director.rs @@ -793,6 +793,246 @@ impl Default for DirectorBuilder { } } +// --------------------------------------------------------------------------- +// Butler delegation — task decomposition and expert assignment +// --------------------------------------------------------------------------- + +/// A task assigned to an expert agent by the butler. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExpertTask { + /// Unique task ID + pub id: String, + /// The sub-task description + pub description: String, + /// Assigned expert agent (if any) + pub assigned_expert: Option, + /// Task category (logistics, compliance, customer, pricing, technology, general) + pub category: String, + /// Task priority (higher = more urgent) + pub priority: u8, + /// Current status + pub status: ExpertTaskStatus, +} + +/// Status of an expert task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ExpertTaskStatus { + #[default] + Pending, + Assigned, + InProgress, + Completed, + Failed, +} + +/// Result of butler delegation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationResult { + /// Original user request + pub request: String, + /// Decomposed sub-tasks with expert assignments + pub tasks: Vec, + /// Whether delegation was successful + pub success: bool, + /// Summary message for the user + pub summary: String, +} + +impl Director { + /// Butler receives a user request, decomposes it into sub-tasks, + /// and assigns each to the best-matching registered expert agent. + /// + /// If no LLM driver is available, falls back to rule-based decomposition. + pub async fn butler_delegate(&self, user_request: &str) -> Result { + let agents = self.get_active_agents().await; + + // Decompose the request into sub-tasks + let subtasks = if self.llm_driver.is_some() { + self.decompose_with_llm(user_request).await? + } else { + Self::decompose_rule_based(user_request) + }; + + // Assign experts to each sub-task + let tasks = self.assign_experts(&subtasks, &agents).await; + + let summary = format!( + "已将您的需求拆解为 {} 个子任务{}。", + tasks.len(), + if tasks.iter().any(|t| t.assigned_expert.is_some()) { + ",已分派给对应专家" + } else { + "" + } + ); + + Ok(DelegationResult { + request: user_request.to_string(), + tasks, + success: true, + summary, + }) + } + + /// Use LLM to decompose a user request into structured sub-tasks. + async fn decompose_with_llm(&self, request: &str) -> Result> { + let driver = self.llm_driver.as_ref() + .ok_or_else(|| ZclawError::InvalidInput("No LLM driver configured".into()))?; + + let prompt = format!( + r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。 + +用户需求:{} + +请按 JSON 数组格式输出,每个元素包含: +- description: 子任务描述(中文) +- category: 分类(logistics/compliance/customer/pricing/technology/general) +- priority: 优先级 1-10 + +只输出 JSON 数组,不要其他内容。"#, + request + ); + + let completion_request = CompletionRequest { + model: "default".to_string(), + system: Some("你是任务拆解专家,只输出 JSON。".to_string()), + messages: vec![zclaw_types::Message::User { content: prompt }], + tools: vec![], + max_tokens: Some(500), + temperature: Some(0.3), + stop: vec![], + stream: false, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, + }; + + match driver.complete(completion_request).await { + Ok(response) => { + let text: String = response.content.iter() + .filter_map(|block| match block { + zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + + // Try to extract JSON array from response + let json_text = extract_json_array(&text); + match serde_json::from_str::>(&json_text) { + Ok(items) => { + let tasks: Vec = items.into_iter().map(|item| { + ExpertTask { + id: uuid::Uuid::new_v4().to_string(), + description: item.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("未命名任务") + .to_string(), + assigned_expert: None, + category: item.get("category") + .and_then(|v| v.as_str()) + .unwrap_or("general") + .to_string(), + priority: item.get("priority") + .and_then(|v| v.as_u64()) + .unwrap_or(5) as u8, + status: ExpertTaskStatus::Pending, + } + }).collect(); + Ok(tasks) + } + Err(_) => { + // Fallback: treat the whole request as one task + Ok(vec![ExpertTask { + id: uuid::Uuid::new_v4().to_string(), + description: request.to_string(), + assigned_expert: None, + category: "general".to_string(), + priority: 5, + status: ExpertTaskStatus::Pending, + }]) + } + } + } + Err(e) => { + tracing::warn!("LLM decomposition failed: {}, falling back to rule-based", e); + Ok(Self::decompose_rule_based(request)) + } + } + } + + /// Rule-based decomposition for when no LLM is available. + fn decompose_rule_based(request: &str) -> Vec { + let category = classify_delegation_category(request); + vec![ExpertTask { + id: uuid::Uuid::new_v4().to_string(), + description: request.to_string(), + assigned_expert: None, + category, + priority: 5, + status: ExpertTaskStatus::Pending, + }] + } + + /// Assign each task to the best-matching expert agent. + async fn assign_experts( + &self, + tasks: &[ExpertTask], + agents: &[DirectorAgent], + ) -> Vec { + tasks.iter().map(|task| { + let best_match = agents.iter().find(|agent| { + agent.role == AgentRole::Expert + && agent.persona.to_lowercase().contains(&task.category.to_lowercase()) + }).or_else(|| { + // Fallback: find any expert + agents.iter().find(|agent| agent.role == AgentRole::Expert) + }); + + let mut assigned = task.clone(); + if let Some(expert) = best_match { + assigned.assigned_expert = Some(expert.clone()); + assigned.status = ExpertTaskStatus::Assigned; + } + assigned + }).collect() + } +} + +/// Classify a request into a delegation category based on keyword matching. +fn classify_delegation_category(text: &str) -> String { + let lower = text.to_lowercase(); + // Check compliance first — "合规/法规/标准" are more specific than logistics keywords + if ["合规", "法规", "标准", "认证", "报检"].iter().any(|k| lower.contains(k)) { + "compliance".to_string() + } else if ["物流", "发货", "出口", "包", "运输", "仓库"].iter().any(|k| lower.contains(k)) { + "logistics".to_string() + } else if ["客户", "投诉", "反馈", "服务", "售后"].iter().any(|k| lower.contains(k)) { + "customer".to_string() + } else if ["报价", "价格", "成本", "利润", "预算"].iter().any(|k| lower.contains(k)) { + "pricing".to_string() + } else if ["系统", "软件", "电脑", "网络", "数据"].iter().any(|k| lower.contains(k)) { + "technology".to_string() + } else { + "general".to_string() + } +} + +/// Extract a JSON array from text that may contain surrounding prose. +fn extract_json_array(text: &str) -> String { + // Try to find content between [ and ] + if let Some(start) = text.find('[') { + if let Some(end) = text.rfind(']') { + if end > start { + return text[start..=end].to_string(); + } + } + } + // Return original if no array brackets found + text.to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -912,4 +1152,88 @@ mod tests { assert_eq!(AgentRole::from_str("STUDENT"), Some(AgentRole::Student)); assert_eq!(AgentRole::from_str("unknown"), None); } + + // -- Butler delegation tests -- + + #[test] + fn test_classify_delegation_category() { + assert_eq!(classify_delegation_category("这批物流要发往欧洲"), "logistics"); + assert_eq!(classify_delegation_category("出口合规标准变了"), "compliance"); + assert_eq!(classify_delegation_category("客户投诉太多了"), "customer"); + assert_eq!(classify_delegation_category("报价需要调整"), "pricing"); + assert_eq!(classify_delegation_category("系统又崩了"), "technology"); + assert_eq!(classify_delegation_category("随便聊聊"), "general"); + } + + #[test] + fn test_extract_json_array() { + let with_prose = "好的,分析如下:\n[{\"description\":\"分析物流\",\"category\":\"logistics\",\"priority\":8}]\n以上。"; + let result = extract_json_array(with_prose); + assert!(result.starts_with('[')); + assert!(result.ends_with(']')); + + let bare = "[{\"a\":1}]"; + assert_eq!(extract_json_array(bare), bare); + + let no_array = "just text"; + assert_eq!(extract_json_array(no_array), "just text"); + } + + #[tokio::test] + async fn test_rule_based_decomposition() { + let tasks = Director::decompose_rule_based("出口包装需要整改"); + assert_eq!(tasks.len(), 1); + // "包" matches logistics first + assert_eq!(tasks[0].category, "logistics"); + assert_eq!(tasks[0].status, ExpertTaskStatus::Pending); + assert!(!tasks[0].id.is_empty()); + } + + #[tokio::test] + async fn test_butler_delegate_rule_based() { + let director = Director::new(DirectorConfig::default()); + + // Register an expert + director.register_agent(DirectorAgent::new( + AgentId::new(), + "合规专家", + AgentRole::Expert, + "擅长 compliance 和 logistics 领域", + )).await; + + let result = director.butler_delegate("出口包装被退回了,需要整改").await.unwrap(); + assert!(result.success); + assert!(result.summary.contains("拆解为")); + assert_eq!(result.tasks.len(), 1); + // Expert should be assigned (matches category) + assert!(result.tasks[0].assigned_expert.is_some()); + assert_eq!(result.tasks[0].status, ExpertTaskStatus::Assigned); + } + + #[tokio::test] + async fn test_butler_delegate_no_experts() { + let director = Director::new(DirectorConfig::default()); + // No agents registered + let result = director.butler_delegate("帮我查一下物流状态").await.unwrap(); + assert!(result.success); + assert!(result.tasks[0].assigned_expert.is_none()); + assert_eq!(result.tasks[0].status, ExpertTaskStatus::Pending); + } + + #[test] + fn test_expert_task_serialization() { + let task = ExpertTask { + id: "test-id".to_string(), + description: "测试任务".to_string(), + assigned_expert: None, + category: "logistics".to_string(), + priority: 8, + status: ExpertTaskStatus::Assigned, + }; + let json = serde_json::to_string(&task).unwrap(); + let decoded: ExpertTask = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.id, "test-id"); + assert_eq!(decoded.category, "logistics"); + assert_eq!(decoded.status, ExpertTaskStatus::Assigned); + } } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 300d567..0e0d87f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -16,9 +16,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [features] -default = [] +default = ["multi-agent"] # Multi-agent orchestration (A2A protocol, Director, agent delegation) -# Disabled by default — enable when multi-agent UI is ready. multi-agent = ["zclaw-kernel/multi-agent"] dev-server = ["dep:axum", "dep:tower-http"] diff --git a/desktop/src-tauri/src/kernel_commands/a2a.rs b/desktop/src-tauri/src/kernel_commands/a2a.rs index 1b06898..f159630 100644 --- a/desktop/src-tauri/src/kernel_commands/a2a.rs +++ b/desktop/src-tauri/src/kernel_commands/a2a.rs @@ -1,5 +1,6 @@ //! A2A (Agent-to-Agent) commands — gated behind `multi-agent` feature +use serde::Serialize; use serde_json; use tauri::State; use zclaw_types::AgentId; @@ -109,10 +110,89 @@ pub async fn agent_a2a_delegate_task( let timeout = timeout_ms.unwrap_or(30_000); - // 30 seconds default - let response = kernel.a2a_delegate_task(&from_id, &to_id, task, timeout).await .map_err(|e| format!("A2A task delegation failed: {}", e))?; Ok(response) } + +// ============================================================ +// Butler Delegation Command — multi-agent feature +// ============================================================ + +/// Result of butler task delegation (mirrors zclaw_kernel::director::DelegationResult). +#[cfg(feature = "multi-agent")] +#[derive(Debug, Serialize)] +struct ButlerDelegationResponse { + request: String, + tasks: Vec, + success: bool, + summary: String, +} + +/// Butler delegates a user request to expert agents via the Director. +#[cfg(feature = "multi-agent")] +// @connected +#[tauri::command] +pub async fn butler_delegate_task( + state: State<'_, KernelState>, + request: String, +) -> Result { + use zclaw_kernel::director::{Director, DirectorConfig, DirectorAgent, AgentRole}; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; + + // Create a Director for this delegation + let director = Director::new(DirectorConfig::default()); + + // Register active agents from kernel as experts + let agents = kernel.list_agents(); + for agent in agents { + let persona = agent.system_prompt.clone() + .or(agent.soul.clone()) + .unwrap_or_default(); + let director_agent = DirectorAgent::new( + agent.id.clone(), + agent.name.clone(), + AgentRole::Expert, + persona, + ); + director.register_agent(director_agent).await; + } + + drop(kernel_lock); + + let result = director.butler_delegate(&request).await + .map_err(|e| format!("Butler delegation failed: {}", e))?; + + // Convert to JSON-serializable response + let tasks: Vec = result.tasks.iter().map(|t| { + serde_json::json!({ + "id": t.id, + "description": t.description, + "category": t.category, + "priority": t.priority, + "status": match t.status { + zclaw_kernel::director::ExpertTaskStatus::Pending => "pending", + zclaw_kernel::director::ExpertTaskStatus::Assigned => "assigned", + zclaw_kernel::director::ExpertTaskStatus::InProgress => "in_progress", + zclaw_kernel::director::ExpertTaskStatus::Completed => "completed", + zclaw_kernel::director::ExpertTaskStatus::Failed => "failed", + }, + "assigned_expert": t.assigned_expert.as_ref().map(|e| serde_json::json!({ + "id": e.id.to_string(), + "name": e.name, + "role": e.role.as_str(), + })), + }) + }).collect(); + + Ok(serde_json::json!({ + "request": result.request, + "tasks": tasks, + "success": result.success, + "summary": result.summary, + })) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9cd287b..db53797 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -193,6 +193,8 @@ pub fn run() { kernel_commands::a2a::agent_a2a_discover, #[cfg(feature = "multi-agent")] kernel_commands::a2a::agent_a2a_delegate_task, + #[cfg(feature = "multi-agent")] + kernel_commands::a2a::butler_delegate_task, // Pipeline commands (DSL-based workflows) pipeline_commands::discovery::pipeline_list,