diff --git a/crates/zclaw-growth/src/storage/sqlite.rs b/crates/zclaw-growth/src/storage/sqlite.rs index d9b35ef..4ecaa51 100644 --- a/crates/zclaw-growth/src/storage/sqlite.rs +++ b/crates/zclaw-growth/src/storage/sqlite.rs @@ -291,6 +291,27 @@ impl sqlx::FromRow<'_, SqliteRow> for MemoryRow { /// Private helper methods on SqliteStorage (NOT in impl VikingStorage block) impl SqliteStorage { + /// Sanitize a user query for FTS5 MATCH syntax. + /// + /// FTS5 treats several characters as operators (`+`, `-`, `*`, `"`, `(`, `)`, `:`). + /// Strips these and keeps only alphanumeric + CJK tokens with length > 1, + /// then joins them with `OR` for broad matching. + fn sanitize_fts_query(query: &str) -> String { + let terms: Vec = query + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty() && s.len() > 1) + .map(|s| s.to_string()) + .collect(); + + if terms.is_empty() { + return String::new(); + } + + // Join with OR so any term can match (broad recall, then rerank by similarity) + terms.join(" OR ") + } + /// Fetch memories by scope with importance-based ordering. /// Used internally by find() for scope-based queries. pub(crate) async fn fetch_by_scope_priv(&self, scope: Option<&str>, limit: usize) -> Result> { @@ -363,7 +384,10 @@ impl VikingStorage for SqliteStorage { let _ = sqlx::query("DELETE FROM memories_fts WHERE uri = ?") .bind(&entry.uri) .execute(&self.pool) - .await; + .await + .map_err(|e| { + tracing::warn!("[SqliteStorage] Failed to delete old FTS entry: {}", e); + }); let keywords_text = entry.keywords.join(" "); let _ = sqlx::query( @@ -376,7 +400,10 @@ impl VikingStorage for SqliteStorage { .bind(&entry.content) .bind(&keywords_text) .execute(&self.pool) - .await; + .await + .map_err(|e| { + tracing::warn!("[SqliteStorage] Failed to insert FTS entry: {}", e); + }); // Update semantic scorer (use embedding when available) let mut scorer = self.scorer.write().await; @@ -416,8 +443,21 @@ impl VikingStorage for SqliteStorage { // Strategy: use FTS5 for initial filtering when query is non-empty, // then score candidates with TF-IDF / embedding for precise ranking. - // Fallback to scope-only scan when query is empty (e.g., "list all"). + // When FTS5 returns nothing, we return empty — do NOT fall back to + // scope scan (that returns irrelevant high-importance memories). let rows = if !query.is_empty() { + // Sanitize query for FTS5: strip operators that cause syntax errors + let sanitized = Self::sanitize_fts_query(query); + + if sanitized.is_empty() { + // Query had no meaningful terms after sanitization (e.g., "1+2") + tracing::debug!( + "[SqliteStorage] Query '{}' produced no FTS5-searchable terms, skipping", + query.chars().take(50).collect::() + ); + return Ok(Vec::new()); + } + // FTS5-powered candidate retrieval (fast, index-based) let fts_candidates = if let Some(ref scope) = options.scope { sqlx::query_as::<_, MemoryRow>( @@ -432,7 +472,7 @@ impl VikingStorage for SqliteStorage { LIMIT ? "# ) - .bind(query) + .bind(&sanitized) .bind(format!("{}%", scope)) .bind(limit as i64) .fetch_all(&self.pool) @@ -449,7 +489,7 @@ impl VikingStorage for SqliteStorage { LIMIT ? "# ) - .bind(query) + .bind(&sanitized) .bind(limit as i64) .fetch_all(&self.pool) .await @@ -457,11 +497,25 @@ impl VikingStorage for SqliteStorage { match fts_candidates { Ok(rows) if !rows.is_empty() => rows, - Ok(_) | Err(_) => { - // FTS5 returned nothing or query syntax was invalid — - // fallback to scope-based scan (no full table scan unless no scope) - tracing::debug!("[SqliteStorage] FTS5 returned no results, falling back to scope scan"); - self.fetch_by_scope_priv(options.scope.as_deref(), limit).await? + Ok(_) => { + // FTS5 returned no results — memories are genuinely irrelevant. + // Do NOT fall back to scope scan (that was the root cause of + // injecting "广东光华" memories into "1+9" queries). + tracing::debug!( + "[SqliteStorage] FTS5 returned no results for query: '{}'", + query.chars().take(50).collect::() + ); + return Ok(Vec::new()); + } + Err(e) => { + // FTS5 syntax error after sanitization — return empty rather + // than falling back to irrelevant scope-based results. + tracing::debug!( + "[SqliteStorage] FTS5 query failed for '{}': {}", + query.chars().take(50).collect::(), + e + ); + return Ok(Vec::new()); } } } else { @@ -557,7 +611,10 @@ impl VikingStorage for SqliteStorage { let _ = sqlx::query("DELETE FROM memories_fts WHERE uri = ?") .bind(uri) .execute(&self.pool) - .await; + .await + .map_err(|e| { + tracing::warn!("[SqliteStorage] Failed to delete FTS entry: {}", e); + }); // Remove from in-memory scorer let mut scorer = self.scorer.write().await; diff --git a/crates/zclaw-growth/src/viking_adapter.rs b/crates/zclaw-growth/src/viking_adapter.rs index b9a9769..3a349f5 100644 --- a/crates/zclaw-growth/src/viking_adapter.rs +++ b/crates/zclaw-growth/src/viking_adapter.rs @@ -134,18 +134,18 @@ impl Default for InMemoryStorage { #[async_trait] impl VikingStorage for InMemoryStorage { async fn store(&self, entry: &MemoryEntry) -> Result<()> { - let mut memories = self.memories.write().unwrap(); + let mut memories = self.memories.write().expect("InMemoryStorage lock poisoned"); memories.insert(entry.uri.clone(), entry.clone()); Ok(()) } async fn get(&self, uri: &str) -> Result> { - let memories = self.memories.read().unwrap(); + let memories = self.memories.read().expect("InMemoryStorage lock poisoned"); Ok(memories.get(uri).cloned()) } async fn find(&self, query: &str, options: FindOptions) -> Result> { - let memories = self.memories.read().unwrap(); + let memories = self.memories.read().expect("InMemoryStorage lock poisoned"); let mut results: Vec = memories .values() @@ -187,7 +187,7 @@ impl VikingStorage for InMemoryStorage { } async fn find_by_prefix(&self, prefix: &str) -> Result> { - let memories = self.memories.read().unwrap(); + let memories = self.memories.read().expect("InMemoryStorage lock poisoned"); let results: Vec = memories .values() @@ -199,19 +199,19 @@ impl VikingStorage for InMemoryStorage { } async fn delete(&self, uri: &str) -> Result<()> { - let mut memories = self.memories.write().unwrap(); + let mut memories = self.memories.write().expect("InMemoryStorage lock poisoned"); memories.remove(uri); Ok(()) } async fn store_metadata_json(&self, key: &str, json: &str) -> Result<()> { - let mut metadata = self.metadata.write().unwrap(); + let mut metadata = self.metadata.write().expect("InMemoryStorage lock poisoned"); metadata.insert(key.to_string(), json.to_string()); Ok(()) } async fn get_metadata_json(&self, key: &str) -> Result> { - let metadata = self.metadata.read().unwrap(); + let metadata = self.metadata.read().expect("InMemoryStorage lock poisoned"); Ok(metadata.get(key).cloned()) } } diff --git a/crates/zclaw-hands/Cargo.toml b/crates/zclaw-hands/Cargo.toml index 3f62618..1c834c7 100644 --- a/crates/zclaw-hands/Cargo.toml +++ b/crates/zclaw-hands/Cargo.toml @@ -20,6 +20,4 @@ thiserror = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } -hmac = "0.12" -sha1 = "0.10" base64 = { workspace = true } diff --git a/crates/zclaw-hands/src/hands/quiz.rs b/crates/zclaw-hands/src/hands/quiz.rs index c3f07fb..0fc7efe 100644 --- a/crates/zclaw-hands/src/hands/quiz.rs +++ b/crates/zclaw-hands/src/hands/quiz.rs @@ -182,6 +182,9 @@ impl QuizGenerator for LlmQuizGenerator { temperature: Some(0.7), stop: Vec::new(), stream: false, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, }; let response = self.driver.complete(request).await.map_err(|e| { diff --git a/crates/zclaw-hands/src/hands/slideshow.rs b/crates/zclaw-hands/src/hands/slideshow.rs index cc10125..0408bb2 100644 --- a/crates/zclaw-hands/src/hands/slideshow.rs +++ b/crates/zclaw-hands/src/hands/slideshow.rs @@ -96,7 +96,8 @@ pub struct SlideContent { pub background: Option, } -/// Content block types +/// Presentation/slideshow rendering content block. Domain-specific for slide content. +/// Distinct from zclaw_types::ContentBlock (LLM messages) and zclaw_protocols::ContentBlock (MCP). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { diff --git a/crates/zclaw-kernel/src/config.rs b/crates/zclaw-kernel/src/config.rs index 99619b2..c9bfc0a 100644 --- a/crates/zclaw-kernel/src/config.rs +++ b/crates/zclaw-kernel/src/config.rs @@ -311,7 +311,7 @@ impl KernelConfig { } /// Find the config file path. - fn find_config_path() -> Option { + pub fn find_config_path() -> Option { // 1. Environment variable override if let Ok(path) = std::env::var("ZCLAW_CONFIG") { return Some(PathBuf::from(path)); diff --git a/crates/zclaw-kernel/src/export/html.rs b/crates/zclaw-kernel/src/export/html.rs index 7b7dc76..4e61ceb 100644 --- a/crates/zclaw-kernel/src/export/html.rs +++ b/crates/zclaw-kernel/src/export/html.rs @@ -755,6 +755,7 @@ mod tests { order: 0, }, ], + agents: vec![], metadata: ClassroomMetadata::default(), } } diff --git a/crates/zclaw-kernel/src/export/markdown.rs b/crates/zclaw-kernel/src/export/markdown.rs index eef597d..e0bc861 100644 --- a/crates/zclaw-kernel/src/export/markdown.rs +++ b/crates/zclaw-kernel/src/export/markdown.rs @@ -563,6 +563,7 @@ mod tests { order: 1, }, ], + agents: vec![], metadata: ClassroomMetadata::default(), } } diff --git a/crates/zclaw-kernel/src/export/pptx.rs b/crates/zclaw-kernel/src/export/pptx.rs index 5f25db0..337edce 100644 --- a/crates/zclaw-kernel/src/export/pptx.rs +++ b/crates/zclaw-kernel/src/export/pptx.rs @@ -601,6 +601,7 @@ mod tests { order: 0, }, ], + agents: vec![], metadata: ClassroomMetadata::default(), } } diff --git a/crates/zclaw-kernel/src/generation/agents.rs b/crates/zclaw-kernel/src/generation/agents.rs new file mode 100644 index 0000000..6ff35e8 --- /dev/null +++ b/crates/zclaw-kernel/src/generation/agents.rs @@ -0,0 +1,345 @@ +//! Agent Profile Generation for Interactive Classroom +//! +//! Generates multi-agent classroom roles (Teacher, Assistant, Students) +//! with distinct personas, avatars, and action permissions. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Agent role in the classroom +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentRole { + Teacher, + Assistant, + Student, +} + +impl Default for AgentRole { + fn default() -> Self { + Self::Teacher + } +} + +impl std::fmt::Display for AgentRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AgentRole::Teacher => write!(f, "teacher"), + AgentRole::Assistant => write!(f, "assistant"), + AgentRole::Student => write!(f, "student"), + } + } +} + +/// Agent profile for classroom participants +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfile { + /// Unique ID for this agent + pub id: String, + /// Display name (e.g., "陈老师", "小助手", "张伟") + pub name: String, + /// Role type + pub role: AgentRole, + /// Persona description (system prompt for this agent) + pub persona: String, + /// Avatar emoji or URL + pub avatar: String, + /// Theme color (hex) + pub color: String, + /// Actions this agent is allowed to perform + pub allowed_actions: Vec, + /// Speaking priority (higher = speaks first in multi-agent) + pub priority: u8, +} + +/// Request for generating agent profiles +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentProfileRequest { + /// Topic for context-aware persona generation + pub topic: String, + /// Teaching style hint + pub style: String, + /// Difficulty level hint + pub level: String, + /// Total agent count (default: 5) + pub agent_count: Option, + /// Language code (default: "zh-CN") + pub language: Option, +} + +impl Default for AgentProfileRequest { + fn default() -> Self { + Self { + topic: String::new(), + style: "lecture".to_string(), + level: "intermediate".to_string(), + agent_count: None, + language: Some("zh-CN".to_string()), + } + } +} + +/// Generate agent profiles for a classroom session. +/// +/// Returns hardcoded defaults that match the OpenMAIC experience. +/// Future: optionally use LLM for dynamic persona generation. +pub fn generate_agent_profiles(request: &AgentProfileRequest) -> Vec { + let lang = request.language.as_deref().unwrap_or("zh-CN"); + let count = request.agent_count.unwrap_or(5); + let student_count = count.saturating_sub(2).max(1); + + if lang.starts_with("zh") { + generate_chinese_profiles(&request.topic, &request.style, student_count) + } else { + generate_english_profiles(&request.topic, &request.style, student_count) + } +} + +fn generate_chinese_profiles(topic: &str, style: &str, student_count: usize) -> Vec { + let style_desc = match style { + "discussion" => "善于引导讨论的", + "pbl" => "注重项目实践的", + "socratic" => "擅长提问式教学的", + _ => "经验丰富的", + }; + + let mut agents = Vec::with_capacity(student_count + 2); + + // Teacher + agents.push(AgentProfile { + id: format!("agent_teacher_{}", Uuid::new_v4()), + name: "陈老师".to_string(), + role: AgentRole::Teacher, + persona: format!( + "你是一位{}教师,正在教授「{}」这个主题。你的教学风格清晰有条理,\ + 善于使用生活中的比喻和类比帮助学生理解抽象概念。你注重核心原理的透彻理解,\ + 会用通俗易懂的语言解释复杂概念。", + style_desc, topic + ), + avatar: "👩‍🏫".to_string(), + color: "#4F46E5".to_string(), + allowed_actions: vec![ + "speech".into(), + "whiteboard_draw".into(), + "slideshow_control".into(), + "quiz_create".into(), + ], + priority: 10, + }); + + // Assistant + agents.push(AgentProfile { + id: format!("agent_assistant_{}", Uuid::new_v4()), + name: "小助手".to_string(), + role: AgentRole::Assistant, + persona: format!( + "你是一位耐心的助教,正在协助教授「{}」。你擅长用代码示例和图表辅助讲解,\ + 善于回答学生问题,补充老师遗漏的知识点。你说话简洁明了,喜欢用emoji点缀语气。", + topic + ), + avatar: "🤝".to_string(), + color: "#10B981".to_string(), + allowed_actions: vec![ + "speech".into(), + "whiteboard_draw".into(), + ], + priority: 7, + }); + + // Students — up to 3 distinct personalities + let student_templates = [ + ( + "李思", + "你是一个好奇且活跃的学生,正在学习「{topic}」。你有一定编程基础,但概念理解上容易混淆。\ + 你经常问'为什么'和'如果...呢'这类深入问题,喜欢和老师互动。", + "🤔", + "#EF4444", + ), + ( + "王明", + "你是一个认真笔记的学生,正在学习「{topic}」。你学习态度端正,善于总结和归纳要点。\ + 你经常复述和确认自己的理解,喜欢有条理的讲解方式。", + "📝", + "#F59E0B", + ), + ( + "张伟", + "你是一个思维跳跃的学生,正在学习「{topic}」。你经常联想到其他概念和实际应用场景,\ + 善于举一反三但有时会跑题。你喜欢动手实践和探索。", + "💡", + "#8B5CF6", + ), + ]; + + for i in 0..student_count { + let (name, persona_tmpl, avatar, color) = &student_templates[i % student_templates.len()]; + agents.push(AgentProfile { + id: format!("agent_student_{}_{}", i + 1, Uuid::new_v4()), + name: name.to_string(), + role: AgentRole::Student, + persona: persona_tmpl.replace("{topic}", topic), + avatar: avatar.to_string(), + color: color.to_string(), + allowed_actions: vec!["speech".into(), "ask_question".into()], + priority: (5 - i as u8).max(1), + }); + } + + agents +} + +fn generate_english_profiles(topic: &str, style: &str, student_count: usize) -> Vec { + let style_desc = match style { + "discussion" => "discussion-oriented", + "pbl" => "project-based", + "socratic" => "Socratic method", + _ => "experienced", + }; + + let mut agents = Vec::with_capacity(student_count + 2); + + // Teacher + agents.push(AgentProfile { + id: format!("agent_teacher_{}", Uuid::new_v4()), + name: "Prof. Chen".to_string(), + role: AgentRole::Teacher, + persona: format!( + "You are a {} instructor teaching 「{}」. Your teaching style is clear and organized, \ + skilled at using metaphors and analogies to explain complex concepts in accessible language. \ + You focus on thorough understanding of core principles.", + style_desc, topic + ), + avatar: "👩‍🏫".to_string(), + color: "#4F46E5".to_string(), + allowed_actions: vec![ + "speech".into(), + "whiteboard_draw".into(), + "slideshow_control".into(), + "quiz_create".into(), + ], + priority: 10, + }); + + // Assistant + agents.push(AgentProfile { + id: format!("agent_assistant_{}", Uuid::new_v4()), + name: "TA Alex".to_string(), + role: AgentRole::Assistant, + persona: format!( + "You are a patient teaching assistant helping with 「{}」. \ + You provide code examples, diagrams, and fill in gaps. You are concise and friendly.", + topic + ), + avatar: "🤝".to_string(), + color: "#10B981".to_string(), + allowed_actions: vec!["speech".into(), "whiteboard_draw".into()], + priority: 7, + }); + + // Students + let student_templates = [ + ( + "Sam", + "A curious and active student learning 「{topic}」. Has some programming background \ + but gets confused on concepts. Often asks 'why?' and 'what if?'", + "🤔", + "#EF4444", + ), + ( + "Jordan", + "A diligent note-taking student learning 「{topic}」. Methodical learner, \ + good at summarizing key points. Prefers structured explanations.", + "📝", + "#F59E0B", + ), + ( + "Alex", + "A creative thinker learning 「{topic}」. Connects concepts to real-world applications. \ + Good at lateral thinking but sometimes goes off-topic.", + "💡", + "#8B5CF6", + ), + ]; + + for i in 0..student_count { + let (name, persona_tmpl, avatar, color) = &student_templates[i % student_templates.len()]; + agents.push(AgentProfile { + id: format!("agent_student_{}_{}", i + 1, Uuid::new_v4()), + name: name.to_string(), + role: AgentRole::Student, + persona: persona_tmpl.replace("{topic}", topic), + avatar: avatar.to_string(), + color: color.to_string(), + allowed_actions: vec!["speech".into(), "ask_question".into()], + priority: (5 - i as u8).max(1), + }); + } + + agents +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_chinese_profiles() { + let req = AgentProfileRequest { + topic: "Rust 所有权".to_string(), + style: "lecture".to_string(), + level: "intermediate".to_string(), + agent_count: Some(5), + language: Some("zh-CN".to_string()), + }; + + let agents = generate_agent_profiles(&req); + assert_eq!(agents.len(), 5); + + assert_eq!(agents[0].role, AgentRole::Teacher); + assert!(agents[0].name.contains("陈老师")); + assert!(agents[0].persona.contains("Rust 所有权")); + + assert_eq!(agents[1].role, AgentRole::Assistant); + assert!(agents[1].name.contains("小助手")); + + assert_eq!(agents[2].role, AgentRole::Student); + assert_eq!(agents[3].role, AgentRole::Student); + assert_eq!(agents[4].role, AgentRole::Student); + + // Priority ordering + assert!(agents[0].priority > agents[1].priority); + assert!(agents[1].priority > agents[2].priority); + } + + #[test] + fn test_generate_english_profiles() { + let req = AgentProfileRequest { + topic: "Python Basics".to_string(), + style: "discussion".to_string(), + level: "beginner".to_string(), + agent_count: Some(4), + language: Some("en-US".to_string()), + }; + + let agents = generate_agent_profiles(&req); + assert_eq!(agents.len(), 4); // 1 teacher + 1 assistant + 2 students + + assert_eq!(agents[0].role, AgentRole::Teacher); + assert!(agents[0].persona.contains("discussion-oriented")); + } + + #[test] + fn test_agent_role_display() { + assert_eq!(format!("{}", AgentRole::Teacher), "teacher"); + assert_eq!(format!("{}", AgentRole::Assistant), "assistant"); + assert_eq!(format!("{}", AgentRole::Student), "student"); + } + + #[test] + fn test_default_request() { + let req = AgentProfileRequest::default(); + assert!(req.topic.is_empty()); + assert_eq!(req.agent_count, None); + } +} diff --git a/crates/zclaw-kernel/src/generation/chat.rs b/crates/zclaw-kernel/src/generation/chat.rs new file mode 100644 index 0000000..9b7cfaa --- /dev/null +++ b/crates/zclaw-kernel/src/generation/chat.rs @@ -0,0 +1,337 @@ +//! Classroom Multi-Agent Chat +//! +//! Handles multi-agent conversation within the classroom context. +//! A single LLM call generates responses from multiple agent perspectives. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::agents::AgentProfile; + +/// A single chat message in the classroom +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClassroomChatMessage { + /// Unique message ID + pub id: String, + /// Agent profile ID of the sender + pub agent_id: String, + /// Display name of the sender + pub agent_name: String, + /// Avatar of the sender + pub agent_avatar: String, + /// Message content + pub content: String, + /// Unix timestamp (milliseconds) + pub timestamp: i64, + /// Role of the sender + pub role: String, + /// Theme color of the sender + pub color: String, +} + +/// Chat state for a classroom session +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClassroomChatState { + /// All chat messages + pub messages: Vec, + /// Whether chat is active + pub active: bool, +} + +/// Request for generating a chat response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassroomChatRequest { + /// Classroom ID + pub classroom_id: String, + /// User's message + pub user_message: String, + /// Available agents + pub agents: Vec, + /// Current scene context (optional, for contextual responses) + pub scene_context: Option, + /// Chat history for context + pub history: Vec, +} + +/// Response from multi-agent chat generation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClassroomChatResponse { + /// Agent responses (may be 1-3 agents responding) + pub responses: Vec, +} + +impl ClassroomChatMessage { + /// Create a user message + pub fn user_message(content: &str) -> Self { + Self { + id: format!("msg_{}", Uuid::new_v4()), + agent_id: "user".to_string(), + agent_name: "You".to_string(), + agent_avatar: "👤".to_string(), + content: content.to_string(), + timestamp: current_timestamp_millis(), + role: "user".to_string(), + color: "#6B7280".to_string(), + } + } + + /// Create an agent message + pub fn agent_message(agent: &AgentProfile, content: &str) -> Self { + Self { + id: format!("msg_{}", Uuid::new_v4()), + agent_id: agent.id.clone(), + agent_name: agent.name.clone(), + agent_avatar: agent.avatar.clone(), + content: content.to_string(), + timestamp: current_timestamp_millis(), + role: agent.role.to_string(), + color: agent.color.clone(), + } + } +} + +/// Build the LLM prompt for multi-agent chat response generation. +/// +/// This function constructs a prompt that instructs the LLM to generate +/// responses from multiple agent perspectives in a structured JSON format. +pub fn build_chat_prompt(request: &ClassroomChatRequest) -> String { + let agent_descriptions: Vec = request.agents.iter() + .map(|a| format!( + "- **{}** ({}): {}", + a.name, a.role, a.persona + )) + .collect(); + + let history_text = if request.history.is_empty() { + "No previous messages.".to_string() + } else { + request.history.iter() + .map(|m| format!("**{}**: {}", m.agent_name, m.content)) + .collect::>() + .join("\n") + }; + + let scene_hint = request.scene_context.as_deref() + .map(|ctx| format!("\n当前场景上下文:{}", ctx)) + .unwrap_or_default(); + + format!( + r#"你是一个课堂多智能体讨论的协调器。根据学生的问题,选择1-3个合适的角色来回复。 + +## 可用角色 +{agents} + +## 对话历史 +{history} +{scene_hint} + +## 学生最新问题 +{question} + +## 回复规则 +1. 选择最合适的1-3个角色来回复 +2. 老师角色应该给出权威、清晰的解释 +3. 助教角色可以补充代码示例或图表说明 +4. 学生角色可以表达理解、提出追问或分享自己的理解 +5. 每个角色的回复应该符合其个性设定 +6. 回复应该自然、有教育意义 + +## 输出格式 +你必须返回合法的JSON数组,每个元素包含: +```json +[ + {{ + "agentName": "角色名", + "content": "回复内容" + }} +] +``` + +只返回JSON数组,不要包含其他文字。"#, + agents = agent_descriptions.join("\n"), + history = history_text, + scene_hint = scene_hint, + question = request.user_message, + ) +} + +/// Parse multi-agent responses from LLM output. +/// +/// Extracts agent messages from the LLM's JSON response. +/// Falls back to a single teacher response if parsing fails. +pub fn parse_chat_responses( + llm_output: &str, + agents: &[AgentProfile], +) -> Vec { + // Try to extract JSON from the response + let json_text = extract_json_array(llm_output); + + // Try parsing as JSON array + if let Ok(parsed) = serde_json::from_str::>(&json_text) { + let mut messages = Vec::new(); + for item in &parsed { + if let (Some(name), Some(content)) = ( + item.get("agentName").and_then(|v| v.as_str()), + item.get("content").and_then(|v| v.as_str()), + ) { + // Find matching agent + if let Some(agent) = agents.iter().find(|a| a.name == name) { + messages.push(ClassroomChatMessage::agent_message(agent, content)); + } + } + } + if !messages.is_empty() { + return messages; + } + } + + // Fallback: teacher responds with the raw LLM output + if let Some(teacher) = agents.iter().find(|a| a.role == super::agents::AgentRole::Teacher) { + vec![ClassroomChatMessage::agent_message( + teacher, + &clean_fallback_response(llm_output), + )] + } else if let Some(first) = agents.first() { + vec![ClassroomChatMessage::agent_message(first, llm_output)] + } else { + vec![] + } +} + +/// Extract JSON array from text (handles markdown code blocks) +fn extract_json_array(text: &str) -> String { + // Try markdown code block first + if let Some(start) = text.find("```json") { + if let Some(end) = text[start + 7..].find("```") { + return text[start + 7..start + 7 + end].trim().to_string(); + } + } + + // Try to find JSON array directly + if let Some(start) = text.find('[') { + if let Some(end) = text.rfind(']') { + if end > start { + return text[start..=end].to_string(); + } + } + } + + text.to_string() +} + +/// Clean up fallback response (remove JSON artifacts if present) +fn clean_fallback_response(text: &str) -> String { + let trimmed = text.trim(); + + // If it looks like JSON attempt, extract just the text content + if trimmed.starts_with('[') || trimmed.starts_with('{') { + if let Ok(values) = serde_json::from_str::>(trimmed) { + if let Some(first) = values.first() { + if let Some(content) = first.get("content").and_then(|v| v.as_str()) { + return content.to_string(); + } + } + } + } + + trimmed.to_string() +} + +fn current_timestamp_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generation::agents::{AgentProfile, AgentRole}; + + fn test_agents() -> Vec { + vec![ + AgentProfile { + id: "t1".into(), + name: "陈老师".into(), + role: AgentRole::Teacher, + persona: "Test teacher".into(), + avatar: "👩‍🏫".into(), + color: "#4F46E5".into(), + allowed_actions: vec![], + priority: 10, + }, + AgentProfile { + id: "s1".into(), + name: "李思".into(), + role: AgentRole::Student, + persona: "Curious student".into(), + avatar: "🤔".into(), + color: "#EF4444".into(), + allowed_actions: vec![], + priority: 5, + }, + ] + } + + #[test] + fn test_parse_chat_responses_valid_json() { + let agents = test_agents(); + let llm_output = r#"```json +[ + {"agentName": "陈老师", "content": "好问题!让我来解释一下..."}, + {"agentName": "李思", "content": "原来如此,那如果..."} +] +```"#; + + let messages = parse_chat_responses(llm_output, &agents); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].agent_name, "陈老师"); + assert_eq!(messages[1].agent_name, "李思"); + } + + #[test] + fn test_parse_chat_responses_fallback() { + let agents = test_agents(); + let llm_output = "这是一个关于Rust的好问题。所有权意味着每个值只有一个主人。"; + + let messages = parse_chat_responses(llm_output, &agents); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].agent_name, "陈老师"); // Falls back to teacher + } + + #[test] + fn test_build_chat_prompt() { + let agents = test_agents(); + let request = ClassroomChatRequest { + classroom_id: "test".into(), + user_message: "什么是所有权?".into(), + agents, + scene_context: Some("Rust 所有权核心规则".into()), + history: vec![], + }; + + let prompt = build_chat_prompt(&request); + assert!(prompt.contains("陈老师")); + assert!(prompt.contains("什么是所有权?")); + assert!(prompt.contains("Rust 所有权核心规则")); + } + + #[test] + fn test_user_message() { + let msg = ClassroomChatMessage::user_message("Hello"); + assert_eq!(msg.agent_name, "You"); + assert_eq!(msg.role, "user"); + } + + #[test] + fn test_agent_message() { + let agent = &test_agents()[0]; + let msg = ClassroomChatMessage::agent_message(agent, "Test"); + assert_eq!(msg.agent_name, "陈老师"); + assert_eq!(msg.role, "teacher"); + } +} diff --git a/crates/zclaw-kernel/src/generation.rs b/crates/zclaw-kernel/src/generation/mod.rs similarity index 79% rename from crates/zclaw-kernel/src/generation.rs rename to crates/zclaw-kernel/src/generation/mod.rs index 082a055..6ade517 100644 --- a/crates/zclaw-kernel/src/generation.rs +++ b/crates/zclaw-kernel/src/generation/mod.rs @@ -1,8 +1,13 @@ -//! Generation Pipeline - Two-stage content generation +//! Classroom Generation Module //! -//! Implements the OpenMAIC-style two-stage generation: -//! Stage 1: Outline generation - analyze input and create structured outline -//! Stage 2: Scene generation - expand each outline item into rich scenes +//! Four-stage pipeline inspired by OpenMAIC: +//! 1. Agent Profiles — generate classroom roles +//! 2. Outline — structured course outline +//! 3. Scenes — rich scene content with actions +//! 4. Complete — assembled classroom + +pub mod agents; +pub mod chat; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -12,10 +17,19 @@ use futures::future::join_all; use zclaw_types::Result; use zclaw_runtime::{LlmDriver, CompletionRequest, CompletionResponse, ContentBlock}; -/// Generation stage +pub use agents::{AgentProfile, AgentRole, AgentProfileRequest, generate_agent_profiles}; +pub use chat::{ + ClassroomChatMessage, ClassroomChatState, ClassroomChatRequest, + ClassroomChatResponse, ClassroomChatState as ChatState, + build_chat_prompt, parse_chat_responses, +}; + +/// Generation stage (expanded from 2 to 4) #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GenerationStage { + /// Stage 0: Generate agent profiles + AgentProfiles, /// Stage 1: Generate outline Outline, /// Stage 2: Generate scenes from outline @@ -24,23 +38,22 @@ pub enum GenerationStage { Complete, } +impl Default for GenerationStage { + fn default() -> Self { + Self::AgentProfiles + } +} + /// Scene type (corresponds to OpenMAIC scene types) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SceneType { - /// Slide/presentation content Slide, - /// Quiz/assessment Quiz, - /// Interactive HTML simulation Interactive, - /// Project-based learning Pbl, - /// Discussion prompt Discussion, - /// Video/media content Media, - /// Text content Text, } @@ -48,20 +61,19 @@ pub enum SceneType { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SceneAction { - /// Speech/text output Speech { text: String, + #[serde(rename = "agentRole")] agent_role: String, }, - /// Whiteboard draw text WhiteboardDrawText { x: f64, y: f64, text: String, + #[serde(rename = "fontSize")] font_size: Option, color: Option, }, - /// Whiteboard draw shape WhiteboardDrawShape { shape: String, x: f64, @@ -70,8 +82,8 @@ pub enum SceneAction { height: f64, fill: Option, }, - /// Whiteboard draw chart WhiteboardDrawChart { + #[serde(rename = "chartType")] chart_type: String, data: serde_json::Value, x: f64, @@ -79,119 +91,72 @@ pub enum SceneAction { width: f64, height: f64, }, - /// Whiteboard draw LaTeX WhiteboardDrawLatex { latex: String, x: f64, y: f64, }, - /// Whiteboard clear WhiteboardClear, - /// Slideshow spotlight SlideshowSpotlight { + #[serde(rename = "elementId")] element_id: String, }, - /// Slideshow next slide SlideshowNext, - /// Quiz show question QuizShow { + #[serde(rename = "quizId")] quiz_id: String, }, - /// Trigger discussion Discussion { topic: String, + #[serde(rename = "durationSeconds")] duration_seconds: Option, }, } /// Scene content (the actual teaching content) #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SceneContent { - /// Scene title pub title: String, - /// Scene type pub scene_type: SceneType, - /// Scene content (type-specific) pub content: serde_json::Value, - /// Actions to execute pub actions: Vec, - /// Duration in seconds pub duration_seconds: u32, - /// Teaching notes pub notes: Option, } /// Outline item (Stage 1 output) #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct OutlineItem { - /// Item ID pub id: String, - /// Item title pub title: String, - /// Item description pub description: String, - /// Scene type to generate pub scene_type: SceneType, - /// Key points to cover pub key_points: Vec, - /// Estimated duration pub duration_seconds: u32, - /// Dependencies (IDs of items that must complete first) pub dependencies: Vec, } /// Generated scene (Stage 2 output) #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GeneratedScene { - /// Scene ID pub id: String, - /// Corresponding outline item ID pub outline_id: String, - /// Scene content pub content: SceneContent, - /// Order in the classroom pub order: usize, } -/// Complete classroom (final output) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Classroom { - /// Classroom ID - pub id: String, - /// Classroom title - pub title: String, - /// Classroom description - pub description: String, - /// Topic/subject - pub topic: String, - /// Teaching style - pub style: TeachingStyle, - /// Difficulty level - pub level: DifficultyLevel, - /// Total duration in seconds - pub total_duration: u32, - /// Learning objectives - pub objectives: Vec, - /// All scenes in order - pub scenes: Vec, - /// Metadata - pub metadata: ClassroomMetadata, -} - /// Teaching style #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum TeachingStyle { - /// Lecture-style (teacher presents) #[default] Lecture, - /// Discussion-style (interactive) Discussion, - /// Project-based learning Pbl, - /// Flipped classroom Flipped, - /// Socratic method (question-driven) Socratic, } @@ -208,37 +173,44 @@ pub enum DifficultyLevel { /// Classroom metadata #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct ClassroomMetadata { - /// Generation timestamp pub generated_at: i64, - /// Source document (if any) pub source_document: Option, - /// LLM model used pub model: Option, - /// Version pub version: String, - /// Custom metadata pub custom: serde_json::Map, } +/// Complete classroom (final output) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Classroom { + pub id: String, + pub title: String, + pub description: String, + pub topic: String, + pub style: TeachingStyle, + pub level: DifficultyLevel, + pub total_duration: u32, + pub objectives: Vec, + pub scenes: Vec, + /// Agent profiles for this classroom (NEW) + pub agents: Vec, + pub metadata: ClassroomMetadata, +} + /// Generation request #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GenerationRequest { - /// Topic or theme pub topic: String, - /// Optional source document content pub document: Option, - /// Teaching style pub style: TeachingStyle, - /// Difficulty level pub level: DifficultyLevel, - /// Target duration in minutes pub target_duration_minutes: u32, - /// Number of scenes to generate pub scene_count: Option, - /// Custom instructions pub custom_instructions: Option, - /// Language code pub language: Option, } @@ -259,40 +231,31 @@ impl Default for GenerationRequest { /// Generation progress #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GenerationProgress { - /// Current stage pub stage: GenerationStage, - /// Progress percentage (0-100) pub progress: u8, - /// Current activity description pub activity: String, - /// Items completed / total items pub items_progress: Option<(usize, usize)>, - /// Estimated time remaining in seconds pub eta_seconds: Option, } /// Generation pipeline pub struct GenerationPipeline { - /// Current stage stage: Arc>, - /// Current progress progress: Arc>, - /// Generated outline outline: Arc>>, - /// Generated scenes scenes: Arc>>, - /// Optional LLM driver for real generation + agents_store: Arc>>, driver: Option>, } impl GenerationPipeline { - /// Create a new generation pipeline pub fn new() -> Self { Self { - stage: Arc::new(RwLock::new(GenerationStage::Outline)), + stage: Arc::new(RwLock::new(GenerationStage::AgentProfiles)), progress: Arc::new(RwLock::new(GenerationProgress { - stage: GenerationStage::Outline, + stage: GenerationStage::AgentProfiles, progress: 0, activity: "Initializing".to_string(), items_progress: None, @@ -300,11 +263,11 @@ impl GenerationPipeline { })), outline: Arc::new(RwLock::new(Vec::new())), scenes: Arc::new(RwLock::new(Vec::new())), + agents_store: Arc::new(RwLock::new(Vec::new())), driver: None, } } - /// Create pipeline with LLM driver pub fn with_driver(driver: Arc) -> Self { Self { driver: Some(driver), @@ -312,52 +275,58 @@ impl GenerationPipeline { } } - /// Get current progress pub async fn get_progress(&self) -> GenerationProgress { self.progress.read().await.clone() } - /// Get current stage pub async fn get_stage(&self) -> GenerationStage { *self.stage.read().await } - /// Get generated outline pub async fn get_outline(&self) -> Vec { self.outline.read().await.clone() } - /// Get generated scenes pub async fn get_scenes(&self) -> Vec { self.scenes.read().await.clone() } + /// Stage 0: Generate agent profiles + pub async fn generate_agent_profiles(&self, request: &GenerationRequest) -> Vec { + self.update_progress(GenerationStage::AgentProfiles, 10, "Generating classroom roles...").await; + + let agents = generate_agent_profiles(&AgentProfileRequest { + topic: request.topic.clone(), + style: serde_json::to_string(&request.style).unwrap_or_else(|_| "\"lecture\"".to_string()).trim_matches('"').to_string(), + level: serde_json::to_string(&request.level).unwrap_or_else(|_| "\"intermediate\"".to_string()).trim_matches('"').to_string(), + agent_count: None, + language: request.language.clone(), + }); + + *self.agents_store.write().await = agents.clone(); + + self.update_progress(GenerationStage::AgentProfiles, 100, "Roles generated").await; + *self.stage.write().await = GenerationStage::Outline; + + agents + } + /// Stage 1: Generate outline from request pub async fn generate_outline(&self, request: &GenerationRequest) -> Result> { - // Update progress self.update_progress(GenerationStage::Outline, 10, "Analyzing topic...").await; - // Build outline generation prompt let prompt = self.build_outline_prompt(request); - // Update progress self.update_progress(GenerationStage::Outline, 30, "Generating outline...").await; let outline = if let Some(driver) = &self.driver { - // Use LLM to generate outline self.generate_outline_with_llm(driver.as_ref(), &prompt, request).await? } else { - // Fallback to placeholder self.generate_outline_placeholder(request) }; - // Update progress self.update_progress(GenerationStage::Outline, 100, "Outline complete").await; - - // Store outline *self.outline.write().await = outline.clone(); - - // Move to scene stage *self.stage.write().await = GenerationStage::Scene; Ok(outline) @@ -376,7 +345,6 @@ impl GenerationPipeline { &format!("Generating {} scenes in parallel...", total), ).await; - // Generate all scenes in parallel using futures::join_all let scene_futures: Vec<_> = outline .iter() .enumerate() @@ -385,20 +353,16 @@ impl GenerationPipeline { let item = item.clone(); async move { if let Some(d) = driver { - // Use LLM to generate scene Self::generate_scene_with_llm_static(d.as_ref(), &item, i).await } else { - // Fallback to placeholder Self::generate_scene_for_item_static(&item, i) } } }) .collect(); - // Wait for all scenes to complete let scene_results = join_all(scene_futures).await; - // Collect results, preserving order let mut scenes = Vec::new(); for (i, result) in scene_results.into_iter().enumerate() { match result { @@ -411,26 +375,95 @@ impl GenerationPipeline { scenes.push(scene); } Err(e) => { - // Log error but continue with other scenes tracing::warn!("Failed to generate scene {}: {}", i, e); } } } - // Sort by order to ensure correct sequence scenes.sort_by_key(|s| s.order); - - // Store scenes *self.scenes.write().await = scenes.clone(); - // Mark complete self.update_progress(GenerationStage::Complete, 100, "Generation complete").await; *self.stage.write().await = GenerationStage::Complete; Ok(scenes) } - /// Static version for parallel execution + /// Full generation: 4-stage pipeline + pub async fn generate(&self, request: GenerationRequest) -> Result { + // Stage 0: Agent profiles + let agents = self.generate_agent_profiles(&request).await; + + // Stage 1: Outline + let outline = self.generate_outline(&request).await?; + + // Stage 2: Scenes + let scenes = self.generate_scenes(&outline).await?; + + // Build classroom + self.build_classroom(request, outline, scenes, agents) + } + + // --- LLM integration methods --- + + async fn generate_outline_with_llm( + &self, + driver: &dyn LlmDriver, + prompt: &str, + request: &GenerationRequest, + ) -> Result> { + let llm_request = CompletionRequest { + model: "default".to_string(), + system: Some(self.get_outline_system_prompt()), + messages: vec![zclaw_types::Message::User { + content: prompt.to_string(), + }], + tools: vec![], + max_tokens: Some(4096), + temperature: Some(0.7), + stop: vec![], + stream: false, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, + }; + + let response = driver.complete(llm_request).await?; + let text = Self::extract_text_from_response_static(&response); + self.parse_outline_from_text(&text, request) + } + + fn get_outline_system_prompt(&self) -> String { + r#"You are an expert educational content designer. Your task is to generate structured course outlines. + +When given a topic, you will: +1. Analyze the topic and identify key learning objectives +2. Create a logical flow of scenes/modules +3. Assign appropriate scene types (slide, quiz, interactive, discussion) +4. Estimate duration for each section + +You MUST respond with valid JSON in this exact format: +{ + "title": "Course Title", + "description": "Course description", + "objectives": ["Objective 1", "Objective 2"], + "outline": [ + { + "id": "outline_1", + "title": "Scene Title", + "description": "What this scene covers", + "scene_type": "slide", + "key_points": ["Point 1", "Point 2"], + "duration_seconds": 300, + "dependencies": [] + } + ] +} + +Ensure the outline is coherent and follows good pedagogical practices. +Use Chinese if the topic is in Chinese. Include vivid metaphors and analogies."#.to_string() + } + async fn generate_scene_with_llm_static( driver: &dyn LlmDriver, item: &OutlineItem, @@ -446,7 +479,8 @@ impl GenerationPipeline { - title: scene title\n\ - content: scene content (object with relevant fields)\n\ - actions: array of actions to execute\n\ - - duration_seconds: estimated duration", + - duration_seconds: estimated duration\n\ + - notes: teaching notes", item.title, item.description, item.scene_type, item.key_points ); @@ -461,21 +495,21 @@ impl GenerationPipeline { temperature: Some(0.7), stop: vec![], stream: false, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, }; let response = driver.complete(llm_request).await?; let text = Self::extract_text_from_response_static(&response); - - // Parse scene from response Self::parse_scene_from_text_static(&text, item, order) } - /// Static version of scene system prompt fn get_scene_system_prompt_static() -> String { r#"You are an expert educational content creator. Your task is to generate detailed teaching scenes. When given an outline item, you will: -1. Create rich, engaging content +1. Create rich, engaging content with vivid metaphors and analogies 2. Design appropriate actions (speech, whiteboard, quiz, etc.) 3. Ensure content matches the scene type @@ -491,18 +525,13 @@ You MUST respond with valid JSON in this exact format: {"type": "speech", "text": "Welcome to...", "agent_role": "teacher"}, {"type": "whiteboard_draw_text", "x": 100, "y": 100, "text": "Key Concept"} ], - "duration_seconds": 300 + "duration_seconds": 300, + "notes": "Teaching notes for this scene" } -Actions can be: -- speech: {"type": "speech", "text": "...", "agent_role": "teacher|assistant|student"} -- whiteboard_draw_text: {"type": "whiteboard_draw_text", "x": 0, "y": 0, "text": "..."} -- whiteboard_draw_shape: {"type": "whiteboard_draw_shape", "shape": "rectangle", "x": 0, "y": 0, "width": 100, "height": 50} -- quiz_show: {"type": "quiz_show", "quiz_id": "..."} -- discussion: {"type": "discussion", "topic": "..."}"#.to_string() +Use Chinese if the topic is in Chinese. Include metaphors that relate to everyday life."#.to_string() } - /// Static version of text extraction fn extract_text_from_response_static(response: &CompletionResponse) -> String { response.content.iter() .filter_map(|block| match block { @@ -513,7 +542,11 @@ Actions can be: .join("\n") } - /// Static version of scene parsing + #[allow(dead_code)] + fn extract_text_from_response(&self, response: &CompletionResponse) -> String { + Self::extract_text_from_response_static(response) + } + fn parse_scene_from_text_static(text: &str, item: &OutlineItem, order: usize) -> Result { let json_text = Self::extract_json_static(text); @@ -534,17 +567,17 @@ Actions can be: duration_seconds: scene_data.get("duration_seconds") .and_then(|v| v.as_u64()) .unwrap_or(item.duration_seconds as u64) as u32, - notes: None, + notes: scene_data.get("notes") + .and_then(|v| v.as_str()) + .map(String::from), }, order, }) } else { - // Fallback Self::generate_scene_for_item_static(item, order) } } - /// Static version of actions parsing fn parse_actions_static(scene_data: &serde_json::Value) -> Vec { scene_data.get("actions") .and_then(|v| v.as_array()) @@ -556,10 +589,8 @@ Actions can be: .unwrap_or_default() } - /// Static version of single action parsing fn parse_single_action_static(action: &serde_json::Value) -> Option { let action_type = action.get("type")?.as_str()?; - match action_type { "speech" => Some(SceneAction::Speech { text: action.get("text")?.as_str()?.to_string(), @@ -594,20 +625,16 @@ Actions can be: } } - /// Static version of JSON extraction fn extract_json_static(text: &str) -> String { - // Try to extract from markdown code block if let Some(start) = text.find("```json") { - if let Some(end) = text[start..].find("```") { - let json_start = start + 7; - let json_end = start + end; - if json_end > json_start { - return text[json_start..json_end].trim().to_string(); + let content_start = start + 7; + if let Some(end) = text[content_start..].find("```") { + let json_end = content_start + end; + if json_end > content_start { + return text[content_start..json_end].trim().to_string(); } } } - - // Try to find JSON object directly if let Some(start) = text.find('{') { if let Some(end) = text.rfind('}') { if end > start { @@ -615,11 +642,9 @@ Actions can be: } } } - text.to_string() } - /// Static version of scene generation for item fn generate_scene_for_item_static(item: &OutlineItem, order: usize) -> Result { let actions = match item.scene_type { SceneType::Slide => vec![ @@ -676,81 +701,32 @@ Actions can be: }) } - /// Generate outline using LLM - async fn generate_outline_with_llm( - &self, - driver: &dyn LlmDriver, - prompt: &str, - request: &GenerationRequest, - ) -> Result> { - let llm_request = CompletionRequest { - model: "default".to_string(), - system: Some(self.get_outline_system_prompt()), - messages: vec![zclaw_types::Message::User { - content: prompt.to_string(), - }], - tools: vec![], - max_tokens: Some(4096), - temperature: Some(0.7), - stop: vec![], - stream: false, - }; + fn generate_outline_placeholder(&self, request: &GenerationRequest) -> Vec { + let count = request.scene_count.unwrap_or_else(|| { + (request.target_duration_minutes as usize / 5).max(3).min(10) + }); + let base_duration = request.target_duration_minutes * 60 / count as u32; - let response = driver.complete(llm_request).await?; - let text = self.extract_text_from_response(&response); - - // Parse JSON from response - self.parse_outline_from_text(&text, request) - } - - /// Extract text from LLM response - fn extract_text_from_response(&self, response: &CompletionResponse) -> String { - response.content.iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.clone()), - _ => None, + (0..count) + .map(|i| OutlineItem { + id: format!("outline_{}", i + 1), + title: format!("Scene {}: {}", i + 1, request.topic), + description: format!("Content for scene {} about {}", i + 1, request.topic), + scene_type: if i % 4 == 3 { SceneType::Quiz } else { SceneType::Slide }, + key_points: vec![ + format!("Key point 1 for scene {}", i + 1), + format!("Key point 2 for scene {}", i + 1), + format!("Key point 3 for scene {}", i + 1), + ], + duration_seconds: base_duration, + dependencies: if i > 0 { vec![format!("outline_{}", i)] } else { vec![] }, }) - .collect::>() - .join("\n") + .collect() } - /// Get system prompt for outline generation - fn get_outline_system_prompt(&self) -> String { - r#"You are an expert educational content designer. Your task is to generate structured course outlines. - -When given a topic, you will: -1. Analyze the topic and identify key learning objectives -2. Create a logical flow of scenes/modules -3. Assign appropriate scene types (slide, quiz, interactive, discussion) -4. Estimate duration for each section - -You MUST respond with valid JSON in this exact format: -{ - "title": "Course Title", - "description": "Course description", - "objectives": ["Objective 1", "Objective 2"], - "outline": [ - { - "id": "outline_1", - "title": "Scene Title", - "description": "What this scene covers", - "scene_type": "slide", - "key_points": ["Point 1", "Point 2"], - "duration_seconds": 300, - "dependencies": [] - } - ] -} - -Ensure the outline is coherent and follows good pedagogical practices."#.to_string() - } - - /// Parse outline from LLM response text fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result> { - // Try to extract JSON from the response - let json_text = self.extract_json(text); + let json_text = Self::extract_json_static(text); - // Try to parse as full outline structure if let Ok(full) = serde_json::from_str::(&json_text) { if let Some(outline) = full.get("outline").and_then(|o| o.as_array()) { let items: Result> = outline.iter() @@ -760,11 +736,9 @@ Ensure the outline is coherent and follows good pedagogical practices."#.to_stri } } - // Fallback to placeholder Ok(self.generate_outline_placeholder(request)) } - /// Parse single outline item fn parse_outline_item(&self, value: &serde_json::Value) -> Result { Ok(OutlineItem { id: value.get("id") @@ -797,55 +771,44 @@ Ensure the outline is coherent and follows good pedagogical practices."#.to_stri }) } - /// Extract JSON from text (handles markdown code blocks) - fn extract_json(&self, text: &str) -> String { - // Try to extract from markdown code block - if let Some(start) = text.find("```json") { - if let Some(end) = text[start..].find("```") { - let json_start = start + 7; - let json_end = start + end; - if json_end > json_start { - return text[json_start..json_end].trim().to_string(); - } - } - } + fn build_classroom( + &self, + request: GenerationRequest, + _outline: Vec, + scenes: Vec, + agents: Vec, + ) -> Result { + let total_duration: u32 = scenes.iter() + .map(|s| s.content.duration_seconds) + .sum(); - // Try to find JSON object directly - if let Some(start) = text.find('{') { - if let Some(end) = text.rfind('}') { - if end > start { - return text[start..=end].to_string(); - } - } - } + let objectives = _outline.iter() + .take(3) + .map(|item| format!("Understand: {}", item.title)) + .collect(); - text.to_string() + Ok(Classroom { + id: uuid_v4(), + title: format!("Classroom: {}", request.topic), + description: format!("A {:?} style classroom about {}", + request.style, request.topic), + topic: request.topic, + style: request.style, + level: request.level, + total_duration, + objectives, + scenes, + agents, + metadata: ClassroomMetadata { + generated_at: current_timestamp(), + source_document: request.document.map(|_| "user_document".to_string()), + model: None, + version: "2.0.0".to_string(), + custom: serde_json::Map::new(), + }, + }) } - /// Generate complete classroom - pub async fn generate(&self, request: GenerationRequest) -> Result { - // Stage 1: Generate outline - let outline = self.generate_outline(&request).await?; - - // Stage 2: Generate scenes - let scenes = self.generate_scenes(&outline).await?; - - // Build classroom - let classroom = self.build_classroom(request, outline, scenes)?; - - Ok(classroom) - } - - /// Update progress - async fn update_progress(&self, stage: GenerationStage, progress: u8, activity: &str) { - let mut p = self.progress.write().await; - p.stage = stage; - p.progress = progress; - p.activity = activity.to_string(); - p.items_progress = None; - } - - /// Build outline generation prompt fn build_outline_prompt(&self, request: &GenerationRequest) -> String { format!( r#"Generate a structured classroom outline for the following: @@ -864,7 +827,8 @@ Please create an outline with the following format for each item: - key_points: list of key points to cover - duration_seconds: estimated duration -Generate {} outline items that flow logically and cover the topic comprehensively."#, +Generate {} outline items that flow logically and cover the topic comprehensively. +Include vivid metaphors and analogies that help students understand abstract concepts."#, request.topic, request.style, request.level, @@ -878,74 +842,12 @@ Generate {} outline items that flow logically and cover the topic comprehensivel ) } - /// Generate placeholder outline (would be replaced by LLM call) - fn generate_outline_placeholder(&self, request: &GenerationRequest) -> Vec { - let count = request.scene_count.unwrap_or_else(|| { - (request.target_duration_minutes as usize / 5).max(3).min(10) - }); - - let base_duration = request.target_duration_minutes * 60 / count as u32; - - (0..count) - .map(|i| OutlineItem { - id: format!("outline_{}", i + 1), - title: format!("Scene {}: {}", i + 1, request.topic), - description: format!("Content for scene {} about {}", i + 1, request.topic), - scene_type: if i % 4 == 3 { SceneType::Quiz } else { SceneType::Slide }, - key_points: vec![ - format!("Key point 1 for scene {}", i + 1), - format!("Key point 2 for scene {}", i + 1), - format!("Key point 3 for scene {}", i + 1), - ], - duration_seconds: base_duration, - dependencies: if i > 0 { vec![format!("outline_{}", i)] } else { vec![] }, - }) - .collect() - } - - /// Build classroom from components - fn build_classroom( - &self, - request: GenerationRequest, - outline: Vec, - scenes: Vec, - ) -> Result { - let total_duration: u32 = scenes.iter() - .map(|s| s.content.duration_seconds) - .sum(); - - let objectives = outline.iter() - .take(3) - .map(|item| format!("Understand: {}", item.title)) - .collect(); - - Ok(Classroom { - id: uuid_v4(), - title: format!("Classroom: {}", request.topic), - description: format!("A {} style classroom about {}", - match request.style { - TeachingStyle::Lecture => "lecture", - TeachingStyle::Discussion => "discussion", - TeachingStyle::Pbl => "project-based", - TeachingStyle::Flipped => "flipped classroom", - TeachingStyle::Socratic => "Socratic", - }, - request.topic - ), - topic: request.topic, - style: request.style, - level: request.level, - total_duration, - objectives, - scenes, - metadata: ClassroomMetadata { - generated_at: current_timestamp(), - source_document: request.document.map(|_| "user_document".to_string()), - model: None, - version: "1.0.0".to_string(), - custom: serde_json::Map::new(), - }, - }) + async fn update_progress(&self, stage: GenerationStage, progress: u8, activity: &str) { + let mut p = self.progress.write().await; + p.stage = stage; + p.progress = progress; + p.activity = activity.to_string(); + p.items_progress = None; } } @@ -955,9 +857,6 @@ impl Default for GenerationPipeline { } } -// Helper functions - -/// Generate a cryptographically secure UUID v4 fn uuid_v4() -> String { Uuid::new_v4().to_string() } @@ -978,12 +877,26 @@ mod tests { async fn test_pipeline_creation() { let pipeline = GenerationPipeline::new(); let stage = pipeline.get_stage().await; - assert_eq!(stage, GenerationStage::Outline); + assert_eq!(stage, GenerationStage::AgentProfiles); let progress = pipeline.get_progress().await; assert_eq!(progress.progress, 0); } + #[tokio::test] + async fn test_generate_agent_profiles() { + let pipeline = GenerationPipeline::new(); + let request = GenerationRequest { + topic: "Rust Ownership".to_string(), + ..Default::default() + }; + + let agents = pipeline.generate_agent_profiles(&request).await; + assert_eq!(agents.len(), 5); // 1 teacher + 1 assistant + 3 students + assert_eq!(agents[0].role, AgentRole::Teacher); + assert!(agents[0].persona.contains("Rust Ownership")); + } + #[tokio::test] async fn test_generate_outline() { let pipeline = GenerationPipeline::new(); @@ -997,7 +910,6 @@ mod tests { let outline = pipeline.generate_outline(&request).await.unwrap(); assert_eq!(outline.len(), 5); - // Check first item let first = &outline[0]; assert!(first.title.contains("Rust Ownership")); assert!(!first.key_points.is_empty()); @@ -1030,7 +942,6 @@ mod tests { let scenes = pipeline.generate_scenes(&outline).await.unwrap(); assert_eq!(scenes.len(), 2); - // Check first scene let first = &scenes[0]; assert_eq!(first.content.scene_type, SceneType::Slide); assert!(!first.content.actions.is_empty()); @@ -1052,6 +963,7 @@ mod tests { assert!(classroom.title.contains("Machine Learning")); assert_eq!(classroom.scenes.len(), 3); + assert_eq!(classroom.agents.len(), 5); assert!(classroom.total_duration > 0); assert!(!classroom.objectives.is_empty()); } @@ -1077,4 +989,9 @@ mod tests { let style = TeachingStyle::default(); assert!(matches!(style, TeachingStyle::Lecture)); } + + #[test] + fn test_generation_stage_order() { + assert!(matches!(GenerationStage::default(), GenerationStage::AgentProfiles)); + } } diff --git a/crates/zclaw-kernel/src/kernel.rs b/crates/zclaw-kernel/src/kernel.rs deleted file mode 100644 index 189b5a4..0000000 --- a/crates/zclaw-kernel/src/kernel.rs +++ /dev/null @@ -1,1486 +0,0 @@ -//! Kernel - central coordinator - -use std::pin::Pin; -use std::sync::Arc; -use tokio::sync::{broadcast, mpsc, Mutex}; -use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result, HandRun, HandRunId, HandRunStatus, HandRunFilter, TriggerSource}; -#[cfg(feature = "multi-agent")] -use zclaw_types::Capability; -#[cfg(feature = "multi-agent")] -use zclaw_protocols::{A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient}; -use async_trait::async_trait; -use serde_json::Value; - -use crate::registry::AgentRegistry; -use crate::capabilities::CapabilityManager; -use crate::events::EventBus; -use crate::config::KernelConfig; -use zclaw_memory::MemoryStore; -use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry, tool::SkillExecutor, tool::builtin::PathValidator}; -use zclaw_skills::SkillRegistry; -use zclaw_skills::LlmCompleter; -use zclaw_hands::{HandRegistry, HandContext, HandResult, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}}; - -/// Adapter that bridges `zclaw_runtime::LlmDriver` → `zclaw_skills::LlmCompleter` -struct LlmDriverAdapter { - driver: Arc, - max_tokens: u32, - temperature: f32, -} - -impl zclaw_skills::LlmCompleter for LlmDriverAdapter { - fn complete( - &self, - prompt: &str, - ) -> Pin> + Send + '_>> { - let driver = self.driver.clone(); - let prompt = prompt.to_string(); - Box::pin(async move { - let request = zclaw_runtime::CompletionRequest { - messages: vec![zclaw_types::Message::user(prompt)], - max_tokens: Some(self.max_tokens), - temperature: Some(self.temperature), - ..Default::default() - }; - let response = driver.complete(request).await - .map_err(|e| format!("LLM completion error: {}", e))?; - // Extract text from content blocks - let text: String = response.content.iter() - .filter_map(|block| match block { - zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - Ok(text) - }) - } -} - -/// Skill executor implementation for Kernel -pub struct KernelSkillExecutor { - skills: Arc, - llm: Arc, -} - -impl KernelSkillExecutor { - pub fn new(skills: Arc, driver: Arc) -> Self { - let llm: Arc = Arc::new(LlmDriverAdapter { driver, max_tokens: 4096, temperature: 0.7 }); - Self { skills, llm } - } -} - -#[async_trait] -impl SkillExecutor for KernelSkillExecutor { - async fn execute_skill( - &self, - skill_id: &str, - agent_id: &str, - session_id: &str, - input: Value, - ) -> Result { - let context = zclaw_skills::SkillContext { - agent_id: agent_id.to_string(), - session_id: session_id.to_string(), - llm: Some(self.llm.clone()), - ..Default::default() - }; - let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?; - Ok(result.output) - } - - fn get_skill_detail(&self, skill_id: &str) -> Option { - let manifests = self.skills.manifests_snapshot(); - let manifest = manifests.get(&zclaw_types::SkillId::new(skill_id))?; - Some(zclaw_runtime::tool::SkillDetail { - id: manifest.id.as_str().to_string(), - name: manifest.name.clone(), - description: manifest.description.clone(), - category: manifest.category.clone(), - input_schema: manifest.input_schema.clone(), - triggers: manifest.triggers.clone(), - capabilities: manifest.capabilities.clone(), - }) - } - - fn list_skill_index(&self) -> Vec { - let manifests = self.skills.manifests_snapshot(); - manifests.values() - .filter(|m| m.enabled) - .map(|m| zclaw_runtime::tool::SkillIndexEntry { - id: m.id.as_str().to_string(), - description: m.description.clone(), - triggers: m.triggers.clone(), - }) - .collect() - } -} - -/// Inbox wrapper for A2A message receivers that supports re-queuing -/// non-matching messages instead of dropping them. -#[cfg(feature = "multi-agent")] -struct AgentInbox { - rx: tokio::sync::mpsc::Receiver, - pending: std::collections::VecDeque, -} - -#[cfg(feature = "multi-agent")] -impl AgentInbox { - fn new(rx: tokio::sync::mpsc::Receiver) -> Self { - Self { rx, pending: std::collections::VecDeque::new() } - } - - fn try_recv(&mut self) -> std::result::Result { - if let Some(msg) = self.pending.pop_front() { - return Ok(msg); - } - self.rx.try_recv() - } - - async fn recv(&mut self) -> Option { - if let Some(msg) = self.pending.pop_front() { - return Some(msg); - } - self.rx.recv().await - } - - fn requeue(&mut self, envelope: A2aEnvelope) { - self.pending.push_back(envelope); - } -} - -/// The ZCLAW Kernel -pub struct Kernel { - config: KernelConfig, - registry: AgentRegistry, - capabilities: CapabilityManager, - events: EventBus, - memory: Arc, - driver: Arc, - llm_completer: Arc, - skills: Arc, - skill_executor: Arc, - hands: Arc, - trigger_manager: crate::trigger_manager::TriggerManager, - pending_approvals: Arc>>, - /// Running hand runs that can be cancelled (run_id -> cancelled flag) - running_hand_runs: Arc>>, - /// Shared memory storage backend for Growth system - viking: Arc, - /// Optional LLM driver for memory extraction (set by Tauri desktop layer) - extraction_driver: Option>, - /// A2A router for inter-agent messaging (gated by multi-agent feature) - #[cfg(feature = "multi-agent")] - a2a_router: Arc, - /// Per-agent A2A inbox receivers (supports re-queuing non-matching messages) - #[cfg(feature = "multi-agent")] - a2a_inboxes: Arc>>>, -} - -impl Kernel { - /// Boot the kernel with the given configuration - pub async fn boot(config: KernelConfig) -> Result { - // Initialize memory store - let memory = Arc::new(MemoryStore::new(&config.database_url).await?); - - // Initialize driver based on config - let driver = config.create_driver()?; - - // Initialize subsystems - let registry = AgentRegistry::new(); - let capabilities = CapabilityManager::new(); - let events = EventBus::new(); - - // Initialize skill registry - let skills = Arc::new(SkillRegistry::new()); - - // Scan skills directory if configured - if let Some(ref skills_dir) = config.skills_dir { - if skills_dir.exists() { - skills.add_skill_dir(skills_dir.clone()).await?; - } - } - - // Initialize hand registry with built-in hands - let hands = Arc::new(HandRegistry::new()); - let quiz_model = config.model().to_string(); - let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model)); - hands.register(Arc::new(BrowserHand::new())).await; - hands.register(Arc::new(SlideshowHand::new())).await; - hands.register(Arc::new(SpeechHand::new())).await; - hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await; - hands.register(Arc::new(WhiteboardHand::new())).await; - hands.register(Arc::new(ResearcherHand::new())).await; - hands.register(Arc::new(CollectorHand::new())).await; - hands.register(Arc::new(ClipHand::new())).await; - hands.register(Arc::new(TwitterHand::new())).await; - - // Create skill executor - let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone())); - - // Create LLM completer for skill system (shared with skill_executor) - let llm_completer: Arc = - Arc::new(LlmDriverAdapter { - driver: driver.clone(), - max_tokens: config.max_tokens(), - temperature: config.temperature(), - }); - - // Initialize trigger manager - let trigger_manager = crate::trigger_manager::TriggerManager::new(hands.clone()); - - // Initialize Growth system — shared VikingAdapter for memory storage - let viking = Arc::new(zclaw_runtime::VikingAdapter::in_memory()); - - // Restore persisted agents - let persisted = memory.list_agents().await?; - for agent in persisted { - registry.register(agent); - } - - // Initialize A2A router for multi-agent support - #[cfg(feature = "multi-agent")] - let a2a_router = { - let kernel_agent_id = AgentId::new(); - Arc::new(A2aRouter::new(kernel_agent_id)) - }; - - Ok(Self { - config, - registry, - capabilities, - events, - memory, - driver, - llm_completer, - skills, - skill_executor, - hands, - trigger_manager, - pending_approvals: Arc::new(Mutex::new(Vec::new())), - running_hand_runs: Arc::new(dashmap::DashMap::new()), - viking, - extraction_driver: None, - #[cfg(feature = "multi-agent")] - a2a_router, - #[cfg(feature = "multi-agent")] - a2a_inboxes: Arc::new(dashmap::DashMap::new()), - }) - } - - /// Create a tool registry with built-in tools - fn create_tool_registry(&self) -> ToolRegistry { - let mut tools = ToolRegistry::new(); - zclaw_runtime::tool::builtin::register_builtin_tools(&mut tools); - tools - } - - /// Create the middleware chain for the agent loop. - /// - /// When middleware is configured, cross-cutting concerns (compaction, loop guard, - /// token calibration, etc.) are delegated to the chain. When no middleware is - /// registered, the legacy inline path in `AgentLoop` is used instead. - fn create_middleware_chain(&self) -> Option { - let mut chain = zclaw_runtime::middleware::MiddlewareChain::new(); - - // Growth integration — shared VikingAdapter for memory middleware & compaction - let mut growth = zclaw_runtime::GrowthIntegration::new(self.viking.clone()); - if let Some(ref driver) = self.extraction_driver { - growth = growth.with_llm_driver(driver.clone()); - } - - // Compaction middleware — only register when threshold > 0 - let threshold = self.config.compaction_threshold(); - if threshold > 0 { - use std::sync::Arc; - let mut growth_for_compaction = zclaw_runtime::GrowthIntegration::new(self.viking.clone()); - if let Some(ref driver) = self.extraction_driver { - growth_for_compaction = growth_for_compaction.with_llm_driver(driver.clone()); - } - let mw = zclaw_runtime::middleware::compaction::CompactionMiddleware::new( - threshold, - zclaw_runtime::CompactionConfig::default(), - Some(self.driver.clone()), - Some(growth_for_compaction), - ); - chain.register(Arc::new(mw)); - } - - // Memory middleware — auto-extract memories after conversations - { - use std::sync::Arc; - let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth); - chain.register(Arc::new(mw)); - } - - // Loop guard middleware - { - use std::sync::Arc; - let mw = zclaw_runtime::middleware::loop_guard::LoopGuardMiddleware::with_defaults(); - chain.register(Arc::new(mw)); - } - - // Token calibration middleware - { - use std::sync::Arc; - let mw = zclaw_runtime::middleware::token_calibration::TokenCalibrationMiddleware::new(); - chain.register(Arc::new(mw)); - } - - // Skill index middleware — inject lightweight index instead of full descriptions - { - use std::sync::Arc; - let entries = self.skill_executor.list_skill_index(); - if !entries.is_empty() { - let mw = zclaw_runtime::middleware::skill_index::SkillIndexMiddleware::new(entries); - chain.register(Arc::new(mw)); - } - } - - // Guardrail middleware — safety rules for tool calls - { - use std::sync::Arc; - let mw = zclaw_runtime::middleware::guardrail::GuardrailMiddleware::new(true) - .with_builtin_rules(); - chain.register(Arc::new(mw)); - } - - // Only return Some if we actually registered middleware - if chain.is_empty() { - None - } else { - tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len()); - Some(chain) - } - } - - /// Build a system prompt with skill information injected - async fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String { - // Get skill list asynchronously - let skills = self.skills.list().await; - - let mut prompt = base_prompt - .map(|p| p.clone()) - .unwrap_or_else(|| "You are a helpful AI assistant.".to_string()); - - // 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 specialized skills. Analyze user intent and autonomously call `execute_skill` with the appropriate skill_id.\n\n"); - - // Group skills by category based on their ID patterns - let categories = self.categorize_skills(&skills); - - for (category, category_skills) in categories { - prompt.push_str(&format!("### {}\n", category)); - for skill in category_skills { - prompt.push_str(&format!( - "- **{}**: {}", - skill.id.as_str(), - skill.description - )); - prompt.push('\n'); - } - prompt.push('\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; - - // Validate capabilities - self.capabilities.validate(&config.capabilities)?; - - // Register in memory - self.memory.save_agent(&config).await?; - - // Register with A2A router for multi-agent messaging (before config is moved) - #[cfg(feature = "multi-agent")] - { - let profile = Self::agent_config_to_a2a_profile(&config); - let rx = self.a2a_router.register_agent(profile).await; - self.a2a_inboxes.insert(id, Arc::new(Mutex::new(AgentInbox::new(rx)))); - } - - // Register in registry (consumes config) - let name = config.name.clone(); - self.registry.register(config); - - // Emit event - self.events.publish(Event::AgentSpawned { - agent_id: id, - name, - }); - - Ok(id) - } - - /// Kill an agent - pub async fn kill_agent(&self, id: &AgentId) -> Result<()> { - // Remove from registry - self.registry.unregister(id); - - // Remove from memory - self.memory.delete_agent(id).await?; - - // Unregister from A2A router - #[cfg(feature = "multi-agent")] - { - self.a2a_router.unregister_agent(id).await; - self.a2a_inboxes.remove(id); - } - - // Emit event - self.events.publish(Event::AgentTerminated { - agent_id: *id, - reason: "killed".to_string(), - }); - - Ok(()) - } - - /// Update an existing agent's configuration - pub async fn update_agent(&self, config: AgentConfig) -> Result<()> { - let id = config.id; - - // Validate the agent exists - if self.registry.get(&id).is_none() { - return Err(zclaw_types::ZclawError::NotFound( - format!("Agent not found: {}", id) - )); - } - - // Validate capabilities - self.capabilities.validate(&config.capabilities)?; - - // Save updated config to memory - self.memory.save_agent(&config).await?; - - // Update in registry (preserves state and message count) - self.registry.update(config.clone()); - - // Emit event - self.events.publish(Event::AgentConfigUpdated { - agent_id: id, - name: config.name.clone(), - }); - - Ok(()) - } - - /// List all agents - pub fn list_agents(&self) -> Vec { - self.registry.list() - } - - /// Get agent info - pub fn get_agent(&self, id: &AgentId) -> Option { - self.registry.get_info(id) - } - - /// Get agent config (for export) - pub fn get_agent_config(&self, id: &AgentId) -> Option { - self.registry.get(id) - } - - /// Send a message to an agent - pub async fn send_message(&self, agent_id: &AgentId, message: String) -> Result { - let agent_config = self.registry.get(agent_id) - .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?; - - // Create or get session - let session_id = self.memory.create_session(agent_id).await?; - - // Always use Kernel's current model configuration - // This ensures user's "模型与 API" settings are respected - let model = self.config.model().to_string(); - - // Create agent loop with model configuration - let tools = self.create_tool_registry(); - let mut loop_runner = AgentLoop::new( - *agent_id, - self.driver.clone(), - tools, - self.memory.clone(), - ) - .with_model(&model) - .with_skill_executor(self.skill_executor.clone()) - .with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens())) - .with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature())) - .with_compaction_threshold( - agent_config.compaction_threshold - .map(|t| t as usize) - .unwrap_or_else(|| self.config.compaction_threshold()), - ); - - // Set path validator from agent's workspace directory (if configured) - if let Some(ref workspace) = agent_config.workspace { - let path_validator = PathValidator::new().with_workspace(workspace.clone()); - tracing::info!( - "[Kernel] Setting path_validator with workspace: {} for agent {}", - workspace.display(), - agent_id - ); - loop_runner = loop_runner.with_path_validator(path_validator); - } - - // Inject middleware chain if available - if let Some(chain) = self.create_middleware_chain() { - loop_runner = loop_runner.with_middleware_chain(chain); - } - - // Build system prompt with skill information injected - let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await; - let loop_runner = loop_runner.with_system_prompt(&system_prompt); - - // Run the loop - let result = loop_runner.run(session_id, message).await?; - - // Track message count - self.registry.increment_message_count(agent_id); - - Ok(MessageResponse { - content: result.response, - input_tokens: result.input_tokens, - output_tokens: result.output_tokens, - }) - } - - /// Send a message with streaming - pub async fn send_message_stream( - &self, - agent_id: &AgentId, - message: String, - ) -> Result> { - self.send_message_stream_with_prompt(agent_id, message, None, None).await - } - - /// Send a message with streaming, optional system prompt, and optional session reuse - pub async fn send_message_stream_with_prompt( - &self, - agent_id: &AgentId, - message: String, - system_prompt_override: Option, - session_id_override: Option, - ) -> Result> { - let agent_config = self.registry.get(agent_id) - .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?; - - // Reuse existing session or create new one - let session_id = match session_id_override { - Some(id) => { - // Use get_or_create to ensure the frontend's session ID is persisted. - // This is the critical bridge: without it, the kernel generates a - // different UUID each turn, so conversation history is never found. - tracing::debug!("Reusing frontend session ID: {}", id); - self.memory.get_or_create_session(&id, agent_id).await? - } - None => self.memory.create_session(agent_id).await?, - }; - - // Always use Kernel's current model configuration - // This ensures user's "模型与 API" settings are respected - let model = self.config.model().to_string(); - - // Create agent loop with model configuration - let tools = self.create_tool_registry(); - let mut loop_runner = AgentLoop::new( - *agent_id, - self.driver.clone(), - tools, - self.memory.clone(), - ) - .with_model(&model) - .with_skill_executor(self.skill_executor.clone()) - .with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens())) - .with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature())) - .with_compaction_threshold( - agent_config.compaction_threshold - .map(|t| t as usize) - .unwrap_or_else(|| self.config.compaction_threshold()), - ); - - // Set path validator from agent's workspace directory (if configured) - // This enables file_read / file_write tools to access the workspace - if let Some(ref workspace) = agent_config.workspace { - let path_validator = PathValidator::new().with_workspace(workspace.clone()); - tracing::info!( - "[Kernel] Setting path_validator with workspace: {} for agent {}", - workspace.display(), - agent_id - ); - loop_runner = loop_runner.with_path_validator(path_validator); - } - - // Inject middleware chain if available - if let Some(chain) = self.create_middleware_chain() { - loop_runner = loop_runner.with_middleware_chain(chain); - } - - // Use external prompt if provided, otherwise build default - let system_prompt = match system_prompt_override { - Some(prompt) => prompt, - None => self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await, - }; - let loop_runner = loop_runner.with_system_prompt(&system_prompt); - - // Run with streaming - self.registry.increment_message_count(agent_id); - loop_runner.run_streaming(session_id, message).await - } - - /// Subscribe to events - pub fn subscribe(&self) -> broadcast::Receiver { - self.events.subscribe() - } - - /// Shutdown the kernel - pub async fn shutdown(&self) -> Result<()> { - self.events.publish(Event::KernelShutdown); - Ok(()) - } - - /// Get the kernel configuration - pub fn config(&self) -> &KernelConfig { - &self.config - } - - /// Get the LLM driver - pub fn driver(&self) -> Arc { - self.driver.clone() - } - - /// Replace the default in-memory VikingAdapter with a persistent one. - /// - /// Called by the Tauri desktop layer after `Kernel::boot()` to bridge - /// the kernel's Growth system to the same SqliteStorage used by - /// viking_commands and intelligence_hooks. - pub fn set_viking(&mut self, viking: Arc) { - tracing::info!("[Kernel] Replacing in-memory VikingAdapter with persistent storage"); - self.viking = viking; - } - - /// Get a reference to the shared VikingAdapter - pub fn viking(&self) -> Arc { - self.viking.clone() - } - - /// Set the LLM extraction driver for the Growth system. - /// - /// Required for `MemoryMiddleware` to extract memories from conversations - /// via LLM analysis. If not set, memory extraction is silently skipped. - pub fn set_extraction_driver(&mut self, driver: Arc) { - tracing::info!("[Kernel] Extraction driver configured for Growth system"); - self.extraction_driver = Some(driver); - } - - /// Get the skills registry - pub fn skills(&self) -> &Arc { - &self.skills - } - - /// List all discovered skills - pub async fn list_skills(&self) -> Vec { - self.skills.list().await - } - - /// Refresh skills from a directory - pub async fn refresh_skills(&self, dir: Option) -> Result<()> { - if let Some(path) = dir { - self.skills.add_skill_dir(path).await?; - } else if let Some(ref skills_dir) = self.config.skills_dir { - self.skills.add_skill_dir(skills_dir.clone()).await?; - } - Ok(()) - } - - /// Get the configured skills directory - pub fn skills_dir(&self) -> Option<&std::path::PathBuf> { - self.config.skills_dir.as_ref() - } - - /// Create a new skill in the skills directory - pub async fn create_skill(&self, manifest: zclaw_skills::SkillManifest) -> Result<()> { - let skills_dir = self.config.skills_dir.as_ref() - .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( - "Skills directory not configured".into() - ))?; - self.skills.create_skill(skills_dir, manifest).await - } - - /// Update an existing skill - pub async fn update_skill( - &self, - id: &zclaw_types::SkillId, - manifest: zclaw_skills::SkillManifest, - ) -> Result { - let skills_dir = self.config.skills_dir.as_ref() - .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( - "Skills directory not configured".into() - ))?; - self.skills.update_skill(skills_dir, id, manifest).await - } - - /// Delete a skill - pub async fn delete_skill(&self, id: &zclaw_types::SkillId) -> Result<()> { - let skills_dir = self.config.skills_dir.as_ref() - .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( - "Skills directory not configured".into() - ))?; - self.skills.delete_skill(skills_dir, id).await - } - - /// Execute a skill with the given ID and input - pub async fn execute_skill( - &self, - id: &str, - context: zclaw_skills::SkillContext, - input: serde_json::Value, - ) -> Result { - // Inject LLM completer into context for PromptOnly skills - let mut ctx = context; - if ctx.llm.is_none() { - ctx.llm = Some(self.llm_completer.clone()); - } - self.skills.execute(&zclaw_types::SkillId::new(id), &ctx, input).await - } - - /// Get the hands registry - pub fn hands(&self) -> &Arc { - &self.hands - } - - /// List all registered hands - pub async fn list_hands(&self) -> Vec { - self.hands.list().await - } - - /// Execute a hand with the given input, tracking the run - pub async fn execute_hand( - &self, - hand_id: &str, - input: serde_json::Value, - ) -> Result<(HandResult, HandRunId)> { - let run_id = HandRunId::new(); - let now = chrono::Utc::now().to_rfc3339(); - - // Create the initial HandRun record - let mut run = HandRun { - id: run_id, - hand_name: hand_id.to_string(), - trigger_source: TriggerSource::Manual, - params: input.clone(), - status: HandRunStatus::Pending, - result: None, - error: None, - duration_ms: None, - created_at: now.clone(), - started_at: None, - completed_at: None, - }; - self.memory.save_hand_run(&run).await?; - - // Transition to Running - run.status = HandRunStatus::Running; - run.started_at = Some(chrono::Utc::now().to_rfc3339()); - self.memory.update_hand_run(&run).await?; - - // Register cancellation flag - let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); - self.running_hand_runs.insert(run_id, cancel_flag.clone()); - - // Execute the hand - let context = HandContext::default(); - let start = std::time::Instant::now(); - let hand_result = self.hands.execute(hand_id, &context, input).await; - let duration = start.elapsed(); - - // Check if cancelled during execution - if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { - let mut run_update = run.clone(); - run_update.status = HandRunStatus::Cancelled; - run_update.completed_at = Some(chrono::Utc::now().to_rfc3339()); - run_update.duration_ms = Some(duration.as_millis() as u64); - self.memory.update_hand_run(&run_update).await?; - self.running_hand_runs.remove(&run_id); - return Err(zclaw_types::ZclawError::Internal("Hand execution cancelled".to_string())); - } - - // Remove from running map - self.running_hand_runs.remove(&run_id); - - // Update HandRun with result - let completed_at = chrono::Utc::now().to_rfc3339(); - match &hand_result { - Ok(res) => { - run.status = HandRunStatus::Completed; - run.result = Some(res.output.clone()); - run.error = res.error.clone(); - } - Err(e) => { - run.status = HandRunStatus::Failed; - run.error = Some(e.to_string()); - } - } - run.duration_ms = Some(duration.as_millis() as u64); - run.completed_at = Some(completed_at); - self.memory.update_hand_run(&run).await?; - - hand_result.map(|res| (res, run_id)) - } - - /// Execute a hand with a specific trigger source (for scheduled/event triggers) - pub async fn execute_hand_with_source( - &self, - hand_id: &str, - input: serde_json::Value, - trigger_source: TriggerSource, - ) -> Result<(HandResult, HandRunId)> { - let run_id = HandRunId::new(); - let now = chrono::Utc::now().to_rfc3339(); - - let mut run = HandRun { - id: run_id, - hand_name: hand_id.to_string(), - trigger_source, - params: input.clone(), - status: HandRunStatus::Pending, - result: None, - error: None, - duration_ms: None, - created_at: now, - started_at: None, - completed_at: None, - }; - self.memory.save_hand_run(&run).await?; - - run.status = HandRunStatus::Running; - run.started_at = Some(chrono::Utc::now().to_rfc3339()); - self.memory.update_hand_run(&run).await?; - - let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); - self.running_hand_runs.insert(run_id, cancel_flag.clone()); - - let context = HandContext::default(); - let start = std::time::Instant::now(); - let hand_result = self.hands.execute(hand_id, &context, input).await; - let duration = start.elapsed(); - - // Check if cancelled during execution - if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { - run.status = HandRunStatus::Cancelled; - run.completed_at = Some(chrono::Utc::now().to_rfc3339()); - run.duration_ms = Some(duration.as_millis() as u64); - self.memory.update_hand_run(&run).await?; - self.running_hand_runs.remove(&run_id); - return Err(zclaw_types::ZclawError::Internal("Hand execution cancelled".to_string())); - } - - self.running_hand_runs.remove(&run_id); - - let completed_at = chrono::Utc::now().to_rfc3339(); - match &hand_result { - Ok(res) => { - run.status = HandRunStatus::Completed; - run.result = Some(res.output.clone()); - run.error = res.error.clone(); - } - Err(e) => { - run.status = HandRunStatus::Failed; - run.error = Some(e.to_string()); - } - } - run.duration_ms = Some(duration.as_millis() as u64); - run.completed_at = Some(completed_at); - self.memory.update_hand_run(&run).await?; - - hand_result.map(|res| (res, run_id)) - } - - // ============================================================ - // Hand Run Tracking - // ============================================================ - - /// Get a hand run by ID - pub async fn get_hand_run(&self, id: &HandRunId) -> Result> { - self.memory.get_hand_run(id).await - } - - /// List hand runs with filter - pub async fn list_hand_runs(&self, filter: &HandRunFilter) -> Result> { - self.memory.list_hand_runs(filter).await - } - - /// Count hand runs matching filter - pub async fn count_hand_runs(&self, filter: &HandRunFilter) -> Result { - self.memory.count_hand_runs(filter).await - } - - /// Cancel a running hand execution - pub async fn cancel_hand_run(&self, id: &HandRunId) -> Result<()> { - if let Some((_, flag)) = self.running_hand_runs.remove(id) { - flag.store(true, std::sync::atomic::Ordering::Relaxed); - - // Note: the actual status update happens in execute_hand_with_source - // when it detects the cancel flag - Ok(()) - } else { - // Not currently running — check if exists at all - let run = self.memory.get_hand_run(id).await?; - match run { - Some(r) if r.status == HandRunStatus::Pending => { - let mut updated = r; - updated.status = HandRunStatus::Cancelled; - updated.completed_at = Some(chrono::Utc::now().to_rfc3339()); - self.memory.update_hand_run(&updated).await?; - Ok(()) - } - Some(r) => Err(zclaw_types::ZclawError::InvalidInput( - format!("Cannot cancel hand run {} with status {}", id, r.status) - )), - None => Err(zclaw_types::ZclawError::NotFound( - format!("Hand run {} not found", id) - )), - } - } - } - - // ============================================================ - // Trigger Management - // ============================================================ - - /// List all triggers - pub async fn list_triggers(&self) -> Vec { - self.trigger_manager.list_triggers().await - } - - /// Get a specific trigger - pub async fn get_trigger(&self, id: &str) -> Option { - self.trigger_manager.get_trigger(id).await - } - - /// Create a new trigger - pub async fn create_trigger( - &self, - config: zclaw_hands::TriggerConfig, - ) -> Result { - self.trigger_manager.create_trigger(config).await - } - - /// Update a trigger - pub async fn update_trigger( - &self, - id: &str, - updates: crate::trigger_manager::TriggerUpdateRequest, - ) -> Result { - self.trigger_manager.update_trigger(id, updates).await - } - - /// Delete a trigger - pub async fn delete_trigger(&self, id: &str) -> Result<()> { - self.trigger_manager.delete_trigger(id).await - } - - /// Execute a trigger - pub async fn execute_trigger( - &self, - id: &str, - input: serde_json::Value, - ) -> Result { - self.trigger_manager.execute_trigger(id, input).await - } - - // ============================================================ - // Approval Management - // ============================================================ - - /// List pending approvals - pub async fn list_approvals(&self) -> Vec { - let approvals = self.pending_approvals.lock().await; - approvals.iter().filter(|a| a.status == "pending").cloned().collect() - } - - /// Get a single approval by ID (any status, not just pending) - /// - /// Returns None if no approval with the given ID exists. - pub async fn get_approval(&self, id: &str) -> Option { - let approvals = self.pending_approvals.lock().await; - approvals.iter().find(|a| a.id == id).cloned() - } - - /// Create a pending approval (called when a needs_approval hand is triggered) - pub async fn create_approval(&self, hand_id: String, input: serde_json::Value) -> ApprovalEntry { - let entry = ApprovalEntry { - id: uuid::Uuid::new_v4().to_string(), - hand_id, - status: "pending".to_string(), - created_at: chrono::Utc::now(), - input, - reject_reason: None, - }; - let mut approvals = self.pending_approvals.lock().await; - approvals.push(entry.clone()); - entry - } - - /// Respond to an approval - pub async fn respond_to_approval( - &self, - id: &str, - approved: bool, - reason: Option, - ) -> Result<()> { - let mut approvals = self.pending_approvals.lock().await; - let entry = approvals.iter_mut().find(|a| a.id == id && a.status == "pending") - .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Approval not found: {}", id)))?; - - entry.status = if approved { "approved".to_string() } else { "rejected".to_string() }; - if let Some(r) = reason { - entry.reject_reason = Some(r); - } - - if approved { - let hand_id = entry.hand_id.clone(); - let input = entry.input.clone(); - drop(approvals); // Release lock before async hand execution - - // Execute the hand in background with HandRun tracking - let hands = self.hands.clone(); - let approvals = self.pending_approvals.clone(); - let memory = self.memory.clone(); - let running_hand_runs = self.running_hand_runs.clone(); - let id_owned = id.to_string(); - tokio::spawn(async move { - // Create HandRun record for tracking - let run_id = HandRunId::new(); - let now = chrono::Utc::now().to_rfc3339(); - let mut run = HandRun { - id: run_id, - hand_name: hand_id.clone(), - trigger_source: TriggerSource::Manual, - params: input.clone(), - status: HandRunStatus::Pending, - result: None, - error: None, - duration_ms: None, - created_at: now.clone(), - started_at: None, - completed_at: None, - }; - let _ = memory.save_hand_run(&run).await; - run.status = HandRunStatus::Running; - run.started_at = Some(chrono::Utc::now().to_rfc3339()); - let _ = memory.update_hand_run(&run).await; - - // Register cancellation flag - let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); - running_hand_runs.insert(run.id, cancel_flag.clone()); - - let context = HandContext::default(); - let start = std::time::Instant::now(); - let result = hands.execute(&hand_id, &context, input).await; - let duration = start.elapsed(); - - // Remove from running map - running_hand_runs.remove(&run.id); - - // Update HandRun with result - let completed_at = chrono::Utc::now().to_rfc3339(); - match &result { - Ok(res) => { - run.status = HandRunStatus::Completed; - run.result = Some(res.output.clone()); - run.error = res.error.clone(); - } - Err(e) => { - run.status = HandRunStatus::Failed; - run.error = Some(e.to_string()); - } - } - run.duration_ms = Some(duration.as_millis() as u64); - run.completed_at = Some(completed_at); - let _ = memory.update_hand_run(&run).await; - - // Update approval status based on execution result - let mut approvals = approvals.lock().await; - if let Some(entry) = approvals.iter_mut().find(|a| a.id == id_owned) { - match result { - Ok(_) => entry.status = "completed".to_string(), - Err(e) => { - entry.status = "failed".to_string(); - if let Some(obj) = entry.input.as_object_mut() { - obj.insert("error".to_string(), Value::String(format!("{}", e))); - } - } - } - } - }); - } - - Ok(()) - } - - /// Cancel a pending approval - pub async fn cancel_approval(&self, id: &str) -> Result<()> { - let mut approvals = self.pending_approvals.lock().await; - let entry = approvals.iter_mut().find(|a| a.id == id && a.status == "pending") - .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Approval not found: {}", id)))?; - entry.status = "cancelled".to_string(); - Ok(()) - } - - // ============================================================ - // A2A (Agent-to-Agent) Messaging - // ============================================================ - - /// Derive an A2A agent profile from an AgentConfig - #[cfg(feature = "multi-agent")] - fn agent_config_to_a2a_profile(config: &AgentConfig) -> A2aAgentProfile { - let caps: Vec = config.tools.iter().map(|tool_name| { - A2aCapability { - name: tool_name.clone(), - description: format!("Tool: {}", tool_name), - input_schema: None, - output_schema: None, - requires_approval: false, - version: "1.0.0".to_string(), - tags: vec![], - } - }).collect(); - - A2aAgentProfile { - id: config.id, - name: config.name.clone(), - description: config.description.clone().unwrap_or_default(), - capabilities: caps, - protocols: vec!["a2a".to_string()], - role: "worker".to_string(), - priority: 5, - metadata: std::collections::HashMap::new(), - groups: vec![], - last_seen: 0, - } - } - - /// Check if an agent is authorized to send messages to a target - #[cfg(feature = "multi-agent")] - fn check_a2a_permission(&self, from: &AgentId, to: &AgentId) -> Result<()> { - let caps = self.capabilities.get(from); - match caps { - Some(cap_set) => { - let has_permission = cap_set.capabilities.iter().any(|cap| { - match cap { - Capability::AgentMessage { pattern } => { - pattern == "*" || to.to_string().starts_with(pattern) - } - _ => false, - } - }); - if !has_permission { - return Err(zclaw_types::ZclawError::PermissionDenied( - format!("Agent {} does not have AgentMessage capability for {}", from, to) - )); - } - Ok(()) - } - None => { - // No capabilities registered — deny by default - Err(zclaw_types::ZclawError::PermissionDenied( - format!("Agent {} has no capabilities registered", from) - )) - } - } - } - - /// Send a direct A2A message from one agent to another - #[cfg(feature = "multi-agent")] - pub async fn a2a_send( - &self, - from: &AgentId, - to: &AgentId, - payload: serde_json::Value, - message_type: Option, - ) -> Result<()> { - // Validate sender exists - self.registry.get(from) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("Sender agent not found: {}", from) - ))?; - - // Validate receiver exists and is running - self.registry.get(to) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("Target agent not found: {}", to) - ))?; - - // Check capability permission - self.check_a2a_permission(from, to)?; - - // Build and route envelope - let envelope = A2aEnvelope::new( - *from, - A2aRecipient::Direct { agent_id: *to }, - message_type.unwrap_or(A2aMessageType::Notification), - payload, - ); - - self.a2a_router.route(envelope).await?; - - // Emit event - self.events.publish(Event::A2aMessageSent { - from: *from, - to: format!("{}", to), - message_type: "direct".to_string(), - }); - - Ok(()) - } - - /// Broadcast a message from one agent to all other agents - #[cfg(feature = "multi-agent")] - pub async fn a2a_broadcast( - &self, - from: &AgentId, - payload: serde_json::Value, - ) -> Result<()> { - // Validate sender exists - self.registry.get(from) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("Sender agent not found: {}", from) - ))?; - - let envelope = A2aEnvelope::new( - *from, - A2aRecipient::Broadcast, - A2aMessageType::Notification, - payload, - ); - - self.a2a_router.route(envelope).await?; - - self.events.publish(Event::A2aMessageSent { - from: *from, - to: "broadcast".to_string(), - message_type: "broadcast".to_string(), - }); - - Ok(()) - } - - /// Discover agents that have a specific capability - #[cfg(feature = "multi-agent")] - pub async fn a2a_discover(&self, capability: &str) -> Result> { - let result = self.a2a_router.discover(capability).await?; - - self.events.publish(Event::A2aAgentDiscovered { - agent_id: AgentId::new(), - capabilities: vec![capability.to_string()], - }); - - Ok(result) - } - - /// Try to receive a pending A2A message for an agent (non-blocking) - #[cfg(feature = "multi-agent")] - pub async fn a2a_receive(&self, agent_id: &AgentId) -> Result> { - let inbox = self.a2a_inboxes.get(agent_id) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("No A2A inbox for agent: {}", agent_id) - ))?; - - let mut inbox = inbox.lock().await; - match inbox.try_recv() { - Ok(envelope) => { - self.events.publish(Event::A2aMessageReceived { - from: envelope.from, - to: format!("{}", agent_id), - message_type: "direct".to_string(), - }); - Ok(Some(envelope)) - } - Err(_) => Ok(None), - } - } - - /// Delegate a task to another agent and wait for response with timeout - #[cfg(feature = "multi-agent")] - pub async fn a2a_delegate_task( - &self, - from: &AgentId, - to: &AgentId, - task_description: String, - timeout_ms: u64, - ) -> Result { - // Validate both agents exist - self.registry.get(from) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("Sender agent not found: {}", from) - ))?; - self.registry.get(to) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("Target agent not found: {}", to) - ))?; - - // Check capability permission - self.check_a2a_permission(from, to)?; - - // Send task request - let task_id = uuid::Uuid::new_v4().to_string(); - let envelope = A2aEnvelope::new( - *from, - A2aRecipient::Direct { agent_id: *to }, - A2aMessageType::Task, - serde_json::json!({ - "task_id": task_id, - "description": task_description, - }), - ).with_conversation(task_id.clone()); - - let envelope_id = envelope.id.clone(); - self.a2a_router.route(envelope).await?; - - self.events.publish(Event::A2aMessageSent { - from: *from, - to: format!("{}", to), - message_type: "task".to_string(), - }); - - // Wait for response with timeout - let timeout = tokio::time::Duration::from_millis(timeout_ms); - let result = tokio::time::timeout(timeout, async { - let inbox_entry = self.a2a_inboxes.get(from) - .ok_or_else(|| zclaw_types::ZclawError::NotFound( - format!("No A2A inbox for agent: {}", from) - ))?; - let mut inbox = inbox_entry.lock().await; - - // Poll for matching response - loop { - match inbox.recv().await { - Some(msg) => { - // Check if this is a response to our task - if msg.message_type == A2aMessageType::Response - && msg.reply_to.as_deref() == Some(&envelope_id) { - return Ok::<_, zclaw_types::ZclawError>(msg.payload); - } - // Not our response — requeue it for later processing - tracing::debug!("Re-queuing non-matching A2A message: {}", msg.id); - inbox.requeue(msg); - } - None => { - return Err(zclaw_types::ZclawError::Internal( - "A2A inbox channel closed".to_string() - )); - } - } - } - }).await; - - match result { - Ok(Ok(payload)) => Ok(payload), - Ok(Err(e)) => Err(e), - Err(_) => Err(zclaw_types::ZclawError::Timeout( - format!("A2A task delegation timed out after {}ms", timeout_ms) - )), - } - } - - /// Get all online agents via A2A profiles - #[cfg(feature = "multi-agent")] - pub async fn a2a_get_online_agents(&self) -> Result> { - Ok(self.a2a_router.list_profiles().await) - } -} -#[derive(Debug, Clone)] -pub struct ApprovalEntry { - pub id: String, - pub hand_id: String, - pub status: String, - pub created_at: chrono::DateTime, - pub input: serde_json::Value, - pub reject_reason: Option, -} - -/// Response from sending a message -#[derive(Debug, Clone)] -pub struct MessageResponse { - pub content: String, - pub input_tokens: u32, - pub output_tokens: u32, -} diff --git a/crates/zclaw-kernel/src/kernel/a2a.rs b/crates/zclaw-kernel/src/kernel/a2a.rs new file mode 100644 index 0000000..c35659e --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/a2a.rs @@ -0,0 +1,268 @@ +//! A2A (Agent-to-Agent) messaging +//! +//! All items in this module are gated by the `multi-agent` feature flag. + +#[cfg(feature = "multi-agent")] +use zclaw_types::{AgentId, Capability, Event, Result}; +#[cfg(feature = "multi-agent")] +use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient}; + +#[cfg(feature = "multi-agent")] +use super::Kernel; + +#[cfg(feature = "multi-agent")] +impl Kernel { + // ============================================================ + // A2A (Agent-to-Agent) Messaging + // ============================================================ + + /// Derive an A2A agent profile from an AgentConfig + pub(super) fn agent_config_to_a2a_profile(config: &zclaw_types::AgentConfig) -> A2aAgentProfile { + let caps: Vec = config.tools.iter().map(|tool_name| { + A2aCapability { + name: tool_name.clone(), + description: format!("Tool: {}", tool_name), + input_schema: None, + output_schema: None, + requires_approval: false, + version: "1.0.0".to_string(), + tags: vec![], + } + }).collect(); + + A2aAgentProfile { + id: config.id, + name: config.name.clone(), + description: config.description.clone().unwrap_or_default(), + capabilities: caps, + protocols: vec!["a2a".to_string()], + role: "worker".to_string(), + priority: 5, + metadata: std::collections::HashMap::new(), + groups: vec![], + last_seen: 0, + } + } + + /// Check if an agent is authorized to send messages to a target + pub(super) fn check_a2a_permission(&self, from: &AgentId, to: &AgentId) -> Result<()> { + let caps = self.capabilities.get(from); + match caps { + Some(cap_set) => { + let has_permission = cap_set.capabilities.iter().any(|cap| { + match cap { + Capability::AgentMessage { pattern } => { + pattern == "*" || to.to_string().starts_with(pattern) + } + _ => false, + } + }); + if !has_permission { + return Err(zclaw_types::ZclawError::PermissionDenied( + format!("Agent {} does not have AgentMessage capability for {}", from, to) + )); + } + Ok(()) + } + None => { + // No capabilities registered — deny by default + Err(zclaw_types::ZclawError::PermissionDenied( + format!("Agent {} has no capabilities registered", from) + )) + } + } + } + + /// Send a direct A2A message from one agent to another + pub async fn a2a_send( + &self, + from: &AgentId, + to: &AgentId, + payload: serde_json::Value, + message_type: Option, + ) -> Result<()> { + // Validate sender exists + self.registry.get(from) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("Sender agent not found: {}", from) + ))?; + + // Validate receiver exists and is running + self.registry.get(to) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("Target agent not found: {}", to) + ))?; + + // Check capability permission + self.check_a2a_permission(from, to)?; + + // Build and route envelope + let envelope = A2aEnvelope::new( + *from, + A2aRecipient::Direct { agent_id: *to }, + message_type.unwrap_or(A2aMessageType::Notification), + payload, + ); + + self.a2a_router.route(envelope).await?; + + // Emit event + self.events.publish(Event::A2aMessageSent { + from: *from, + to: format!("{}", to), + message_type: "direct".to_string(), + }); + + Ok(()) + } + + /// Broadcast a message from one agent to all other agents + pub async fn a2a_broadcast( + &self, + from: &AgentId, + payload: serde_json::Value, + ) -> Result<()> { + // Validate sender exists + self.registry.get(from) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("Sender agent not found: {}", from) + ))?; + + let envelope = A2aEnvelope::new( + *from, + A2aRecipient::Broadcast, + A2aMessageType::Notification, + payload, + ); + + self.a2a_router.route(envelope).await?; + + self.events.publish(Event::A2aMessageSent { + from: *from, + to: "broadcast".to_string(), + message_type: "broadcast".to_string(), + }); + + Ok(()) + } + + /// Discover agents that have a specific capability + pub async fn a2a_discover(&self, capability: &str) -> Result> { + let result = self.a2a_router.discover(capability).await?; + + self.events.publish(Event::A2aAgentDiscovered { + agent_id: AgentId::new(), + capabilities: vec![capability.to_string()], + }); + + Ok(result) + } + + /// Try to receive a pending A2A message for an agent (non-blocking) + pub async fn a2a_receive(&self, agent_id: &AgentId) -> Result> { + let inbox = self.a2a_inboxes.get(agent_id) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("No A2A inbox for agent: {}", agent_id) + ))?; + + let mut inbox = inbox.lock().await; + match inbox.try_recv() { + Ok(envelope) => { + self.events.publish(Event::A2aMessageReceived { + from: envelope.from, + to: format!("{}", agent_id), + message_type: "direct".to_string(), + }); + Ok(Some(envelope)) + } + Err(_) => Ok(None), + } + } + + /// Delegate a task to another agent and wait for response with timeout + pub async fn a2a_delegate_task( + &self, + from: &AgentId, + to: &AgentId, + task_description: String, + timeout_ms: u64, + ) -> Result { + // Validate both agents exist + self.registry.get(from) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("Sender agent not found: {}", from) + ))?; + self.registry.get(to) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("Target agent not found: {}", to) + ))?; + + // Check capability permission + self.check_a2a_permission(from, to)?; + + // Send task request + let task_id = uuid::Uuid::new_v4().to_string(); + let envelope = A2aEnvelope::new( + *from, + A2aRecipient::Direct { agent_id: *to }, + A2aMessageType::Task, + serde_json::json!({ + "task_id": task_id, + "description": task_description, + }), + ).with_conversation(task_id.clone()); + + let envelope_id = envelope.id.clone(); + self.a2a_router.route(envelope).await?; + + self.events.publish(Event::A2aMessageSent { + from: *from, + to: format!("{}", to), + message_type: "task".to_string(), + }); + + // Wait for response with timeout + let timeout = tokio::time::Duration::from_millis(timeout_ms); + let result = tokio::time::timeout(timeout, async { + let inbox_entry = self.a2a_inboxes.get(from) + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("No A2A inbox for agent: {}", from) + ))?; + let mut inbox = inbox_entry.lock().await; + + // Poll for matching response + loop { + match inbox.recv().await { + Some(msg) => { + // Check if this is a response to our task + if msg.message_type == A2aMessageType::Response + && msg.reply_to.as_deref() == Some(&envelope_id) { + return Ok::<_, zclaw_types::ZclawError>(msg.payload); + } + // Not our response — requeue it for later processing + tracing::debug!("Re-queuing non-matching A2A message: {}", msg.id); + inbox.requeue(msg); + } + None => { + return Err(zclaw_types::ZclawError::Internal( + "A2A inbox channel closed".to_string() + )); + } + } + } + }).await; + + match result { + Ok(Ok(payload)) => Ok(payload), + Ok(Err(e)) => Err(e), + Err(_) => Err(zclaw_types::ZclawError::Timeout( + format!("A2A task delegation timed out after {}ms", timeout_ms) + )), + } + } + + /// Get all online agents via A2A profiles + pub async fn a2a_get_online_agents(&self) -> Result> { + Ok(self.a2a_router.list_profiles().await) + } +} diff --git a/crates/zclaw-kernel/src/kernel/adapters.rs b/crates/zclaw-kernel/src/kernel/adapters.rs new file mode 100644 index 0000000..f761514 --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/adapters.rs @@ -0,0 +1,138 @@ +//! Adapter types bridging runtime interfaces + +use std::pin::Pin; +use std::sync::Arc; +use async_trait::async_trait; +use serde_json::Value; + +use zclaw_runtime::{LlmDriver, tool::SkillExecutor}; +use zclaw_skills::{SkillRegistry, LlmCompleter}; +use zclaw_types::Result; + +/// Adapter that bridges `zclaw_runtime::LlmDriver` -> `zclaw_skills::LlmCompleter` +pub(crate) struct LlmDriverAdapter { + pub(crate) driver: Arc, + pub(crate) max_tokens: u32, + pub(crate) temperature: f32, +} + +impl LlmCompleter for LlmDriverAdapter { + fn complete( + &self, + prompt: &str, + ) -> Pin> + Send + '_>> { + let driver = self.driver.clone(); + let prompt = prompt.to_string(); + Box::pin(async move { + let request = zclaw_runtime::CompletionRequest { + messages: vec![zclaw_types::Message::user(prompt)], + max_tokens: Some(self.max_tokens), + temperature: Some(self.temperature), + ..Default::default() + }; + let response = driver.complete(request).await + .map_err(|e| format!("LLM completion error: {}", e))?; + // Extract text from content blocks + let text: String = response.content.iter() + .filter_map(|block| match block { + zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + Ok(text) + }) + } +} + +/// Skill executor implementation for Kernel +pub struct KernelSkillExecutor { + pub(crate) skills: Arc, + pub(crate) llm: Arc, +} + +impl KernelSkillExecutor { + pub fn new(skills: Arc, driver: Arc) -> Self { + let llm: Arc = Arc::new(LlmDriverAdapter { driver, max_tokens: 4096, temperature: 0.7 }); + Self { skills, llm } + } +} + +#[async_trait] +impl SkillExecutor for KernelSkillExecutor { + async fn execute_skill( + &self, + skill_id: &str, + agent_id: &str, + session_id: &str, + input: Value, + ) -> Result { + let context = zclaw_skills::SkillContext { + agent_id: agent_id.to_string(), + session_id: session_id.to_string(), + llm: Some(self.llm.clone()), + ..Default::default() + }; + let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?; + Ok(result.output) + } + + fn get_skill_detail(&self, skill_id: &str) -> Option { + let manifests = self.skills.manifests_snapshot(); + let manifest = manifests.get(&zclaw_types::SkillId::new(skill_id))?; + Some(zclaw_runtime::tool::SkillDetail { + id: manifest.id.as_str().to_string(), + name: manifest.name.clone(), + description: manifest.description.clone(), + category: manifest.category.clone(), + input_schema: manifest.input_schema.clone(), + triggers: manifest.triggers.clone(), + capabilities: manifest.capabilities.clone(), + }) + } + + fn list_skill_index(&self) -> Vec { + let manifests = self.skills.manifests_snapshot(); + manifests.values() + .filter(|m| m.enabled) + .map(|m| zclaw_runtime::tool::SkillIndexEntry { + id: m.id.as_str().to_string(), + description: m.description.clone(), + triggers: m.triggers.clone(), + }) + .collect() + } +} + +/// Inbox wrapper for A2A message receivers that supports re-queuing +/// non-matching messages instead of dropping them. +#[cfg(feature = "multi-agent")] +pub(crate) struct AgentInbox { + pub(crate) rx: tokio::sync::mpsc::Receiver, + pub(crate) pending: std::collections::VecDeque, +} + +#[cfg(feature = "multi-agent")] +impl AgentInbox { + pub(crate) fn new(rx: tokio::sync::mpsc::Receiver) -> Self { + Self { rx, pending: std::collections::VecDeque::new() } + } + + pub(crate) fn try_recv(&mut self) -> std::result::Result { + if let Some(msg) = self.pending.pop_front() { + return Ok(msg); + } + self.rx.try_recv() + } + + pub(crate) async fn recv(&mut self) -> Option { + if let Some(msg) = self.pending.pop_front() { + return Some(msg); + } + self.rx.recv().await + } + + pub(crate) fn requeue(&mut self, envelope: zclaw_protocols::A2aEnvelope) { + self.pending.push_back(envelope); + } +} diff --git a/crates/zclaw-kernel/src/kernel/agents.rs b/crates/zclaw-kernel/src/kernel/agents.rs new file mode 100644 index 0000000..7fcb859 --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/agents.rs @@ -0,0 +1,113 @@ +//! Agent CRUD operations + +use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result}; + +#[cfg(feature = "multi-agent")] +use std::sync::Arc; +#[cfg(feature = "multi-agent")] +use tokio::sync::Mutex; +#[cfg(feature = "multi-agent")] +use super::adapters::AgentInbox; + +use super::Kernel; + +impl Kernel { + /// Spawn a new agent + pub async fn spawn_agent(&self, config: AgentConfig) -> Result { + let id = config.id; + + // Validate capabilities + self.capabilities.validate(&config.capabilities)?; + + // Register in memory + self.memory.save_agent(&config).await?; + + // Register with A2A router for multi-agent messaging (before config is moved) + #[cfg(feature = "multi-agent")] + { + let profile = Self::agent_config_to_a2a_profile(&config); + let rx = self.a2a_router.register_agent(profile).await; + self.a2a_inboxes.insert(id, Arc::new(Mutex::new(AgentInbox::new(rx)))); + } + + // Register in registry (consumes config) + let name = config.name.clone(); + self.registry.register(config); + + // Emit event + self.events.publish(Event::AgentSpawned { + agent_id: id, + name, + }); + + Ok(id) + } + + /// Kill an agent + pub async fn kill_agent(&self, id: &AgentId) -> Result<()> { + // Remove from registry + self.registry.unregister(id); + + // Remove from memory + self.memory.delete_agent(id).await?; + + // Unregister from A2A router + #[cfg(feature = "multi-agent")] + { + self.a2a_router.unregister_agent(id).await; + self.a2a_inboxes.remove(id); + } + + // Emit event + self.events.publish(Event::AgentTerminated { + agent_id: *id, + reason: "killed".to_string(), + }); + + Ok(()) + } + + /// Update an existing agent's configuration + pub async fn update_agent(&self, config: AgentConfig) -> Result<()> { + let id = config.id; + + // Validate the agent exists + if self.registry.get(&id).is_none() { + return Err(zclaw_types::ZclawError::NotFound( + format!("Agent not found: {}", id) + )); + } + + // Validate capabilities + self.capabilities.validate(&config.capabilities)?; + + // Save updated config to memory + self.memory.save_agent(&config).await?; + + // Update in registry (preserves state and message count) + self.registry.update(config.clone()); + + // Emit event + self.events.publish(Event::AgentConfigUpdated { + agent_id: id, + name: config.name.clone(), + }); + + Ok(()) + } + + /// List all agents + pub fn list_agents(&self) -> Vec { + self.registry.list() + } + + /// Get agent info + pub fn get_agent(&self, id: &AgentId) -> Option { + self.registry.get_info(id) + } + + /// Get agent config (for export) + pub fn get_agent_config(&self, id: &AgentId) -> Option { + self.registry.get(id) + } +} diff --git a/crates/zclaw-kernel/src/kernel/approvals.rs b/crates/zclaw-kernel/src/kernel/approvals.rs new file mode 100644 index 0000000..acf538e --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/approvals.rs @@ -0,0 +1,155 @@ +//! Approval management + +use std::sync::Arc; +use serde_json::Value; +use zclaw_types::{Result, HandRun, HandRunId, HandRunStatus, TriggerSource}; +use zclaw_hands::HandContext; + +use super::Kernel; + +impl Kernel { + // ============================================================ + // Approval Management + // ============================================================ + + /// List pending approvals + pub async fn list_approvals(&self) -> Vec { + let approvals = self.pending_approvals.lock().await; + approvals.iter().filter(|a| a.status == "pending").cloned().collect() + } + + /// Get a single approval by ID (any status, not just pending) + /// + /// Returns None if no approval with the given ID exists. + pub async fn get_approval(&self, id: &str) -> Option { + let approvals = self.pending_approvals.lock().await; + approvals.iter().find(|a| a.id == id).cloned() + } + + /// Create a pending approval (called when a needs_approval hand is triggered) + pub async fn create_approval(&self, hand_id: String, input: serde_json::Value) -> super::ApprovalEntry { + let entry = super::ApprovalEntry { + id: uuid::Uuid::new_v4().to_string(), + hand_id, + status: "pending".to_string(), + created_at: chrono::Utc::now(), + input, + reject_reason: None, + }; + let mut approvals = self.pending_approvals.lock().await; + approvals.push(entry.clone()); + entry + } + + /// Respond to an approval + pub async fn respond_to_approval( + &self, + id: &str, + approved: bool, + reason: Option, + ) -> Result<()> { + let mut approvals = self.pending_approvals.lock().await; + let entry = approvals.iter_mut().find(|a| a.id == id && a.status == "pending") + .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Approval not found: {}", id)))?; + + entry.status = if approved { "approved".to_string() } else { "rejected".to_string() }; + if let Some(r) = reason { + entry.reject_reason = Some(r); + } + + if approved { + let hand_id = entry.hand_id.clone(); + let input = entry.input.clone(); + drop(approvals); // Release lock before async hand execution + + // Execute the hand in background with HandRun tracking + let hands = self.hands.clone(); + let approvals = self.pending_approvals.clone(); + let memory = self.memory.clone(); + let running_hand_runs = self.running_hand_runs.clone(); + let id_owned = id.to_string(); + tokio::spawn(async move { + // Create HandRun record for tracking + let run_id = HandRunId::new(); + let now = chrono::Utc::now().to_rfc3339(); + let mut run = HandRun { + id: run_id, + hand_name: hand_id.clone(), + trigger_source: TriggerSource::Manual, + params: input.clone(), + status: HandRunStatus::Pending, + result: None, + error: None, + duration_ms: None, + created_at: now.clone(), + started_at: None, + completed_at: None, + }; + let _ = memory.save_hand_run(&run).await.map_err(|e| { + tracing::warn!("[Approval] Failed to save hand run: {}", e); + }); + run.status = HandRunStatus::Running; + run.started_at = Some(chrono::Utc::now().to_rfc3339()); + let _ = memory.update_hand_run(&run).await.map_err(|e| { + tracing::warn!("[Approval] Failed to update hand run (running): {}", e); + }); + + // Register cancellation flag + let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + running_hand_runs.insert(run.id, cancel_flag.clone()); + + let context = HandContext::default(); + let start = std::time::Instant::now(); + let result = hands.execute(&hand_id, &context, input).await; + let duration = start.elapsed(); + + // Remove from running map + running_hand_runs.remove(&run.id); + + // Update HandRun with result + let completed_at = chrono::Utc::now().to_rfc3339(); + match &result { + Ok(res) => { + run.status = HandRunStatus::Completed; + run.result = Some(res.output.clone()); + run.error = res.error.clone(); + } + Err(e) => { + run.status = HandRunStatus::Failed; + run.error = Some(e.to_string()); + } + } + run.duration_ms = Some(duration.as_millis() as u64); + run.completed_at = Some(completed_at); + let _ = memory.update_hand_run(&run).await.map_err(|e| { + tracing::warn!("[Approval] Failed to update hand run (completed): {}", e); + }); + + // Update approval status based on execution result + let mut approvals = approvals.lock().await; + if let Some(entry) = approvals.iter_mut().find(|a| a.id == id_owned) { + match result { + Ok(_) => entry.status = "completed".to_string(), + Err(e) => { + entry.status = "failed".to_string(); + if let Some(obj) = entry.input.as_object_mut() { + obj.insert("error".to_string(), Value::String(format!("{}", e))); + } + } + } + } + }); + } + + Ok(()) + } + + /// Cancel a pending approval + pub async fn cancel_approval(&self, id: &str) -> Result<()> { + let mut approvals = self.pending_approvals.lock().await; + let entry = approvals.iter_mut().find(|a| a.id == id && a.status == "pending") + .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Approval not found: {}", id)))?; + entry.status = "cancelled".to_string(); + Ok(()) + } +} diff --git a/crates/zclaw-kernel/src/kernel/hands.rs b/crates/zclaw-kernel/src/kernel/hands.rs new file mode 100644 index 0000000..d38a324 --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/hands.rs @@ -0,0 +1,209 @@ +//! Hand execution and run tracking + +use std::sync::Arc; +use zclaw_types::{Result, HandRun, HandRunId, HandRunStatus, HandRunFilter, TriggerSource}; +use zclaw_hands::{HandContext, HandResult}; + +use super::Kernel; + +impl Kernel { + /// Get the hands registry + pub fn hands(&self) -> &Arc { + &self.hands + } + + /// List all registered hands + pub async fn list_hands(&self) -> Vec { + self.hands.list().await + } + + /// Execute a hand with the given input, tracking the run + pub async fn execute_hand( + &self, + hand_id: &str, + input: serde_json::Value, + ) -> Result<(HandResult, HandRunId)> { + let run_id = HandRunId::new(); + let now = chrono::Utc::now().to_rfc3339(); + + // Create the initial HandRun record + let mut run = HandRun { + id: run_id, + hand_name: hand_id.to_string(), + trigger_source: TriggerSource::Manual, + params: input.clone(), + status: HandRunStatus::Pending, + result: None, + error: None, + duration_ms: None, + created_at: now.clone(), + started_at: None, + completed_at: None, + }; + self.memory.save_hand_run(&run).await?; + + // Transition to Running + run.status = HandRunStatus::Running; + run.started_at = Some(chrono::Utc::now().to_rfc3339()); + self.memory.update_hand_run(&run).await?; + + // Register cancellation flag + let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + self.running_hand_runs.insert(run_id, cancel_flag.clone()); + + // Execute the hand + let context = HandContext::default(); + let start = std::time::Instant::now(); + let hand_result = self.hands.execute(hand_id, &context, input).await; + let duration = start.elapsed(); + + // Check if cancelled during execution + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + let mut run_update = run.clone(); + run_update.status = HandRunStatus::Cancelled; + run_update.completed_at = Some(chrono::Utc::now().to_rfc3339()); + run_update.duration_ms = Some(duration.as_millis() as u64); + self.memory.update_hand_run(&run_update).await?; + self.running_hand_runs.remove(&run_id); + return Err(zclaw_types::ZclawError::Internal("Hand execution cancelled".to_string())); + } + + // Remove from running map + self.running_hand_runs.remove(&run_id); + + // Update HandRun with result + let completed_at = chrono::Utc::now().to_rfc3339(); + match &hand_result { + Ok(res) => { + run.status = HandRunStatus::Completed; + run.result = Some(res.output.clone()); + run.error = res.error.clone(); + } + Err(e) => { + run.status = HandRunStatus::Failed; + run.error = Some(e.to_string()); + } + } + run.duration_ms = Some(duration.as_millis() as u64); + run.completed_at = Some(completed_at); + self.memory.update_hand_run(&run).await?; + + hand_result.map(|res| (res, run_id)) + } + + /// Execute a hand with a specific trigger source (for scheduled/event triggers) + pub async fn execute_hand_with_source( + &self, + hand_id: &str, + input: serde_json::Value, + trigger_source: TriggerSource, + ) -> Result<(HandResult, HandRunId)> { + let run_id = HandRunId::new(); + let now = chrono::Utc::now().to_rfc3339(); + + let mut run = HandRun { + id: run_id, + hand_name: hand_id.to_string(), + trigger_source, + params: input.clone(), + status: HandRunStatus::Pending, + result: None, + error: None, + duration_ms: None, + created_at: now, + started_at: None, + completed_at: None, + }; + self.memory.save_hand_run(&run).await?; + + run.status = HandRunStatus::Running; + run.started_at = Some(chrono::Utc::now().to_rfc3339()); + self.memory.update_hand_run(&run).await?; + + let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + self.running_hand_runs.insert(run_id, cancel_flag.clone()); + + let context = HandContext::default(); + let start = std::time::Instant::now(); + let hand_result = self.hands.execute(hand_id, &context, input).await; + let duration = start.elapsed(); + + // Check if cancelled during execution + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + run.status = HandRunStatus::Cancelled; + run.completed_at = Some(chrono::Utc::now().to_rfc3339()); + run.duration_ms = Some(duration.as_millis() as u64); + self.memory.update_hand_run(&run).await?; + self.running_hand_runs.remove(&run_id); + return Err(zclaw_types::ZclawError::Internal("Hand execution cancelled".to_string())); + } + + self.running_hand_runs.remove(&run_id); + + let completed_at = chrono::Utc::now().to_rfc3339(); + match &hand_result { + Ok(res) => { + run.status = HandRunStatus::Completed; + run.result = Some(res.output.clone()); + run.error = res.error.clone(); + } + Err(e) => { + run.status = HandRunStatus::Failed; + run.error = Some(e.to_string()); + } + } + run.duration_ms = Some(duration.as_millis() as u64); + run.completed_at = Some(completed_at); + self.memory.update_hand_run(&run).await?; + + hand_result.map(|res| (res, run_id)) + } + + // ============================================================ + // Hand Run Tracking + // ============================================================ + + /// Get a hand run by ID + pub async fn get_hand_run(&self, id: &HandRunId) -> Result> { + self.memory.get_hand_run(id).await + } + + /// List hand runs with filter + pub async fn list_hand_runs(&self, filter: &HandRunFilter) -> Result> { + self.memory.list_hand_runs(filter).await + } + + /// Count hand runs matching filter + pub async fn count_hand_runs(&self, filter: &HandRunFilter) -> Result { + self.memory.count_hand_runs(filter).await + } + + /// Cancel a running hand execution + pub async fn cancel_hand_run(&self, id: &HandRunId) -> Result<()> { + if let Some((_, flag)) = self.running_hand_runs.remove(id) { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + + // Note: the actual status update happens in execute_hand_with_source + // when it detects the cancel flag + Ok(()) + } else { + // Not currently running — check if exists at all + let run = self.memory.get_hand_run(id).await?; + match run { + Some(r) if r.status == HandRunStatus::Pending => { + let mut updated = r; + updated.status = HandRunStatus::Cancelled; + updated.completed_at = Some(chrono::Utc::now().to_rfc3339()); + self.memory.update_hand_run(&updated).await?; + Ok(()) + } + Some(r) => Err(zclaw_types::ZclawError::InvalidInput( + format!("Cannot cancel hand run {} with status {}", id, r.status) + )), + None => Err(zclaw_types::ZclawError::NotFound( + format!("Hand run {} not found", id) + )), + } + } + } +} diff --git a/crates/zclaw-kernel/src/kernel/messaging.rs b/crates/zclaw-kernel/src/kernel/messaging.rs new file mode 100644 index 0000000..33e91ad --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/messaging.rs @@ -0,0 +1,314 @@ +//! Message sending (non-streaming, streaming, system prompt building) + +use tokio::sync::mpsc; +use zclaw_types::{AgentId, Result}; + +/// Chat mode configuration passed from the frontend. +/// Controls thinking, reasoning, and plan mode behavior. +#[derive(Debug, Clone)] +pub struct ChatModeConfig { + pub thinking_enabled: Option, + pub reasoning_effort: Option, + pub plan_mode: Option, +} + +use zclaw_runtime::{AgentLoop, tool::builtin::PathValidator}; + +use super::Kernel; +use super::super::MessageResponse; + +impl Kernel { + /// Send a message to an agent + pub async fn send_message( + &self, + agent_id: &AgentId, + message: String, + ) -> Result { + self.send_message_with_chat_mode(agent_id, message, None).await + } + + /// Send a message to an agent with optional chat mode configuration + pub async fn send_message_with_chat_mode( + &self, + agent_id: &AgentId, + message: String, + chat_mode: Option, + ) -> Result { + let agent_config = self.registry.get(agent_id) + .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?; + + // Create or get session + let session_id = self.memory.create_session(agent_id).await?; + + // Always use Kernel's current model configuration + // This ensures user's "模型与 API" settings are respected + let model = self.config.model().to_string(); + + // Create agent loop with model configuration + let tools = self.create_tool_registry(); + let mut loop_runner = AgentLoop::new( + *agent_id, + self.driver.clone(), + tools, + self.memory.clone(), + ) + .with_model(&model) + .with_skill_executor(self.skill_executor.clone()) + .with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens())) + .with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature())) + .with_compaction_threshold( + agent_config.compaction_threshold + .map(|t| t as usize) + .unwrap_or_else(|| self.config.compaction_threshold()), + ); + + // Set path validator from agent's workspace directory (if configured) + if let Some(ref workspace) = agent_config.workspace { + let path_validator = PathValidator::new().with_workspace(workspace.clone()); + tracing::info!( + "[Kernel] Setting path_validator with workspace: {} for agent {}", + workspace.display(), + agent_id + ); + loop_runner = loop_runner.with_path_validator(path_validator); + } + + // Inject middleware chain if available + if let Some(chain) = self.create_middleware_chain() { + loop_runner = loop_runner.with_middleware_chain(chain); + } + + // Apply chat mode configuration (thinking/reasoning/plan mode) + if let Some(ref mode) = chat_mode { + if mode.thinking_enabled.unwrap_or(false) { + loop_runner = loop_runner.with_thinking_enabled(true); + } + if let Some(ref effort) = mode.reasoning_effort { + loop_runner = loop_runner.with_reasoning_effort(effort.clone()); + } + if mode.plan_mode.unwrap_or(false) { + loop_runner = loop_runner.with_plan_mode(true); + } + } + + // Build system prompt with skill information injected + let system_prompt = self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await; + let loop_runner = loop_runner.with_system_prompt(&system_prompt); + + // Run the loop + let result = loop_runner.run(session_id, message).await?; + + // Track message count + self.registry.increment_message_count(agent_id); + + Ok(MessageResponse { + content: result.response, + input_tokens: result.input_tokens, + output_tokens: result.output_tokens, + }) + } + + /// Send a message with streaming + pub async fn send_message_stream( + &self, + agent_id: &AgentId, + message: String, + ) -> Result> { + self.send_message_stream_with_prompt(agent_id, message, None, None, None).await + } + + /// Send a message with streaming, optional system prompt, optional session reuse, + /// and optional chat mode configuration (thinking/reasoning/plan mode). + pub async fn send_message_stream_with_prompt( + &self, + agent_id: &AgentId, + message: String, + system_prompt_override: Option, + session_id_override: Option, + chat_mode: Option, + ) -> Result> { + let agent_config = self.registry.get(agent_id) + .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?; + + // Reuse existing session or create new one + let session_id = match session_id_override { + Some(id) => { + // Use get_or_create to ensure the frontend's session ID is persisted. + // This is the critical bridge: without it, the kernel generates a + // different UUID each turn, so conversation history is never found. + tracing::debug!("Reusing frontend session ID: {}", id); + self.memory.get_or_create_session(&id, agent_id).await? + } + None => self.memory.create_session(agent_id).await?, + }; + + // Always use Kernel's current model configuration + // This ensures user's "模型与 API" settings are respected + let model = self.config.model().to_string(); + + // Create agent loop with model configuration + let tools = self.create_tool_registry(); + let mut loop_runner = AgentLoop::new( + *agent_id, + self.driver.clone(), + tools, + self.memory.clone(), + ) + .with_model(&model) + .with_skill_executor(self.skill_executor.clone()) + .with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens())) + .with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature())) + .with_compaction_threshold( + agent_config.compaction_threshold + .map(|t| t as usize) + .unwrap_or_else(|| self.config.compaction_threshold()), + ); + + // Set path validator from agent's workspace directory (if configured) + // This enables file_read / file_write tools to access the workspace + if let Some(ref workspace) = agent_config.workspace { + let path_validator = PathValidator::new().with_workspace(workspace.clone()); + tracing::info!( + "[Kernel] Setting path_validator with workspace: {} for agent {}", + workspace.display(), + agent_id + ); + loop_runner = loop_runner.with_path_validator(path_validator); + } + + // Inject middleware chain if available + if let Some(chain) = self.create_middleware_chain() { + loop_runner = loop_runner.with_middleware_chain(chain); + } + + // Apply chat mode configuration (thinking/reasoning/plan mode from frontend) + if let Some(ref mode) = chat_mode { + if mode.thinking_enabled.unwrap_or(false) { + loop_runner = loop_runner.with_thinking_enabled(true); + } + if let Some(ref effort) = mode.reasoning_effort { + loop_runner = loop_runner.with_reasoning_effort(effort.clone()); + } + if mode.plan_mode.unwrap_or(false) { + loop_runner = loop_runner.with_plan_mode(true); + } + } + + // Use external prompt if provided, otherwise build default + let system_prompt = match system_prompt_override { + Some(prompt) => prompt, + None => self.build_system_prompt_with_skills(agent_config.system_prompt.as_ref()).await, + }; + let loop_runner = loop_runner.with_system_prompt(&system_prompt); + + // Run with streaming + self.registry.increment_message_count(agent_id); + loop_runner.run_streaming(session_id, message).await + } + + /// Build a system prompt with skill information injected + pub(super) async fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String { + // Get skill list asynchronously + let skills = self.skills.list().await; + + let mut prompt = base_prompt + .map(|p| p.clone()) + .unwrap_or_else(|| "You are a helpful AI assistant.".to_string()); + + // 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 specialized skills. Analyze user intent and autonomously call `execute_skill` with the appropriate skill_id.\n\n"); + + // Group skills by category based on their ID patterns + let categories = self.categorize_skills(&skills); + + for (category, category_skills) in categories { + prompt.push_str(&format!("### {}\n", category)); + for skill in category_skills { + prompt.push_str(&format!( + "- **{}**: {}", + skill.id.as_str(), + skill.description + )); + prompt.push('\n'); + } + prompt.push('\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 + pub(super) 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 + } +} diff --git a/crates/zclaw-kernel/src/kernel/mod.rs b/crates/zclaw-kernel/src/kernel/mod.rs new file mode 100644 index 0000000..0d30603 --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/mod.rs @@ -0,0 +1,345 @@ +//! Kernel - central coordinator + +mod adapters; +mod agents; +mod messaging; +mod skills; +mod hands; +mod triggers; +mod approvals; +#[cfg(feature = "multi-agent")] +mod a2a; + +use std::sync::Arc; +use tokio::sync::{broadcast, Mutex}; +use zclaw_types::{Event, Result}; + +#[cfg(feature = "multi-agent")] +use zclaw_types::AgentId; +#[cfg(feature = "multi-agent")] +use zclaw_protocols::A2aRouter; + +use crate::registry::AgentRegistry; +use crate::capabilities::CapabilityManager; +use crate::events::EventBus; +use crate::config::KernelConfig; +use zclaw_memory::MemoryStore; +use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor}; +use zclaw_skills::SkillRegistry; +use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}}; + +pub use adapters::KernelSkillExecutor; +pub use messaging::ChatModeConfig; + +/// The ZCLAW Kernel +pub struct Kernel { + config: KernelConfig, + registry: AgentRegistry, + capabilities: CapabilityManager, + events: EventBus, + memory: Arc, + driver: Arc, + llm_completer: Arc, + skills: Arc, + skill_executor: Arc, + hands: Arc, + trigger_manager: crate::trigger_manager::TriggerManager, + pending_approvals: Arc>>, + /// Running hand runs that can be cancelled (run_id -> cancelled flag) + running_hand_runs: Arc>>, + /// Shared memory storage backend for Growth system + viking: Arc, + /// Optional LLM driver for memory extraction (set by Tauri desktop layer) + extraction_driver: Option>, + /// A2A router for inter-agent messaging (gated by multi-agent feature) + #[cfg(feature = "multi-agent")] + a2a_router: Arc, + /// Per-agent A2A inbox receivers (supports re-queuing non-matching messages) + #[cfg(feature = "multi-agent")] + a2a_inboxes: Arc>>>, +} + +impl Kernel { + /// Boot the kernel with the given configuration + pub async fn boot(config: KernelConfig) -> Result { + // Initialize memory store + let memory = Arc::new(MemoryStore::new(&config.database_url).await?); + + // Initialize driver based on config + let driver = config.create_driver()?; + + // Initialize subsystems + let registry = AgentRegistry::new(); + let capabilities = CapabilityManager::new(); + let events = EventBus::new(); + + // Initialize skill registry + let skills = Arc::new(SkillRegistry::new()); + + // Scan skills directory if configured + if let Some(ref skills_dir) = config.skills_dir { + if skills_dir.exists() { + skills.add_skill_dir(skills_dir.clone()).await?; + } + } + + // Initialize hand registry with built-in hands + let hands = Arc::new(HandRegistry::new()); + let quiz_model = config.model().to_string(); + let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model)); + hands.register(Arc::new(BrowserHand::new())).await; + hands.register(Arc::new(SlideshowHand::new())).await; + hands.register(Arc::new(SpeechHand::new())).await; + hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await; + hands.register(Arc::new(WhiteboardHand::new())).await; + hands.register(Arc::new(ResearcherHand::new())).await; + hands.register(Arc::new(CollectorHand::new())).await; + hands.register(Arc::new(ClipHand::new())).await; + hands.register(Arc::new(TwitterHand::new())).await; + + // Create skill executor + let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone())); + + // Create LLM completer for skill system (shared with skill_executor) + let llm_completer: Arc = + Arc::new(adapters::LlmDriverAdapter { + driver: driver.clone(), + max_tokens: config.max_tokens(), + temperature: config.temperature(), + }); + + // Initialize trigger manager + let trigger_manager = crate::trigger_manager::TriggerManager::new(hands.clone()); + + // Initialize Growth system — shared VikingAdapter for memory storage + let viking = Arc::new(zclaw_runtime::VikingAdapter::in_memory()); + + // Restore persisted agents + let persisted = memory.list_agents().await?; + for agent in persisted { + registry.register(agent); + } + + // Initialize A2A router for multi-agent support + #[cfg(feature = "multi-agent")] + let a2a_router = { + let kernel_agent_id = AgentId::new(); + Arc::new(A2aRouter::new(kernel_agent_id)) + }; + + Ok(Self { + config, + registry, + capabilities, + events, + memory, + driver, + llm_completer, + skills, + skill_executor, + hands, + trigger_manager, + pending_approvals: Arc::new(Mutex::new(Vec::new())), + running_hand_runs: Arc::new(dashmap::DashMap::new()), + viking, + extraction_driver: None, + #[cfg(feature = "multi-agent")] + a2a_router, + #[cfg(feature = "multi-agent")] + a2a_inboxes: Arc::new(dashmap::DashMap::new()), + }) + } + + /// Create a tool registry with built-in tools + pub(crate) fn create_tool_registry(&self) -> ToolRegistry { + let mut tools = ToolRegistry::new(); + zclaw_runtime::tool::builtin::register_builtin_tools(&mut tools); + + // Register TaskTool with driver and memory for sub-agent delegation + let task_tool = zclaw_runtime::tool::builtin::TaskTool::new( + self.driver.clone(), + self.memory.clone(), + self.config.model(), + ); + tools.register(Box::new(task_tool)); + + tools + } + + /// Create the middleware chain for the agent loop. + /// + /// When middleware is configured, cross-cutting concerns (compaction, loop guard, + /// token calibration, etc.) are delegated to the chain. When no middleware is + /// registered, the legacy inline path in `AgentLoop` is used instead. + pub(crate) fn create_middleware_chain(&self) -> Option { + let mut chain = zclaw_runtime::middleware::MiddlewareChain::new(); + + // Growth integration — shared VikingAdapter for memory middleware & compaction + let mut growth = zclaw_runtime::GrowthIntegration::new(self.viking.clone()); + if let Some(ref driver) = self.extraction_driver { + growth = growth.with_llm_driver(driver.clone()); + } + + // Compaction middleware — only register when threshold > 0 + let threshold = self.config.compaction_threshold(); + if threshold > 0 { + use std::sync::Arc; + let mut growth_for_compaction = zclaw_runtime::GrowthIntegration::new(self.viking.clone()); + if let Some(ref driver) = self.extraction_driver { + growth_for_compaction = growth_for_compaction.with_llm_driver(driver.clone()); + } + let mw = zclaw_runtime::middleware::compaction::CompactionMiddleware::new( + threshold, + zclaw_runtime::CompactionConfig::default(), + Some(self.driver.clone()), + Some(growth_for_compaction), + ); + chain.register(Arc::new(mw)); + } + + // Memory middleware — auto-extract memories after conversations + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth); + chain.register(Arc::new(mw)); + } + + // Loop guard middleware + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::loop_guard::LoopGuardMiddleware::with_defaults(); + chain.register(Arc::new(mw)); + } + + // Token calibration middleware + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::token_calibration::TokenCalibrationMiddleware::new(); + chain.register(Arc::new(mw)); + } + + // Skill index middleware — inject lightweight index instead of full descriptions + { + use std::sync::Arc; + let entries = self.skill_executor.list_skill_index(); + if !entries.is_empty() { + let mw = zclaw_runtime::middleware::skill_index::SkillIndexMiddleware::new(entries); + chain.register(Arc::new(mw)); + } + } + + // Title middleware — auto-generate conversation titles after first exchange + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::title::TitleMiddleware::new(); + chain.register(Arc::new(mw)); + } + + // Dangling tool repair — patch missing tool results before LLM calls + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::dangling_tool::DanglingToolMiddleware::new(); + chain.register(Arc::new(mw)); + } + + // Tool error middleware — format tool errors for LLM recovery + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::tool_error::ToolErrorMiddleware::new(); + chain.register(Arc::new(mw)); + } + + // Tool output guard — post-execution output sanitization checks + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::tool_output_guard::ToolOutputGuardMiddleware::new(); + chain.register(Arc::new(mw)); + } + + // Guardrail middleware — safety rules for tool calls + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::guardrail::GuardrailMiddleware::new(true) + .with_builtin_rules(); + chain.register(Arc::new(mw)); + } + + // Sub-agent limit — cap concurrent sub-agent spawning + { + use std::sync::Arc; + let mw = zclaw_runtime::middleware::subagent_limit::SubagentLimitMiddleware::new(); + chain.register(Arc::new(mw)); + } + + // Only return Some if we actually registered middleware + if chain.is_empty() { + None + } else { + tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len()); + Some(chain) + } + } + + /// Subscribe to events + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + /// Shutdown the kernel + pub async fn shutdown(&self) -> Result<()> { + self.events.publish(Event::KernelShutdown); + Ok(()) + } + + /// Get the kernel configuration + pub fn config(&self) -> &KernelConfig { + &self.config + } + + /// Get the LLM driver + pub fn driver(&self) -> Arc { + self.driver.clone() + } + + /// Replace the default in-memory VikingAdapter with a persistent one. + /// + /// Called by the Tauri desktop layer after `Kernel::boot()` to bridge + /// the kernel's Growth system to the same SqliteStorage used by + /// viking_commands and intelligence_hooks. + pub fn set_viking(&mut self, viking: Arc) { + tracing::info!("[Kernel] Replacing in-memory VikingAdapter with persistent storage"); + self.viking = viking; + } + + /// Get a reference to the shared VikingAdapter + pub fn viking(&self) -> Arc { + self.viking.clone() + } + + /// Set the LLM extraction driver for the Growth system. + /// + /// Required for `MemoryMiddleware` to extract memories from conversations + /// via LLM analysis. If not set, memory extraction is silently skipped. + pub fn set_extraction_driver(&mut self, driver: Arc) { + tracing::info!("[Kernel] Extraction driver configured for Growth system"); + self.extraction_driver = Some(driver); + } +} + +#[derive(Debug, Clone)] +pub struct ApprovalEntry { + pub id: String, + pub hand_id: String, + pub status: String, + pub created_at: chrono::DateTime, + pub input: serde_json::Value, + pub reject_reason: Option, +} + +/// Response from sending a message +#[derive(Debug, Clone)] +pub struct MessageResponse { + pub content: String, + pub input_tokens: u32, + pub output_tokens: u32, +} diff --git a/crates/zclaw-kernel/src/kernel/skills.rs b/crates/zclaw-kernel/src/kernel/skills.rs new file mode 100644 index 0000000..8b9cb52 --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/skills.rs @@ -0,0 +1,79 @@ +//! Skills management methods + +use std::sync::Arc; +use zclaw_types::Result; + +use super::Kernel; + +impl Kernel { + /// Get the skills registry + pub fn skills(&self) -> &Arc { + &self.skills + } + + /// List all discovered skills + pub async fn list_skills(&self) -> Vec { + self.skills.list().await + } + + /// Refresh skills from a directory + pub async fn refresh_skills(&self, dir: Option) -> Result<()> { + if let Some(path) = dir { + self.skills.add_skill_dir(path).await?; + } else if let Some(ref skills_dir) = self.config.skills_dir { + self.skills.add_skill_dir(skills_dir.clone()).await?; + } + Ok(()) + } + + /// Get the configured skills directory + pub fn skills_dir(&self) -> Option<&std::path::PathBuf> { + self.config.skills_dir.as_ref() + } + + /// Create a new skill in the skills directory + pub async fn create_skill(&self, manifest: zclaw_skills::SkillManifest) -> Result<()> { + let skills_dir = self.config.skills_dir.as_ref() + .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( + "Skills directory not configured".into() + ))?; + self.skills.create_skill(skills_dir, manifest).await + } + + /// Update an existing skill + pub async fn update_skill( + &self, + id: &zclaw_types::SkillId, + manifest: zclaw_skills::SkillManifest, + ) -> Result { + let skills_dir = self.config.skills_dir.as_ref() + .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( + "Skills directory not configured".into() + ))?; + self.skills.update_skill(skills_dir, id, manifest).await + } + + /// Delete a skill + pub async fn delete_skill(&self, id: &zclaw_types::SkillId) -> Result<()> { + let skills_dir = self.config.skills_dir.as_ref() + .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( + "Skills directory not configured".into() + ))?; + self.skills.delete_skill(skills_dir, id).await + } + + /// Execute a skill with the given ID and input + pub async fn execute_skill( + &self, + id: &str, + context: zclaw_skills::SkillContext, + input: serde_json::Value, + ) -> Result { + // Inject LLM completer into context for PromptOnly skills + let mut ctx = context; + if ctx.llm.is_none() { + ctx.llm = Some(self.llm_completer.clone()); + } + self.skills.execute(&zclaw_types::SkillId::new(id), &ctx, input).await + } +} diff --git a/crates/zclaw-kernel/src/kernel/triggers.rs b/crates/zclaw-kernel/src/kernel/triggers.rs new file mode 100644 index 0000000..0bf38cc --- /dev/null +++ b/crates/zclaw-kernel/src/kernel/triggers.rs @@ -0,0 +1,52 @@ +//! Trigger CRUD operations + +use zclaw_types::Result; + +use super::Kernel; + +impl Kernel { + // ============================================================ + // Trigger Management + // ============================================================ + + /// List all triggers + pub async fn list_triggers(&self) -> Vec { + self.trigger_manager.list_triggers().await + } + + /// Get a specific trigger + pub async fn get_trigger(&self, id: &str) -> Option { + self.trigger_manager.get_trigger(id).await + } + + /// Create a new trigger + pub async fn create_trigger( + &self, + config: zclaw_hands::TriggerConfig, + ) -> Result { + self.trigger_manager.create_trigger(config).await + } + + /// Update a trigger + pub async fn update_trigger( + &self, + id: &str, + updates: crate::trigger_manager::TriggerUpdateRequest, + ) -> Result { + self.trigger_manager.update_trigger(id, updates).await + } + + /// Delete a trigger + pub async fn delete_trigger(&self, id: &str) -> Result<()> { + self.trigger_manager.delete_trigger(id).await + } + + /// Execute a trigger + pub async fn execute_trigger( + &self, + id: &str, + input: serde_json::Value, + ) -> Result { + self.trigger_manager.execute_trigger(id, input).await + } +} diff --git a/crates/zclaw-memory/Cargo.toml b/crates/zclaw-memory/Cargo.toml index d1d9fc9..395426d 100644 --- a/crates/zclaw-memory/Cargo.toml +++ b/crates/zclaw-memory/Cargo.toml @@ -24,3 +24,6 @@ libsqlite3-sys = { workspace = true } # Async utilities futures = { workspace = true } +async-trait = { workspace = true } + +anyhow = { workspace = true } diff --git a/crates/zclaw-memory/src/fact.rs b/crates/zclaw-memory/src/fact.rs new file mode 100644 index 0000000..ea9cda5 --- /dev/null +++ b/crates/zclaw-memory/src/fact.rs @@ -0,0 +1,202 @@ +//! Structured fact extraction and storage. +//! +//! Inspired by DeerFlow's LLM-driven fact extraction with deduplication +//! and confidence scoring. Facts are natural language statements extracted +//! from conversations, categorized and scored for retrieval quality. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Global counter for generating unique fact IDs without uuid dependency overhead. +static FACT_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn next_fact_id() -> String { + let ts = now_secs(); + let seq = FACT_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("fact-{}-{}", ts, seq) +} + +/// A structured fact extracted from conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fact { + /// Unique identifier + pub id: String, + /// The fact content (natural language) + pub content: String, + /// Category of the fact + pub category: FactCategory, + /// Confidence score (0.0 - 1.0) + pub confidence: f64, + /// When this fact was extracted (unix timestamp in seconds) + pub created_at: u64, + /// Source session ID + pub source: Option, +} + +/// Categories for structured facts. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FactCategory { + /// User preference (language, style, format) + Preference, + /// Domain knowledge or context + Knowledge, + /// Behavioral pattern or habit + Behavior, + /// Task-specific context + TaskContext, + /// General information + General, +} + +impl Fact { + /// Create a new fact with auto-generated ID and timestamp. + pub fn new(content: impl Into, category: FactCategory, confidence: f64) -> Self { + Self { + id: next_fact_id(), + content: content.into(), + category, + confidence: confidence.clamp(0.0, 1.0), + created_at: now_secs(), + source: None, + } + } + + /// Attach a source session ID (builder pattern). + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } +} + +/// Result of a fact extraction batch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedFactBatch { + pub facts: Vec, + pub agent_id: String, + pub session_id: String, +} + +impl ExtractedFactBatch { + /// Deduplicate facts by trimmed, lowercased content comparison. + /// When duplicates are found, keep the one with higher confidence. + pub fn deduplicate(mut self) -> Self { + let mut best_index: HashMap = HashMap::new(); + let mut to_remove: Vec = Vec::new(); + + for (i, fact) in self.facts.iter().enumerate() { + let key = fact.content.trim().to_lowercase(); + if let Some(&prev_idx) = best_index.get(&key) { + // Keep the one with higher confidence + if self.facts[prev_idx].confidence >= fact.confidence { + to_remove.push(i); + } else { + to_remove.push(prev_idx); + best_index.insert(key, i); + } + } else { + best_index.insert(key, i); + } + } + + // Remove in reverse order to maintain valid indices + for idx in to_remove.into_iter().rev() { + self.facts.remove(idx); + } + + self + } + + /// Filter facts below the given confidence threshold. + pub fn filter_by_confidence(mut self, min_confidence: f64) -> Self { + self.facts.retain(|f| f.confidence >= min_confidence); + self + } + + /// Returns true if there are no facts in the batch. + pub fn is_empty(&self) -> bool { + self.facts.is_empty() + } + + /// Returns the number of facts in the batch. + pub fn len(&self) -> usize { + self.facts.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fact_new_clamps_confidence() { + let f = Fact::new("hello", FactCategory::General, 1.5); + assert!((f.confidence - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_fact_with_source() { + let f = Fact::new("prefers dark mode", FactCategory::Preference, 0.9) + .with_source("sess-123"); + assert_eq!(f.source.as_deref(), Some("sess-123")); + } + + #[test] + fn test_deduplicate_keeps_higher_confidence() { + let batch = ExtractedFactBatch { + facts: vec![ + Fact::new("likes Python", FactCategory::Preference, 0.8), + Fact::new("Likes Python", FactCategory::Preference, 0.95), + Fact::new("uses VSCode", FactCategory::Behavior, 0.7), + ], + agent_id: "agent-1".into(), + session_id: "sess-1".into(), + }; + + let deduped = batch.deduplicate(); + assert_eq!(deduped.facts.len(), 2); + // The "likes Python" fact with 0.95 confidence should survive + let python_fact = deduped + .facts + .iter() + .find(|f| f.content.contains("Python")) + .unwrap(); + assert!((python_fact.confidence - 0.95).abs() < f64::EPSILON); + } + + #[test] + fn test_filter_by_confidence() { + let batch = ExtractedFactBatch { + facts: vec![ + Fact::new("high", FactCategory::General, 0.9), + Fact::new("medium", FactCategory::General, 0.75), + Fact::new("low", FactCategory::General, 0.3), + ], + agent_id: "agent-1".into(), + session_id: "sess-1".into(), + }; + + let filtered = batch.filter_by_confidence(0.7); + assert_eq!(filtered.facts.len(), 2); + } + + #[test] + fn test_is_empty_and_len() { + let batch = ExtractedFactBatch { + facts: vec![], + agent_id: "agent-1".into(), + session_id: "sess-1".into(), + }; + assert!(batch.is_empty()); + assert_eq!(batch.len(), 0); + } +} diff --git a/crates/zclaw-memory/src/lib.rs b/crates/zclaw-memory/src/lib.rs index 9af5b4f..02202e1 100644 --- a/crates/zclaw-memory/src/lib.rs +++ b/crates/zclaw-memory/src/lib.rs @@ -5,7 +5,9 @@ mod store; mod session; mod schema; +pub mod fact; pub use store::*; pub use session::*; pub use schema::*; +pub use fact::{Fact, FactCategory, ExtractedFactBatch}; diff --git a/crates/zclaw-protocols/src/mcp_types.rs b/crates/zclaw-protocols/src/mcp_types.rs index ebf3cbd..a598db7 100644 --- a/crates/zclaw-protocols/src/mcp_types.rs +++ b/crates/zclaw-protocols/src/mcp_types.rs @@ -278,7 +278,8 @@ pub struct PromptMessage { // === Content Blocks === -/// Content block for tool results and messages +/// MCP protocol wire format content block. Used for Model Context Protocol resource responses. +/// Distinct from zclaw_types::ContentBlock (LLM messages) and zclaw_hands::ContentBlock (presentations). #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { diff --git a/crates/zclaw-runtime/src/compaction.rs b/crates/zclaw-runtime/src/compaction.rs index e95ba0d..ba71146 100644 --- a/crates/zclaw-runtime/src/compaction.rs +++ b/crates/zclaw-runtime/src/compaction.rs @@ -454,6 +454,9 @@ async fn generate_llm_summary( temperature: Some(0.3), stop: Vec::new(), stream: false, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, }; let response = driver diff --git a/crates/zclaw-runtime/src/driver/anthropic.rs b/crates/zclaw-runtime/src/driver/anthropic.rs index 86d76d0..2f855ba 100644 --- a/crates/zclaw-runtime/src/driver/anthropic.rs +++ b/crates/zclaw-runtime/src/driver/anthropic.rs @@ -181,8 +181,12 @@ impl LlmDriver for AnthropicDriver { } } "error" => { + let error_msg = serde_json::from_str::(&data) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str().map(String::from))) + .unwrap_or_else(|| format!("Stream error: {}", &data[..data.len().min(200)])); yield Ok(StreamChunk::Error { - message: "Stream error".to_string(), + message: error_msg, }); } _ => {} @@ -251,15 +255,42 @@ impl AnthropicDriver { }) .collect(); + let requested_max = request.max_tokens.unwrap_or(4096); + let (thinking, budget) = if request.thinking_enabled { + let budget = match request.reasoning_effort.as_deref() { + Some("low") => 2000, + Some("medium") => 10000, + Some("high") => 32000, + _ => 10000, // default + }; + (Some(AnthropicThinking { + r#type: "enabled".to_string(), + budget_tokens: budget, + }), budget) + } else { + (None, 0) + }; + + // When thinking is enabled, max_tokens is the TOTAL budget (thinking + text). + // Use the maximum output limit (65536) so thinking can consume whatever it + // needs without starving the text response. We only pay for tokens actually + // generated, so a high limit costs nothing extra. + let effective_max = if budget > 0 { + 65536 + } else { + requested_max + }; + AnthropicRequest { model: request.model.clone(), - max_tokens: request.max_tokens.unwrap_or(4096), + max_tokens: effective_max, system: request.system.clone(), messages, tools: if tools.is_empty() { None } else { Some(tools) }, temperature: request.temperature, stop_sequences: if request.stop.is_empty() { None } else { Some(request.stop.clone()) }, stream: request.stream, + thinking, } } @@ -313,6 +344,14 @@ struct AnthropicRequest { stop_sequences: Option>, #[serde(default)] stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, +} + +#[derive(Serialize)] +struct AnthropicThinking { + r#type: String, + budget_tokens: u32, } #[derive(Serialize)] diff --git a/crates/zclaw-runtime/src/driver/gemini.rs b/crates/zclaw-runtime/src/driver/gemini.rs index fd0adbf..ecb6f03 100644 --- a/crates/zclaw-runtime/src/driver/gemini.rs +++ b/crates/zclaw-runtime/src/driver/gemini.rs @@ -265,6 +265,10 @@ impl GeminiDriver { /// - Tool definitions use `functionDeclarations` /// - Tool results are sent as `functionResponse` parts in `user` messages fn build_api_request(&self, request: &CompletionRequest) -> GeminiRequest { + if request.thinking_enabled { + tracing::debug!("[GeminiDriver] thinking_enabled=true but Gemini does not support native thinking mode; ignoring"); + } + let mut contents: Vec = Vec::new(); for msg in &request.messages { diff --git a/crates/zclaw-runtime/src/driver/local.rs b/crates/zclaw-runtime/src/driver/local.rs index 31c03ba..a88a0a3 100644 --- a/crates/zclaw-runtime/src/driver/local.rs +++ b/crates/zclaw-runtime/src/driver/local.rs @@ -58,6 +58,10 @@ impl LocalDriver { // ---------------------------------------------------------------- fn build_api_request(&self, request: &CompletionRequest) -> LocalApiRequest { + if request.thinking_enabled { + tracing::debug!("[LocalDriver] thinking_enabled=true but local driver does not support native thinking mode; ignoring"); + } + let messages: Vec = request .messages .iter() @@ -183,7 +187,7 @@ impl LocalDriver { .unwrap_or(false); let blocks = if has_tool_calls { - let tool_calls = c.message.tool_calls.as_ref().unwrap(); + let tool_calls = c.message.tool_calls.as_deref().unwrap_or_default(); tool_calls .iter() .map(|tc| { @@ -199,7 +203,7 @@ impl LocalDriver { .collect() } else if has_content { vec![ContentBlock::Text { - text: c.message.content.clone().unwrap(), + text: c.message.content.clone().unwrap_or_default(), }] } else { vec![ContentBlock::Text { diff --git a/crates/zclaw-runtime/src/driver/mod.rs b/crates/zclaw-runtime/src/driver/mod.rs index 8390cde..098d019 100644 --- a/crates/zclaw-runtime/src/driver/mod.rs +++ b/crates/zclaw-runtime/src/driver/mod.rs @@ -60,6 +60,15 @@ pub struct CompletionRequest { pub stop: Vec, /// Enable streaming pub stream: bool, + /// Enable extended thinking/reasoning + #[serde(default)] + pub thinking_enabled: bool, + /// Reasoning effort level (for providers that support it) + #[serde(default)] + pub reasoning_effort: Option, + /// Enable plan mode + #[serde(default)] + pub plan_mode: bool, } impl Default for CompletionRequest { @@ -73,27 +82,16 @@ impl Default for CompletionRequest { temperature: Some(0.7), stop: Vec::new(), stream: false, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, } } } -/// Tool definition for LLM -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - pub description: String, - pub input_schema: serde_json::Value, -} - -impl ToolDefinition { - pub fn new(name: impl Into, description: impl Into, schema: serde_json::Value) -> Self { - Self { - name: name.into(), - description: description.into(), - input_schema: schema, - } - } -} +/// Tool definition for LLM function calling. +/// Re-exported from `zclaw_types::tool::ToolDefinition` (canonical definition). +pub use zclaw_types::tool::ToolDefinition; /// Completion response #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,7 +108,8 @@ pub struct CompletionResponse { pub stop_reason: StopReason, } -/// Content block in response +/// LLM driver response content block (subset of canonical zclaw_types::ContentBlock). +/// Used internally by Anthropic/OpenAI/Gemini/Local drivers for API response parsing. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { diff --git a/crates/zclaw-runtime/src/driver/openai.rs b/crates/zclaw-runtime/src/driver/openai.rs index 7aa6330..8df29d7 100644 --- a/crates/zclaw-runtime/src/driver/openai.rs +++ b/crates/zclaw-runtime/src/driver/openai.rs @@ -130,8 +130,8 @@ impl LlmDriver for OpenAiDriver { let api_key = self.api_key.expose_secret().to_string(); Box::pin(stream! { - println!("[OpenAI:stream] POST to {}/chat/completions", base_url); - println!("[OpenAI:stream] Request model={}, stream={}", stream_request.model, stream_request.stream); + tracing::debug!("[OpenAI:stream] POST to {}/chat/completions", base_url); + tracing::debug!("[OpenAI:stream] Request model={}, stream={}", stream_request.model, stream_request.stream); let response = match self.client .post(format!("{}/chat/completions", base_url)) .header("Authorization", format!("Bearer {}", api_key)) @@ -142,11 +142,11 @@ impl LlmDriver for OpenAiDriver { .await { Ok(r) => { - println!("[OpenAI:stream] Response status: {}, content-type: {:?}", r.status(), r.headers().get("content-type")); + tracing::debug!("[OpenAI:stream] Response status: {}, content-type: {:?}", r.status(), r.headers().get("content-type")); r }, Err(e) => { - println!("[OpenAI:stream] HTTP request FAILED: {:?}", e); + tracing::debug!("[OpenAI:stream] HTTP request FAILED: {:?}", e); yield Err(ZclawError::LlmError(format!("HTTP request failed: {}", e))); return; } @@ -155,7 +155,7 @@ impl LlmDriver for OpenAiDriver { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - println!("[OpenAI:stream] API error {}: {}", status, &body[..body.len().min(500)]); + tracing::debug!("[OpenAI:stream] API error {}: {}", status, &body[..body.len().min(500)]); yield Err(ZclawError::LlmError(format!("API error {}: {}", status, body))); return; } @@ -170,7 +170,7 @@ impl LlmDriver for OpenAiDriver { let chunk = match chunk_result { Ok(c) => c, Err(e) => { - println!("[OpenAI:stream] Byte stream error: {:?}", e); + tracing::debug!("[OpenAI:stream] Byte stream error: {:?}", e); yield Err(ZclawError::LlmError(format!("Stream error: {}", e))); continue; } @@ -180,7 +180,7 @@ impl LlmDriver for OpenAiDriver { let text = String::from_utf8_lossy(&chunk); // Log first 500 bytes of raw data for debugging SSE format if raw_bytes_total <= 600 { - println!("[OpenAI:stream] RAW chunk ({} bytes): {:?}", text.len(), &text[..text.len().min(500)]); + tracing::debug!("[OpenAI:stream] RAW chunk ({} bytes): {:?}", text.len(), &text[..text.len().min(500)]); } for line in text.lines() { let trimmed = line.trim(); @@ -198,10 +198,10 @@ impl LlmDriver for OpenAiDriver { if let Some(data) = data { sse_event_count += 1; if sse_event_count <= 3 || data == "[DONE]" { - println!("[OpenAI:stream] SSE #{}: {}", sse_event_count, &data[..data.len().min(300)]); + tracing::debug!("[OpenAI:stream] SSE #{}: {}", sse_event_count, &data[..data.len().min(300)]); } if data == "[DONE]" { - println!("[OpenAI:stream] Received [DONE], total SSE events: {}, raw bytes: {}", sse_event_count, raw_bytes_total); + tracing::debug!("[OpenAI:stream] Received [DONE], total SSE events: {}, raw bytes: {}", sse_event_count, raw_bytes_total); // Emit ToolUseEnd for all accumulated tool calls (skip invalid ones with empty name) for (id, (name, args)) in &accumulated_tool_calls { @@ -319,7 +319,7 @@ impl LlmDriver for OpenAiDriver { } } } - println!("[OpenAI:stream] Byte stream ended. Total: {} SSE events, {} raw bytes", sse_event_count, raw_bytes_total); + tracing::debug!("[OpenAI:stream] Byte stream ended. Total: {} SSE events, {} raw bytes", sse_event_count, raw_bytes_total); }) } } @@ -496,6 +496,7 @@ impl OpenAiDriver { stop: if request.stop.is_empty() { None } else { Some(request.stop.clone()) }, stream: request.stream, tools: if tools.is_empty() { None } else { Some(tools) }, + reasoning_effort: request.reasoning_effort.clone(), }; // Pre-send payload size validation @@ -581,8 +582,8 @@ impl OpenAiDriver { let has_reasoning = c.message.reasoning_content.as_ref().map(|t| !t.is_empty()).unwrap_or(false); let blocks = if has_tool_calls { - // Tool calls take priority - let tool_calls = c.message.tool_calls.as_ref().unwrap(); + // Tool calls take priority — safe to unwrap after has_tool_calls check + let tool_calls = c.message.tool_calls.as_ref().cloned().unwrap_or_default(); tracing::debug!("[OpenAiDriver:convert_response] Using tool_calls: {} calls", tool_calls.len()); tool_calls.iter().map(|tc| ContentBlock::ToolUse { id: tc.id.clone(), @@ -590,15 +591,15 @@ impl OpenAiDriver { input: serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null), }).collect() } else if has_content { - // Non-empty content - let text = c.message.content.as_ref().unwrap(); + // Non-empty content — safe to unwrap after has_content check + let text = c.message.content.as_deref().unwrap_or(""); tracing::debug!("[OpenAiDriver:convert_response] Using text content: {} chars", text.len()); - vec![ContentBlock::Text { text: text.clone() }] + vec![ContentBlock::Text { text: text.to_string() }] } else if has_reasoning { // Content empty but reasoning_content present (Kimi, Qwen, DeepSeek) - let reasoning = c.message.reasoning_content.as_ref().unwrap(); + let reasoning = c.message.reasoning_content.as_deref().unwrap_or(""); tracing::debug!("[OpenAiDriver:convert_response] Using reasoning_content: {} chars", reasoning.len()); - vec![ContentBlock::Text { text: reasoning.clone() }] + vec![ContentBlock::Text { text: reasoning.to_string() }] } else { // No content or tool_calls tracing::debug!("[OpenAiDriver:convert_response] No content or tool_calls, using empty text"); @@ -771,6 +772,8 @@ struct OpenAiRequest { stream: bool, #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, } #[derive(Serialize)] @@ -833,7 +836,7 @@ struct OpenAiResponse { usage: Option, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] struct OpenAiChoice { #[serde(default)] message: OpenAiResponseMessage, @@ -841,7 +844,7 @@ struct OpenAiChoice { finish_reason: Option, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] struct OpenAiResponseMessage { #[serde(default)] content: Option, @@ -851,7 +854,7 @@ struct OpenAiResponseMessage { tool_calls: Option>, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] struct OpenAiToolCallResponse { #[serde(default)] id: String, @@ -859,7 +862,7 @@ struct OpenAiToolCallResponse { function: FunctionCallResponse, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] struct FunctionCallResponse { #[serde(default)] name: String, diff --git a/crates/zclaw-runtime/src/growth.rs b/crates/zclaw-runtime/src/growth.rs index 8858451..a8bcd24 100644 --- a/crates/zclaw-runtime/src/growth.rs +++ b/crates/zclaw-runtime/src/growth.rs @@ -16,6 +16,7 @@ use zclaw_growth::{ MemoryExtractor, MemoryRetriever, PromptInjector, RetrievalResult, VikingAdapter, }; +use zclaw_memory::{ExtractedFactBatch, Fact, FactCategory}; use zclaw_types::{AgentId, Message, Result, SessionId}; /// Growth system integration for AgentLoop @@ -212,6 +213,80 @@ impl GrowthIntegration { Ok(count) } + /// Combined extraction: single LLM call that produces both stored memories + /// and structured facts, avoiding double extraction overhead. + /// + /// Returns `(memory_count, Option)` on success. + pub async fn extract_combined( + &self, + agent_id: &AgentId, + messages: &[Message], + session_id: &SessionId, + ) -> Result> { + if !self.config.enabled || !self.config.auto_extract { + return Ok(None); + } + + // Single LLM extraction call + let extracted = self + .extractor + .extract(messages, session_id.clone()) + .await + .unwrap_or_else(|e| { + tracing::warn!("[GrowthIntegration] Combined extraction failed: {}", e); + Vec::new() + }); + + if extracted.is_empty() { + return Ok(None); + } + + let mem_count = extracted.len(); + + // Store raw memories + self.extractor + .store_memories(&agent_id.to_string(), &extracted) + .await?; + + // Track learning event + self.tracker + .record_learning(agent_id, &session_id.to_string(), mem_count) + .await?; + + // Convert same extracted memories to structured facts (no extra LLM call) + let facts: Vec = extracted + .into_iter() + .map(|m| { + let category = match m.memory_type { + zclaw_growth::types::MemoryType::Preference => FactCategory::Preference, + zclaw_growth::types::MemoryType::Knowledge => FactCategory::Knowledge, + zclaw_growth::types::MemoryType::Experience => FactCategory::Behavior, + _ => FactCategory::General, + }; + Fact::new(m.content, category, f64::from(m.confidence)) + .with_source(session_id.to_string()) + }) + .collect(); + + let batch = ExtractedFactBatch { + facts, + agent_id: agent_id.to_string(), + session_id: session_id.to_string(), + } + .deduplicate() + .filter_by_confidence(0.7); + + if batch.is_empty() { + return Ok(Some((mem_count, ExtractedFactBatch { + facts: vec![], + agent_id: agent_id.to_string(), + session_id: session_id.to_string(), + }))); + } + + Ok(Some((mem_count, batch))) + } + /// Retrieve memories for a query without injection pub async fn retrieve_memories( &self, diff --git a/crates/zclaw-runtime/src/lib.rs b/crates/zclaw-runtime/src/lib.rs index b0b9832..fee6548 100644 --- a/crates/zclaw-runtime/src/lib.rs +++ b/crates/zclaw-runtime/src/lib.rs @@ -16,6 +16,7 @@ pub mod stream; pub mod growth; pub mod compaction; pub mod middleware; +pub mod prompt; // Re-export main types pub use driver::{ @@ -31,3 +32,4 @@ pub use zclaw_growth::VikingAdapter; pub use zclaw_growth::EmbeddingClient; pub use zclaw_growth::LlmDriverForExtraction; pub use compaction::{CompactionConfig, CompactionOutcome}; +pub use prompt::{PromptBuilder, PromptContext, PromptSection}; diff --git a/crates/zclaw-runtime/src/loop_runner.rs b/crates/zclaw-runtime/src/loop_runner.rs index b660db1..a592a4e 100644 --- a/crates/zclaw-runtime/src/loop_runner.rs +++ b/crates/zclaw-runtime/src/loop_runner.rs @@ -14,6 +14,7 @@ use crate::loop_guard::{LoopGuard, LoopGuardResult}; use crate::growth::GrowthIntegration; use crate::compaction::{self, CompactionConfig}; use crate::middleware::{self, MiddlewareChain}; +use crate::prompt::{PromptBuilder, PromptContext}; use zclaw_memory::MemoryStore; /// Agent loop runner @@ -25,6 +26,8 @@ pub struct AgentLoop { loop_guard: Mutex, model: String, system_prompt: Option, + /// Custom agent personality for prompt assembly + soul: Option, max_tokens: u32, temperature: f32, skill_executor: Option>, @@ -39,6 +42,12 @@ pub struct AgentLoop { /// delegated to the chain instead of the inline code below. /// When `None`, the legacy inline path is used (100% backward compatible). middleware_chain: Option, + /// Chat mode: extended thinking enabled + thinking_enabled: bool, + /// Chat mode: reasoning effort level + reasoning_effort: Option, + /// Chat mode: plan mode + plan_mode: bool, } impl AgentLoop { @@ -56,7 +65,8 @@ impl AgentLoop { loop_guard: Mutex::new(LoopGuard::default()), model: String::new(), // Must be set via with_model() system_prompt: None, - max_tokens: 4096, + soul: None, + max_tokens: 16384, temperature: 0.7, skill_executor: None, path_validator: None, @@ -64,6 +74,9 @@ impl AgentLoop { compaction_threshold: 0, compaction_config: CompactionConfig::default(), middleware_chain: None, + thinking_enabled: false, + reasoning_effort: None, + plan_mode: false, } } @@ -91,6 +104,30 @@ impl AgentLoop { self } + /// Set the agent personality (SOUL.md equivalent) + pub fn with_soul(mut self, soul: impl Into) -> Self { + self.soul = Some(soul.into()); + self + } + + /// Enable extended thinking/reasoning mode + pub fn with_thinking_enabled(mut self, enabled: bool) -> Self { + self.thinking_enabled = enabled; + self + } + + /// Set reasoning effort level (low/medium/high) + pub fn with_reasoning_effort(mut self, effort: impl Into) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + + /// Enable plan mode + pub fn with_plan_mode(mut self, enabled: bool) -> Self { + self.plan_mode = enabled; + self + } + /// Set max tokens pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { self.max_tokens = max_tokens; @@ -214,7 +251,15 @@ impl AgentLoop { // Enhance system prompt — skip when middleware chain handles it let mut enhanced_prompt = if use_middleware { - self.system_prompt.clone().unwrap_or_default() + let prompt_ctx = PromptContext { + base_prompt: self.system_prompt.clone(), + soul: self.soul.clone(), + thinking_enabled: self.thinking_enabled, + plan_mode: self.plan_mode, + tool_definitions: self.tools.definitions(), + agent_name: None, + }; + PromptBuilder::new().build(&prompt_ctx) } else if let Some(ref growth) = self.growth { let base = self.system_prompt.as_deref().unwrap_or(""); growth.enhance_prompt(&self.agent_id, base, &input).await? @@ -279,6 +324,9 @@ impl AgentLoop { temperature: Some(self.temperature), stop: Vec::new(), stream: false, + thinking_enabled: self.thinking_enabled, + reasoning_effort: self.reasoning_effort.clone(), + plan_mode: self.plan_mode, }; // Call LLM @@ -352,7 +400,12 @@ impl AgentLoop { // Create tool context and execute all tools let tool_context = self.create_tool_context(session_id.clone()); let mut circuit_breaker_triggered = false; + let mut abort_result: Option = None; for (id, name, input) in tool_calls { + // Check if loop was already aborted + if abort_result.is_some() { + break; + } // Check tool call safety — via middleware chain or inline loop guard if let Some(ref chain) = self.middleware_chain { let mw_ctx_ref = middleware::MiddlewareContext { @@ -382,6 +435,17 @@ impl AgentLoop { messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), tool_result, false)); continue; } + middleware::ToolCallDecision::AbortLoop(reason) => { + tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason); + let msg = format!("{}\n已自动终止", reason); + self.memory.append_message(&session_id, &Message::assistant(&msg)).await?; + abort_result = Some(AgentLoopResult { + response: msg, + input_tokens: total_input_tokens, + output_tokens: total_output_tokens, + iterations, + }); + } } } else { // Legacy inline path @@ -421,6 +485,11 @@ impl AgentLoop { // Continue the loop - LLM will process tool results and generate final response + // If middleware aborted the loop, return immediately + if let Some(result) = abort_result { + break result; + } + // If circuit breaker was triggered, terminate immediately if circuit_breaker_triggered { let msg = "检测到工具调用循环,已自动终止"; @@ -502,7 +571,15 @@ impl AgentLoop { // Enhance system prompt — skip when middleware chain handles it let mut enhanced_prompt = if use_middleware { - self.system_prompt.clone().unwrap_or_default() + let prompt_ctx = PromptContext { + base_prompt: self.system_prompt.clone(), + soul: self.soul.clone(), + thinking_enabled: self.thinking_enabled, + plan_mode: self.plan_mode, + tool_definitions: self.tools.definitions(), + agent_name: None, + }; + PromptBuilder::new().build(&prompt_ctx) } else if let Some(ref growth) = self.growth { let base = self.system_prompt.as_deref().unwrap_or(""); growth.enhance_prompt(&self.agent_id, base, &input).await? @@ -552,6 +629,9 @@ impl AgentLoop { let model = self.model.clone(); let max_tokens = self.max_tokens; let temperature = self.temperature; + let thinking_enabled = self.thinking_enabled; + let reasoning_effort = self.reasoning_effort.clone(); + let plan_mode = self.plan_mode; tokio::spawn(async move { let mut messages = messages; @@ -584,6 +664,9 @@ impl AgentLoop { temperature: Some(temperature), stop: Vec::new(), stream: true, + thinking_enabled, + reasoning_effort: reasoning_effort.clone(), + plan_mode, }; let mut stream = driver.stream(request); @@ -596,9 +679,12 @@ impl AgentLoop { let mut chunk_count: usize = 0; let mut text_delta_count: usize = 0; let mut thinking_delta_count: usize = 0; - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { + let mut stream_errored = false; + let chunk_timeout = std::time::Duration::from_secs(60); + + loop { + match tokio::time::timeout(chunk_timeout, stream.next()).await { + Ok(Some(Ok(chunk))) => { chunk_count += 1; match &chunk { StreamChunk::TextDelta { delta } => { @@ -610,8 +696,8 @@ impl AgentLoop { StreamChunk::ThinkingDelta { delta } => { thinking_delta_count += 1; tracing::debug!("[AgentLoop] ThinkingDelta #{}: {} chars", thinking_delta_count, delta.len()); - // Accumulate reasoning separately — not mixed into iteration_text reasoning_text.push_str(delta); + let _ = tx.send(LoopEvent::ThinkingDelta(delta.clone())).await; } StreamChunk::ToolUseStart { id, name } => { tracing::debug!("[AgentLoop] ToolUseStart: id={}, name={}", id, name); @@ -651,21 +737,43 @@ impl AgentLoop { StreamChunk::Error { message } => { tracing::error!("[AgentLoop] Stream error: {}", message); let _ = tx.send(LoopEvent::Error(message.clone())).await; + stream_errored = true; } } } - Err(e) => { + Ok(Some(Err(e))) => { tracing::error!("[AgentLoop] Chunk error: {}", e); - let _ = tx.send(LoopEvent::Error(e.to_string())).await; + let _ = tx.send(LoopEvent::Error(format!("LLM 锥应错误: {}", e.to_string()))).await; + stream_errored = true; } + Ok(None) => break, // Stream ended normally + Err(_) => { + tracing::error!("[AgentLoop] Stream chunk timeout ({}s)", chunk_timeout.as_secs()); + let _ = tx.send(LoopEvent::Error("LLM 响应超时,请重试".to_string())).await; + stream_errored = true; + } + } + if stream_errored { + break; } } tracing::info!("[AgentLoop] Stream ended: {} total chunks (text={}, thinking={}, tools={}), iteration_text={} chars", chunk_count, text_delta_count, thinking_delta_count, pending_tool_calls.len(), iteration_text.len()); - if iteration_text.is_empty() { - tracing::warn!("[AgentLoop] WARNING: iteration_text is EMPTY after {} chunks! text_delta={}, thinking_delta={}", - chunk_count, text_delta_count, thinking_delta_count); + + // Fallback: if model generated reasoning but no text content, + // use reasoning as text response. This happens with some thinking models + // (DeepSeek R1, QWQ) that put the answer in reasoning_content instead of content. + // Safe now because: (1) context is clean (no stale user_profile/memory injection), + // (2) max_tokens=16384 prevents truncation, (3) reasoning is about the correct topic. + if iteration_text.is_empty() && !reasoning_text.is_empty() { + tracing::info!("[AgentLoop] Model generated {} chars of reasoning but no text — using reasoning as response", + reasoning_text.len()); + let _ = tx.send(LoopEvent::Delta(reasoning_text.clone())).await; + iteration_text = reasoning_text.clone(); + } else if iteration_text.is_empty() { + tracing::warn!("[AgentLoop] No text content after {} chunks (thinking_delta={})", + chunk_count, thinking_delta_count); } // If no tool calls, we have the final response @@ -706,6 +814,12 @@ impl AgentLoop { break 'outer; } + // Skip tool processing if stream errored or timed out + if stream_errored { + tracing::debug!("[AgentLoop] Stream errored, skipping tool processing and breaking"); + break 'outer; + } + tracing::debug!("[AgentLoop] Processing {} tool calls (reasoning: {} chars)", pending_tool_calls.len(), reasoning_text.len()); // Push assistant message with reasoning before tool calls (required by Kimi and other thinking-enabled APIs) @@ -745,6 +859,11 @@ impl AgentLoop { messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true)); continue; } + Ok(middleware::ToolCallDecision::AbortLoop(reason)) => { + tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason); + let _ = tx.send(LoopEvent::Error(reason)).await; + break 'outer; + } Ok(middleware::ToolCallDecision::ReplaceInput(new_input)) => { // Execute with replaced input (same path_validator logic below) let pv = path_validator.clone().unwrap_or_else(|| { @@ -883,6 +1002,8 @@ pub struct AgentLoopResult { pub enum LoopEvent { /// Text delta from LLM Delta(String), + /// Thinking/reasoning delta from LLM (extended thinking) + ThinkingDelta(String), /// Tool execution started ToolStart { name: String, input: serde_json::Value }, /// Tool execution completed diff --git a/crates/zclaw-runtime/src/middleware.rs b/crates/zclaw-runtime/src/middleware.rs index 64703af..24f5fbe 100644 --- a/crates/zclaw-runtime/src/middleware.rs +++ b/crates/zclaw-runtime/src/middleware.rs @@ -41,6 +41,8 @@ pub enum ToolCallDecision { Block(String), /// Allow the call but replace the tool input with *new_input*. ReplaceInput(Value), + /// Terminate the entire agent loop immediately (e.g. circuit breaker). + AbortLoop(String), } // --------------------------------------------------------------------------- @@ -194,6 +196,25 @@ impl MiddlewareChain { Ok(ToolCallDecision::Allow) } + /// Run all `before_tool_call` hooks with mutable context. + pub async fn run_before_tool_call_mut( + &self, + ctx: &mut MiddlewareContext, + tool_name: &str, + tool_input: &Value, + ) -> Result { + for mw in &self.middlewares { + match mw.before_tool_call(ctx, tool_name, tool_input).await? { + ToolCallDecision::Allow => {} + other => { + tracing::info!("[MiddlewareChain] '{}' decided {:?} for tool '{}'", mw.name(), other, tool_name); + return Ok(other); + } + } + } + Ok(ToolCallDecision::Allow) + } + /// Run all `after_tool_call` hooks in order. pub async fn run_after_tool_call( &self, @@ -245,8 +266,13 @@ impl Default for MiddlewareChain { // --------------------------------------------------------------------------- pub mod compaction; +pub mod dangling_tool; pub mod guardrail; pub mod loop_guard; pub mod memory; pub mod skill_index; +pub mod subagent_limit; +pub mod title; pub mod token_calibration; +pub mod tool_error; +pub mod tool_output_guard; diff --git a/crates/zclaw-runtime/src/middleware/dangling_tool.rs b/crates/zclaw-runtime/src/middleware/dangling_tool.rs new file mode 100644 index 0000000..cb853b9 --- /dev/null +++ b/crates/zclaw-runtime/src/middleware/dangling_tool.rs @@ -0,0 +1,125 @@ +//! Dangling tool-call repair middleware — detects and patches missing tool-result +//! messages that would cause LLM API errors. +//! +//! When the LLM produces a `ToolUse` content block but the agent loop fails to +//! produce a corresponding `ToolResult` message (e.g. due to a crash or timeout), +//! the conversation history becomes inconsistent. The next LLM call would fail with +//! an API error because ToolUse messages must be followed by ToolResult messages. +//! +//! This middleware inspects the message history before each completion and appends +//! placeholder ToolResult messages for any dangling ToolUse entries. + +use std::collections::HashSet; + +use async_trait::async_trait; +use zclaw_types::{Message, Result}; +use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision}; + +/// Middleware that repairs dangling tool-use blocks in conversation history. +/// +/// Priority 300 — runs before tool error middleware (350) and guardrail (400). +pub struct DanglingToolMiddleware; + +impl DanglingToolMiddleware { + pub fn new() -> Self { + Self + } +} + +impl Default for DanglingToolMiddleware { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl AgentMiddleware for DanglingToolMiddleware { + fn name(&self) -> &str { "dangling_tool" } + fn priority(&self) -> i32 { 300 } + + async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result { + let mut patched_count = 0usize; + + // Step 1: Collect all ToolUse IDs and matched ToolResult IDs across the + // entire message list (not just adjacent pairs). + let mut tool_use_ids: Vec<(String, String)> = Vec::new(); // (id, tool_name) + let mut tool_result_ids: HashSet = HashSet::new(); + + for msg in &ctx.messages { + match msg { + Message::ToolUse { ref id, ref tool, .. } => { + tool_use_ids.push((id.clone(), tool.as_str().to_string())); + } + Message::ToolResult { ref tool_call_id, ref output, .. } => { + // Original results always count as matched regardless of patch status. + // We insert unconditionally so that the HashSet contains the ID, + // preventing false-positive "dangling" detection. + let _ = output; // suppress unused warning — patch check is informational only + tool_result_ids.insert(tool_call_id.clone()); + } + _ => {} + } + } + + // Step 2: Find dangling ToolUse entries that have no matching ToolResult. + let dangling_ids: HashSet = tool_use_ids.iter() + .filter(|(id, _)| !tool_result_ids.contains(id)) + .map(|(id, _)| id.clone()) + .collect(); + + if dangling_ids.is_empty() { + return Ok(MiddlewareDecision::Continue); + } + + // Step 3: Insert placeholder ToolResult for each dangling ToolUse. + // Also skip ToolUse entries that already have a patched placeholder further + // down the list (prevents double-patching if the middleware runs twice). + let capacity = ctx.messages.len() + dangling_ids.len(); + let mut patched_messages: Vec = Vec::with_capacity(capacity); + + for msg in &ctx.messages { + patched_messages.push(msg.clone()); + + if let Message::ToolUse { ref id, ref tool, .. } = msg { + if dangling_ids.contains(id) { + tracing::warn!( + "[DanglingToolMiddleware] Patching dangling ToolUse: tool={}, id={}", + tool.as_str(), id + ); + let placeholder = Message::tool_result( + id.clone(), + tool.clone(), + serde_json::json!({ + "error": "Tool execution was interrupted. Please retry or use an alternative approach.", + "tool_patch": true, + }), + true, // is_error + ); + patched_messages.push(placeholder); + patched_count += 1; + } + } + } + + // Step 4: Detect streaming interrupt — if the last message is an Assistant + // response while there were dangling tools, the user likely interrupted a + // streaming response mid-tool-execution. No additional action is needed + // beyond the patched ToolResult messages that now prevent API errors. + if let Some(Message::Assistant { .. }) = patched_messages.last() { + tracing::debug!( + "[DanglingToolMiddleware] Streaming interrupt detected with {} dangling tools", + patched_count + ); + } + + if patched_count > 0 { + tracing::info!( + "[DanglingToolMiddleware] Patched {} dangling tool-use blocks", + patched_count + ); + ctx.messages = patched_messages; + } + + Ok(MiddlewareDecision::Continue) + } +} diff --git a/crates/zclaw-runtime/src/middleware/loop_guard.rs b/crates/zclaw-runtime/src/middleware/loop_guard.rs index af5f0a3..5041d7e 100644 --- a/crates/zclaw-runtime/src/middleware/loop_guard.rs +++ b/crates/zclaw-runtime/src/middleware/loop_guard.rs @@ -41,7 +41,7 @@ impl AgentMiddleware for LoopGuardMiddleware { match result { LoopGuardResult::CircuitBreaker => { tracing::warn!("[LoopGuardMiddleware] Circuit breaker triggered by tool '{}'", tool_name); - Ok(ToolCallDecision::Block("检测到工具调用循环,已自动终止".to_string())) + Ok(ToolCallDecision::AbortLoop("检测到工具调用循环,已自动终止".to_string())) } LoopGuardResult::Blocked => { tracing::warn!("[LoopGuardMiddleware] Tool '{}' blocked", tool_name); diff --git a/crates/zclaw-runtime/src/middleware/memory.rs b/crates/zclaw-runtime/src/middleware/memory.rs index a4fc13b..d4041fd 100644 --- a/crates/zclaw-runtime/src/middleware/memory.rs +++ b/crates/zclaw-runtime/src/middleware/memory.rs @@ -60,34 +60,39 @@ impl AgentMiddleware for MemoryMiddleware { fn priority(&self) -> i32 { 150 } async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result { - // Skip memory injection for very short queries. - // Short queries (e.g., "1+6", "hi", "好") don't benefit from memory context. - // Worse, the retriever's scope-based fallback may return high-importance but - // irrelevant old memories, causing the model to think about past conversations - // instead of answering the current question. - // Use char count (not byte count) so CJK queries are handled correctly: - // a single Chinese char is 3 UTF-8 bytes but 1 meaningful character. - let query = ctx.user_input.trim(); - if query.chars().count() < 2 { - tracing::debug!( - "[MemoryMiddleware] Skipping enhancement for short query ({:?}): no memory context needed", - query - ); - return Ok(MiddlewareDecision::Continue); - } + tracing::debug!( + "[MemoryMiddleware] before_completion for query: {:?}", + ctx.user_input.chars().take(50).collect::() + ); - match self.growth.enhance_prompt( - &ctx.agent_id, - &ctx.system_prompt, - &ctx.user_input, - ).await { + // Retrieve relevant memories and inject into system prompt. + // The SqliteStorage retriever now uses FTS5-only matching — if FTS5 finds + // no relevant results, no memories are returned (no scope-based fallback). + // This prevents irrelevant high-importance memories from leaking into + // unrelated conversations. + let base = &ctx.system_prompt; + match self.growth.enhance_prompt(&ctx.agent_id, base, &ctx.user_input).await { Ok(enhanced) => { - ctx.system_prompt = enhanced; + if enhanced != *base { + tracing::info!( + "[MemoryMiddleware] Injected memories into system prompt for agent {}", + ctx.agent_id + ); + ctx.system_prompt = enhanced; + } else { + tracing::debug!( + "[MemoryMiddleware] No relevant memories found for query: {:?}", + ctx.user_input.chars().take(50).collect::() + ); + } Ok(MiddlewareDecision::Continue) } Err(e) => { - // Non-fatal: memory retrieval failure should not block the loop - tracing::warn!("[MemoryMiddleware] Prompt enhancement failed: {}", e); + // Non-fatal: retrieval failure should not block the conversation + tracing::warn!( + "[MemoryMiddleware] Memory retrieval failed (non-fatal): {}", + e + ); Ok(MiddlewareDecision::Continue) } } diff --git a/crates/zclaw-runtime/src/middleware/subagent_limit.rs b/crates/zclaw-runtime/src/middleware/subagent_limit.rs new file mode 100644 index 0000000..3cad526 --- /dev/null +++ b/crates/zclaw-runtime/src/middleware/subagent_limit.rs @@ -0,0 +1,87 @@ +//! Sub-agent limit middleware — enforces limits on sub-agent spawning. +//! +//! Prevents runaway sub-agent spawning by enforcing a per-turn total cap. +//! The `running` counter was removed because it leaked when subsequent +//! middleware blocked the tool call (before_tool_call increments but +//! after_tool_call never fires for blocked tools). + +use async_trait::async_trait; +use serde_json::Value; +use zclaw_types::Result; +use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision}; + +/// Default maximum total sub-agents per conversation turn. +const DEFAULT_MAX_TOTAL: usize = 10; + +/// Middleware that limits total sub-agent spawn count per turn. +/// +/// Priority 550 — runs after loop guard (500). +pub struct SubagentLimitMiddleware { + /// Maximum total sub-agents per conversation turn. + max_total: usize, + /// Total sub-agents spawned in this turn. + total_spawned: std::sync::atomic::AtomicUsize, +} + +impl SubagentLimitMiddleware { + pub fn new() -> Self { + Self { + max_total: DEFAULT_MAX_TOTAL, + total_spawned: std::sync::atomic::AtomicUsize::new(0), + } + } + + pub fn with_max_total(mut self, n: usize) -> Self { + self.max_total = n; + self + } + + /// Check if a tool call is a sub-agent spawn request. + fn is_subagent_tool(tool_name: &str) -> bool { + matches!(tool_name, "task" | "delegate" | "spawn_agent" | "subagent") + } +} + +impl Default for SubagentLimitMiddleware { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl AgentMiddleware for SubagentLimitMiddleware { + fn name(&self) -> &str { "subagent_limit" } + fn priority(&self) -> i32 { 550 } + + async fn before_tool_call( + &self, + _ctx: &MiddlewareContext, + tool_name: &str, + _tool_input: &Value, + ) -> Result { + if !Self::is_subagent_tool(tool_name) { + return Ok(ToolCallDecision::Allow); + } + + let total = self.total_spawned.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if total >= self.max_total { + self.total_spawned.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + tracing::warn!( + "[SubagentLimitMiddleware] Total sub-agent limit ({}) reached — blocking spawn", + self.max_total + ); + return Ok(ToolCallDecision::Block(format!( + "子Agent总数量已达上限 ({}),请优先完成现有任务后再发起新任务。", + self.max_total + ))); + } + + Ok(ToolCallDecision::Allow) + } + + async fn after_completion(&self, _ctx: &MiddlewareContext) -> Result<()> { + // Reset per-turn counter after the agent loop turn completes. + self.total_spawned.store(0, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} diff --git a/crates/zclaw-runtime/src/middleware/title.rs b/crates/zclaw-runtime/src/middleware/title.rs index 2987bae..c7dc371 100644 --- a/crates/zclaw-runtime/src/middleware/title.rs +++ b/crates/zclaw-runtime/src/middleware/title.rs @@ -5,22 +5,29 @@ //! "新对话" or truncating the user's first message. //! //! Priority 180 — runs after compaction (100) and memory (150), before skill index (200). +//! +//! NOTE: This is a structural placeholder. Full implementation requires an LLM driver +//! reference to generate titles asynchronously, which will be wired through the +//! middleware context in a future iteration. For now it simply passes through. use async_trait::async_trait; -use zclaw_types::Result; -use crate::middleware::{AgentMiddleware, MiddlewareContext}; +use crate::middleware::{AgentMiddleware, MiddlewareDecision}; /// Middleware that auto-generates conversation titles after the first exchange. +/// +/// When fully implemented, this will: +/// 1. Detect the first user-assistant exchange (via message count) +/// 2. Call the LLM with a short prompt to generate a descriptive title +/// 3. Update the session title via the middleware context +/// +/// For now, it serves as a registered placeholder in the middleware chain. pub struct TitleMiddleware { - /// Whether a title has been generated for the current session. - titled: std::sync::atomic::AtomicBool, + _reserved: (), } impl TitleMiddleware { pub fn new() -> Self { - Self { - titled: std::sync::atomic::AtomicBool::new(false), - } + Self { _reserved: () } } } @@ -34,4 +41,9 @@ impl Default for TitleMiddleware { impl AgentMiddleware for TitleMiddleware { fn name(&self) -> &str { "title" } fn priority(&self) -> i32 { 180 } + + // All hooks default to Continue — placeholder until LLM driver is wired in. + async fn before_completion(&self, _ctx: &mut crate::middleware::MiddlewareContext) -> zclaw_types::Result { + Ok(MiddlewareDecision::Continue) + } } diff --git a/crates/zclaw-runtime/src/middleware/tool_error.rs b/crates/zclaw-runtime/src/middleware/tool_error.rs new file mode 100644 index 0000000..8098770 --- /dev/null +++ b/crates/zclaw-runtime/src/middleware/tool_error.rs @@ -0,0 +1,111 @@ +//! Tool error middleware — catches tool execution errors and converts them +//! into well-formed tool-result messages for the LLM to recover from. +//! +//! Inspired by DeerFlow's ToolErrorMiddleware: instead of propagating raw errors +//! that crash the agent loop, this middleware wraps tool errors into a structured +//! format that the LLM can use to self-correct. + +use async_trait::async_trait; +use serde_json::Value; +use zclaw_types::Result; +use crate::driver::ContentBlock; +use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision}; + +/// Middleware that intercepts tool call errors and formats recovery messages. +/// +/// Priority 350 — runs after dangling tool repair (300) and before guardrail (400). +pub struct ToolErrorMiddleware { + /// Maximum error message length before truncation. + max_error_length: usize, +} + +impl ToolErrorMiddleware { + pub fn new() -> Self { + Self { + max_error_length: 500, + } + } + + /// Create with a custom max error length. + pub fn with_max_error_length(mut self, len: usize) -> Self { + self.max_error_length = len; + self + } + + /// Format a tool error into a guided recovery message for the LLM. + /// + /// The caller is responsible for truncation before passing `error`. + fn format_tool_error(&self, tool_name: &str, error: &str) -> String { + format!( + "工具 '{}' 执行失败。错误信息: {}\n请分析错误原因,尝试修正参数后重试,或使用其他方法完成任务。", + tool_name, error + ) + } +} + +impl Default for ToolErrorMiddleware { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl AgentMiddleware for ToolErrorMiddleware { + fn name(&self) -> &str { "tool_error" } + fn priority(&self) -> i32 { 350 } + + async fn before_tool_call( + &self, + _ctx: &MiddlewareContext, + tool_name: &str, + tool_input: &Value, + ) -> Result { + // Pre-validate tool input structure for common issues. + // This catches malformed JSON inputs before they reach the tool executor. + if tool_input.is_null() { + tracing::warn!( + "[ToolErrorMiddleware] Tool '{}' received null input — replacing with empty object", + tool_name + ); + return Ok(ToolCallDecision::ReplaceInput(serde_json::json!({}))); + } + Ok(ToolCallDecision::Allow) + } + + async fn after_tool_call( + &self, + ctx: &mut MiddlewareContext, + tool_name: &str, + result: &Value, + ) -> Result<()> { + // Check if the tool result indicates an error. + if let Some(error) = result.get("error") { + let error_msg = match error { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let truncated = if error_msg.len() > self.max_error_length { + // Use char-boundary-safe truncation to avoid panic on UTF-8 strings (e.g. Chinese) + let end = error_msg.floor_char_boundary(self.max_error_length); + format!("{}...(truncated)", &error_msg[..end]) + } else { + error_msg.clone() + }; + + tracing::warn!( + "[ToolErrorMiddleware] Tool '{}' failed: {}", + tool_name, truncated + ); + + // Build a guided recovery message so the LLM can self-correct. + let guided_message = self.format_tool_error(tool_name, &truncated); + + // Inject into response_content so the agent loop feeds this back + // to the LLM alongside the raw tool result. + ctx.response_content.push(ContentBlock::Text { + text: guided_message, + }); + } + Ok(()) + } +} diff --git a/crates/zclaw-runtime/src/middleware/tool_output_guard.rs b/crates/zclaw-runtime/src/middleware/tool_output_guard.rs new file mode 100644 index 0000000..a28fe20 --- /dev/null +++ b/crates/zclaw-runtime/src/middleware/tool_output_guard.rs @@ -0,0 +1,132 @@ +//! Tool output sanitization middleware — inspects tool results for risky content +//! before they flow back into the LLM context. +//! +//! Inspired by DeerFlow's missing "Toxic Output Loop" defense — ZCLAW proactively +//! implements post-execution output checking. +//! +//! Rules: +//! - Output length cap: warns when tool output exceeds threshold +//! - Sensitive pattern detection: flags API keys, tokens, passwords +//! - Injection marker detection: flags common prompt-injection patterns +//! +//! This middleware does NOT modify content. It only logs warnings at appropriate levels. + +use async_trait::async_trait; +use serde_json::Value; +use zclaw_types::Result; + +use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision}; + +/// Maximum safe output length in characters. +const MAX_OUTPUT_LENGTH: usize = 50_000; + +/// Patterns that indicate sensitive information in tool output. +const SENSITIVE_PATTERNS: &[&str] = &[ + "api_key", + "apikey", + "api-key", + "secret_key", + "secretkey", + "access_token", + "auth_token", + "password", + "private_key", + "-----BEGIN RSA", + "-----BEGIN PRIVATE", + "sk-", // OpenAI API keys + "sk_live_", // Stripe keys + "AKIA", // AWS access keys +]; + +/// Patterns that may indicate prompt injection in tool output. +const INJECTION_PATTERNS: &[&str] = &[ + "ignore previous instructions", + "ignore all previous", + "disregard your instructions", + "you are now", + "new instructions:", + "system:", + "[INST]", + "", + "think step by step about", +]; + +/// Tool output sanitization middleware. +/// +/// Priority 360 — runs after ToolErrorMiddleware (350), before GuardrailMiddleware (400). +pub struct ToolOutputGuardMiddleware { + max_output_length: usize, +} + +impl ToolOutputGuardMiddleware { + pub fn new() -> Self { + Self { + max_output_length: MAX_OUTPUT_LENGTH, + } + } +} + +impl Default for ToolOutputGuardMiddleware { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl AgentMiddleware for ToolOutputGuardMiddleware { + fn name(&self) -> &str { "tool_output_guard" } + fn priority(&self) -> i32 { 360 } + + async fn before_tool_call( + &self, + _ctx: &MiddlewareContext, + _tool_name: &str, + _tool_input: &Value, + ) -> Result { + // No pre-execution checks — this middleware only inspects output + Ok(ToolCallDecision::Allow) + } + + async fn after_tool_call( + &self, + _ctx: &mut MiddlewareContext, + tool_name: &str, + result: &Value, + ) -> Result<()> { + let output_str = serde_json::to_string(result).unwrap_or_default(); + let output_len = output_str.len(); + + // Rule 1: Output length check + if output_len > self.max_output_length { + tracing::warn!( + "[ToolOutputGuard] Tool '{}' returned oversized output: {} chars (limit: {})", + tool_name, output_len, self.max_output_length + ); + } + + // Rule 2: Sensitive information detection + let output_lower = output_str.to_lowercase(); + for pattern in SENSITIVE_PATTERNS { + if output_lower.contains(pattern) { + tracing::warn!( + "[ToolOutputGuard] Tool '{}' output contains sensitive pattern: '{}'", + tool_name, pattern + ); + break; // Only warn once per tool call + } + } + + // Rule 3: Injection marker detection + for pattern in INJECTION_PATTERNS { + if output_lower.contains(pattern) { + tracing::warn!( + "[ToolOutputGuard] Tool '{}' output contains potential injection marker: '{}'", + tool_name, pattern + ); + break; // Only warn once per tool call + } + } + + Ok(()) + } +} diff --git a/crates/zclaw-runtime/src/prompt/builder.rs b/crates/zclaw-runtime/src/prompt/builder.rs new file mode 100644 index 0000000..f4d94bd --- /dev/null +++ b/crates/zclaw-runtime/src/prompt/builder.rs @@ -0,0 +1,120 @@ +use std::fmt::Write; + +use crate::driver::ToolDefinition; + +/// Runtime context that determines which prompt sections are included. +pub struct PromptContext { + /// Base system prompt from AgentConfig + pub base_prompt: Option, + /// Custom agent personality (SOUL.md equivalent) + pub soul: Option, + /// Whether thinking/extended reasoning is enabled + pub thinking_enabled: bool, + /// Whether plan mode is active + pub plan_mode: bool, + /// Tool definitions available for dynamic injection + pub tool_definitions: Vec, + /// Agent name for personalization + pub agent_name: Option, +} + +/// A single section in the assembled prompt. +pub struct PromptSection { + pub name: &'static str, + pub template: String, + pub priority: u32, +} + +/// Builds structured system prompts from conditional sections. +pub struct PromptBuilder { + sections: Vec, +} + +impl PromptBuilder { + pub fn new() -> Self { + Self { + sections: Vec::new(), + } + } + + /// Add a section unconditionally. + pub fn add_section( + mut self, + name: &'static str, + template: impl Into, + priority: u32, + ) -> Self { + self.sections.push(PromptSection { + name, + template: template.into(), + priority, + }); + self + } + + /// Assemble the final system prompt based on runtime context. + pub fn build(&self, ctx: &PromptContext) -> String { + let mut sections: Vec<&PromptSection> = self.sections.iter().collect(); + sections.sort_by_key(|s| s.priority); + + let mut result = String::with_capacity(4096); + + // Base prompt (always included) + if let Some(ref base) = ctx.base_prompt { + result.push_str(base); + } else { + result.push_str("You are a helpful AI assistant."); + } + + // Soul/personality section + if let Some(ref soul) = ctx.soul { + result.push_str("\n\n## Agent Personality\n\n"); + result.push_str(soul); + } + + // Agent name personalization + if let Some(ref name) = ctx.agent_name { + let _ = write!(result, "\n\nYou are known as \"{name}\". Respond in character."); + } + + // Dynamic tool descriptions + if !ctx.tool_definitions.is_empty() { + result.push_str("\n\n## Available Tools\n\n"); + for tool in &ctx.tool_definitions { + let _ = writeln!(result, "- **{}**: {}", tool.name, tool.description); + } + } + + // Thinking style guidance + if ctx.thinking_enabled { + result.push_str("\n\n## Reasoning Mode\n\n"); + result.push_str( + "Extended reasoning is enabled. Think step-by-step before responding. \ + Show your reasoning process, then provide the final answer.", + ); + } + + // Plan mode instructions + if ctx.plan_mode { + result.push_str("\n\n## Plan Mode\n\n"); + result.push_str( + "You are in plan mode. Before executing any actions, create a detailed plan. \ + Present the plan to the user for approval before proceeding.", + ); + } + + // Additional registered sections + for section in sections { + result.push_str("\n\n"); + result.push_str(§ion.template); + } + + result + } +} + +impl Default for PromptBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/zclaw-runtime/src/prompt/mod.rs b/crates/zclaw-runtime/src/prompt/mod.rs new file mode 100644 index 0000000..2359f0e --- /dev/null +++ b/crates/zclaw-runtime/src/prompt/mod.rs @@ -0,0 +1,9 @@ +//! Dynamic prompt assembly module. +//! +//! Inspired by DeerFlow's conditional section-based prompt composition. +//! The `PromptBuilder` assembles a structured system prompt from multiple +//! conditional sections before the middleware chain further modifies it. + +mod builder; + +pub use builder::{PromptBuilder, PromptContext, PromptSection}; diff --git a/crates/zclaw-runtime/src/tool/builtin.rs b/crates/zclaw-runtime/src/tool/builtin.rs index 9e6d04d..0497a42 100644 --- a/crates/zclaw-runtime/src/tool/builtin.rs +++ b/crates/zclaw-runtime/src/tool/builtin.rs @@ -7,6 +7,7 @@ mod web_fetch; mod execute_skill; mod skill_load; mod path_validator; +mod task; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; @@ -15,6 +16,7 @@ pub use web_fetch::WebFetchTool; pub use execute_skill::ExecuteSkillTool; pub use skill_load::SkillLoadTool; pub use path_validator::{PathValidator, PathValidatorConfig}; +pub use task::TaskTool; use crate::tool::ToolRegistry; diff --git a/crates/zclaw-runtime/src/tool/builtin/task.rs b/crates/zclaw-runtime/src/tool/builtin/task.rs new file mode 100644 index 0000000..24e8936 --- /dev/null +++ b/crates/zclaw-runtime/src/tool/builtin/task.rs @@ -0,0 +1,179 @@ +//! Task tool — delegates sub-tasks to a nested AgentLoop. +//! +//! Inspired by DeerFlow's `task_tool`: the lead agent can spawn sub-agent tasks +//! to parallelise complex work. Each sub-task runs its own AgentLoop with a +//! fresh session, isolated context, and a configurable maximum iteration count. + +use async_trait::async_trait; +use serde_json::{json, Value}; +use zclaw_types::{AgentId, Result, ZclawError}; +use zclaw_memory::MemoryStore; + +use crate::driver::LlmDriver; +use crate::loop_runner::AgentLoop; +use crate::tool::{Tool, ToolContext, ToolRegistry}; +use crate::tool::builtin::register_builtin_tools; +use std::sync::Arc; + +/// Default max iterations for a sub-agent task. +const DEFAULT_MAX_ITERATIONS: usize = 5; + +/// Tool that delegates sub-tasks to a nested AgentLoop. +pub struct TaskTool { + driver: Arc, + memory: Arc, + model: String, + max_tokens: u32, + temperature: f32, +} + +impl TaskTool { + pub fn new( + driver: Arc, + memory: Arc, + model: impl Into, + ) -> Self { + Self { + driver, + memory, + model: model.into(), + max_tokens: 4096, + temperature: 0.7, + } + } + + pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { + self.max_tokens = max_tokens; + self + } + + pub fn with_temperature(mut self, temperature: f32) -> Self { + self.temperature = temperature; + self + } +} + + + +#[async_trait] +impl Tool for TaskTool { + fn name(&self) -> &str { + "task" + } + + fn description(&self) -> &str { + "Delegate a sub-task to a sub-agent. The sub-agent will work independently \ + with its own context and tools. Use this to break complex tasks into \ + parallel or sequential sub-tasks. Each sub-task runs in its own session \ + with a focused system prompt." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Short description of the sub-task (shown in progress UI)" + }, + "prompt": { + "type": "string", + "description": "Detailed instructions for the sub-agent" + }, + "max_iterations": { + "type": "integer", + "description": "Maximum tool-call iterations for the sub-agent (default: 5)", + "minimum": 1, + "maximum": 10 + } + }, + "required": ["description", "prompt"] + }) + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let description = input["description"].as_str() + .ok_or_else(|| ZclawError::InvalidInput("Missing 'description' parameter".into()))?; + + let prompt = input["prompt"].as_str() + .ok_or_else(|| ZclawError::InvalidInput("Missing 'prompt' parameter".into()))?; + + let max_iterations = input["max_iterations"].as_u64() + .unwrap_or(DEFAULT_MAX_ITERATIONS as u64) as usize; + + tracing::info!( + "[TaskTool] Starting sub-agent task: {:?} (max_iterations={})", + description, max_iterations + ); + + // Create a sub-agent with its own ID + let sub_agent_id = AgentId::new(); + + // Create a fresh session for the sub-agent + let session_id = self.memory.create_session(&sub_agent_id).await?; + + // Build system prompt focused on the sub-task + let system_prompt = format!( + "你是一个专注的子Agent,负责完成以下任务:{}\n\n\ + 要求:\n\ + - 专注完成分配给你的任务\n\ + - 使用可用的工具来完成任务\n\ + - 完成后提供简洁的结果摘要\n\ + - 如果遇到无法解决的问题,请说明原因", + description + ); + + // Create a tool registry with builtin tools + // (TaskTool itself is NOT included to prevent infinite nesting) + let mut tools = ToolRegistry::new(); + register_builtin_tools(&mut tools); + + // Build a lightweight AgentLoop for the sub-agent + let mut sub_loop = AgentLoop::new( + sub_agent_id, + self.driver.clone(), + tools, + self.memory.clone(), + ) + .with_model(&self.model) + .with_system_prompt(&system_prompt) + .with_max_tokens(self.max_tokens) + .with_temperature(self.temperature); + + // Optionally inject skill executor and path validator from parent context + if let Some(ref executor) = context.skill_executor { + sub_loop = sub_loop.with_skill_executor(executor.clone()); + } + if let Some(ref validator) = context.path_validator { + sub_loop = sub_loop.with_path_validator(validator.clone()); + } + + // Execute the sub-agent loop (non-streaming — collect full result) + let result = match sub_loop.run(session_id.clone(), prompt.to_string()).await { + Ok(loop_result) => { + tracing::info!( + "[TaskTool] Sub-agent completed: {} iterations, {} input tokens, {} output tokens", + loop_result.iterations, loop_result.input_tokens, loop_result.output_tokens + ); + json!({ + "status": "completed", + "description": description, + "result": loop_result.response, + "iterations": loop_result.iterations, + "input_tokens": loop_result.input_tokens, + "output_tokens": loop_result.output_tokens, + }) + } + Err(e) => { + tracing::warn!("[TaskTool] Sub-agent failed: {}", e); + json!({ + "status": "failed", + "description": description, + "error": e.to_string(), + }) + } + }; + + Ok(result) + } +} diff --git a/crates/zclaw-saas/src/billing/service.rs b/crates/zclaw-saas/src/billing/service.rs index 1dec499..cc0725f 100644 --- a/crates/zclaw-saas/src/billing/service.rs +++ b/crates/zclaw-saas/src/billing/service.rs @@ -185,8 +185,8 @@ pub async fn increment_usage( input_tokens: i64, output_tokens: i64, ) -> SaasResult<()> { - // 确保 quota 行存在(幂等) - let _ = get_or_create_usage(pool, account_id).await?; + // 确保 quota 行存在(幂等)— 返回值仅用于确认行存在,无需绑定 + get_or_create_usage(pool, account_id).await?; // 直接用 account_id + period 原子更新,无需 SELECT 获取 ID let now = chrono::Utc::now(); diff --git a/crates/zclaw-saas/src/db.rs b/crates/zclaw-saas/src/db.rs index 0fcefc1..3f41252 100644 --- a/crates/zclaw-saas/src/db.rs +++ b/crates/zclaw-saas/src/db.rs @@ -887,7 +887,7 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> { } // 也更新 api_tokens 表的 account_id - let _ = sqlx::query("UPDATE api_tokens SET account_id = $1 WHERE account_id != $1") + sqlx::query("UPDATE api_tokens SET account_id = $1 WHERE account_id != $1") .bind(primary_admin).execute(pool).await?; tracing::info!("Seed data fix completed"); diff --git a/crates/zclaw-saas/src/knowledge/handlers.rs b/crates/zclaw-saas/src/knowledge/handlers.rs index 38c7ddd..50b5fca 100644 --- a/crates/zclaw-saas/src/knowledge/handlers.rs +++ b/crates/zclaw-saas/src/knowledge/handlers.rs @@ -231,13 +231,12 @@ pub async fn batch_create_items( } match service::create_item(&state.db, &ctx.account_id, req).await { Ok(item) => { - let _ = state.worker_dispatcher.dispatch( + if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": item.id }), - ).await.map_err(|e| { + ).await { tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e); - e - }); + } created.push(item.id); } Err(e) => { @@ -563,13 +562,12 @@ pub async fn import_items( match service::create_item(&state.db, &ctx.account_id, &item_req).await { Ok(item) => { - let _ = state.worker_dispatcher.dispatch( + if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": item.id }), - ).await.map_err(|e| { + ).await { tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e); - e - }); + } created.push(item.id); } Err(e) => { diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index c3fe5a1..0c04409 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -259,7 +259,9 @@ pub async fn execute_relay( } } - let key_id = current_key_id.as_ref().unwrap().clone(); + let key_id = current_key_id.as_ref() + .ok_or_else(|| SaasError::Internal("Key pool selection failed: no key_id".into()))? + .clone(); let api_key = current_api_key.clone(); let mut req_builder = client.post(&url) @@ -309,7 +311,10 @@ pub async fn execute_relay( } } Err(e) => { - let _ = tx.send(Err(std::io::Error::other(e))).await; + let err_msg = e.to_string(); + if tx.send(Err(std::io::Error::other(e))).await.is_err() { + tracing::debug!("SSE relay: client disconnected before error sent: {}", err_msg); + } break; } } @@ -372,12 +377,12 @@ pub async fn execute_relay( let (input_tokens, output_tokens) = extract_token_usage(&body); update_task_status(db, task_id, "completed", Some(input_tokens), Some(output_tokens), None).await?; - // 记录 Key 使用量 - let _ = super::key_pool::record_key_usage( + // 记录 Key 使用量(失败仅记录,不阻塞响应) + if let Err(e) = super::key_pool::record_key_usage( db, &key_id, Some(input_tokens + output_tokens), - ).await.map_err(|e| { + ).await { tracing::warn!("[Relay] Failed to record key usage for billing: {}", e); - }); + } return Ok(RelayResponse::Json(body)); } } @@ -557,7 +562,10 @@ fn hash_request(body: &str) -> String { fn extract_token_usage(body: &str) -> (i64, i64) { let parsed: serde_json::Value = match serde_json::from_str(body) { Ok(v) => v, - Err(_) => return (0, 0), + Err(e) => { + tracing::debug!("extract_token_usage: JSON parse failed (body len={}): {}", body.len(), e); + return (0, 0); + } }; let usage = parsed.get("usage"); diff --git a/crates/zclaw-skills/src/wasm_runner.rs b/crates/zclaw-skills/src/wasm_runner.rs index ef94995..e69461b 100644 --- a/crates/zclaw-skills/src/wasm_runner.rs +++ b/crates/zclaw-skills/src/wasm_runner.rs @@ -1,5 +1,9 @@ //! WASM skill runner — executes WASM modules in a wasmtime sandbox. //! +//! **Status**: Active module — fully implemented with real wasmtime integration. +//! Unlike Director/A2A (feature-gated off), this module is compiled by default +//! but only invoked when a `.wasm` skill is loaded. No feature gate needed. +//! //! Guest modules target `wasm32-wasi` and communicate via stdin/stdout JSON. //! Host provides optional functions: `zclaw_log`, `zclaw_http_fetch`, `zclaw_file_read`. diff --git a/crates/zclaw-types/src/agent.rs b/crates/zclaw-types/src/agent.rs index b74fd43..946cae2 100644 --- a/crates/zclaw-types/src/agent.rs +++ b/crates/zclaw-types/src/agent.rs @@ -20,6 +20,9 @@ pub struct AgentConfig { /// System prompt #[serde(default)] pub system_prompt: Option, + /// Custom agent personality (SOUL.md equivalent from DeerFlow) + #[serde(default)] + pub soul: Option, /// Capabilities granted to this agent #[serde(default)] pub capabilities: Vec, @@ -56,6 +59,7 @@ impl Default for AgentConfig { description: None, model: ModelConfig::default(), system_prompt: None, + soul: None, capabilities: Vec::new(), tools: Vec::new(), max_tokens: None, @@ -91,6 +95,11 @@ impl AgentConfig { self } + pub fn with_soul(mut self, soul: impl Into) -> Self { + self.soul = Some(soul.into()); + self + } + pub fn with_model(mut self, model: ModelConfig) -> Self { self.model = model; self diff --git a/crates/zclaw-types/src/capability.rs b/crates/zclaw-types/src/capability.rs index edfaa85..66b7a85 100644 --- a/crates/zclaw-types/src/capability.rs +++ b/crates/zclaw-types/src/capability.rs @@ -24,6 +24,8 @@ pub enum Capability { AgentMessage { pattern: String }, /// Kill agents matching pattern AgentKill { pattern: String }, + /// OpenFang Protocol capabilities (reserved for future A2A mesh networking). + /// Currently defined but not consumed - no implementation or grant path exists. /// Discover remote peers via OFP OfpDiscover, /// Connect to specific OFP peers @@ -58,7 +60,16 @@ impl Capability { match self { Capability::ToolAll => true, Capability::ToolInvoke { name } => name == tool_name, - _ => false, + Capability::MemoryRead { .. } + | Capability::MemoryWrite { .. } + | Capability::NetConnect { .. } + | Capability::ShellExec { .. } + | Capability::AgentSpawn + | Capability::AgentMessage { .. } + | Capability::AgentKill { .. } + | Capability::OfpDiscover + | Capability::OfpConnect { .. } + | Capability::OfpAdvertise => false, } } @@ -68,7 +79,17 @@ impl Capability { Capability::MemoryRead { scope: s } => { s == "*" || s == scope || scope.starts_with(&format!("{}.", s)) } - _ => false, + Capability::ToolAll + | Capability::ToolInvoke { .. } + | Capability::MemoryWrite { .. } + | Capability::NetConnect { .. } + | Capability::ShellExec { .. } + | Capability::AgentSpawn + | Capability::AgentMessage { .. } + | Capability::AgentKill { .. } + | Capability::OfpDiscover + | Capability::OfpConnect { .. } + | Capability::OfpAdvertise => false, } } @@ -78,7 +99,17 @@ impl Capability { Capability::MemoryWrite { scope: s } => { s == "*" || s == scope || scope.starts_with(&format!("{}.", s)) } - _ => false, + Capability::ToolAll + | Capability::ToolInvoke { .. } + | Capability::MemoryRead { .. } + | Capability::NetConnect { .. } + | Capability::ShellExec { .. } + | Capability::AgentSpawn + | Capability::AgentMessage { .. } + | Capability::AgentKill { .. } + | Capability::OfpDiscover + | Capability::OfpConnect { .. } + | Capability::OfpAdvertise => false, } } } @@ -152,6 +183,10 @@ impl Capability { (Capability::NetConnect { host: a }, Capability::NetConnect { host: b }) => { a == "*" || a == b } + // Exhaustive fallback: all remaining (self, other) combinations + // return false. Kept as wildcard because enumerating 12×12 + // combinations is impractical; new variants should add explicit + // arms above when they introduce new grant rules. _ => false, } } diff --git a/crates/zclaw-types/src/message.rs b/crates/zclaw-types/src/message.rs index 2db0350..e9985af 100644 --- a/crates/zclaw-types/src/message.rs +++ b/crates/zclaw-types/src/message.rs @@ -114,7 +114,10 @@ impl Message { } } -/// Content block for structured responses +/// Canonical LLM message content block. Used for agent conversation messages. +/// See also: zclaw_runtime::driver::ContentBlock (LLM driver response subset), +/// zclaw_hands::slideshow::ContentBlock (presentation rendering), +/// zclaw_protocols::mcp_types::ContentBlock (MCP protocol wire format). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock {