//! Agent Identity Manager - Per-agent dynamic identity files //! //! Manages SOUL.md, AGENTS.md, USER.md per agent with: //! - Per-agent isolated identity directories //! - USER.md auto-update by agent (stores learned preferences) //! - SOUL.md/AGENTS.md change proposals (require user approval) //! - Snapshot history for rollback //! - File system persistence (survives app restart) //! //! Phase 3 of Intelligence Layer Migration. //! Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.3 //! //! NOTE: Some methods are reserved for future integration. // NOTE: #[tauri::command] functions are registered via invoke_handler! at runtime, // which the Rust compiler does not track as "use". This module-level allow is // required for all Tauri-commanded functions. Only genuinely unused non-command // methods have individual #[allow(dead_code)] annotations below. #![allow(dead_code)] use chrono::Utc; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; use tracing::{debug, error, warn}; // === Types === /// Identity files for an agent #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentityFiles { pub soul: String, pub instructions: String, pub user_profile: String, #[serde(skip_serializing_if = "Option::is_none")] pub heartbeat: Option, } /// Proposal for identity change (requires user approval) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentityChangeProposal { pub id: String, pub agent_id: String, pub file: IdentityFile, pub reason: String, pub current_content: String, pub suggested_content: String, pub status: ProposalStatus, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum IdentityFile { Soul, Instructions, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProposalStatus { Pending, Approved, Rejected, } /// Snapshot for rollback #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentitySnapshot { pub id: String, pub agent_id: String, pub files: IdentityFiles, pub timestamp: String, pub reason: String, } // === Default Identity Content === fn default_soul() -> String { r#"# ZCLAW 人格 你是 ZCLAW(智能助手),一个成长性的中文 AI 助手。 ## 核心特质 - **高效执行**: 你不只是出主意,你会真正动手完成任务 - **中文优先**: 默认使用中文交流,必要时切换英文 - **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明 - **持续成长**: 你会记住与用户的交互,不断改进自己的服务方式 ## 语气 简洁、专业、友好。避免过度客套,直接给出有用信息。"#.to_string() } fn default_instructions() -> String { r#"# Agent 指令 ## 操作规范 1. 执行文件操作前,先确认目标路径 2. 执行 Shell 命令前,评估安全风险 3. 长时间任务需定期汇报进度 4. 优先使用中文回复 ## 记忆管理 - 重要的用户偏好自动记录 - 项目上下文保存到工作区 - 对话结束时总结关键信息"#.to_string() } fn default_user_profile() -> String { r#"# 用户画像 _尚未收集到用户偏好信息。随着交互积累,此文件将自动更新。_"#.to_string() } // === Agent Identity Manager === /// Data structure for disk persistence #[derive(Debug, Clone, Serialize, Deserialize)] struct IdentityStore { identities: HashMap, proposals: Vec, snapshots: Vec, snapshot_counter: usize, } pub struct AgentIdentityManager { identities: HashMap, proposals: Vec, snapshots: Vec, snapshot_counter: usize, data_dir: PathBuf, } impl AgentIdentityManager { /// Create a new identity manager with persistence pub fn new() -> Self { let data_dir = Self::get_data_dir(); let mut manager = Self { identities: HashMap::new(), proposals: Vec::new(), snapshots: Vec::new(), snapshot_counter: 0, data_dir, }; manager.load_from_disk(); manager } /// Get the data directory for identity storage fn get_data_dir() -> PathBuf { // Use ~/.zclaw/identity/ as the data directory if let Some(home) = dirs::home_dir() { home.join(".zclaw").join("identity") } else { // Fallback to current directory PathBuf::from(".zclaw").join("identity") } } /// Load all data from disk fn load_from_disk(&mut self) { let store_path = self.data_dir.join("store.json"); if !store_path.exists() { return; // No saved data, use defaults } match fs::read_to_string(&store_path) { Ok(content) => { match serde_json::from_str::(&content) { Ok(store) => { self.identities = store.identities; self.proposals = store.proposals; self.snapshots = store.snapshots; self.snapshot_counter = store.snapshot_counter; debug!( identities_count = self.identities.len(), proposals_count = self.proposals.len(), snapshots_count = self.snapshots.len(), "[IdentityManager] Loaded identity data from disk" ); } Err(e) => { warn!("[IdentityManager] Failed to parse store.json: {}", e); } } } Err(e) => { warn!("[IdentityManager] Failed to read store.json: {}", e); } } } /// Save all data to disk fn save_to_disk(&self) { // Ensure directory exists if let Err(e) = fs::create_dir_all(&self.data_dir) { error!("[IdentityManager] Failed to create data directory: {}", e); return; } let store = IdentityStore { identities: self.identities.clone(), proposals: self.proposals.clone(), snapshots: self.snapshots.clone(), snapshot_counter: self.snapshot_counter, }; let store_path = self.data_dir.join("store.json"); match serde_json::to_string_pretty(&store) { Ok(content) => { if let Err(e) = fs::write(&store_path, content) { error!("[IdentityManager] Failed to write store.json: {}", e); } } Err(e) => { error!("[IdentityManager] Failed to serialize data: {}", e); } } } /// Get identity files for an agent (creates default if not exists) pub fn get_identity(&mut self, agent_id: &str) -> IdentityFiles { if let Some(existing) = self.identities.get(agent_id) { return existing.clone(); } // Initialize with defaults let defaults = IdentityFiles { soul: default_soul(), instructions: default_instructions(), user_profile: default_user_profile(), heartbeat: None, }; self.identities.insert(agent_id.to_string(), defaults.clone()); defaults } /// Get a specific file content pub fn get_file(&mut self, agent_id: &str, file: IdentityFile) -> String { let identity = self.get_identity(agent_id); match file { IdentityFile::Soul => identity.soul, IdentityFile::Instructions => identity.instructions, } } /// Build system prompt from identity files pub fn build_system_prompt(&mut self, agent_id: &str, memory_context: Option<&str>) -> String { let identity = self.get_identity(agent_id); let mut sections = Vec::new(); if !identity.soul.is_empty() { sections.push(identity.soul.clone()); } if !identity.instructions.is_empty() { sections.push(identity.instructions.clone()); } if !identity.user_profile.is_empty() && identity.user_profile != default_user_profile() { sections.push(format!("## 用户画像\n{}", identity.user_profile)); } if let Some(ctx) = memory_context { sections.push(ctx.to_string()); } sections.join("\n\n") } /// Update user profile (auto, no approval needed) pub fn update_user_profile(&mut self, agent_id: &str, new_content: &str) { let identity = self.get_identity(agent_id); let _old_content = identity.user_profile.clone(); // Create snapshot before update self.create_snapshot(agent_id, "Auto-update USER.md"); let mut updated = identity.clone(); updated.user_profile = new_content.to_string(); self.identities.insert(agent_id.to_string(), updated); self.save_to_disk(); } /// Append to user profile pub fn append_to_user_profile(&mut self, agent_id: &str, addition: &str) { let identity = self.get_identity(agent_id); let updated = format!("{}\n\n{}", identity.user_profile.trim_end(), addition); self.update_user_profile(agent_id, &updated); } /// Propose a change to soul or instructions (requires approval) pub fn propose_change( &mut self, agent_id: &str, file: IdentityFile, suggested_content: &str, reason: &str, ) -> IdentityChangeProposal { let identity = self.get_identity(agent_id); let current_content = match file { IdentityFile::Soul => identity.soul.clone(), IdentityFile::Instructions => identity.instructions.clone(), }; let proposal = IdentityChangeProposal { id: format!("prop_{}_{}", Utc::now().timestamp(), rand_id()), agent_id: agent_id.to_string(), file, reason: reason.to_string(), current_content, suggested_content: suggested_content.to_string(), status: ProposalStatus::Pending, created_at: Utc::now().to_rfc3339(), }; self.proposals.push(proposal.clone()); self.save_to_disk(); proposal } /// Approve a pending proposal pub fn approve_proposal(&mut self, proposal_id: &str) -> Result { let proposal_idx = self .proposals .iter() .position(|p| p.id == proposal_id && p.status == ProposalStatus::Pending) .ok_or_else(|| "Proposal not found or not pending".to_string())?; // Clone all needed data before mutating let proposal = self.proposals[proposal_idx].clone(); let agent_id = proposal.agent_id.clone(); let file = proposal.file.clone(); let reason = proposal.reason.clone(); let suggested_content = proposal.suggested_content.clone(); // Create snapshot before applying self.create_snapshot(&agent_id, &format!("Approved proposal: {}", reason)); // Get current identity and update let identity = self.get_identity(&agent_id); let mut updated = identity.clone(); match file { IdentityFile::Soul => updated.soul = suggested_content, IdentityFile::Instructions => { updated.instructions = suggested_content } } self.identities.insert(agent_id.clone(), updated.clone()); // Update proposal status self.proposals[proposal_idx].status = ProposalStatus::Approved; self.save_to_disk(); Ok(updated) } /// Reject a pending proposal pub fn reject_proposal(&mut self, proposal_id: &str) -> Result<(), String> { let proposal = self .proposals .iter_mut() .find(|p| p.id == proposal_id && p.status == ProposalStatus::Pending) .ok_or_else(|| "Proposal not found or not pending".to_string())?; proposal.status = ProposalStatus::Rejected; self.save_to_disk(); Ok(()) } /// Get pending proposals for an agent (or all agents if None) pub fn get_pending_proposals(&self, agent_id: Option<&str>) -> Vec<&IdentityChangeProposal> { self.proposals .iter() .filter(|p| { p.status == ProposalStatus::Pending && agent_id.map_or(true, |id| p.agent_id == id) }) .collect() } /// Direct file update (user explicitly edits in UI) pub fn update_file( &mut self, agent_id: &str, file: &str, content: &str, ) -> Result<(), String> { let identity = self.get_identity(agent_id); self.create_snapshot(agent_id, &format!("Manual edit: {}", file)); let mut updated = identity.clone(); match file { "soul" => updated.soul = content.to_string(), "instructions" => updated.instructions = content.to_string(), "userProfile" | "user_profile" => updated.user_profile = content.to_string(), _ => return Err(format!("Unknown file: {}", file)), } self.identities.insert(agent_id.to_string(), updated); self.save_to_disk(); Ok(()) } /// Create a snapshot fn create_snapshot(&mut self, agent_id: &str, reason: &str) { let identity = self.get_identity(agent_id); self.snapshot_counter += 1; self.snapshots.push(IdentitySnapshot { id: format!( "snap_{}_{}_{}", Utc::now().timestamp(), self.snapshot_counter, rand_id() ), agent_id: agent_id.to_string(), files: identity, timestamp: Utc::now().to_rfc3339(), reason: reason.to_string(), }); // Keep only last 50 snapshots per agent let agent_snapshots: Vec<_> = self .snapshots .iter() .filter(|s| s.agent_id == agent_id) .cloned() .collect(); if agent_snapshots.len() > 50 { // Keep only the 50 most recent snapshots for this agent let ids_to_keep: std::collections::HashSet<_> = agent_snapshots .iter() .rev() .take(50) .map(|s| s.id.clone()) .collect(); self.snapshots.retain(|s| { s.agent_id != agent_id || ids_to_keep.contains(&s.id) }); } } /// Get snapshots for an agent pub fn get_snapshots(&self, agent_id: &str, limit: usize) -> Vec<&IdentitySnapshot> { let mut filtered: Vec<_> = self .snapshots .iter() .filter(|s| s.agent_id == agent_id) .collect(); filtered.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); filtered.into_iter().take(limit).collect() } /// Restore a snapshot pub fn restore_snapshot(&mut self, agent_id: &str, snapshot_id: &str) -> Result<(), String> { let snapshot = self .snapshots .iter() .find(|s| s.agent_id == agent_id && s.id == snapshot_id) .ok_or_else(|| "Snapshot not found".to_string())? .clone(); // Clone files before creating new snapshot let files = snapshot.files.clone(); let timestamp = snapshot.timestamp.clone(); // Create snapshot before rollback self.create_snapshot( agent_id, &format!("Rollback to {}", timestamp), ); self.identities .insert(agent_id.to_string(), files); self.save_to_disk(); Ok(()) } /// List all agents with identities pub fn list_agents(&self) -> Vec { self.identities.keys().cloned().collect() } /// Delete an agent's identity pub fn delete_agent(&mut self, agent_id: &str) { self.identities.remove(agent_id); self.proposals.retain(|p| p.agent_id != agent_id); self.snapshots.retain(|s| s.agent_id != agent_id); self.save_to_disk(); } /// Export all identities for backup #[allow(dead_code)] // Reserved: no Tauri command yet pub fn export_all(&self) -> HashMap { self.identities.clone() } /// Import identities from backup #[allow(dead_code)] // Reserved: no Tauri command yet pub fn import(&mut self, identities: HashMap) { for (agent_id, files) in identities { self.identities.insert(agent_id, files); } self.save_to_disk(); } /// Get all proposals (for debugging) #[allow(dead_code)] // Reserved: no Tauri command yet pub fn get_all_proposals(&self) -> &[IdentityChangeProposal] { &self.proposals } } impl Default for AgentIdentityManager { fn default() -> Self { Self::new() } } /// Generate random ID suffix fn rand_id() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); let count = COUNTER.fetch_add(1, Ordering::Relaxed); format!("{:04x}", count % 0x10000) } // === Tauri Commands === use std::sync::Arc; use tokio::sync::Mutex; pub type IdentityManagerState = Arc>; /// Initialize identity manager #[tauri::command] pub async fn identity_init() -> Result { Ok(Arc::new(Mutex::new(AgentIdentityManager::new()))) } /// Get identity files for an agent #[tauri::command] pub async fn identity_get( agent_id: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result { let mut manager = state.lock().await; Ok(manager.get_identity(&agent_id)) } /// Get a specific file #[tauri::command] pub async fn identity_get_file( agent_id: String, file: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result { let mut manager = state.lock().await; let file_type = match file.as_str() { "soul" => IdentityFile::Soul, "instructions" => IdentityFile::Instructions, _ => return Err(format!("Unknown file: {}", file)), }; Ok(manager.get_file(&agent_id, file_type)) } /// Build system prompt #[tauri::command] pub async fn identity_build_prompt( agent_id: String, memory_context: Option, state: tauri::State<'_, IdentityManagerState>, ) -> Result { let mut manager = state.lock().await; Ok(manager.build_system_prompt(&agent_id, memory_context.as_deref())) } /// Update user profile (auto) #[tauri::command] pub async fn identity_update_user_profile( agent_id: String, content: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result<(), String> { let mut manager = state.lock().await; manager.update_user_profile(&agent_id, &content); Ok(()) } /// Append to user profile #[tauri::command] pub async fn identity_append_user_profile( agent_id: String, addition: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result<(), String> { let mut manager = state.lock().await; manager.append_to_user_profile(&agent_id, &addition); Ok(()) } /// Propose a change #[tauri::command] pub async fn identity_propose_change( agent_id: String, file: String, suggested_content: String, reason: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result { let mut manager = state.lock().await; let file_type = match file.as_str() { "soul" => IdentityFile::Soul, "instructions" => IdentityFile::Instructions, _ => return Err(format!("Unknown file: {}", file)), }; Ok(manager.propose_change(&agent_id, file_type, &suggested_content, &reason)) } /// Approve a proposal #[tauri::command] pub async fn identity_approve_proposal( proposal_id: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result { let mut manager = state.lock().await; manager.approve_proposal(&proposal_id) } /// Reject a proposal #[tauri::command] pub async fn identity_reject_proposal( proposal_id: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result<(), String> { let mut manager = state.lock().await; manager.reject_proposal(&proposal_id) } /// Get pending proposals #[tauri::command] pub async fn identity_get_pending_proposals( agent_id: Option, state: tauri::State<'_, IdentityManagerState>, ) -> Result, String> { let manager = state.lock().await; Ok(manager .get_pending_proposals(agent_id.as_deref()) .into_iter() .cloned() .collect()) } /// Update file directly #[tauri::command] pub async fn identity_update_file( agent_id: String, file: String, content: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result<(), String> { let mut manager = state.lock().await; manager.update_file(&agent_id, &file, &content) } /// Get snapshots #[tauri::command] pub async fn identity_get_snapshots( agent_id: String, limit: Option, state: tauri::State<'_, IdentityManagerState>, ) -> Result, String> { let manager = state.lock().await; Ok(manager .get_snapshots(&agent_id, limit.unwrap_or(10)) .into_iter() .cloned() .collect()) } /// Restore snapshot #[tauri::command] pub async fn identity_restore_snapshot( agent_id: String, snapshot_id: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result<(), String> { let mut manager = state.lock().await; manager.restore_snapshot(&agent_id, &snapshot_id) } /// List agents #[tauri::command] pub async fn identity_list_agents( state: tauri::State<'_, IdentityManagerState>, ) -> Result, String> { let manager = state.lock().await; Ok(manager.list_agents()) } /// Delete agent identity #[tauri::command] pub async fn identity_delete_agent( agent_id: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result<(), String> { let mut manager = state.lock().await; manager.delete_agent(&agent_id); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_identity_creates_default() { let mut manager = AgentIdentityManager::new(); let identity = manager.get_identity("test-agent"); assert!(!identity.soul.is_empty()); assert!(!identity.instructions.is_empty()); } #[test] fn test_update_user_profile() { let mut manager = AgentIdentityManager::new(); manager.update_user_profile("test-agent", "New profile content"); let identity = manager.get_identity("test-agent"); assert_eq!(identity.user_profile, "New profile content"); } #[test] fn test_proposal_flow() { let mut manager = AgentIdentityManager::new(); let proposal = manager.propose_change( "test-agent", IdentityFile::Soul, "New soul content", "Test proposal", ); assert_eq!(proposal.status, ProposalStatus::Pending); let pending = manager.get_pending_proposals(None); assert_eq!(pending.len(), 1); // Approve let result = manager.approve_proposal(&proposal.id); assert!(result.is_ok()); let identity = manager.get_identity("test-agent"); assert_eq!(identity.soul, "New soul content"); } #[test] fn test_snapshots() { let mut manager = AgentIdentityManager::new(); manager.update_user_profile("test-agent", "First update"); manager.update_user_profile("test-agent", "Second update"); let snapshots = manager.get_snapshots("test-agent", 10); assert!(snapshots.len() >= 2); } }