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:
parent
1cfc63831c
commit
f1e3b1166d
5 changed files with 1020 additions and 37 deletions
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue