feat(agent): multi-agent personalities with scoped memory and delegation (#2895)

Co-authored-by: cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
This commit is contained in:
Steven Enamakel
2026-05-29 19:31:46 +05:30
committed by GitHub
co-authored by cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
parent 29bf148f38
commit 6dceef7bf4
55 changed files with 2090 additions and 20 deletions
+8
View File
@@ -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 {
+1
View File
@@ -8,6 +8,7 @@ export interface Thread {
createdAt: string;
parentThreadId?: string;
labels: string[];
personalityId?: string | null;
}
export interface ThreadMessage {
@@ -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());
@@ -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());
@@ -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());
@@ -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![],
}
}
@@ -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());
@@ -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![],
}
}
+3
View File
@@ -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));
@@ -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![],
}
}
@@ -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![],
}
}
@@ -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![],
}
}
@@ -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![],
}
}
@@ -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());
@@ -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());
@@ -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());
@@ -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());
@@ -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());
@@ -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());
@@ -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());
@@ -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());
+3
View File
@@ -429,6 +429,9 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
include_memory_md: !definition.omit_memory_md,
curated_snapshot: None,
user_identity: None,
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
};
let mut text = build(&ctx)
@@ -534,6 +534,9 @@ fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() {
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();
@@ -2175,6 +2175,13 @@ impl Agent {
include_memory_md: !self.omit_memory_md,
curated_snapshot: None,
user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(),
// TODO(phase-2): Wire personality context into the live agent turn.
// Currently personalities only take effect during delegate_to_personality sub-agent runs.
// To activate: load the active profile via AgentProfileStore::resolve(), build
// PersonalityContext::from_profile(), and populate these fields.
personality_soul_md: None, // TODO: personality_ctx.soul_md_override
personality_memory_md: None, // TODO: personality_ctx.memory_md_override
personality_roster: vec![], // TODO: build_personality_roster(&workspace_dir)
};
// Route through the global context manager so every
// prompt-building call-site — main agent, sub-agent runner,
@@ -1089,6 +1089,9 @@ async fn run_typed_mode(
include_memory_md: !definition.omit_memory_md,
curated_snapshot: None,
user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(),
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
};
let system_prompt = match &definition.system_prompt {
@@ -1589,6 +1589,9 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() {
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
}
};
let system_prompt = orch_prompt::build(&ctx).expect("build orchestrator prompt");
+2
View File
@@ -722,6 +722,7 @@ mod tests {
title: "Chat A".to_string(),
created_at: "2026-04-10T12:00:00Z".to_string(),
labels: None,
personality_id: None,
})
.expect("ensure thread-a");
store
@@ -748,6 +749,7 @@ mod tests {
title: "Chat B".to_string(),
created_at: "2026-04-10T13:00:00Z".to_string(),
labels: None,
personality_id: None,
})
.expect("ensure thread-b");
+1
View File
@@ -29,6 +29,7 @@ pub mod hooks;
pub mod host_runtime;
pub mod memory_loader;
pub mod multimodal;
pub mod personality_paths;
pub mod pformat;
pub mod profiles;
pub mod progress;
+394
View File
@@ -0,0 +1,394 @@
//! Personality-scoped path resolution and context for multi-agent sessions.
use std::path::{Component, Path};
use crate::openhuman::agent::profiles::AgentProfile;
/// Reject path strings that could escape the workspace: absolute paths,
/// root/prefix components, or any `..` segment.
fn is_safe_relative_path(rel: &Path) -> 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<String> {
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<String> {
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<String>,
pub memory_md_override: Option<String>,
pub composio_allowlist: Option<Vec<String>>,
pub voice_id: Option<String>,
}
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<T: Clone + HasToolkit>(
all: &[T],
allowlist: Option<&[String]>,
) -> Vec<T> {
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<String> = vec![];
let filtered = filter_integrations(&all, Some(&allowed));
assert!(filtered.is_empty());
}
}
+308 -1
View File
@@ -33,6 +33,28 @@ pub struct AgentProfile {
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub built_in: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice_id: Option<String>,
/// Inline SOUL.md content for this personality. Falls back to workspace root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub soul_md: Option<String>,
/// 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<String>,
/// Composio toolkit slugs this personality can access. None = all integrations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub composio_integrations: Option<Vec<String>>,
/// Auto-assigned memory directory suffix: "" for default, "-1", "-2", etc.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_dir_suffix: Option<String>,
/// 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<u32>,
}
#[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<String> = 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<String> = 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<AgentProfile> {
),
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<AgentProfile> {
),
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<AgentProfile> {
),
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::<Vec<_>>()
});
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>) -> 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(""));
}
}
+103 -11
View File
@@ -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<String> {
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
+45
View File
@@ -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<UserIdentity>) -> 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!(
+20
View File
@@ -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<String>,
}
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<UserIdentity>,
/// 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<String>,
/// 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<String>,
/// Non-self personality roster entries for the master agent's prompt.
/// Empty for non-master agents.
pub personality_roster: Vec<PersonalityRosterEntry>,
}
// ─────────────────────────────────────────────────────────────────────────────
+3
View File
@@ -720,6 +720,9 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option<String> {
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),
@@ -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}");
@@ -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,
}
}
+6
View File
@@ -73,6 +73,8 @@ pub struct EmptyRequest {}
pub struct CreateConversationThreadRequest {
#[serde(default)]
pub labels: Option<Vec<String>>,
#[serde(default)]
pub personality_id: Option<String>,
}
/// Request payload for `openhuman.memory_init`.
@@ -114,6 +116,8 @@ pub struct ConversationThreadSummary {
pub parent_thread_id: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personality_id: Option<String>,
}
/// A single persisted conversation message.
@@ -141,6 +145,8 @@ pub struct UpsertConversationThreadRequest {
pub parent_thread_id: Option<String>,
#[serde(default)]
pub labels: Option<Vec<String>>,
#[serde(default)]
pub personality_id: Option<String>,
}
/// Request to update labels for a conversation thread.
@@ -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,
},
)?;
+20 -1
View File
@@ -91,6 +91,8 @@ enum ThreadLogEntry {
parent_thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
labels: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
personality_id: Option<String>,
},
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<usize>,
/// Timestamp of the newest message, or `None` if unknown (legacy).
last_message_at: Option<String>,
personality_id: Option<String>,
}
fn infer_labels(thread_id: &str) -> Vec<String> {
@@ -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
@@ -20,6 +20,8 @@ pub struct ConversationThread {
pub parent_thread_id: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personality_id: Option<String>,
}
/// A single message appended to a thread's JSONL log.
@@ -47,6 +49,8 @@ pub struct CreateConversationThread {
pub parent_thread_id: Option<String>,
#[serde(default)]
pub labels: Option<Vec<String>>,
#[serde(default)]
pub personality_id: Option<String>,
}
/// 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();
@@ -22,8 +22,13 @@ impl UnifiedMemory {
) -> anyhow::Result<String> {
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);
+68 -6
View File
@@ -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<dyn EmbeddingProvider>,
_open_timeout_secs: Option<u64>,
) -> anyhow::Result<Self> {
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<dyn EmbeddingProvider>,
_open_timeout_secs: Option<u64>,
) -> anyhow::Result<Self> {
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();
@@ -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<Mutex<Connection>>,
+3
View File
@@ -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"));
+1
View File
@@ -573,6 +573,7 @@ fn handle_reflections_act(params: Map<String, Value>) -> 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}"))?;
+3
View File
@@ -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!(
+2
View File
@@ -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");
@@ -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,
}
}
@@ -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<ToolResult> {
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
)))
}
}
}
}
+2
View File
@@ -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};
@@ -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}"))?;
@@ -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();
+1
View File
@@ -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
+727
View File
@@ -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<String> = 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<String> = 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");
}