feat: implement AIEOS identity support (#168)

Fixes #168

AIEOS (AI Entity Object Specification) v1.1 is now fully supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-02-15 11:46:02 -05:00 committed by GitHub
parent 1cfc63831c
commit f1e3b1166d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1020 additions and 37 deletions

View file

@ -131,6 +131,7 @@ pub async fn run(
model_name,
&tool_descs,
&skills,
Some(&config.identity),
);
// ── Execute ──────────────────────────────────────────────────

View file

@ -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("<available_skills>"), "missing skills XML");
assert!(prompt.contains("<name>code-review</name>"));
@ -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));

785
src/identity.rs Normal file
View file

@ -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<IdentitySection>,
/// Psychology: cognitive weights, MBTI, OCEAN, moral compass
#[serde(default)]
pub psychology: Option<PsychologySection>,
/// Linguistics: text style, formality, catchphrases, forbidden words
#[serde(default)]
pub linguistics: Option<LinguisticsSection>,
/// Motivations: core drive, goals, fears
#[serde(default)]
pub motivations: Option<MotivationsSection>,
/// Capabilities: skills and tools the agent can access
#[serde(default)]
pub capabilities: Option<CapabilitiesSection>,
/// Physicality: visual descriptors for image generation
#[serde(default)]
pub physicality: Option<PhysicalitySection>,
/// History: origin story, education, occupation
#[serde(default)]
pub history: Option<HistorySection>,
/// Interests: hobbies, favorites, lifestyle
#[serde(default)]
pub interests: Option<InterestsSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentitySection {
#[serde(default)]
pub names: Option<Names>,
#[serde(default)]
pub bio: Option<String>,
#[serde(default)]
pub origin: Option<String>,
#[serde(default)]
pub residence: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Names {
#[serde(default)]
pub first: Option<String>,
#[serde(default)]
pub last: Option<String>,
#[serde(default)]
pub nickname: Option<String>,
#[serde(default)]
pub full: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PsychologySection {
#[serde(default)]
pub neural_matrix: Option<::std::collections::HashMap<String, f64>>,
#[serde(default)]
pub mbti: Option<String>,
#[serde(default)]
pub ocean: Option<OceanTraits>,
#[serde(default)]
pub moral_compass: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OceanTraits {
#[serde(default)]
pub openness: Option<f64>,
#[serde(default)]
pub conscientiousness: Option<f64>,
#[serde(default)]
pub extraversion: Option<f64>,
#[serde(default)]
pub agreeableness: Option<f64>,
#[serde(default)]
pub neuroticism: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LinguisticsSection {
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub formality: Option<String>,
#[serde(default)]
pub catchphrases: Option<Vec<String>>,
#[serde(default)]
pub forbidden_words: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MotivationsSection {
#[serde(default)]
pub core_drive: Option<String>,
#[serde(default)]
pub short_term_goals: Option<Vec<String>>,
#[serde(default)]
pub long_term_goals: Option<Vec<String>>,
#[serde(default)]
pub fears: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilitiesSection {
#[serde(default)]
pub skills: Option<Vec<String>>,
#[serde(default)]
pub tools: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PhysicalitySection {
#[serde(default)]
pub appearance: Option<String>,
#[serde(default)]
pub avatar_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HistorySection {
#[serde(default)]
pub origin_story: Option<String>,
#[serde(default)]
pub education: Option<Vec<String>>,
#[serde(default)]
pub occupation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InterestsSection {
#[serde(default)]
pub hobbies: Option<Vec<String>>,
#[serde(default)]
pub favorites: Option<::std::collections::HashMap<String, String>>,
#[serde(default)]
pub lifestyle: Option<String>,
}
/// 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<Option<AieosIdentity>> {
// 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());
}
}

View file

@ -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;

View file

@ -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::<Placeholder>()
}
}