mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
cyrus@tinyhumans.ai <cyrus@tinyhumans.ai>
parent
29bf148f38
commit
6dceef7bf4
@@ -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 {
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user