diff --git a/app/src/types/agentProfile.ts b/app/src/types/agentProfile.ts index 0f0ffc982..4b48f9c37 100644 --- a/app/src/types/agentProfile.ts +++ b/app/src/types/agentProfile.ts @@ -8,6 +8,14 @@ export interface AgentProfile { systemPromptSuffix?: string | null; allowedTools?: string[] | null; builtIn: boolean; + avatarUrl?: string | null; + voiceId?: string | null; + soulMd?: string | null; + soulMdPath?: string | null; + composioIntegrations?: string[] | null; + memoryDirSuffix?: string | null; + isMaster?: boolean | null; + sortOrder?: number | null; } export interface AgentProfilesResponse { diff --git a/app/src/types/thread.ts b/app/src/types/thread.ts index 87663d703..8b5f6c79a 100644 --- a/app/src/types/thread.ts +++ b/app/src/types/thread.ts @@ -8,6 +8,7 @@ export interface Thread { createdAt: string; parentThreadId?: string; labels: string[]; + personalityId?: string | null; } export interface ThreadMessage { diff --git a/src/openhuman/agent/agents/archivist/prompt.rs b/src/openhuman/agent/agents/archivist/prompt.rs index 2adb0cc06..25161b090 100644 --- a/src/openhuman/agent/agents/archivist/prompt.rs +++ b/src/openhuman/agent/agents/archivist/prompt.rs @@ -63,6 +63,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/code_executor/prompt.rs b/src/openhuman/agent/agents/code_executor/prompt.rs index 5cd8d5935..149ada447 100644 --- a/src/openhuman/agent/agents/code_executor/prompt.rs +++ b/src/openhuman/agent/agents/code_executor/prompt.rs @@ -67,6 +67,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/critic/prompt.rs b/src/openhuman/agent/agents/critic/prompt.rs index 75a97a5f3..45a2e8c58 100644 --- a/src/openhuman/agent/agents/critic/prompt.rs +++ b/src/openhuman/agent/agents/critic/prompt.rs @@ -62,6 +62,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/crypto_agent/prompt.rs b/src/openhuman/agent/agents/crypto_agent/prompt.rs index 26a36570b..4e736dace 100644 --- a/src/openhuman/agent/agents/crypto_agent/prompt.rs +++ b/src/openhuman/agent/agents/crypto_agent/prompt.rs @@ -88,6 +88,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } diff --git a/src/openhuman/agent/agents/help/prompt.rs b/src/openhuman/agent/agents/help/prompt.rs index b00fc266a..d1084cdbd 100644 --- a/src/openhuman/agent/agents/help/prompt.rs +++ b/src/openhuman/agent/agents/help/prompt.rs @@ -62,6 +62,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.rs b/src/openhuman/agent/agents/integrations_agent/prompt.rs index 679179dcd..65af9a9e3 100644 --- a/src/openhuman/agent/agents/integrations_agent/prompt.rs +++ b/src/openhuman/agent/agents/integrations_agent/prompt.rs @@ -227,6 +227,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index 2bb7e5785..ba0161555 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -368,6 +368,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx) .unwrap_or_else(|e| panic!("{} prompt build failed: {e}", def.id)); diff --git a/src/openhuman/agent/agents/markets_agent/prompt.rs b/src/openhuman/agent/agents/markets_agent/prompt.rs index dde0286a9..123c1a80b 100644 --- a/src/openhuman/agent/agents/markets_agent/prompt.rs +++ b/src/openhuman/agent/agents/markets_agent/prompt.rs @@ -90,6 +90,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } diff --git a/src/openhuman/agent/agents/mcp_setup/prompt.rs b/src/openhuman/agent/agents/mcp_setup/prompt.rs index 9017c9d82..5d3c40e9a 100644 --- a/src/openhuman/agent/agents/mcp_setup/prompt.rs +++ b/src/openhuman/agent/agents/mcp_setup/prompt.rs @@ -63,6 +63,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } diff --git a/src/openhuman/agent/agents/morning_briefing/prompt.rs b/src/openhuman/agent/agents/morning_briefing/prompt.rs index 817cc546d..b1e5779eb 100644 --- a/src/openhuman/agent/agents/morning_briefing/prompt.rs +++ b/src/openhuman/agent/agents/morning_briefing/prompt.rs @@ -78,6 +78,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: identity, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } diff --git a/src/openhuman/agent/agents/orchestrator/prompt.rs b/src/openhuman/agent/agents/orchestrator/prompt.rs index 0bde1cd77..735ceb335 100644 --- a/src/openhuman/agent/agents/orchestrator/prompt.rs +++ b/src/openhuman/agent/agents/orchestrator/prompt.rs @@ -191,6 +191,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } diff --git a/src/openhuman/agent/agents/planner/prompt.rs b/src/openhuman/agent/agents/planner/prompt.rs index 33bc1c3a0..8ff3c31d2 100644 --- a/src/openhuman/agent/agents/planner/prompt.rs +++ b/src/openhuman/agent/agents/planner/prompt.rs @@ -69,6 +69,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/researcher/prompt.rs b/src/openhuman/agent/agents/researcher/prompt.rs index 4fc1d79a5..c8028cfd6 100644 --- a/src/openhuman/agent/agents/researcher/prompt.rs +++ b/src/openhuman/agent/agents/researcher/prompt.rs @@ -63,6 +63,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/skill_creator/prompt.rs b/src/openhuman/agent/agents/skill_creator/prompt.rs index 5de0ebc22..7daadc4a5 100644 --- a/src/openhuman/agent/agents/skill_creator/prompt.rs +++ b/src/openhuman/agent/agents/skill_creator/prompt.rs @@ -112,6 +112,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/summarizer/prompt.rs b/src/openhuman/agent/agents/summarizer/prompt.rs index 8222b7c13..0c36aa915 100644 --- a/src/openhuman/agent/agents/summarizer/prompt.rs +++ b/src/openhuman/agent/agents/summarizer/prompt.rs @@ -63,6 +63,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/tool_maker/prompt.rs b/src/openhuman/agent/agents/tool_maker/prompt.rs index da505b1ce..1308a99e2 100644 --- a/src/openhuman/agent/agents/tool_maker/prompt.rs +++ b/src/openhuman/agent/agents/tool_maker/prompt.rs @@ -67,6 +67,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/tools_agent/prompt.rs b/src/openhuman/agent/agents/tools_agent/prompt.rs index 50c30e399..97c2e8d5b 100644 --- a/src/openhuman/agent/agents/tools_agent/prompt.rs +++ b/src/openhuman/agent/agents/tools_agent/prompt.rs @@ -64,6 +64,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/trigger_reactor/prompt.rs b/src/openhuman/agent/agents/trigger_reactor/prompt.rs index da98eedd6..ab6aaba2c 100644 --- a/src/openhuman/agent/agents/trigger_reactor/prompt.rs +++ b/src/openhuman/agent/agents/trigger_reactor/prompt.rs @@ -63,6 +63,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/trigger_triage/prompt.rs b/src/openhuman/agent/agents/trigger_triage/prompt.rs index 6873090bc..e1ad8e049 100644 --- a/src/openhuman/agent/agents/trigger_triage/prompt.rs +++ b/src/openhuman/agent/agents/trigger_triage/prompt.rs @@ -63,6 +63,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/debug/mod.rs b/src/openhuman/agent/debug/mod.rs index 87bab059d..255857bba 100644 --- a/src/openhuman/agent/debug/mod.rs +++ b/src/openhuman/agent/debug/mod.rs @@ -429,6 +429,9 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result bool { + !rel.is_absolute() + && rel.components().all(|c| { + !matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) +} + +/// Resolve the memory subdirectory name for a given suffix. +/// `""` → `"memory"`, `"-1"` → `"memory-1"`, `"-2"` → `"memory-2"`. +pub fn memory_subdir_for_suffix(suffix: &str) -> String { + if suffix.is_empty() { + "memory".to_string() + } else { + format!("memory{suffix}") + } +} + +/// Resolve the memory_tree subdirectory name for a given suffix. +pub fn memory_tree_subdir_for_suffix(suffix: &str) -> String { + if suffix.is_empty() { + "memory_tree".to_string() + } else { + format!("memory_tree{suffix}") + } +} + +/// Resolve the session_raw subdirectory name for a given suffix. +pub fn session_raw_subdir_for_suffix(suffix: &str) -> String { + if suffix.is_empty() { + "session_raw".to_string() + } else { + format!("session_raw{suffix}") + } +} + +/// Resolve the SOUL.md content for a personality. +/// +/// Resolution order: +/// 1. `soul_md_path` — read the file at that relative path under workspace. +/// 2. `soul_md` — inline content from the profile. +/// 3. `None` — caller falls back to the workspace root `SOUL.md`. +pub fn resolve_personality_soul(workspace_dir: &Path, profile: &AgentProfile) -> Option { + if let Some(ref rel_path) = profile.soul_md_path { + let rel = Path::new(rel_path); + if !is_safe_relative_path(rel) { + tracing::debug!( + profile_id = %profile.id, + soul_md_path = %rel_path, + "[personality] rejected unsafe soul_md_path, trying inline" + ); + // Fall through to inline check below. + return profile + .soul_md + .as_ref() + .filter(|s| !s.trim().is_empty()) + .cloned(); + } + let path = workspace_dir.join(rel); + // Guard against symlink traversal: a symlink inside the workspace can + // point outside it. Canonicalize both sides and reject if the resolved + // path escapes the workspace root. + // Note: synchronous fs calls here are intentional — soul_md is loaded + // during prompt construction on a tokio blocking thread; the workspace + // is always local disk (never a remote mount). + if let (Ok(canonical_ws), Ok(canonical_p)) = + (workspace_dir.canonicalize(), path.canonicalize()) + { + if !canonical_p.starts_with(&canonical_ws) { + tracing::warn!( + path = %path.display(), + profile_id = %profile.id, + "[personality] soul_md_path escapes workspace after canonicalization, trying inline" + ); + return profile + .soul_md + .as_ref() + .filter(|s| !s.trim().is_empty()) + .cloned(); + } + } + match std::fs::read_to_string(&path) { + Ok(content) if !content.trim().is_empty() => { + tracing::debug!( + path = %path.display(), + profile_id = %profile.id, + "[personality] soul_md loaded from file" + ); + return Some(content); + } + Ok(_) => { + tracing::debug!( + path = %path.display(), + profile_id = %profile.id, + "[personality] soul_md_path file empty, trying inline" + ); + } + Err(e) => { + tracing::debug!( + path = %path.display(), + profile_id = %profile.id, + error = %e, + "[personality] soul_md_path read failed, trying inline" + ); + } + } + } + + if let Some(ref inline) = profile.soul_md { + if !inline.trim().is_empty() { + tracing::debug!( + profile_id = %profile.id, + len = inline.len(), + "[personality] soul_md loaded from inline" + ); + return Some(inline.clone()); + } + } + + tracing::debug!( + profile_id = %profile.id, + "[personality] no personality-specific soul_md, falling back to root" + ); + None +} + +/// Resolve a personality's MEMORY.md content. +/// +/// Looks for `personalities/{profile_id}/MEMORY.md` under the workspace. +/// Returns `None` if the file doesn't exist or is empty — caller falls +/// back to the workspace root `MEMORY.md`. +pub fn resolve_personality_memory_md( + workspace_dir: &Path, + profile: &AgentProfile, +) -> Option { + let path = workspace_dir + .join("personalities") + .join(&profile.id) + .join("MEMORY.md"); + match std::fs::read_to_string(&path) { + Ok(content) if !content.trim().is_empty() => { + tracing::debug!( + path = %path.display(), + profile_id = %profile.id, + "[personality] memory_md loaded from personality dir" + ); + Some(content) + } + _ => None, + } +} + +/// All personality-resolved overrides needed to build a scoped agent session. +#[derive(Debug, Clone)] +pub struct PersonalityContext { + pub profile: AgentProfile, + pub memory_suffix: String, + pub soul_md_override: Option, + pub memory_md_override: Option, + pub composio_allowlist: Option>, + pub voice_id: Option, +} + +impl PersonalityContext { + /// Build from a resolved `AgentProfile`, reading personality files from the workspace. + pub fn from_profile(workspace_dir: &Path, profile: AgentProfile) -> Self { + let memory_suffix = profile.memory_dir_suffix.clone().unwrap_or_default(); + let soul_md_override = resolve_personality_soul(workspace_dir, &profile); + let memory_md_override = resolve_personality_memory_md(workspace_dir, &profile); + let composio_allowlist = profile.composio_integrations.clone(); + let voice_id = profile.voice_id.clone(); + + Self { + profile, + memory_suffix, + soul_md_override, + memory_md_override, + composio_allowlist, + voice_id, + } + } +} + +/// Filter connected integrations by an allowlist of toolkit slugs. +/// +/// - `None` → passthrough (all integrations). +/// - `Some([])` → no integrations. +/// - `Some(["slack", "gmail"])` → only those toolkits. +pub fn filter_integrations( + all: &[T], + allowlist: Option<&[String]>, +) -> Vec { + match allowlist { + None => all.to_vec(), + Some(allowed) => all + .iter() + .filter(|ci| { + allowed + .iter() + .any(|a| a.eq_ignore_ascii_case(ci.toolkit_name())) + }) + .cloned() + .collect(), + } +} + +/// Trait to abstract over integration types that have a toolkit name. +pub trait HasToolkit { + fn toolkit_name(&self) -> &str; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::agent::profiles::AgentProfile; + use tempfile::TempDir; + + fn test_profile(id: &str) -> AgentProfile { + AgentProfile { + id: id.to_string(), + name: id.to_string(), + description: String::new(), + agent_id: "orchestrator".to_string(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + } + } + + #[test] + fn memory_subdir_for_suffix_patterns() { + assert_eq!(memory_subdir_for_suffix(""), "memory"); + assert_eq!(memory_subdir_for_suffix("-1"), "memory-1"); + assert_eq!(memory_subdir_for_suffix("-2"), "memory-2"); + assert_eq!(memory_subdir_for_suffix("-10"), "memory-10"); + } + + #[test] + fn memory_tree_subdir_for_suffix_patterns() { + assert_eq!(memory_tree_subdir_for_suffix(""), "memory_tree"); + assert_eq!(memory_tree_subdir_for_suffix("-1"), "memory_tree-1"); + } + + #[test] + fn session_raw_subdir_for_suffix_patterns() { + assert_eq!(session_raw_subdir_for_suffix(""), "session_raw"); + assert_eq!(session_raw_subdir_for_suffix("-1"), "session_raw-1"); + } + + #[test] + fn resolve_soul_inline_fallback() { + let tmp = TempDir::new().unwrap(); + let mut profile = test_profile("alice"); + profile.soul_md = Some("I am Alice, a friendly assistant.".to_string()); + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("I am Alice, a friendly assistant.")); + } + + #[test] + fn resolve_soul_file_takes_precedence() { + let tmp = TempDir::new().unwrap(); + let soul_path = tmp.path().join("souls").join("alice.md"); + std::fs::create_dir_all(soul_path.parent().unwrap()).unwrap(); + std::fs::write(&soul_path, "File-based soul").unwrap(); + + let mut profile = test_profile("alice"); + profile.soul_md_path = Some("souls/alice.md".to_string()); + profile.soul_md = Some("Inline soul".to_string()); + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("File-based soul")); + } + + #[test] + fn resolve_soul_returns_none_when_empty() { + let tmp = TempDir::new().unwrap(); + let profile = test_profile("alice"); + let result = resolve_personality_soul(tmp.path(), &profile); + assert!(result.is_none()); + } + + #[test] + fn resolve_memory_md_from_personality_dir() { + let tmp = TempDir::new().unwrap(); + let mem_path = tmp + .path() + .join("personalities") + .join("alice") + .join("MEMORY.md"); + std::fs::create_dir_all(mem_path.parent().unwrap()).unwrap(); + std::fs::write(&mem_path, "Alice remembers things.").unwrap(); + + let profile = test_profile("alice"); + let result = resolve_personality_memory_md(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Alice remembers things.")); + } + + #[test] + fn resolve_memory_md_returns_none_when_missing() { + let tmp = TempDir::new().unwrap(); + let profile = test_profile("alice"); + let result = resolve_personality_memory_md(tmp.path(), &profile); + assert!(result.is_none()); + } + + #[test] + fn personality_context_from_profile() { + let tmp = TempDir::new().unwrap(); + let mut profile = test_profile("bob"); + profile.memory_dir_suffix = Some("-1".to_string()); + profile.voice_id = Some("voice-xyz".to_string()); + profile.composio_integrations = Some(vec!["slack".to_string()]); + profile.soul_md = Some("I am Bob.".to_string()); + + let ctx = PersonalityContext::from_profile(tmp.path(), profile); + assert_eq!(ctx.memory_suffix, "-1"); + assert_eq!(ctx.voice_id.as_deref(), Some("voice-xyz")); + assert_eq!(ctx.soul_md_override.as_deref(), Some("I am Bob.")); + assert_eq!(ctx.composio_allowlist.as_ref().unwrap(), &["slack"]); + } + + #[derive(Clone)] + struct FakeIntegration { + toolkit: String, + } + impl HasToolkit for FakeIntegration { + fn toolkit_name(&self) -> &str { + &self.toolkit + } + } + + #[test] + fn filter_integrations_none_passthrough() { + let all = vec![ + FakeIntegration { + toolkit: "slack".into(), + }, + FakeIntegration { + toolkit: "gmail".into(), + }, + ]; + let filtered = filter_integrations(&all, None); + assert_eq!(filtered.len(), 2); + } + + #[test] + fn filter_integrations_allowlist() { + let all = vec![ + FakeIntegration { + toolkit: "slack".into(), + }, + FakeIntegration { + toolkit: "gmail".into(), + }, + FakeIntegration { + toolkit: "notion".into(), + }, + ]; + let allowed = vec!["slack".to_string(), "notion".to_string()]; + let filtered = filter_integrations(&all, Some(&allowed)); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].toolkit, "slack"); + assert_eq!(filtered[1].toolkit, "notion"); + } + + #[test] + fn filter_integrations_empty_allowlist() { + let all = vec![FakeIntegration { + toolkit: "slack".into(), + }]; + let allowed: Vec = vec![]; + let filtered = filter_integrations(&all, Some(&allowed)); + assert!(filtered.is_empty()); + } +} diff --git a/src/openhuman/agent/profiles.rs b/src/openhuman/agent/profiles.rs index ce8d76a5f..1b10b581e 100644 --- a/src/openhuman/agent/profiles.rs +++ b/src/openhuman/agent/profiles.rs @@ -33,6 +33,28 @@ pub struct AgentProfile { pub allowed_tools: Option>, #[serde(default)] pub built_in: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub voice_id: Option, + /// Inline SOUL.md content for this personality. Falls back to workspace root. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub soul_md: Option, + /// Relative path to a personality-specific SOUL.md file (checked before inline). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub soul_md_path: Option, + /// Composio toolkit slugs this personality can access. None = all integrations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub composio_integrations: Option>, + /// Auto-assigned memory directory suffix: "" for default, "-1", "-2", etc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_dir_suffix: Option, + /// Whether this profile is the master orchestrator personality. + #[serde(default)] + pub is_master: bool, + /// Display order (lower = shown first). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort_order: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -191,6 +213,13 @@ impl AgentProfileStore { default.temperature = profile.temperature; default.system_prompt_suffix = profile.system_prompt_suffix; default.allowed_tools = profile.allowed_tools; + default.avatar_url = profile.avatar_url; + default.voice_id = profile.voice_id; + default.soul_md = profile.soul_md; + default.soul_md_path = profile.soul_md_path; + default.composio_integrations = profile.composio_integrations; + // memory_dir_suffix stays as built-in default (don't let user override the default's suffix) + default.sort_order = profile.sort_order; default } else { AgentProfile { @@ -198,10 +227,52 @@ impl AgentProfileStore { || built_in_profiles() .iter() .any(|builtin| builtin.id == profile.id), + is_master: false, // only DEFAULT_PROFILE_ID may be master ..profile } }; + let profile = if profile.id != DEFAULT_PROFILE_ID && profile.memory_dir_suffix.is_none() { + // Re-upsert of an existing profile without a suffix → reuse the stored + // suffix so its memory directory doesn't migrate (and silently orphan + // its database). + if let Some(existing) = state.profiles.iter().find(|p| p.id == profile.id) { + if let Some(ref existing_suffix) = existing.memory_dir_suffix { + AgentProfile { + memory_dir_suffix: Some(existing_suffix.clone()), + ..profile + } + } else { + // Pre-personality profile getting its first suffix assignment. + let existing_suffixes: std::collections::HashSet = state + .profiles + .iter() + .filter(|p| p.id != profile.id) + .filter_map(|p| p.memory_dir_suffix.clone()) + .filter(|s| !s.is_empty()) + .collect(); + AgentProfile { + memory_dir_suffix: Some(next_available_suffix(&existing_suffixes)), + ..profile + } + } + } else { + // New non-default profile: assign the lowest unused suffix. + let existing_suffixes: std::collections::HashSet = state + .profiles + .iter() + .filter_map(|p| p.memory_dir_suffix.clone()) + .filter(|s| !s.is_empty()) + .collect(); + AgentProfile { + memory_dir_suffix: Some(next_available_suffix(&existing_suffixes)), + ..profile + } + } + } else { + profile + }; + if let Some(existing) = state.profiles.iter_mut().find(|p| p.id == profile.id) { tracing::debug!(profile_id = %profile.id, "[agent:profiles] upsert replace_existing"); *existing = profile; @@ -305,6 +376,14 @@ pub fn built_in_profiles() -> Vec { ), allowed_tools: None, built_in: true, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }, AgentProfile { id: "planner".to_string(), @@ -319,6 +398,14 @@ pub fn built_in_profiles() -> Vec { ), allowed_tools: None, built_in: true, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }, AgentProfile { id: "review".to_string(), @@ -333,6 +420,14 @@ pub fn built_in_profiles() -> Vec { ), allowed_tools: None, built_in: true, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }, ] } @@ -375,6 +470,14 @@ fn built_in_default_profile() -> AgentProfile { system_prompt_suffix: None, allowed_tools: None, built_in: true, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: Some("".into()), + is_master: true, + sort_order: None, } } @@ -390,10 +493,14 @@ fn normalise_state(state: AgentProfilesState) -> AgentProfilesState { .collect(); for profile in state.profiles { - let profile = normalise_profile(profile); + let mut profile = normalise_profile(profile); if profile.id.is_empty() { continue; } + if profile.id == DEFAULT_PROFILE_ID { + profile.is_master = true; + profile.memory_dir_suffix = Some(String::new()); + } by_id.insert(profile.id.clone(), profile); } @@ -456,9 +563,58 @@ fn normalise_profile(mut profile: AgentProfile) -> AgentProfile { if matches!(profile.allowed_tools.as_ref(), Some(tools) if tools.is_empty()) { profile.allowed_tools = None; } + profile.avatar_url = profile + .avatar_url + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + profile.voice_id = profile + .voice_id + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + profile.soul_md = profile + .soul_md + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + profile.soul_md_path = profile + .soul_md_path + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + profile.composio_integrations = profile.composio_integrations.map(|tools| { + tools + .into_iter() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect::>() + }); + if matches!(profile.composio_integrations.as_ref(), Some(v) if v.is_empty()) { + profile.composio_integrations = None; + } + // Note: `Some("")` is the sentinel used exclusively by the default profile + // to indicate the legacy `memory/` directory (no suffix). `normalise_state` + // re-applies it after the filter below, so any `Some("")` on a non-default + // profile is silently dropped to `None` here, causing it to receive the + // next available numbered suffix on the following `upsert` path. + profile.memory_dir_suffix = profile + .memory_dir_suffix + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); profile } +/// Return the lowest available numbered suffix (`"-1"`, `"-2"`, …) not present +/// in `existing`. Used during `upsert` to auto-assign a unique memory directory +/// suffix to a new non-default personality profile. +fn next_available_suffix(existing: &std::collections::HashSet) -> String { + let mut n = 1u32; + loop { + let candidate = format!("-{n}"); + if !existing.contains(&candidate) { + return candidate; + } + n += 1; + } +} + fn slugify_profile_id(input: &str) -> String { let mut out = String::new(); let mut last_was_sep = false; @@ -506,6 +662,14 @@ mod tests { system_prompt_suffix: Some(" Be brief. ".into()), allowed_tools: Some(vec![" todo ".into(), "".into()]), built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }) .expect("upsert"); assert!(state.profiles.iter().any(|p| p.id == "custom-profile")); @@ -564,6 +728,14 @@ mod tests { system_prompt_suffix: Some(" ".into()), allowed_tools: Some(vec![" ".into()]), built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }], }); @@ -586,6 +758,14 @@ mod tests { system_prompt_suffix: Some(" suffix ".into()), allowed_tools: Some(vec![" todo ".into()]), built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }) .expect("upsert default"); let default = state @@ -636,6 +816,14 @@ mod tests { system_prompt_suffix: None, allowed_tools: None, built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }) .expect("upsert"); store.select("writer").expect("select"); @@ -674,6 +862,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = section.build(&ctx).expect("render profile section"); assert!(rendered.starts_with("## Agent profile")); @@ -698,6 +889,14 @@ mod tests { system_prompt_suffix: None, allowed_tools: None, built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, }) .expect("upsert"); store.select("tmp").expect("select"); @@ -705,4 +904,112 @@ mod tests { assert_eq!(state.active_profile_id, DEFAULT_PROFILE_ID); assert!(!state.profiles.iter().any(|p| p.id == "tmp")); } + + #[test] + fn memory_dir_suffix_auto_assigned_on_upsert() { + let dir = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(dir.path().to_path_buf()); + // First custom profile gets "-1" + let state = store + .upsert(AgentProfile { + id: "alice".into(), + name: "Alice".into(), + description: "First personality".into(), + agent_id: "orchestrator".into(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + }) + .expect("upsert alice"); + let alice = state.profiles.iter().find(|p| p.id == "alice").unwrap(); + assert_eq!(alice.memory_dir_suffix.as_deref(), Some("-1")); + + // Second custom profile gets "-2" + let state = store + .upsert(AgentProfile { + id: "bob".into(), + name: "Bob".into(), + description: "Second personality".into(), + agent_id: "orchestrator".into(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + }) + .expect("upsert bob"); + let bob = state.profiles.iter().find(|p| p.id == "bob").unwrap(); + assert_eq!(bob.memory_dir_suffix.as_deref(), Some("-2")); + + // Delete alice, create charlie — should reuse "-1" + store.delete("alice").expect("delete alice"); + let state = store + .upsert(AgentProfile { + id: "charlie".into(), + name: "Charlie".into(), + description: "Third personality".into(), + agent_id: "orchestrator".into(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + }) + .expect("upsert charlie"); + let charlie = state.profiles.iter().find(|p| p.id == "charlie").unwrap(); + assert_eq!(charlie.memory_dir_suffix.as_deref(), Some("-1")); + } + + #[test] + fn backwards_compat_deserialize_without_new_fields() { + let json = r#"{ + "activeProfileId": "default", + "profiles": [{ + "id": "default", + "name": "Default", + "description": "The standard OpenHuman orchestrator.", + "agentId": "orchestrator", + "builtIn": true + }] + }"#; + let state: AgentProfilesState = serde_json::from_str(json).expect("deserialize"); + let profile = &state.profiles[0]; + assert_eq!(profile.avatar_url, None); + assert_eq!(profile.voice_id, None); + assert_eq!(profile.memory_dir_suffix, None); + assert!(!profile.is_master); + } + + #[test] + fn default_profile_has_master_and_memory_suffix() { + let default = built_in_default_profile(); + assert!(default.is_master); + assert_eq!(default.memory_dir_suffix.as_deref(), Some("")); + } } diff --git a/src/openhuman/agent/prompts/mod.rs b/src/openhuman/agent/prompts/mod.rs index 0bf448ca3..640d2205d 100644 --- a/src/openhuman/agent/prompts/mod.rs +++ b/src/openhuman/agent/prompts/mod.rs @@ -422,6 +422,49 @@ pub struct UserIdentitySection; /// [`AgentDefinition::omit_profile`] / `omit_memory_md`. pub struct UserFilesSection; +/// Renders the personality roster for the master agent's system prompt. +/// +/// When [`PromptContext::personality_roster`] is non-empty, emits an +/// `## Available Personalities` section listing each non-self personality +/// with its `id`, `name`, `description`, and an optional truncated +/// `memory_summary`. Empty (and skipped) for non-master agents. +pub struct PersonalityRosterSection; + +impl PromptSection for PersonalityRosterSection { + fn name(&self) -> &str { + "personality_roster" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + if ctx.personality_roster.is_empty() { + return Ok(String::new()); + } + let mut out = String::from("## Available Personalities\n\n"); + out.push_str( + "You are the master agent. You can delegate tasks to these personality agents \ + using the `delegate_to_personality` tool. Each personality has its own memory, \ + identity, and expertise.\n\n", + ); + for entry in &ctx.personality_roster { + out.push_str(&format!( + "- **{}** (`{}`): {}", + entry.name, entry.id, entry.description + )); + if let Some(ref summary) = entry.memory_summary { + let truncated = if summary.chars().count() > 200 { + let head: String = summary.chars().take(200).collect(); + format!("{head}…") + } else { + summary.clone() + }; + out.push_str(&format!("\n Recent context: {truncated}")); + } + out.push('\n'); + } + Ok(out) + } +} + impl PromptSection for IdentitySection { fn name(&self) -> &str { "identity" @@ -448,9 +491,20 @@ impl PromptSection for IdentitySection { for file in all_files { // Always sync to disk so builtin updates ship. sync_workspace_file(ctx.workspace_dir, file); - if !skip_in_prompt.contains(file) { - inject_workspace_file(&mut prompt, ctx.workspace_dir, file); + if skip_in_prompt.contains(file) { + continue; } + if *file == "SOUL.md" { + if let Some(ref soul) = ctx.personality_soul_md { + tracing::debug!( + "[identity] personality SOUL.md override active ({} chars)", + soul.len() + ); + inject_inline_content(&mut prompt, "SOUL.md", soul, BOOTSTRAP_MAX_CHARS); + continue; + } + } + inject_workspace_file(&mut prompt, ctx.workspace_dir, file); } // PROFILE.md / MEMORY.md injection lives in the dedicated @@ -490,12 +544,16 @@ impl PromptSection for UserFilesSection { ); } if ctx.include_memory_md { - // Prefer the session-frozen curated-memory snapshot when the - // session has taken one — that's the runtime-writable store - // behind `curated_memory.add/replace/remove`. Fall back to - // the workspace file only when no snapshot is attached (pure - // prompt-unit tests and older call sites). - if let Some(snap) = &ctx.curated_snapshot { + // Personality-specific MEMORY.md takes highest priority, then + // the session-frozen curated-memory snapshot, then the + // workspace file (pure prompt-unit tests and older call sites). + if let Some(ref memory_md) = ctx.personality_memory_md { + tracing::debug!( + "[user_files] personality MEMORY.md override active ({} chars)", + memory_md.len() + ); + inject_inline_content(&mut out, "MEMORY.md", memory_md, USER_FILE_MAX_CHARS); + } else if let Some(snap) = &ctx.curated_snapshot { inject_snapshot_content(&mut out, "MEMORY.md", &snap.memory, USER_FILE_MAX_CHARS); inject_snapshot_content(&mut out, "USER.md", &snap.user, USER_FILE_MAX_CHARS); } else { @@ -928,6 +986,9 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } @@ -1279,9 +1340,40 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &s inject_workspace_file_capped(prompt, workspace_dir, filename, BOOTSTRAP_MAX_CHARS); } -/// Inject `content` into `prompt` under a header matching -/// [`inject_workspace_file_capped`]'s format — so a swap from the -/// file-based loader to a curated-memory snapshot is byte-compatible +/// Inject pre-loaded string content into `prompt` under a `### label` heading, +/// capped at `max_chars`. Mirrors the format of [`inject_snapshot_content`] +/// and [`inject_workspace_file_capped`] but takes a `&str` instead of a file +/// path. Used for personality-specific overrides (`personality_soul_md`, +/// `personality_memory_md`) on [`PromptContext`] so a swap from the file-based +/// loader to an inline override is byte-compatible with the workspace-file path. +/// +/// Empty/whitespace content is silently skipped. +fn inject_inline_content(prompt: &mut String, label: &str, content: &str, max_chars: usize) { + let trimmed = content.trim(); + if trimmed.is_empty() { + return; + } + let _ = writeln!(prompt, "### {label}\n"); + let truncated = if trimmed.chars().count() > max_chars { + trimmed + .char_indices() + .nth(max_chars) + .map(|(idx, _)| &trimmed[..idx]) + .unwrap_or(trimmed) + } else { + trimmed + }; + prompt.push_str(truncated); + if truncated.len() < trimmed.len() { + let _ = writeln!( + prompt, + "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" + ); + } else { + prompt.push_str("\n\n"); + } +} + /// for the output header and truncation semantics. /// /// Empty/whitespace content is silently skipped, mirroring the file diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index 6967b21cc..e2048ca18 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -50,6 +50,9 @@ fn prompt_builder_assembles_sections() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); assert!(rendered.contains("## Tools")); @@ -81,6 +84,9 @@ fn identity_section_creates_missing_workspace_files() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let section = IdentitySection; @@ -121,6 +127,9 @@ fn datetime_section_includes_timestamp_and_timezone() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = DateTimeSection.build(&ctx).unwrap(); @@ -163,6 +172,9 @@ fn ctx_with_identity(identity: Option) -> PromptContext<'static> { include_memory_md: false, curated_snapshot: None, user_identity: identity, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } @@ -302,6 +314,9 @@ fn tools_section_pformat_renders_signature_not_schema() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = ToolsSection.build(&ctx).unwrap(); @@ -342,6 +357,9 @@ fn tools_section_uses_pformat_signature_for_text_dispatchers() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = ToolsSection.build(&ctx).unwrap(); assert!( @@ -384,6 +402,9 @@ fn user_memory_section_renders_namespaces_with_headings() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.starts_with("## User Memory\n\n")); @@ -414,6 +435,9 @@ fn user_memory_section_returns_empty_when_no_summaries() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.is_empty()); @@ -1059,6 +1083,9 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { include_memory_md: true, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; // Test a narrow-agent runtime path: @@ -1102,6 +1129,9 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let narrow = builder.build(&ctx_narrow).unwrap(); assert!( @@ -1182,6 +1212,9 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.contains("### user")); @@ -1207,6 +1240,9 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], } } @@ -1343,6 +1379,9 @@ fn tools_section_empty_for_native() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let out = ToolsSection.build(&ctx).unwrap(); assert!( @@ -1373,6 +1412,9 @@ fn tools_section_nonempty_for_pformat() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let out = ToolsSection.build(&ctx).unwrap(); assert!( @@ -1405,6 +1447,9 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let out = ToolsSection.build(&ctx).unwrap(); assert!( diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs index c14ee025b..cf537f506 100644 --- a/src/openhuman/agent/prompts/types.rs +++ b/src/openhuman/agent/prompts/types.rs @@ -274,6 +274,15 @@ pub struct CuratedMemoryPromptSnapshot { // Prompt context (everything a section needs) // ───────────────────────────────────────────────────────────────────────────── +/// An entry in the master agent's personality roster prompt section. +#[derive(Debug, Clone, Default)] +pub struct PersonalityRosterEntry { + pub id: String, + pub name: String, + pub description: String, + pub memory_summary: Option, +} + pub struct PromptContext<'a> { pub workspace_dir: &'a Path, pub model_name: &'a str, @@ -311,6 +320,17 @@ pub struct PromptContext<'a> { /// session, tests). Pre-fetched by the caller from the /// `auth_get_me` cache so prompt builders never reach the network. pub user_identity: Option, + /// Personality-specific SOUL.md content. When `Some`, the + /// `IdentitySection` uses this instead of reading the workspace + /// root `SOUL.md`. `None` falls back to existing behavior. + pub personality_soul_md: Option, + /// Personality-specific MEMORY.md content. When `Some`, the + /// `UserFilesSection` uses this instead of reading the workspace + /// root `MEMORY.md`. `None` falls back to existing behavior. + pub personality_memory_md: Option, + /// Non-self personality roster entries for the master agent's prompt. + /// Empty for non-master agents. + pub personality_roster: Vec, } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs index d131f4953..24225808b 100644 --- a/src/openhuman/agent/triage/evaluator.rs +++ b/src/openhuman/agent/triage/evaluator.rs @@ -720,6 +720,9 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; match build(&ctx) { Ok(body) if !body.is_empty() => Some(body), diff --git a/src/openhuman/channels/providers/telegram/remote_control.rs b/src/openhuman/channels/providers/telegram/remote_control.rs index 86a9e4b74..93c35c232 100644 --- a/src/openhuman/channels/providers/telegram/remote_control.rs +++ b/src/openhuman/channels/providers/telegram/remote_control.rs @@ -234,6 +234,7 @@ async fn build_new_session_response(ctx: &ChannelRuntimeContext, msg: &ChannelMe created_at, parent_thread_id: None, labels: Some(vec!["telegram".to_string(), "remote".to_string()]), + personality_id: None, }, ) { tracing::warn!("{LOG_PREFIX} new: ensure_thread failed: {error}"); diff --git a/src/openhuman/learning/prompt_sections.rs b/src/openhuman/learning/prompt_sections.rs index 0bbb0026a..f6858f2af 100644 --- a/src/openhuman/learning/prompt_sections.rs +++ b/src/openhuman/learning/prompt_sections.rs @@ -306,6 +306,9 @@ mod tests { include_profile: false, include_memory_md: false, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], curated_snapshot: None, } } diff --git a/src/openhuman/memory/rpc_models.rs b/src/openhuman/memory/rpc_models.rs index 833ced63b..b5f2981a3 100644 --- a/src/openhuman/memory/rpc_models.rs +++ b/src/openhuman/memory/rpc_models.rs @@ -73,6 +73,8 @@ pub struct EmptyRequest {} pub struct CreateConversationThreadRequest { #[serde(default)] pub labels: Option>, + #[serde(default)] + pub personality_id: Option, } /// Request payload for `openhuman.memory_init`. @@ -114,6 +116,8 @@ pub struct ConversationThreadSummary { pub parent_thread_id: Option, #[serde(default)] pub labels: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub personality_id: Option, } /// A single persisted conversation message. @@ -141,6 +145,8 @@ pub struct UpsertConversationThreadRequest { pub parent_thread_id: Option, #[serde(default)] pub labels: Option>, + #[serde(default)] + pub personality_id: Option, } /// Request to update labels for a conversation thread. diff --git a/src/openhuman/memory_conversations/bus.rs b/src/openhuman/memory_conversations/bus.rs index 4e671acd3..5d91aa232 100644 --- a/src/openhuman/memory_conversations/bus.rs +++ b/src/openhuman/memory_conversations/bus.rs @@ -234,6 +234,7 @@ fn persist_channel_turn( created_at: created_at.clone(), parent_thread_id: None, labels: Some(vec!["work".to_string()]), + personality_id: None, }, )?; diff --git a/src/openhuman/memory_conversations/store.rs b/src/openhuman/memory_conversations/store.rs index f2bd5e293..078558379 100644 --- a/src/openhuman/memory_conversations/store.rs +++ b/src/openhuman/memory_conversations/store.rs @@ -91,6 +91,8 @@ enum ThreadLogEntry { parent_thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] labels: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + personality_id: Option, }, Delete { thread_id: String, @@ -137,6 +139,7 @@ impl ConversationStore { updated_at: now, parent_thread_id: request.parent_thread_id.clone(), labels: request.labels.clone(), + personality_id: request.personality_id.clone(), }, )?; debug!( @@ -333,6 +336,7 @@ impl ConversationStore { updated_at: updated_at.to_string(), parent_thread_id: entry.parent_thread_id.clone(), labels: Some(entry.labels.clone()), + personality_id: entry.personality_id.clone(), }, )?; debug!( @@ -367,6 +371,7 @@ impl ConversationStore { updated_at: updated_at.to_string(), parent_thread_id: entry.parent_thread_id.clone(), labels: Some(labels), + personality_id: entry.personality_id.clone(), }, )?; debug!( @@ -568,6 +573,7 @@ impl ConversationStore { created_at: entry.created_at.clone(), parent_thread_id: entry.parent_thread_id.clone(), labels: entry.labels.clone(), + personality_id: entry.personality_id.clone(), } }) .collect(); @@ -627,6 +633,7 @@ impl ConversationStore { created_at: entry.created_at.clone(), parent_thread_id: entry.parent_thread_id.clone(), labels: entry.labels.clone(), + personality_id: entry.personality_id.clone(), })) } @@ -646,6 +653,7 @@ impl ConversationStore { created_at, parent_thread_id, labels, + personality_id, .. } => { let ( @@ -654,6 +662,7 @@ impl ConversationStore { labels_value, message_count_value, last_message_at_value, + personality_id_value, ) = match index.get(&thread_id) { Some(existing) => ( existing.created_at.clone(), @@ -661,10 +670,18 @@ impl ConversationStore { labels.unwrap_or_else(|| existing.labels.clone()), existing.message_count, existing.last_message_at.clone(), + personality_id.or_else(|| existing.personality_id.clone()), ), None => { let inferred = labels.unwrap_or_else(|| infer_labels(&thread_id)); - (created_at, parent_thread_id, inferred, None, None) + ( + created_at, + parent_thread_id, + inferred, + None, + None, + personality_id, + ) } }; index.insert( @@ -676,6 +693,7 @@ impl ConversationStore { labels: labels_value, message_count: message_count_value, last_message_at: last_message_at_value, + personality_id: personality_id_value, }, ); } @@ -736,6 +754,7 @@ struct ThreadIndexEntry { message_count: Option, /// Timestamp of the newest message, or `None` if unknown (legacy). last_message_at: Option, + personality_id: Option, } fn infer_labels(thread_id: &str) -> Vec { diff --git a/src/openhuman/memory_conversations/store_tests.rs b/src/openhuman/memory_conversations/store_tests.rs index efbf4b28e..de92986f5 100644 --- a/src/openhuman/memory_conversations/store_tests.rs +++ b/src/openhuman/memory_conversations/store_tests.rs @@ -23,6 +23,7 @@ fn store_roundtrips_threads_and_messages() { title: "Conversation".to_string(), created_at: created_at.clone(), labels: None, + personality_id: None, }) .expect("ensure thread"); assert_eq!(thread.message_count, 0); @@ -61,6 +62,7 @@ fn get_messages_for_new_empty_thread_returns_empty_list() { title: "Conversation".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .expect("ensure thread"); @@ -78,6 +80,7 @@ fn store_updates_message_metadata() { title: "Conversation".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .expect("ensure thread"); store @@ -119,6 +122,7 @@ fn purge_removes_threads_and_messages() { title: "Conversation".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .expect("ensure thread"); store @@ -150,6 +154,7 @@ fn ensure_thread_is_idempotent() { title: "Thread".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }; store.ensure_thread(req.clone()).unwrap(); store.ensure_thread(req).unwrap(); @@ -167,6 +172,7 @@ fn delete_thread_removes_thread_and_messages() { title: "Thread".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -206,6 +212,7 @@ fn get_messages_empty_thread() { title: "Empty".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); let messages = store.get_messages("t1").unwrap(); @@ -230,6 +237,7 @@ fn multiple_threads_and_messages() { title: format!("Thread {i}"), created_at: format!("2026-04-10T12:0{i}:00Z"), labels: None, + personality_id: None, }) .unwrap(); store @@ -268,6 +276,7 @@ fn update_message_nonexistent_returns_error() { title: "Thread".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); let result = store.update_message( @@ -290,6 +299,7 @@ fn update_thread_title_persists_latest_title() { title: "Chat Apr 10 12:00 PM".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); @@ -315,6 +325,7 @@ fn store_handles_labels_and_inference() { title: "Thread 1".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: Some(vec!["custom".to_string()]), + personality_id: None, }) .unwrap(); @@ -326,6 +337,7 @@ fn store_handles_labels_and_inference() { title: "Morning Briefing".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); @@ -337,6 +349,7 @@ fn store_handles_labels_and_inference() { title: "System Notification".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); @@ -348,6 +361,7 @@ fn store_handles_labels_and_inference() { title: "User Chat".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); @@ -422,6 +436,7 @@ fn list_threads_does_not_read_per_thread_files_after_first_call() { title: "T1".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); for i in 0..3 { @@ -555,6 +570,7 @@ fn delete_thread_clears_stats_from_index() { title: "Doomed".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -590,6 +606,7 @@ fn search_cross_thread_messages_finds_hits_outside_excluded_thread() { title: "Chat A".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -616,6 +633,7 @@ fn search_cross_thread_messages_finds_hits_outside_excluded_thread() { title: "Chat B".to_string(), created_at: "2026-04-10T13:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -656,6 +674,7 @@ fn search_cross_thread_messages_excludes_active_thread() { title: "Only".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -697,6 +716,7 @@ fn search_cross_thread_messages_skips_short_terms_and_empty_queries() { title: "T".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -735,6 +755,7 @@ fn search_cross_thread_messages_finds_polish_substring_without_diacritics() { title: "PL".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -773,6 +794,7 @@ fn search_cross_thread_messages_finds_japanese_bigram_match() { title: "JP".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -812,6 +834,7 @@ fn search_cross_thread_messages_rebuilds_index_from_jsonl_after_reopen() { title: "X".to_string(), created_at: "2026-04-10T12:00:00Z".to_string(), labels: None, + personality_id: None, }) .unwrap(); store @@ -860,6 +883,7 @@ fn cold_search_does_not_serialize_on_outer_lock() { created_at: "2026-01-01T00:00:00Z".to_string(), parent_thread_id: None, labels: None, + personality_id: None, }) .unwrap(); store diff --git a/src/openhuman/memory_conversations/types.rs b/src/openhuman/memory_conversations/types.rs index 4593c4bf1..c7dd11f5d 100644 --- a/src/openhuman/memory_conversations/types.rs +++ b/src/openhuman/memory_conversations/types.rs @@ -20,6 +20,8 @@ pub struct ConversationThread { pub parent_thread_id: Option, #[serde(default)] pub labels: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub personality_id: Option, } /// A single message appended to a thread's JSONL log. @@ -47,6 +49,8 @@ pub struct CreateConversationThread { pub parent_thread_id: Option, #[serde(default)] pub labels: Option>, + #[serde(default)] + pub personality_id: Option, } /// Partial update to apply to a stored message (e.g. rewriting `extraMetadata`). @@ -122,6 +126,7 @@ mod tests { created_at: "2026-05-24T08:00:00Z".into(), parent_thread_id: None, labels: Some(vec!["important".into(), "memory".into()]), + personality_id: None, }; let encoded = serde_json::to_value(&create).unwrap(); diff --git a/src/openhuman/memory_store/unified/helpers.rs b/src/openhuman/memory_store/unified/helpers.rs index baeffe65f..c59d7ee67 100644 --- a/src/openhuman/memory_store/unified/helpers.rs +++ b/src/openhuman/memory_store/unified/helpers.rs @@ -22,8 +22,13 @@ impl UnifiedMemory { ) -> anyhow::Result { let docs_dir = self.namespace_dir(namespace).join("docs"); tokio::fs::create_dir_all(&docs_dir).await?; + let memory_subdir = self + .memory_dir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("memory"); let rel_path = format!( - "memory/namespaces/{}/docs/{doc_id}.md", + "{memory_subdir}/namespaces/{}/docs/{doc_id}.md", Self::sanitize_namespace(namespace) ); let abs_path = self.workspace_dir.join(&rel_path); diff --git a/src/openhuman/memory_store/unified/init.rs b/src/openhuman/memory_store/unified/init.rs index 7c86e991c..51b2d4d99 100644 --- a/src/openhuman/memory_store/unified/init.rs +++ b/src/openhuman/memory_store/unified/init.rs @@ -21,15 +21,44 @@ use super::UnifiedMemory; impl UnifiedMemory { /// Open (or create) the unified store rooted at `workspace_dir`. /// - /// Creates the on-disk layout, runs all `CREATE TABLE` statements, and - /// applies idempotent legacy-namespace migrations. Safe to call on every - /// boot. + /// Delegates to [`Self::new_with_memory_dir`] using the default + /// `"memory"` subdirectory name. Safe to call on every boot. pub fn new( workspace_dir: &Path, embedder: Arc, _open_timeout_secs: Option, ) -> anyhow::Result { - let memory_dir = workspace_dir.join("memory"); + Self::new_with_memory_dir(workspace_dir, "memory", embedder, _open_timeout_secs) + } + + /// Open (or create) a unified store using an explicit memory subdirectory + /// name under `workspace_dir`. + /// + /// This enables multiple independent stores inside the same workspace (e.g. + /// per-personality databases) without path collisions. `memory_subdir` is + /// joined directly to `workspace_dir`, so `"memory-1"` yields + /// `workspace_dir/memory-1/memory.db`. + /// + /// Creates the on-disk layout, runs all `CREATE TABLE` statements, and + /// applies idempotent legacy-namespace migrations. Safe to call on every + /// boot. + pub fn new_with_memory_dir( + workspace_dir: &Path, + memory_subdir: &str, + embedder: Arc, + _open_timeout_secs: Option, + ) -> anyhow::Result { + use std::path::Component; + anyhow::ensure!(!memory_subdir.is_empty(), "memory_subdir must not be empty"); + let subdir_path = Path::new(memory_subdir); + anyhow::ensure!( + subdir_path.components().count() == 1 + && subdir_path + .components() + .all(|c| matches!(c, Component::Normal(_))), + "memory_subdir must be a single relative path component without traversal" + ); + let memory_dir = workspace_dir.join(subdir_path); let namespaces_dir = memory_dir.join("namespaces"); let vectors_dir = memory_dir.join("vectors"); std::fs::create_dir_all(&namespaces_dir)?; @@ -262,6 +291,7 @@ impl UnifiedMemory { Ok(Self { workspace_dir: workspace_dir.to_path_buf(), + memory_dir, db_path, vectors_dir, conn: Arc::new(Mutex::new(conn)), @@ -309,9 +339,15 @@ impl UnifiedMemory { .collect() } + /// Resolved memory subdirectory for this store instance (e.g. + /// `workspace_dir/memory` for the default store, or a custom subdir for + /// personality-specific stores). + pub fn memory_dir(&self) -> &Path { + &self.memory_dir + } + pub(crate) fn namespace_dir(&self, namespace: &str) -> PathBuf { - self.workspace_dir - .join("memory") + self.memory_dir .join("namespaces") .join(Self::sanitize_namespace(namespace)) } @@ -348,6 +384,32 @@ mod tests { ); } + #[test] + fn new_with_memory_dir_creates_separate_db() { + let tmp = TempDir::new().unwrap(); + let mem1 = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + let mem2 = UnifiedMemory::new_with_memory_dir( + tmp.path(), + "memory-1", + Arc::new(NoopEmbedding), + None, + ) + .unwrap(); + assert_ne!(mem1.db_path(), mem2.db_path()); + assert!( + mem1.db_path().ends_with("memory/memory.db"), + "expected mem1 db under memory/memory.db, got {:?}", + mem1.db_path() + ); + assert!( + mem2.db_path().ends_with("memory-1/memory.db"), + "expected mem2 db under memory-1/memory.db, got {:?}", + mem2.db_path() + ); + assert!(mem1.db_path().exists(), "mem1 db file must exist on disk"); + assert!(mem2.db_path().exists(), "mem2 db file must exist on disk"); + } + #[test] fn connection_has_busy_timeout_set() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/memory_store/unified/mod.rs b/src/openhuman/memory_store/unified/mod.rs index aa64afe66..1dae348df 100644 --- a/src/openhuman/memory_store/unified/mod.rs +++ b/src/openhuman/memory_store/unified/mod.rs @@ -14,6 +14,11 @@ use crate::openhuman::embeddings::EmbeddingProvider; /// modules (`documents`, `kv`, `graph`, `query`, …) via `impl` blocks. pub struct UnifiedMemory { pub(crate) workspace_dir: PathBuf, + /// Resolved memory subdirectory (e.g. `workspace_dir/memory` or a custom + /// per-personality path). Drives `db_path`, `vectors_dir`, and + /// `namespace_dir()` so that multiple stores rooted in the same workspace + /// don't collide. + pub(crate) memory_dir: PathBuf, pub(crate) db_path: PathBuf, pub(crate) vectors_dir: PathBuf, pub(crate) conn: Arc>, diff --git a/src/openhuman/memory_tools/prompt.rs b/src/openhuman/memory_tools/prompt.rs index 029dd9c71..1709433fb 100644 --- a/src/openhuman/memory_tools/prompt.rs +++ b/src/openhuman/memory_tools/prompt.rs @@ -243,6 +243,9 @@ mod tests { include_memory_md: false, curated_snapshot: None, user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], }; let built = section.build(&ctx).unwrap(); assert!(built.contains("never email Sarah")); diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index 6dc1f3e3b..7ae3deddf 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -573,6 +573,7 @@ fn handle_reflections_act(params: Map) -> ControllerFuture { created_at: now_iso.clone(), parent_thread_id: None, labels: Some(vec!["from_reflection".to_string()]), + personality_id: None, }, ) .map_err(|e| format!("ensure_thread (reflection-spawned) failed: {e}"))?; diff --git a/src/openhuman/threads/ops.rs b/src/openhuman/threads/ops.rs index 872f4cc70..3c149ba6a 100644 --- a/src/openhuman/threads/ops.rs +++ b/src/openhuman/threads/ops.rs @@ -81,6 +81,7 @@ fn thread_to_summary(thread: ConversationThread) -> ConversationThreadSummary { created_at: thread.created_at, parent_thread_id: thread.parent_thread_id, labels: thread.labels, + personality_id: thread.personality_id, } } @@ -168,6 +169,7 @@ pub async fn thread_upsert( created_at: request.created_at, parent_thread_id: request.parent_thread_id, labels: request.labels, + personality_id: request.personality_id, }, )?; Ok(envelope( @@ -197,6 +199,7 @@ pub async fn thread_create_new( // the same default on index rebuild, so this is the single source // of truth for default labels. labels: request.labels, + personality_id: request.personality_id, }, )?; tracing::debug!( diff --git a/src/openhuman/threads/ops_tests.rs b/src/openhuman/threads/ops_tests.rs index 0dd25878c..503372818 100644 --- a/src/openhuman/threads/ops_tests.rs +++ b/src/openhuman/threads/ops_tests.rs @@ -343,6 +343,7 @@ fn sample_thread() -> ConversationThread { created_at: "2026-01-01T00:00:00Z".into(), parent_thread_id: None, labels: vec!["work".to_string()], + personality_id: None, } } @@ -493,6 +494,7 @@ async fn create_thread_with_title(_workspace: &tempfile::TempDir, thread_id: &st created_at: "2026-01-01T00:00:00Z".to_string(), parent_thread_id: None, labels: None, + personality_id: None, }, ) .expect("ensure thread"); diff --git a/src/openhuman/threads/welcome_migration.rs b/src/openhuman/threads/welcome_migration.rs index 7ccb563d1..b1d33987e 100644 --- a/src/openhuman/threads/welcome_migration.rs +++ b/src/openhuman/threads/welcome_migration.rs @@ -410,6 +410,7 @@ mod tests { created_at: "2024-01-01T00:00:00Z".to_string(), parent_thread_id: None, labels: Some(labels), + personality_id: None, } } diff --git a/src/openhuman/tools/impl/agent/delegate_to_personality.rs b/src/openhuman/tools/impl/agent/delegate_to_personality.rs new file mode 100644 index 000000000..39328b779 --- /dev/null +++ b/src/openhuman/tools/impl/agent/delegate_to_personality.rs @@ -0,0 +1,246 @@ +//! Tool: `delegate_to_personality` — delegate a task to a personality-specific agent. + +use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; +use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; +use crate::openhuman::agent::personality_paths::PersonalityContext; +use crate::openhuman::agent::profiles::AgentProfileStore; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +pub struct DelegateToPersonalityTool; + +impl Default for DelegateToPersonalityTool { + fn default() -> Self { + Self::new() + } +} + +impl DelegateToPersonalityTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for DelegateToPersonalityTool { + fn name(&self) -> &str { + "delegate_to_personality" + } + + fn description(&self) -> &str { + "Delegate a task to another personality agent. Each personality has its own \ + memory, identity (SOUL.md), and integration access. Use when the task aligns \ + with a specific personality's expertise or context. The personality roster \ + in the system prompt lists available personalities." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["personality_id", "prompt"], + "properties": { + "personality_id": { + "type": "string", + "description": "The profile id of the target personality (from the personality roster)." + }, + "prompt": { + "type": "string", + "description": "Clear, specific instruction for the personality agent. Include all context needed — the personality has its own memory but no awareness of the current conversation." + }, + "context": { + "type": "string", + "description": "Optional context blob from prior task results." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let personality_id = args + .get("personality_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let context = args + .get("context") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if personality_id.is_empty() { + return Ok(ToolResult::error( + "delegate_to_personality: `personality_id` is required.", + )); + } + if prompt.is_empty() { + return Ok(ToolResult::error( + "delegate_to_personality: `prompt` is required.", + )); + } + + let parent_ctx = match current_parent() { + Some(ctx) => ctx, + None => { + return Ok(ToolResult::error( + "delegate_to_personality: no parent execution context available.", + )); + } + }; + + tracing::debug!( + personality_id = %personality_id, + "[delegate_to_personality] resolving personality profile" + ); + + let store = AgentProfileStore::new(parent_ctx.workspace_dir.clone()); + + // Guard: only the master personality may delegate. This prevents a + // delegated sub-agent from recursing back through this tool — there is + // no built-in depth counter on cross-personality delegation, so we gate + // at the entry point. + let (state, active_profile) = match store.resolve(None) { + Ok(result) => result, + Err(e) => { + return Ok(ToolResult::error(format!( + "delegate_to_personality: failed to resolve active profile: {e}" + ))); + } + }; + if !active_profile.is_master { + tracing::debug!( + active_profile_id = %active_profile.id, + "[delegate_to_personality] rejected — caller is not the master personality" + ); + return Ok(ToolResult::error(format!( + "delegate_to_personality: only the master personality may delegate \ + (active='{}', is_master=false).", + active_profile.id + ))); + } + + // Reuse the state already loaded above — no second store.resolve() call needed. + let profile = match state.profiles.iter().find(|p| p.id == personality_id) { + Some(p) => p.clone(), + None => { + tracing::debug!( + personality_id = %personality_id, + "[delegate_to_personality] profile resolve failed" + ); + return Ok(ToolResult::error(format!( + "delegate_to_personality: personality '{personality_id}' not found." + ))); + } + }; + + let personality_ctx = + PersonalityContext::from_profile(&parent_ctx.workspace_dir, profile.clone()); + + tracing::debug!( + personality_id = %personality_id, + agent_id = %profile.agent_id, + memory_suffix = %personality_ctx.memory_suffix, + "[delegate_to_personality] personality resolved, delegating" + ); + + // TODO(phase-2): Memory isolation not yet enforced during delegation. + // The personality gets its own SQLite DB (plumbing exists in + // UnifiedMemory::new_with_memory_dir), but run_subagent currently + // receives the parent's memory instance. To fix: construct a new + // UnifiedMemory using personality_ctx.memory_suffix and pass it through + // SubagentRunOptions (needs an additional field there). + // Also: composio_integrations allowlist is not yet filtered during + // delegation — personality_ctx.composio_allowlist exists but is not + // applied to the SubagentRunOptions toolkit_override. + // + // Until these are wired, the sub-agent gets the personality's + // voice/identity at the prompt level but still writes to the parent's + // memory store and has access to all parent integrations. + let mut personality_preamble = format!( + "You are acting as the personality `{}` (\"{}\"). {}", + profile.id, profile.name, profile.description + ); + if let Some(ref soul) = personality_ctx.soul_md_override { + let soul_truncated: String = soul.chars().take(800).collect(); + personality_preamble.push_str("\n\n[Personality SOUL.md]\n"); + personality_preamble.push_str(&soul_truncated); + } + if let Some(ref mem) = personality_ctx.memory_md_override { + let mem_truncated: String = mem.chars().take(800).collect(); + personality_preamble.push_str("\n\n[Personality MEMORY.md]\n"); + personality_preamble.push_str(&mem_truncated); + } + let combined_context = match context.as_deref() { + Some(ctx_str) => format!("{personality_preamble}\n\n[Caller Context]\n{ctx_str}"), + None => personality_preamble, + }; + + // Look up the agent definition for this personality's agent_id + let registry = match AgentDefinitionRegistry::global() { + Some(reg) => reg, + None => { + return Ok(ToolResult::error( + "delegate_to_personality: agent definition registry not initialized.", + )); + } + }; + + let definition = match registry.get(&profile.agent_id) { + Some(def) => def, + None => { + return Ok(ToolResult::error(format!( + "delegate_to_personality: agent definition '{}' not found in registry.", + profile.agent_id + ))); + } + }; + + let options = SubagentRunOptions { + context: Some(combined_context), + model_override: profile.model_override.clone(), + toolkit_override: None, + skill_filter_override: None, + task_id: None, + worker_thread_id: None, + }; + + match run_subagent(&definition, &prompt, options).await { + Ok(outcome) => { + tracing::debug!( + personality_id = %personality_id, + iterations = outcome.iterations, + elapsed_ms = outcome.elapsed.as_millis(), + "[delegate_to_personality] delegation completed" + ); + Ok(ToolResult::success(format!( + "[Personality: {}]\n{}", + profile.name, outcome.output + ))) + } + Err(e) => { + tracing::debug!( + personality_id = %personality_id, + error = %e, + "[delegate_to_personality] delegation failed" + ); + Ok(ToolResult::error(format!( + "delegate_to_personality failed for '{}': {e}", + personality_id + ))) + } + } + } +} diff --git a/src/openhuman/tools/impl/agent/mod.rs b/src/openhuman/tools/impl/agent/mod.rs index a516acb40..d08c90c38 100644 --- a/src/openhuman/tools/impl/agent/mod.rs +++ b/src/openhuman/tools/impl/agent/mod.rs @@ -1,6 +1,7 @@ mod archetype_delegation; mod ask_clarification; mod delegate; +mod delegate_to_personality; mod dispatch; mod plan_exit; pub mod remember_preference; @@ -17,6 +18,7 @@ pub(crate) use dispatch::dispatch_subagent; pub use archetype_delegation::ArchetypeDelegationTool; pub use ask_clarification::AskClarificationTool; pub use delegate::DelegateTool; +pub use delegate_to_personality::DelegateToPersonalityTool; pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER}; pub use remember_preference::RememberPreferenceTool; pub use run_skill::{RunSkillTool, RUN_SKILL_TOOL_NAME}; diff --git a/src/openhuman/tools/impl/agent/spawn_subagent.rs b/src/openhuman/tools/impl/agent/spawn_subagent.rs index 42408b781..4b0e45485 100644 --- a/src/openhuman/tools/impl/agent/spawn_subagent.rs +++ b/src/openhuman/tools/impl/agent/spawn_subagent.rs @@ -570,6 +570,7 @@ fn persist_worker_thread( created_at: now.clone(), parent_thread_id: None, labels: Some(vec!["worker".to_string()]), + personality_id: None, }, ) .map_err(|err| format!("ensure_thread: {err}"))?; diff --git a/src/openhuman/tools/impl/agent/spawn_worker_thread.rs b/src/openhuman/tools/impl/agent/spawn_worker_thread.rs index 3e691fab2..4f04bfb67 100644 --- a/src/openhuman/tools/impl/agent/spawn_worker_thread.rs +++ b/src/openhuman/tools/impl/agent/spawn_worker_thread.rs @@ -185,6 +185,7 @@ impl Tool for SpawnWorkerThreadTool { created_at: now.clone(), parent_thread_id: Some(current_thread_id.clone()), labels: Some(vec!["worker".to_string()]), + personality_id: None, }, ) .map_err(|e| anyhow::anyhow!(e))?; @@ -407,6 +408,7 @@ mod tests { created_at: "now".into(), parent_thread_id: None, labels: Some(vec!["worker".to_string()]), + personality_id: None, }, ) .unwrap(); @@ -449,6 +451,7 @@ mod tests { created_at: "now".into(), parent_thread_id: Some("parent".into()), labels: None, + personality_id: None, }, ) .unwrap(); diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 4800cd334..2f7c593de 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -150,6 +150,7 @@ pub fn all_tools_with_runtime( // `agent::harness::subagent_runner` for the dispatch path. Box::new(SpawnSubagentTool::new()), Box::new(SpawnParallelAgentsTool::new()), + Box::new(DelegateToPersonalityTool::new()), // Coding-harness control flow (issue #1205): a process-global // todo registry the agent can rewrite end-to-end, plus the // `plan_exit` marker that hands a plan-mode pass off to a diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs new file mode 100644 index 000000000..737e3d778 --- /dev/null +++ b/tests/personality_e2e.rs @@ -0,0 +1,727 @@ +//! E2E integration tests for multi-agent personalities. +//! +//! Covers the full personality feature surface end-to-end without requiring +//! the JSON-RPC HTTP stack: +//! +//! 1. Profile lifecycle — backwards compat, suffix auto-assignment, field roundtrip. +//! 2. Memory isolation — two personalities have separate SQLite stores. +//! 3. Personality file resolution — soul/memory.md fallback chain. +//! 4. Prompt section behaviour — IdentitySection / UserFilesSection / PersonalityRosterSection. +//! 5. Integration filtering — allowlist / passthrough / empty. +//! 6. Thread-personality binding — persists through ConversationStore. +//! +//! Run with: `cargo test --test personality_e2e` + +use std::collections::HashSet; +use std::sync::Arc; + +use serde_json::json; +use tempfile::tempdir; + +use openhuman_core::openhuman::agent::personality_paths::{ + filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, + resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, + HasToolkit, PersonalityContext, +}; +use openhuman_core::openhuman::agent::profiles::{ + built_in_profiles, AgentProfile, AgentProfileStore, DEFAULT_PROFILE_ID, +}; +use openhuman_core::openhuman::agent::prompts::types::LearnedContextData; +use openhuman_core::openhuman::agent::prompts::{ + IdentitySection, PersonalityRosterEntry, PersonalityRosterSection, PromptContext, + PromptSection, ToolCallFormat, UserFilesSection, +}; +use openhuman_core::openhuman::embeddings::NoopEmbedding; +use openhuman_core::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory}; +use openhuman_core::openhuman::memory_conversations::{ + ensure_thread, list_threads, update_thread_title, ConversationStore, CreateConversationThread, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn make_profile(id: &str, name: &str) -> AgentProfile { + AgentProfile { + id: id.to_string(), + name: name.to_string(), + description: format!("{name} personality"), + agent_id: "orchestrator".to_string(), + model_override: None, + temperature: None, + system_prompt_suffix: None, + allowed_tools: None, + built_in: false, + avatar_url: None, + voice_id: None, + soul_md: None, + soul_md_path: None, + composio_integrations: None, + memory_dir_suffix: None, + is_master: false, + sort_order: None, + } +} + +fn empty_prompt_context<'a>(workspace_dir: &'a std::path::Path) -> PromptContext<'a> { + let visible_tool_names: &'a HashSet = Box::leak(Box::new(HashSet::new())); + PromptContext { + workspace_dir, + model_name: "test-model", + agent_id: "orchestrator", + tools: &[], + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + curated_snapshot: None, + user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Profile lifecycle +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn backwards_compat_old_profiles_json_deserializes_cleanly() { + let tmp = tempdir().expect("tempdir"); + let json = r#"{ + "activeProfileId": "default", + "profiles": [{ + "id": "default", + "name": "Default", + "description": "Old profile pre-personalities.", + "agentId": "orchestrator", + "builtIn": true + }, { + "id": "research", + "name": "Research", + "description": "", + "agentId": "researcher", + "builtIn": true + }] + }"#; + std::fs::write(tmp.path().join("agent_profiles.json"), json).expect("write"); + + let store = AgentProfileStore::new(tmp.path().to_path_buf()); + let state = store.load().expect("load"); + + let default = state + .profiles + .iter() + .find(|p| p.id == DEFAULT_PROFILE_ID) + .expect("default"); + assert!(default.is_master, "default should be master on first read"); + assert_eq!(default.memory_dir_suffix.as_deref(), Some("")); + assert_eq!(default.avatar_url, None); +} + +#[test] +fn three_personalities_get_sequential_memory_suffixes() { + let tmp = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(tmp.path().to_path_buf()); + + let state = store.upsert(make_profile("alice", "Alice")).expect("alice"); + let alice = state.profiles.iter().find(|p| p.id == "alice").unwrap(); + assert_eq!(alice.memory_dir_suffix.as_deref(), Some("-1")); + + let state = store.upsert(make_profile("bob", "Bob")).expect("bob"); + let bob = state.profiles.iter().find(|p| p.id == "bob").unwrap(); + assert_eq!(bob.memory_dir_suffix.as_deref(), Some("-2")); + + let state = store.upsert(make_profile("carol", "Carol")).expect("carol"); + let carol = state.profiles.iter().find(|p| p.id == "carol").unwrap(); + assert_eq!(carol.memory_dir_suffix.as_deref(), Some("-3")); +} + +#[test] +fn deleted_suffix_is_reused_by_next_personality() { + let tmp = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(tmp.path().to_path_buf()); + + store.upsert(make_profile("alice", "Alice")).expect("alice"); + store.upsert(make_profile("bob", "Bob")).expect("bob"); + store.upsert(make_profile("carol", "Carol")).expect("carol"); + + store.delete("bob").expect("delete bob"); + + let state = store.upsert(make_profile("dave", "Dave")).expect("dave"); + let dave = state.profiles.iter().find(|p| p.id == "dave").unwrap(); + assert_eq!( + dave.memory_dir_suffix.as_deref(), + Some("-2"), + "freed suffix -2 should be reused" + ); +} + +#[test] +fn personality_fields_roundtrip_through_upsert() { + let tmp = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(tmp.path().to_path_buf()); + + let mut alice = make_profile("alice", "Alice"); + alice.avatar_url = Some("https://example.com/alice.png".to_string()); + alice.voice_id = Some("voice-alice-123".to_string()); + alice.soul_md = Some("I am Alice.".to_string()); + alice.composio_integrations = Some(vec!["slack".to_string(), "gmail".to_string()]); + alice.sort_order = Some(5); + + store.upsert(alice).expect("upsert"); + + let state = store.load().expect("reload"); + let alice = state.profiles.iter().find(|p| p.id == "alice").unwrap(); + assert_eq!( + alice.avatar_url.as_deref(), + Some("https://example.com/alice.png") + ); + assert_eq!(alice.voice_id.as_deref(), Some("voice-alice-123")); + assert_eq!(alice.soul_md.as_deref(), Some("I am Alice.")); + assert_eq!( + alice.composio_integrations.as_ref().unwrap(), + &["slack", "gmail"] + ); + assert_eq!(alice.sort_order, Some(5)); +} + +#[test] +fn default_profile_memory_suffix_cannot_be_overridden() { + let tmp = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(tmp.path().to_path_buf()); + + let mut default_override = built_in_profiles() + .into_iter() + .find(|p| p.id == DEFAULT_PROFILE_ID) + .expect("default profile in built-ins"); + default_override.memory_dir_suffix = Some("-99".to_string()); + default_override.avatar_url = Some("https://example.com/default.png".to_string()); + + let state = store.upsert(default_override).expect("upsert"); + let default = state + .profiles + .iter() + .find(|p| p.id == DEFAULT_PROFILE_ID) + .unwrap(); + assert_eq!( + default.memory_dir_suffix.as_deref(), + Some(""), + "default suffix must stay empty regardless of user input" + ); + assert_eq!( + default.avatar_url.as_deref(), + Some("https://example.com/default.png") + ); + assert!(default.is_master); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Memory isolation +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn two_personalities_have_isolated_sqlite_stores() { + let tmp = tempdir().expect("tempdir"); + + let mem_default = UnifiedMemory::new_with_memory_dir( + tmp.path(), + &memory_subdir_for_suffix(""), + Arc::new(NoopEmbedding), + None, + ) + .expect("default memory"); + let mem_alice = UnifiedMemory::new_with_memory_dir( + tmp.path(), + &memory_subdir_for_suffix("-1"), + Arc::new(NoopEmbedding), + None, + ) + .expect("alice memory"); + + assert_ne!(mem_default.db_path(), mem_alice.db_path()); + assert!(mem_default.db_path().ends_with("memory/memory.db")); + assert!(mem_alice.db_path().ends_with("memory-1/memory.db")); + assert!(mem_default.db_path().exists()); + assert!(mem_alice.db_path().exists()); + + mem_default + .upsert_document(NamespaceDocumentInput { + namespace: "shared".to_string(), + key: "default-only".to_string(), + title: "Default's note".to_string(), + content: "Only the default agent knows about this.".to_string(), + source_type: "doc".to_string(), + priority: "high".to_string(), + tags: vec![], + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + }) + .await + .expect("write default"); + + mem_alice + .upsert_document(NamespaceDocumentInput { + namespace: "shared".to_string(), + key: "alice-only".to_string(), + title: "Alice's note".to_string(), + content: "Only Alice knows about this.".to_string(), + source_type: "doc".to_string(), + priority: "high".to_string(), + tags: vec![], + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + }) + .await + .expect("write alice"); + + let default_hits = mem_default + .query_namespace_ranked("shared", "note", 10) + .await + .expect("default query"); + let alice_hits = mem_alice + .query_namespace_ranked("shared", "note", 10) + .await + .expect("alice query"); + + let default_keys: Vec<_> = default_hits.iter().map(|h| h.key.as_str()).collect(); + let alice_keys: Vec<_> = alice_hits.iter().map(|h| h.key.as_str()).collect(); + + assert!( + default_keys.contains(&"default-only"), + "default sees its own doc" + ); + assert!( + !default_keys.contains(&"alice-only"), + "default must NOT see alice's doc" + ); + assert!(alice_keys.contains(&"alice-only"), "alice sees her own doc"); + assert!( + !alice_keys.contains(&"default-only"), + "alice must NOT see default's doc" + ); +} + +#[tokio::test] +async fn personality_memory_persists_across_reopens() { + let tmp = tempdir().expect("tempdir"); + + { + let mem = UnifiedMemory::new_with_memory_dir( + tmp.path(), + &memory_subdir_for_suffix("-1"), + Arc::new(NoopEmbedding), + None, + ) + .expect("open 1"); + mem.upsert_document(NamespaceDocumentInput { + namespace: "alice".to_string(), + key: "persistent".to_string(), + title: "Persistent note".to_string(), + content: "This must survive a reopen.".to_string(), + source_type: "doc".to_string(), + priority: "high".to_string(), + tags: vec![], + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + }) + .await + .expect("write"); + } + + let mem2 = UnifiedMemory::new_with_memory_dir( + tmp.path(), + &memory_subdir_for_suffix("-1"), + Arc::new(NoopEmbedding), + None, + ) + .expect("reopen"); + let hits = mem2 + .query_namespace_ranked("alice", "persistent", 10) + .await + .expect("query"); + assert!(hits.iter().any(|h| h.key == "persistent")); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Personality file resolution +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn resolve_soul_falls_back_through_path_inline_none() { + let tmp = tempdir().expect("tempdir"); + let mut profile = make_profile("alice", "Alice"); + + // No overrides → None. + assert!(resolve_personality_soul(tmp.path(), &profile).is_none()); + + // Inline only. + profile.soul_md = Some("Inline soul.".to_string()); + assert_eq!( + resolve_personality_soul(tmp.path(), &profile).as_deref(), + Some("Inline soul.") + ); + + // File path takes precedence over inline. + let soul_path = tmp.path().join("souls").join("alice.md"); + std::fs::create_dir_all(soul_path.parent().unwrap()).unwrap(); + std::fs::write(&soul_path, "File-based soul.").unwrap(); + profile.soul_md_path = Some("souls/alice.md".to_string()); + assert_eq!( + resolve_personality_soul(tmp.path(), &profile).as_deref(), + Some("File-based soul.") + ); + + // Missing file falls back to inline. + profile.soul_md_path = Some("souls/missing.md".to_string()); + assert_eq!( + resolve_personality_soul(tmp.path(), &profile).as_deref(), + Some("Inline soul.") + ); +} + +#[test] +fn resolve_memory_md_reads_personality_dir() { + let tmp = tempdir().expect("tempdir"); + let profile = make_profile("alice", "Alice"); + + assert!(resolve_personality_memory_md(tmp.path(), &profile).is_none()); + + let mem_path = tmp + .path() + .join("personalities") + .join("alice") + .join("MEMORY.md"); + std::fs::create_dir_all(mem_path.parent().unwrap()).unwrap(); + std::fs::write(&mem_path, "Alice's curated memory.").unwrap(); + + assert_eq!( + resolve_personality_memory_md(tmp.path(), &profile).as_deref(), + Some("Alice's curated memory.") + ); +} + +#[test] +fn personality_context_aggregates_all_overrides() { + let tmp = tempdir().expect("tempdir"); + let mut alice = make_profile("alice", "Alice"); + alice.memory_dir_suffix = Some("-1".to_string()); + alice.voice_id = Some("voice-alice".to_string()); + alice.soul_md = Some("I am Alice.".to_string()); + alice.composio_integrations = Some(vec!["slack".to_string()]); + + let mem_path = tmp + .path() + .join("personalities") + .join("alice") + .join("MEMORY.md"); + std::fs::create_dir_all(mem_path.parent().unwrap()).unwrap(); + std::fs::write(&mem_path, "Curated.").unwrap(); + + let ctx = PersonalityContext::from_profile(tmp.path(), alice); + + assert_eq!(ctx.memory_suffix, "-1"); + assert_eq!(ctx.voice_id.as_deref(), Some("voice-alice")); + assert_eq!(ctx.soul_md_override.as_deref(), Some("I am Alice.")); + assert_eq!(ctx.memory_md_override.as_deref(), Some("Curated.")); + assert_eq!(ctx.composio_allowlist.as_ref().unwrap(), &["slack"]); +} + +#[test] +fn subdir_helpers_handle_empty_and_numeric_suffixes() { + assert_eq!(memory_subdir_for_suffix(""), "memory"); + assert_eq!(memory_subdir_for_suffix("-1"), "memory-1"); + assert_eq!(memory_tree_subdir_for_suffix(""), "memory_tree"); + assert_eq!(memory_tree_subdir_for_suffix("-2"), "memory_tree-2"); + assert_eq!(session_raw_subdir_for_suffix(""), "session_raw"); + assert_eq!(session_raw_subdir_for_suffix("-3"), "session_raw-3"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Prompt section behaviour +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn identity_section_uses_personality_soul_when_set() { + let tmp = tempdir().expect("tempdir"); + let mut ctx = empty_prompt_context(tmp.path()); + ctx.personality_soul_md = Some("# I am Alice\n\nI specialise in research.".to_string()); + + let rendered = IdentitySection.build(&ctx).expect("render"); + assert!( + rendered.contains("I am Alice"), + "personality soul must be in rendered prompt: {rendered}" + ); + assert!(rendered.contains("specialise in research")); +} + +#[test] +fn user_files_section_prefers_personality_memory_md() { + let tmp = tempdir().expect("tempdir"); + + // Write a workspace-level MEMORY.md so we can prove personality override wins. + std::fs::write( + tmp.path().join("MEMORY.md"), + "WORKSPACE memory contents — should not appear.", + ) + .expect("write workspace memory"); + + let mut ctx = empty_prompt_context(tmp.path()); + ctx.include_memory_md = true; + ctx.personality_memory_md = Some("PERSONALITY memory contents.".to_string()); + + let rendered = UserFilesSection.build(&ctx).expect("render"); + assert!( + rendered.contains("PERSONALITY memory"), + "personality memory should be rendered: {rendered}" + ); + assert!( + !rendered.contains("WORKSPACE memory"), + "workspace memory must not be rendered when personality memory is set" + ); +} + +#[test] +fn user_files_section_falls_back_to_workspace_memory_md() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("MEMORY.md"), "Workspace fallback memory.").expect("write"); + + let mut ctx = empty_prompt_context(tmp.path()); + ctx.include_memory_md = true; + // No personality override. + + let rendered = UserFilesSection.build(&ctx).expect("render"); + assert!( + rendered.contains("Workspace fallback memory"), + "should fall back to workspace MEMORY.md: {rendered}" + ); +} + +#[test] +fn personality_roster_renders_entries_with_truncated_summaries() { + let tmp = tempdir().expect("tempdir"); + let mut ctx = empty_prompt_context(tmp.path()); + + let long_summary = "A".repeat(300); + ctx.personality_roster = vec![ + PersonalityRosterEntry { + id: "alice".to_string(), + name: "Alice".to_string(), + description: "Research specialist.".to_string(), + memory_summary: Some(long_summary.clone()), + }, + PersonalityRosterEntry { + id: "bob".to_string(), + name: "Bob".to_string(), + description: "Code reviewer.".to_string(), + memory_summary: None, + }, + ]; + + let rendered = PersonalityRosterSection.build(&ctx).expect("render"); + assert!(rendered.contains("Available Personalities")); + assert!(rendered.contains("Alice")); + assert!(rendered.contains("alice")); + assert!(rendered.contains("Research specialist.")); + assert!(rendered.contains("Bob")); + assert!(rendered.contains("Code reviewer.")); + // Summary capped at 200 chars + ellipsis. + assert!( + !rendered.contains(&"A".repeat(250)), + "summary should be truncated below 250 chars" + ); + assert!(rendered.contains("…")); +} + +#[test] +fn personality_roster_renders_empty_when_no_entries() { + let tmp = tempdir().expect("tempdir"); + let ctx = empty_prompt_context(tmp.path()); + let rendered = PersonalityRosterSection.build(&ctx).expect("render"); + assert!(rendered.is_empty(), "empty roster should render nothing"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Integration filtering +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct FakeIntegration { + toolkit: String, +} + +impl HasToolkit for FakeIntegration { + fn toolkit_name(&self) -> &str { + &self.toolkit + } +} + +#[test] +fn filter_integrations_none_is_passthrough() { + let all = vec![ + FakeIntegration { + toolkit: "slack".into(), + }, + FakeIntegration { + toolkit: "gmail".into(), + }, + FakeIntegration { + toolkit: "notion".into(), + }, + ]; + let filtered = filter_integrations(&all, None); + assert_eq!(filtered.len(), 3); +} + +#[test] +fn filter_integrations_allowlist_is_case_insensitive() { + let all = vec![ + FakeIntegration { + toolkit: "slack".into(), + }, + FakeIntegration { + toolkit: "gmail".into(), + }, + FakeIntegration { + toolkit: "notion".into(), + }, + ]; + let allowed = vec!["SLACK".to_string(), "Gmail".to_string()]; + let filtered = filter_integrations(&all, Some(&allowed)); + let names: Vec<_> = filtered.iter().map(|i| i.toolkit.as_str()).collect(); + assert_eq!(names, vec!["slack", "gmail"]); +} + +#[test] +fn filter_integrations_empty_allowlist_returns_nothing() { + let all = vec![FakeIntegration { + toolkit: "slack".into(), + }]; + let empty: Vec = vec![]; + let filtered = filter_integrations(&all, Some(&empty)); + assert!(filtered.is_empty()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Thread-personality binding +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn thread_personality_id_persists_through_store() { + let tmp = tempdir().expect("tempdir"); + let store = ConversationStore::new(tmp.path().to_path_buf()); + + let thread = store + .ensure_thread(CreateConversationThread { + id: "thread-alice".to_string(), + title: "Alice chat".to_string(), + created_at: "2026-05-27T10:00:00Z".to_string(), + parent_thread_id: None, + labels: Some(vec!["work".to_string()]), + personality_id: Some("alice".to_string()), + }) + .expect("ensure"); + + assert_eq!(thread.personality_id.as_deref(), Some("alice")); + + let listed = store.list_threads().expect("list"); + let alice_thread = listed.iter().find(|t| t.id == "thread-alice").unwrap(); + assert_eq!(alice_thread.personality_id.as_deref(), Some("alice")); +} + +#[test] +fn threads_without_personality_id_remain_none() { + let tmp = tempdir().expect("tempdir"); + let store = ConversationStore::new(tmp.path().to_path_buf()); + + store + .ensure_thread(CreateConversationThread { + id: "legacy-thread".to_string(), + title: "Legacy".to_string(), + created_at: "2026-05-27T10:00:00Z".to_string(), + parent_thread_id: None, + labels: None, + personality_id: None, + }) + .expect("ensure"); + + let listed = store.list_threads().expect("list"); + let legacy = listed.iter().find(|t| t.id == "legacy-thread").unwrap(); + assert!(legacy.personality_id.is_none()); +} + +#[test] +fn updating_thread_title_preserves_personality_id() { + let tmp = tempdir().expect("tempdir"); + let store = ConversationStore::new(tmp.path().to_path_buf()); + + store + .ensure_thread(CreateConversationThread { + id: "thread-bob".to_string(), + title: "Original".to_string(), + created_at: "2026-05-27T10:00:00Z".to_string(), + parent_thread_id: None, + labels: None, + personality_id: Some("bob".to_string()), + }) + .expect("ensure"); + + store + .update_thread_title("thread-bob", "Updated title", "2026-05-27T11:00:00Z") + .expect("update title"); + + let listed = store.list_threads().expect("list"); + let bob = listed.iter().find(|t| t.id == "thread-bob").unwrap(); + assert_eq!(bob.title, "Updated title"); + assert_eq!( + bob.personality_id.as_deref(), + Some("bob"), + "personality_id must survive title updates" + ); +} + +#[test] +fn module_level_helpers_round_trip_personality_id() { + // Verify the free-function API (used by some callers) also propagates personality_id. + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + + let thread = ensure_thread( + workspace.clone(), + CreateConversationThread { + id: "module-thread".to_string(), + title: "via module".to_string(), + created_at: "2026-05-27T12:00:00Z".to_string(), + parent_thread_id: None, + labels: None, + personality_id: Some("carol".to_string()), + }, + ) + .expect("ensure"); + assert_eq!(thread.personality_id.as_deref(), Some("carol")); + + update_thread_title( + workspace.clone(), + "module-thread", + "Updated", + "2026-05-27T13:00:00Z", + ) + .expect("update title"); + + let threads = list_threads(workspace).expect("list"); + let found = threads.iter().find(|t| t.id == "module-thread").unwrap(); + assert_eq!(found.personality_id.as_deref(), Some("carol")); + assert_eq!(found.title, "Updated"); +}