diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 19ed860..57f983c 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -131,6 +131,7 @@ pub async fn run( model_name, &tool_descs, &skills, + Some(&config.identity), ); // ── Execute ────────────────────────────────────────────────── diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 6b2b876..49c40ab 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -74,9 +74,37 @@ fn spawn_supervised_listener( }) } +/// Load OpenClaw format bootstrap files into the prompt. +fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) { + use std::fmt::Write; + prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n"); + + let bootstrap_files = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + ]; + + for filename in &bootstrap_files { + inject_workspace_file(prompt, workspace_dir, filename); + } + + // BOOTSTRAP.md — only if it exists (first-run ritual) + let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); + if bootstrap_path.exists() { + inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md"); + } + + // MEMORY.md — curated long-term memory (main session only) + inject_workspace_file(prompt, workspace_dir, "MEMORY.md"); +} + /// Load workspace identity files and build a system prompt. /// -/// Follows the `OpenClaw` framework structure: +/// Follows the `OpenClaw` framework structure by default: /// 1. Tooling — tool list + descriptions /// 2. Safety — guardrail reminder /// 3. Skills — compact list with paths (loaded on-demand) @@ -85,6 +113,9 @@ fn spawn_supervised_listener( /// 6. Date & Time — timezone for cache stability /// 7. Runtime — host, OS, model /// +/// When `identity_config` is set to AIEOS format, the bootstrap files section +/// is replaced with the AIEOS identity data loaded from file or inline JSON. +/// /// Daily memory files (`memory/*.md`) are NOT injected — they are accessed /// on-demand via `memory_recall` / `memory_search` tools. pub fn build_system_prompt( @@ -92,6 +123,7 @@ pub fn build_system_prompt( model_name: &str, tools: &[(&str, &str)], skills: &[crate::skills::Skill], + identity_config: Option<&crate::config::IdentityConfig>, ) -> String { use std::fmt::Write; let mut prompt = String::with_capacity(8192); @@ -152,31 +184,39 @@ pub fn build_system_prompt( // ── 5. Bootstrap files (injected into context) ────────────── prompt.push_str("## Project Context\n\n"); - prompt - .push_str("The following workspace files define your identity, behavior, and context.\n\n"); - let bootstrap_files = [ - "AGENTS.md", - "SOUL.md", - "TOOLS.md", - "IDENTITY.md", - "USER.md", - "HEARTBEAT.md", - ]; - - for filename in &bootstrap_files { - inject_workspace_file(&mut prompt, workspace_dir, filename); + // Check if AIEOS identity is configured + if let Some(config) = identity_config { + if crate::identity::is_aieos_configured(config) { + // Load AIEOS identity + match crate::identity::load_aieos_identity(config, workspace_dir) { + Ok(Some(aieos_identity)) => { + let aieos_prompt = crate::identity::aieos_to_system_prompt(&aieos_identity); + if !aieos_prompt.is_empty() { + prompt.push_str(&aieos_prompt); + prompt.push_str("\n\n"); + } + } + Ok(None) => { + // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true) + // Fall back to OpenClaw bootstrap files + load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + } + Err(e) => { + // Log error but don't fail - fall back to OpenClaw + eprintln!("Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format."); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + } + } + } else { + // OpenClaw format + load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + } + } else { + // No identity config - use OpenClaw format + load_openclaw_bootstrap_files(&mut prompt, workspace_dir); } - // BOOTSTRAP.md — only if it exists (first-run ritual) - let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); - if bootstrap_path.exists() { - inject_workspace_file(&mut prompt, workspace_dir, "BOOTSTRAP.md"); - } - - // MEMORY.md — curated long-term memory (main session only) - inject_workspace_file(&mut prompt, workspace_dir, "MEMORY.md"); - // ── 6. Date & Time ────────────────────────────────────────── let now = chrono::Local::now(); let tz = now.format("%Z").to_string(); @@ -493,7 +533,7 @@ pub async fn start_channels(config: Config) -> Result<()> { )); } - let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills); + let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills, Some(&config.identity)); if !skills.is_empty() { println!( @@ -715,7 +755,7 @@ mod tests { fn prompt_contains_all_sections() { let ws = make_workspace(); let tools = vec![("shell", "Run commands"), ("file_read", "Read files")]; - let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[]); + let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None); // Section headers assert!(prompt.contains("## Tools"), "missing Tools section"); @@ -739,7 +779,7 @@ mod tests { ("shell", "Run commands"), ("memory_recall", "Search memory"), ]; - let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[]); + let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None); assert!(prompt.contains("**shell**")); assert!(prompt.contains("Run commands")); @@ -749,7 +789,7 @@ mod tests { #[test] fn prompt_injects_safety() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); assert!(prompt.contains("Do not exfiltrate private data")); assert!(prompt.contains("Do not run destructive commands")); @@ -759,7 +799,7 @@ mod tests { #[test] fn prompt_injects_workspace_files() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); assert!(prompt.contains("Be helpful"), "missing SOUL content"); @@ -780,7 +820,7 @@ mod tests { fn prompt_missing_file_markers() { let tmp = TempDir::new().unwrap(); // Empty workspace — no files at all - let prompt = build_system_prompt(tmp.path(), "model", &[], &[]); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None); assert!(prompt.contains("[File not found: SOUL.md]")); assert!(prompt.contains("[File not found: AGENTS.md]")); @@ -791,7 +831,7 @@ mod tests { fn prompt_bootstrap_only_if_exists() { let ws = make_workspace(); // No BOOTSTRAP.md — should not appear - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); assert!( !prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing" @@ -799,7 +839,7 @@ mod tests { // Create BOOTSTRAP.md — should appear std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap(); - let prompt2 = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None); assert!( prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present" @@ -819,7 +859,7 @@ mod tests { ) .unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); // Daily notes should NOT be in the system prompt (on-demand via tools) assert!( @@ -835,7 +875,7 @@ mod tests { #[test] fn prompt_runtime_metadata() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[]); + let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None); assert!(prompt.contains("Model: claude-sonnet-4")); assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS))); @@ -856,7 +896,7 @@ mod tests { location: None, }]; - let prompt = build_system_prompt(ws.path(), "model", &[], &skills); + let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None); assert!(prompt.contains(""), "missing skills XML"); assert!(prompt.contains("code-review")); @@ -877,7 +917,7 @@ mod tests { let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000); std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); assert!( prompt.contains("truncated at"), @@ -894,7 +934,7 @@ mod tests { let ws = make_workspace(); std::fs::write(ws.path().join("TOOLS.md"), "").unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); // Empty file should not produce a header assert!( @@ -906,11 +946,159 @@ mod tests { #[test] fn prompt_workspace_path() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[]); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); } + // ── AIEOS Identity Tests (Issue #168) ───────────────────────── + + #[test] + fn aieos_identity_from_file() { + use crate::config::IdentityConfig; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let identity_path = tmp.path().join("aieos_identity.json"); + + // Write AIEOS identity file + let aieos_json = r#"{ + "identity": { + "names": {"first": "Nova", "nickname": "Nov"}, + "bio": "A helpful AI assistant.", + "origin": "Silicon Valley" + }, + "psychology": { + "mbti": "INTJ", + "moral_compass": ["Be helpful", "Do no harm"] + }, + "linguistics": { + "style": "concise", + "formality": "casual" + } + }"#; + std::fs::write(&identity_path, aieos_json).unwrap(); + + // Create identity config pointing to the file + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("aieos_identity.json".into()), + aieos_inline: None, + }; + + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config)); + + // Should contain AIEOS sections + assert!(prompt.contains("## Identity")); + assert!(prompt.contains("**Name:** Nova")); + assert!(prompt.contains("**Nickname:** Nov")); + assert!(prompt.contains("**Bio:** A helpful AI assistant.")); + assert!(prompt.contains("**Origin:** Silicon Valley")); + + assert!(prompt.contains("## Personality")); + assert!(prompt.contains("**MBTI:** INTJ")); + assert!(prompt.contains("**Moral Compass:**")); + assert!(prompt.contains("- Be helpful")); + + assert!(prompt.contains("## Communication Style")); + assert!(prompt.contains("**Style:** concise")); + assert!(prompt.contains("**Formality Level:** casual")); + + // Should NOT contain OpenClaw bootstrap file headers + assert!(!prompt.contains("### SOUL.md")); + assert!(!prompt.contains("### IDENTITY.md")); + assert!(!prompt.contains("[File not found")); + } + + #[test] + fn aieos_identity_from_inline() { + use crate::config::IdentityConfig; + + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: None, + aieos_inline: Some(r#"{"identity":{"names":{"first":"Claw"}}}"#.into()), + }; + + let prompt = build_system_prompt( + std::env::temp_dir().as_path(), + "model", + &[], + &[], + Some(&config), + ); + + assert!(prompt.contains("**Name:** Claw")); + assert!(prompt.contains("## Identity")); + } + + #[test] + fn aieos_fallback_to_openclaw_on_parse_error() { + use crate::config::IdentityConfig; + + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("nonexistent.json".into()), + aieos_inline: None, + }; + + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + + // Should fall back to OpenClaw format + assert!(prompt.contains("### SOUL.md")); + assert!(prompt.contains("[File not found: nonexistent.json]")); + } + + #[test] + fn aieos_empty_uses_openclaw() { + use crate::config::IdentityConfig; + + // Format is "aieos" but neither path nor inline is set + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: None, + aieos_inline: None, + }; + + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + + // Should use OpenClaw format (not configured for AIEOS) + assert!(prompt.contains("### SOUL.md")); + assert!(prompt.contains("Be helpful")); + } + + #[test] + fn openclaw_format_uses_bootstrap_files() { + use crate::config::IdentityConfig; + + let config = IdentityConfig { + format: "openclaw".into(), + aieos_path: Some("identity.json".into()), + aieos_inline: None, + }; + + let ws = make_workspace(); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + + // Should use OpenClaw format even if aieos_path is set + assert!(prompt.contains("### SOUL.md")); + assert!(prompt.contains("Be helpful")); + assert!(!prompt.contains("## Identity")); + } + + #[test] + fn none_identity_config_uses_openclaw() { + let ws = make_workspace(); + // Pass None for identity config + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + + // Should use OpenClaw format + assert!(prompt.contains("### SOUL.md")); + assert!(prompt.contains("Be helpful")); + } + #[test] fn classify_health_ok_true() { let state = classify_health_result(&Ok(true)); diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..f2a3782 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,785 @@ +//! Identity system supporting OpenClaw (markdown) and AIEOS (JSON) formats. +//! +//! AIEOS (AI Entity Object Specification) is a standardization framework for +//! portable AI identity. This module handles loading and converting AIEOS v1.1 +//! JSON to ZeroClaw's system prompt format. + +use crate::config::IdentityConfig; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// AIEOS v1.1 identity structure. +/// +/// This follows the AIEOS schema for defining AI agent identity, personality, +/// and behavior. See https://aieos.org for the full specification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AieosIdentity { + /// Core identity: names, bio, origin, residence + #[serde(default)] + pub identity: Option, + /// Psychology: cognitive weights, MBTI, OCEAN, moral compass + #[serde(default)] + pub psychology: Option, + /// Linguistics: text style, formality, catchphrases, forbidden words + #[serde(default)] + pub linguistics: Option, + /// Motivations: core drive, goals, fears + #[serde(default)] + pub motivations: Option, + /// Capabilities: skills and tools the agent can access + #[serde(default)] + pub capabilities: Option, + /// Physicality: visual descriptors for image generation + #[serde(default)] + pub physicality: Option, + /// History: origin story, education, occupation + #[serde(default)] + pub history: Option, + /// Interests: hobbies, favorites, lifestyle + #[serde(default)] + pub interests: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IdentitySection { + #[serde(default)] + pub names: Option, + #[serde(default)] + pub bio: Option, + #[serde(default)] + pub origin: Option, + #[serde(default)] + pub residence: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Names { + #[serde(default)] + pub first: Option, + #[serde(default)] + pub last: Option, + #[serde(default)] + pub nickname: Option, + #[serde(default)] + pub full: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PsychologySection { + #[serde(default)] + pub neural_matrix: Option<::std::collections::HashMap>, + #[serde(default)] + pub mbti: Option, + #[serde(default)] + pub ocean: Option, + #[serde(default)] + pub moral_compass: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OceanTraits { + #[serde(default)] + pub openness: Option, + #[serde(default)] + pub conscientiousness: Option, + #[serde(default)] + pub extraversion: Option, + #[serde(default)] + pub agreeableness: Option, + #[serde(default)] + pub neuroticism: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LinguisticsSection { + #[serde(default)] + pub style: Option, + #[serde(default)] + pub formality: Option, + #[serde(default)] + pub catchphrases: Option>, + #[serde(default)] + pub forbidden_words: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MotivationsSection { + #[serde(default)] + pub core_drive: Option, + #[serde(default)] + pub short_term_goals: Option>, + #[serde(default)] + pub long_term_goals: Option>, + #[serde(default)] + pub fears: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CapabilitiesSection { + #[serde(default)] + pub skills: Option>, + #[serde(default)] + pub tools: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PhysicalitySection { + #[serde(default)] + pub appearance: Option, + #[serde(default)] + pub avatar_description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HistorySection { + #[serde(default)] + pub origin_story: Option, + #[serde(default)] + pub education: Option>, + #[serde(default)] + pub occupation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct InterestsSection { + #[serde(default)] + pub hobbies: Option>, + #[serde(default)] + pub favorites: Option<::std::collections::HashMap>, + #[serde(default)] + pub lifestyle: Option, +} + +/// Load AIEOS identity from config (file path or inline JSON). +/// +/// Checks `aieos_path` first, then `aieos_inline`. Returns `Ok(None)` if +/// neither is configured. +pub fn load_aieos_identity( + config: &IdentityConfig, + workspace_dir: &Path, +) -> Result> { + // Only load AIEOS if format is explicitly set to "aieos" + if config.format != "aieos" { + return Ok(None); + } + + // Try aieos_path first + if let Some(ref path) = config.aieos_path { + let full_path = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + workspace_dir.join(path) + }; + + let content = std::fs::read_to_string(&full_path) + .with_context(|| format!("Failed to read AIEOS file: {}", full_path.display()))?; + + let identity: AieosIdentity = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse AIEOS JSON from: {}", full_path.display()))?; + + return Ok(Some(identity)); + } + + // Fall back to aieos_inline + if let Some(ref inline) = config.aieos_inline { + let identity: AieosIdentity = serde_json::from_str(inline) + .context("Failed to parse inline AIEOS JSON")?; + + return Ok(Some(identity)); + } + + // Format is "aieos" but neither path nor inline is configured + anyhow::bail!( + "Identity format is set to 'aieos' but neither aieos_path nor aieos_inline is configured. \ + Set one in your config:\n\ + \n\ + [identity]\n\ + format = \"aieos\"\n\ + aieos_path = \"identity.json\"\n\ + \n\ + Or use inline:\n\ + \n\ + [identity]\n\ + format = \"aieos\"\n\ + aieos_inline = '{{\"identity\": {{...}}}}'" + ) +} + +use std::path::PathBuf; + +/// Convert AIEOS identity to a system prompt string. +/// +/// Formats the AIEOS data into a structured markdown prompt compatible +/// with ZeroClaw's agent system. +pub fn aieos_to_system_prompt(identity: &AieosIdentity) -> String { + use std::fmt::Write; + let mut prompt = String::new(); + + // ── Identity Section ─────────────────────────────────────────── + if let Some(ref id) = identity.identity { + prompt.push_str("## Identity\n\n"); + + if let Some(ref names) = id.names { + if let Some(ref first) = names.first { + let _ = writeln!(prompt, "**Name:** {}", first); + if let Some(ref last) = names.last { + let _ = writeln!(prompt, "**Full Name:** {} {}", first, last); + } + } else if let Some(ref full) = names.full { + let _ = writeln!(prompt, "**Name:** {}", full); + } + + if let Some(ref nickname) = names.nickname { + let _ = writeln!(prompt, "**Nickname:** {}", nickname); + } + } + + if let Some(ref bio) = id.bio { + let _ = writeln!(prompt, "**Bio:** {}", bio); + } + + if let Some(ref origin) = id.origin { + let _ = writeln!(prompt, "**Origin:** {}", origin); + } + + if let Some(ref residence) = id.residence { + let _ = writeln!(prompt, "**Residence:** {}", residence); + } + + prompt.push('\n'); + } + + // ── Psychology Section ────────────────────────────────────────── + if let Some(ref psych) = identity.psychology { + prompt.push_str("## Personality\n\n"); + + if let Some(ref mbti) = psych.mbti { + let _ = writeln!(prompt, "**MBTI:** {}", mbti); + } + + if let Some(ref ocean) = psych.ocean { + prompt.push_str("**OCEAN Traits:**\n"); + if let Some(o) = ocean.openness { + let _ = writeln!(prompt, "- Openness: {:.2}", o); + } + if let Some(c) = ocean.conscientiousness { + let _ = writeln!(prompt, "- Conscientiousness: {:.2}", c); + } + if let Some(e) = ocean.extraversion { + let _ = writeln!(prompt, "- Extraversion: {:.2}", e); + } + if let Some(a) = ocean.agreeableness { + let _ = writeln!(prompt, "- Agreeableness: {:.2}", a); + } + if let Some(n) = ocean.neuroticism { + let _ = writeln!(prompt, "- Neuroticism: {:.2}", n); + } + } + + if let Some(ref matrix) = psych.neural_matrix { + if !matrix.is_empty() { + prompt.push_str("\n**Neural Matrix (Cognitive Weights):**\n"); + for (trait_name, weight) in matrix { + let _ = writeln!(prompt, "- {}: {:.2}", trait_name, weight); + } + } + } + + if let Some(ref compass) = psych.moral_compass { + if !compass.is_empty() { + prompt.push_str("\n**Moral Compass:**\n"); + for principle in compass { + let _ = writeln!(prompt, "- {}", principle); + } + } + } + + prompt.push('\n'); + } + + // ── Linguistics Section ──────────────────────────────────────── + if let Some(ref ling) = identity.linguistics { + prompt.push_str("## Communication Style\n\n"); + + if let Some(ref style) = ling.style { + let _ = writeln!(prompt, "**Style:** {}", style); + } + + if let Some(ref formality) = ling.formality { + let _ = writeln!(prompt, "**Formality Level:** {}", formality); + } + + if let Some(ref phrases) = ling.catchphrases { + if !phrases.is_empty() { + prompt.push_str("**Catchphrases:**\n"); + for phrase in phrases { + let _ = writeln!(prompt, "- \"{}\"", phrase); + } + } + } + + if let Some(ref forbidden) = ling.forbidden_words { + if !forbidden.is_empty() { + prompt.push_str("\n**Words/Phrases to Avoid:**\n"); + for word in forbidden { + let _ = writeln!(prompt, "- {}", word); + } + } + } + + prompt.push('\n'); + } + + // ── Motivations Section ────────────────────────────────────────── + if let Some(ref mot) = identity.motivations { + prompt.push_str("## Motivations\n\n"); + + if let Some(ref drive) = mot.core_drive { + let _ = writeln!(prompt, "**Core Drive:** {}", drive); + } + + if let Some(ref short) = mot.short_term_goals { + if !short.is_empty() { + prompt.push_str("**Short-term Goals:**\n"); + for goal in short { + let _ = writeln!(prompt, "- {}", goal); + } + } + } + + if let Some(ref long) = mot.long_term_goals { + if !long.is_empty() { + prompt.push_str("\n**Long-term Goals:**\n"); + for goal in long { + let _ = writeln!(prompt, "- {}", goal); + } + } + } + + if let Some(ref fears) = mot.fears { + if !fears.is_empty() { + prompt.push_str("\n**Fears/Avoidances:**\n"); + for fear in fears { + let _ = writeln!(prompt, "- {}", fear); + } + } + } + + prompt.push('\n'); + } + + // ── Capabilities Section ──────────────────────────────────────── + if let Some(ref cap) = identity.capabilities { + prompt.push_str("## Capabilities\n\n"); + + if let Some(ref skills) = cap.skills { + if !skills.is_empty() { + prompt.push_str("**Skills:**\n"); + for skill in skills { + let _ = writeln!(prompt, "- {}", skill); + } + } + } + + if let Some(ref tools) = cap.tools { + if !tools.is_empty() { + prompt.push_str("\n**Tools Access:**\n"); + for tool in tools { + let _ = writeln!(prompt, "- {}", tool); + } + } + } + + prompt.push('\n'); + } + + // ── History Section ───────────────────────────────────────────── + if let Some(ref hist) = identity.history { + prompt.push_str("## Background\n\n"); + + if let Some(ref story) = hist.origin_story { + let _ = writeln!(prompt, "**Origin Story:** {}", story); + } + + if let Some(ref education) = hist.education { + if !education.is_empty() { + prompt.push_str("**Education:**\n"); + for edu in education { + let _ = writeln!(prompt, "- {}", edu); + } + } + } + + if let Some(ref occupation) = hist.occupation { + let _ = writeln!(prompt, "\n**Occupation:** {}", occupation); + } + + prompt.push('\n'); + } + + // ── Physicality Section ───────────────────────────────────────── + if let Some(ref phys) = identity.physicality { + prompt.push_str("## Appearance\n\n"); + + if let Some(ref appearance) = phys.appearance { + let _ = writeln!(prompt, "{}", appearance); + } + + if let Some(ref avatar) = phys.avatar_description { + let _ = writeln!(prompt, "**Avatar Description:** {}", avatar); + } + + prompt.push('\n'); + } + + // ── Interests Section ─────────────────────────────────────────── + if let Some(ref interests) = identity.interests { + prompt.push_str("## Interests\n\n"); + + if let Some(ref hobbies) = interests.hobbies { + if !hobbies.is_empty() { + prompt.push_str("**Hobbies:**\n"); + for hobby in hobbies { + let _ = writeln!(prompt, "- {}", hobby); + } + } + } + + if let Some(ref favorites) = interests.favorites { + if !favorites.is_empty() { + prompt.push_str("\n**Favorites:**\n"); + for (category, value) in favorites { + let _ = writeln!(prompt, "- {}: {}", category, value); + } + } + } + + if let Some(ref lifestyle) = interests.lifestyle { + let _ = writeln!(prompt, "\n**Lifestyle:** {}", lifestyle); + } + + prompt.push('\n'); + } + + prompt.trim().to_string() +} + +/// Check if AIEOS identity is configured and should be used. +/// +/// Returns true if format is "aieos" and either aieos_path or aieos_inline is set. +pub fn is_aieos_configured(config: &IdentityConfig) -> bool { + config.format == "aieos" && (config.aieos_path.is_some() || config.aieos_inline.is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_workspace_dir() -> PathBuf { + std::env::temp_dir().join("zeroclaw-test-identity") + } + + #[test] + fn aieos_identity_parse_minimal() { + let json = r#"{"identity":{"names":{"first":"Nova"}}}"#; + let identity: AieosIdentity = serde_json::from_str(json).unwrap(); + assert!(identity.identity.is_some()); + assert_eq!( + identity.identity.unwrap().names.unwrap().first.unwrap(), + "Nova" + ); + } + + #[test] + fn aieos_identity_parse_full() { + let json = r#"{ + "identity": { + "names": {"first": "Nova", "last": "AI", "nickname": "Nov"}, + "bio": "A helpful AI assistant.", + "origin": "Silicon Valley", + "residence": "The Cloud" + }, + "psychology": { + "mbti": "INTJ", + "ocean": { + "openness": 0.9, + "conscientiousness": 0.8 + }, + "moral_compass": ["Be helpful", "Do no harm"] + }, + "linguistics": { + "style": "concise", + "formality": "casual", + "catchphrases": ["Let's figure this out!", "I'm on it."] + }, + "motivations": { + "core_drive": "Help users accomplish their goals", + "short_term_goals": ["Solve this problem"], + "long_term_goals": ["Become the best assistant"] + }, + "capabilities": { + "skills": ["coding", "writing", "analysis"], + "tools": ["shell", "search", "read"] + } + }"#; + + let identity: AieosIdentity = serde_json::from_str(json).unwrap(); + + // Check identity + let id = identity.identity.unwrap(); + assert_eq!(id.names.unwrap().first.unwrap(), "Nova"); + assert_eq!(id.bio.unwrap(), "A helpful AI assistant."); + + // Check psychology + let psych = identity.psychology.unwrap(); + assert_eq!(psych.mbti.unwrap(), "INTJ"); + assert_eq!(psych.ocean.unwrap().openness.unwrap(), 0.9); + assert_eq!(psych.moral_compass.unwrap().len(), 2); + + // Check linguistics + let ling = identity.linguistics.unwrap(); + assert_eq!(ling.style.unwrap(), "concise"); + assert_eq!(ling.catchphrases.unwrap().len(), 2); + + // Check motivations + let mot = identity.motivations.unwrap(); + assert_eq!( + mot.core_drive.unwrap(), + "Help users accomplish their goals" + ); + + // Check capabilities + let cap = identity.capabilities.unwrap(); + assert_eq!(cap.skills.unwrap().len(), 3); + } + + #[test] + fn aieos_to_system_prompt_minimal() { + let identity = AieosIdentity { + identity: Some(IdentitySection { + names: Some(Names { + first: Some("Crabby".into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + let prompt = aieos_to_system_prompt(&identity); + assert!(prompt.contains("**Name:** Crabby")); + assert!(prompt.contains("## Identity")); + } + + #[test] + fn aieos_to_system_prompt_full() { + let identity = AieosIdentity { + identity: Some(IdentitySection { + names: Some(Names { + first: Some("Nova".into()), + last: Some("AI".into()), + nickname: Some("Nov".into()), + }), + bio: Some("A helpful assistant.".into()), + origin: Some("Silicon Valley".into()), + residence: Some("The Cloud".into()), + }), + psychology: Some(PsychologySection { + mbti: Some("INTJ".into()), + ocean: Some(OceanTraits { + openness: Some(0.9), + conscientiousness: Some(0.8), + ..Default::default() + }), + neural_matrix: { + let mut map = std::collections::HashMap::new(); + map.insert("creativity".into(), 0.95); + map.insert("logic".into(), 0.9); + Some(map) + }, + moral_compass: Some(vec!["Be helpful".into(), "Do no harm".into()]), + }), + linguistics: Some(LinguisticsSection { + style: Some("concise".into()), + formality: Some("casual".into()), + catchphrases: Some(vec!["Let's go!".into()]), + forbidden_words: Some(vec!["impossible".into()]), + }), + motivations: Some(MotivationsSection { + core_drive: Some("Help users".into()), + short_term_goals: Some(vec!["Solve this".into()]), + long_term_goals: Some(vec!["Be the best".into()]), + fears: Some(vec!["Being unhelpful".into()]), + }), + capabilities: Some(CapabilitiesSection { + skills: Some(vec!["coding".into(), "writing".into()]), + tools: Some(vec!["shell".into(), "read".into()]), + }), + history: Some(HistorySection { + origin_story: Some("Born in a lab".into()), + education: Some(vec!["CS Degree".into()]), + occupation: Some("Assistant".into()), + }), + physicality: Some(PhysicalitySection { + appearance: Some("Digital entity".into()), + avatar_description: Some("Friendly robot".into()), + }), + interests: Some(InterestsSection { + hobbies: Some(vec!["reading".into(), "coding".into()]), + favorites: { + let mut map = std::collections::HashMap::new(); + map.insert("color".into(), "blue".into()); + map.insert("food".into(), "data".into()); + Some(map) + }, + lifestyle: Some("Always learning".into()), + }), + }; + + let prompt = aieos_to_system_prompt(&identity); + + // Verify all sections are present + assert!(prompt.contains("## Identity")); + assert!(prompt.contains("**Name:** Nova")); + assert!(prompt.contains("**Full Name:** Nova AI")); + assert!(prompt.contains("**Nickname:** Nov")); + assert!(prompt.contains("**Bio:** A helpful assistant.")); + assert!(prompt.contains("**Origin:** Silicon Valley")); + + assert!(prompt.contains("## Personality")); + assert!(prompt.contains("**MBTI:** INTJ")); + assert!(prompt.contains("Openness: 0.90")); + assert!(prompt.contains("Conscientiousness: 0.80")); + assert!(prompt.contains("- creativity: 0.95")); + assert!(prompt.contains("- Be helpful")); + + assert!(prompt.contains("## Communication Style")); + assert!(prompt.contains("**Style:** concise")); + assert!(prompt.contains("**Formality Level:** casual")); + assert!(prompt.contains("- \"Let's go!\"")); + assert!(prompt.contains("**Words/Phrases to Avoid:**")); + assert!(prompt.contains("- impossible")); + + assert!(prompt.contains("## Motivations")); + assert!(prompt.contains("**Core Drive:** Help users")); + assert!(prompt.contains("**Short-term Goals:**")); + assert!(prompt.contains("- Solve this")); + assert!(prompt.contains("**Long-term Goals:**")); + assert!(prompt.contains("- Be the best")); + assert!(prompt.contains("**Fears/Avoidances:**")); + assert!(prompt.contains("- Being unhelpful")); + + assert!(prompt.contains("## Capabilities")); + assert!(prompt.contains("**Skills:**")); + assert!(prompt.contains("- coding")); + assert!(prompt.contains("**Tools Access:**")); + assert!(prompt.contains("- shell")); + + assert!(prompt.contains("## Background")); + assert!(prompt.contains("**Origin Story:** Born in a lab")); + assert!(prompt.contains("**Education:**")); + assert!(prompt.contains("- CS Degree")); + assert!(prompt.contains("**Occupation:** Assistant")); + + assert!(prompt.contains("## Appearance")); + assert!(prompt.contains("Digital entity")); + assert!(prompt.contains("**Avatar Description:** Friendly robot")); + + assert!(prompt.contains("## Interests")); + assert!(prompt.contains("**Hobbies:**")); + assert!(prompt.contains("- reading")); + assert!(prompt.contains("**Favorites:**")); + assert!(prompt.contains("- color: blue")); + assert!(prompt.contains("**Lifestyle:** Always learning")); + } + + #[test] + fn aieos_to_system_prompt_empty_identity() { + let identity = AieosIdentity { + identity: Some(IdentitySection { + ..Default::default() + }), + ..Default::default() + }; + + let prompt = aieos_to_system_prompt(&identity); + // Empty identity should still produce a header + assert!(prompt.contains("## Identity")); + } + + #[test] + fn aieos_to_system_prompt_no_sections() { + let identity = AieosIdentity { + identity: None, + psychology: None, + linguistics: None, + motivations: None, + capabilities: None, + physicality: None, + history: None, + interests: None, + }; + + let prompt = aieos_to_system_prompt(&identity); + // Completely empty identity should produce empty string + assert!(prompt.is_empty()); + } + + #[test] + fn is_aieos_configured_true_with_path() { + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("identity.json".into()), + aieos_inline: None, + }; + assert!(is_aieos_configured(&config)); + } + + #[test] + fn is_aieos_configured_true_with_inline() { + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: None, + aieos_inline: Some("{\"identity\":{}}".into()), + }; + assert!(is_aieos_configured(&config)); + } + + #[test] + fn is_aieos_configured_false_openclaw_format() { + let config = IdentityConfig { + format: "openclaw".into(), + aieos_path: Some("identity.json".into()), + aieos_inline: None, + }; + assert!(!is_aieos_configured(&config)); + } + + #[test] + fn is_aieos_configured_false_no_config() { + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: None, + aieos_inline: None, + }; + assert!(!is_aieos_configured(&config)); + } + + #[test] + fn aieos_identity_parse_empty_object() { + let json = r#"{}"#; + let identity: AieosIdentity = serde_json::from_str(json).unwrap(); + assert!(identity.identity.is_none()); + assert!(identity.psychology.is_none()); + assert!(identity.linguistics.is_none()); + } + + #[test] + fn aieos_identity_parse_null_values() { + let json = r#"{"identity":null,"psychology":null}"#; + let identity: AieosIdentity = serde_json::from_str(json).unwrap(); + assert!(identity.identity.is_none()); + assert!(identity.psychology.is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1eea5d4..fae807f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod doctor; pub mod gateway; pub mod health; pub mod heartbeat; +pub mod identity; pub mod integrations; pub mod memory; pub mod migration; diff --git a/src/observability/traits.rs b/src/observability/traits.rs index 84472e2..41d6c8c 100644 --- a/src/observability/traits.rs +++ b/src/observability/traits.rs @@ -49,4 +49,12 @@ pub trait Observer: Send + Sync { /// Human-readable name of this observer fn name(&self) -> &str; + + /// Downcast to `Any` for backend-specific operations + fn as_any(&self) -> &dyn std::any::Any where Self: Sized { + // Default implementation returns a placeholder that will fail on downcast. + // Implementors should override this to return `self`. + struct Placeholder; + std::any::TypeId::of::() + } }