772 lines
24 KiB
Rust
772 lines
24 KiB
Rust
//! 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);
|
||
}
|
||
}
|