Files
zclaw_openfang/desktop/src-tauri/src/intelligence/identity.rs
iven 5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00

772 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>,
}
/// 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<String, IdentityFiles>,
proposals: Vec<IdentityChangeProposal>,
snapshots: Vec<IdentitySnapshot>,
snapshot_counter: usize,
}
pub struct AgentIdentityManager {
identities: HashMap<String, IdentityFiles>,
proposals: Vec<IdentityChangeProposal>,
snapshots: Vec<IdentitySnapshot>,
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::<IdentityStore>(&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<IdentityFiles, String> {
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<String> {
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<String, IdentityFiles> {
self.identities.clone()
}
/// Import identities from backup
#[allow(dead_code)] // Reserved: no Tauri command yet
pub fn import(&mut self, identities: HashMap<String, IdentityFiles>) {
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<Mutex<AgentIdentityManager>>;
/// Initialize identity manager
#[tauri::command]
pub async fn identity_init() -> Result<IdentityManagerState, String> {
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<IdentityFiles, String> {
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<String, String> {
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<String>,
state: tauri::State<'_, IdentityManagerState>,
) -> Result<String, String> {
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<IdentityChangeProposal, String> {
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<IdentityFiles, String> {
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<String>,
state: tauri::State<'_, IdentityManagerState>,
) -> Result<Vec<IdentityChangeProposal>, 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<usize>,
state: tauri::State<'_, IdentityManagerState>,
) -> Result<Vec<IdentitySnapshot>, 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<Vec<String>, 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);
}
}