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

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