feat(intelligence): complete Phase 2-3 migration to Rust

Phase 2 - Core Engines:
- Heartbeat Engine: Periodic proactive checks with quiet hours support
- Context Compactor: Token estimation and message summarization
  - CJK character handling (1.5 tokens per char)
  - Rule-based summary generation

Phase 3 - Advanced Features:
- Reflection Engine: Pattern analysis and improvement suggestions
- Agent Identity: SOUL.md/AGENTS.md/USER.md management
  - Proposal-based changes (requires user approval)
  - Snapshot history for rollback

All modules include:
- Tauri commands for frontend integration
- Unit tests
- Re-exported types via mod.rs

Reference: docs/plans/INTELLIGENCE-LAYER-MIGRATION.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-21 00:52:44 +08:00
parent 0db8a2822f
commit ef8f5cdb43
6 changed files with 2235 additions and 1 deletions

View File

@@ -0,0 +1,651 @@
//! 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
//!
//! Phase 3 of Intelligence Layer Migration.
//! Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.3
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// === 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, 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小龙虾一个基于 OpenClaw 定制的中文 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 ===
pub struct AgentIdentityManager {
identities: HashMap<String, IdentityFiles>,
proposals: Vec<IdentityChangeProposal>,
snapshots: Vec<IdentitySnapshot>,
snapshot_counter: usize,
}
impl AgentIdentityManager {
pub fn new() -> Self {
Self {
identities: HashMap::new(),
proposals: Vec::new(),
snapshots: Vec::new(),
snapshot_counter: 0,
}
}
/// 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);
}
/// 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());
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())?;
let proposal = &self.proposals[proposal_idx];
let agent_id = proposal.agent_id.clone();
let file = proposal.file.clone();
// Create snapshot before applying
self.create_snapshot(&agent_id, &format!("Approved proposal: {}", 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 = proposal.suggested_content.clone(),
IdentityFile::Instructions => {
updated.instructions = proposal.suggested_content.clone()
}
}
self.identities.insert(agent_id.clone(), updated.clone());
// Update proposal status
self.proposals[proposal_idx].status = ProposalStatus::Approved;
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;
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);
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)
.collect();
if agent_snapshots.len() > 50 {
// Remove oldest snapshots for this agent
self.snapshots.retain(|s| {
s.agent_id != agent_id
|| agent_snapshots
.iter()
.rev()
.take(50)
.any(|&s_ref| s_ref.id == 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())?;
// Create snapshot before rollback
self.create_snapshot(
agent_id,
&format!("Rollback to {}", snapshot.timestamp),
);
self.identities
.insert(agent_id.to_string(), snapshot.files.clone());
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);
}
/// Export all identities for backup
pub fn export_all(&self) -> HashMap<String, IdentityFiles> {
self.identities.clone()
}
/// Import identities from backup
pub fn import(&mut self, identities: HashMap<String, IdentityFiles>) {
for (agent_id, files) in identities {
self.identities.insert(agent_id, files);
}
}
/// Get all proposals (for debugging)
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);
}
}