use serde_json::{Value, json};
use crate::autonomy::AutonomyLlm;
use crate::config::FeatureTier;
use crate::mcp::param_names;
use crate::persona::{PersonaConfig, PersonaError, PersonaGenerator, get_latest_persona};
pub(super) fn handle_persona(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
let entity_id = params["entity_id"]
.as_str()
.ok_or("entity_id is required")?;
if entity_id.is_empty() {
return Err(crate::errors::msg::ENTITY_ID_EMPTY.to_string());
}
let namespace = params["namespace"]
.as_str()
.unwrap_or(crate::DEFAULT_NAMESPACE);
let persona = get_latest_persona(conn, entity_id, namespace)
.map_err(|e| format!("memory_persona substrate error: {e}"))?;
Ok(json!({ "persona": persona }))
}
pub fn handle_persona_generate(
conn: &rusqlite::Connection,
params: &Value,
llm: Option<&dyn AutonomyLlm>,
tier: FeatureTier,
active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
) -> Result<Value, String> {
if !matches!(tier, FeatureTier::Smart | FeatureTier::Autonomous) {
return Err(format!(
"memory_persona_generate requires smart tier or higher (current: {tier:?})"
));
}
let llm = llm.ok_or(
"memory_persona_generate requires an LLM client; none is wired into this dispatch",
)?;
let entity_id = params["entity_id"]
.as_str()
.ok_or("entity_id is required")?;
if entity_id.is_empty() {
return Err(crate::errors::msg::ENTITY_ID_EMPTY.to_string());
}
let scoped_single: Option<&str> = match params.get(param_names::NAMESPACE) {
None => None,
Some(v) if v.is_null() => None,
Some(v) => match v.as_str() {
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
None => {
return Err("namespace must be a string or null".to_string());
}
},
};
let generator = PersonaGenerator::new(conn, llm, active_keypair, PersonaConfig::default());
let (persona, scope_label) = match scoped_single {
Some(ns) => (
generator
.generate(entity_id, ns)
.map_err(persona_error_to_string)?,
"single".to_string(),
),
None => (
generator
.generate_cross_namespace(entity_id, crate::DEFAULT_NAMESPACE)
.map_err(persona_error_to_string)?,
"cross_namespace".to_string(),
),
};
Ok(json!({
"persona": persona,
"regenerated": true,
"namespace_scope": scope_label,
}))
}
fn persona_error_to_string(e: PersonaError) -> String {
e.to_string()
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct PersonaRequest {
pub entity_id: String,
#[serde(default)]
pub namespace: Option<String>,
}
#[allow(dead_code)]
pub struct PersonaTool;
impl McpTool for PersonaTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_PERSONA
}
fn description() -> &'static str {
"Fetch the latest Persona artefact for an entity (read-only)."
}
fn docs() -> &'static str {
"QW-2: latest MemoryKind::Persona for (entity_id, namespace). Returns envelope {id, entity_id, namespace, body_md, sources, generated_at, version, attest_level}. null when none. Pair with memory_persona_generate."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<PersonaRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Power.name()
}
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct PersonaGenerateRequest {
pub entity_id: String,
#[serde(default)]
pub namespace: Option<String>,
}
#[allow(dead_code)]
pub struct PersonaGenerateTool;
impl McpTool for PersonaGenerateTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_PERSONA_GENERATE
}
fn description() -> &'static str {
"Generate/regen a Persona artefact for an entity."
}
fn docs() -> &'static str {
"QW-2 / #848: synthesise MemoryKind::Persona from top-K Reflection memories. Omit namespace (or pass null) for cross-namespace aggregation (#848 — persona lands in 'global'); pass a namespace string for single-namespace scope. Response includes namespace_scope=single|cross_namespace."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<PersonaGenerateRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Power.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn persona_parity_986() {
let derived = derived_props_for::<PersonaRequest>();
assert_property_set_parity("memory_persona", &derived);
assert_descriptions_match("memory_persona", &derived);
}
#[test]
fn persona_tool_metadata_986() {
assert_eq!(PersonaTool::name(), "memory_persona");
assert_eq!(PersonaTool::family(), "power");
}
#[test]
fn persona_generate_parity_986() {
let derived = derived_props_for::<PersonaGenerateRequest>();
assert_property_set_parity("memory_persona_generate", &derived);
assert_descriptions_match("memory_persona_generate", &derived);
}
#[test]
fn persona_generate_tool_metadata_986() {
assert_eq!(PersonaGenerateTool::name(), "memory_persona_generate");
assert_eq!(PersonaGenerateTool::family(), "power");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::autonomy::AutonomyLlm;
use crate::models::{Memory, MemoryKind, Tier};
use crate::storage as db;
use chrono::Utc;
use rusqlite::Connection;
use tempfile::TempDir;
struct StubLlm;
impl AutonomyLlm for StubLlm {
fn auto_tag(&self, _t: &str, _c: &str) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
Ok(false)
}
fn summarize_memories(&self, mems: &[(String, String)]) -> anyhow::Result<String> {
Ok(format!("Stub persona body [{} sources]", mems.len()))
}
}
fn fresh_db() -> (Connection, TempDir) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ai-memory.db");
let conn = db::open(&path).unwrap();
(conn, dir)
}
fn seed_reflection(conn: &Connection, namespace: &str, title: &str, body: &str) -> String {
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: namespace.to_string(),
title: title.to_string(),
content: body.to_string(),
tags: vec!["reflection".into()],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "ai:test", "entity_id": "alice"}),
reflection_depth: 1,
memory_kind: MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
db::insert(conn, &mem).unwrap()
}
#[test]
fn handle_persona_returns_null_when_unminted() {
let (conn, _dir) = fresh_db();
let out = handle_persona(
&conn,
&json!({"entity_id": "alice", "namespace": "team/alpha"}),
)
.unwrap();
assert!(out["persona"].is_null());
}
#[test]
fn handle_persona_rejects_empty_entity_id() {
let (conn, _dir) = fresh_db();
let err = handle_persona(&conn, &json!({"entity_id": ""})).unwrap_err();
assert!(err.contains("entity_id cannot be empty"));
}
#[test]
fn handle_persona_generate_refuses_below_smart_tier() {
let (conn, _dir) = fresh_db();
let llm = StubLlm;
let err = handle_persona_generate(
&conn,
&json!({"entity_id": "alice"}),
Some(&llm),
FeatureTier::Keyword,
None,
)
.unwrap_err();
assert!(err.contains("requires smart tier"));
}
#[test]
fn handle_persona_generate_writes_and_handle_persona_returns_it() {
let (conn, _dir) = fresh_db();
seed_reflection(
&conn,
"team/alpha",
"obs about alice",
"alice is methodical alice is patient",
);
let llm = StubLlm;
let gen_res = handle_persona_generate(
&conn,
&json!({"entity_id": "alice", "namespace": "team/alpha"}),
Some(&llm),
FeatureTier::Smart,
None,
)
.unwrap();
assert_eq!(gen_res["regenerated"], true);
assert_eq!(
gen_res["namespace_scope"], "single",
"explicit namespace must report single-namespace scope per #848"
);
let p = &gen_res["persona"];
assert_eq!(p["entity_id"], "alice");
assert_eq!(p["version"], 1);
let got = handle_persona(
&conn,
&json!({"entity_id": "alice", "namespace": "team/alpha"}),
)
.unwrap();
assert_eq!(got["persona"]["entity_id"], "alice");
assert_eq!(got["persona"]["version"], 1);
}
#[test]
fn issue_848_handle_persona_generate_omitted_namespace_aggregates_cross_namespace() {
let (conn, _dir) = fresh_db();
let id_a = seed_reflection(
&conn,
"global/policies",
"discipline reflection",
"alice keeps the tree clean across rounds",
);
let id_b = seed_reflection(
&conn,
"ai-memory/v0.7.0-nhi-testing",
"campaign reflection",
"alice closed the L1-6 governance gap end-to-end",
);
let llm = StubLlm;
let gen_res = handle_persona_generate(
&conn,
&json!({"entity_id": "alice"}),
Some(&llm),
FeatureTier::Smart,
None,
)
.expect("cross-namespace generate must succeed when sources exist in any namespace");
assert_eq!(gen_res["regenerated"], true);
assert_eq!(
gen_res["namespace_scope"], "cross_namespace",
"namespace omitted → handler must report cross_namespace scope"
);
let p = &gen_res["persona"];
assert_eq!(p["entity_id"], "alice");
assert_eq!(
p["namespace"], "global",
"cross-namespace persona must land in 'global' per #848 default"
);
let sources = p["sources"]
.as_array()
.expect("sources must serialise as an array");
let source_set: std::collections::HashSet<String> = sources
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
assert!(
source_set.contains(&id_a),
"cross-namespace aggregation must include global/policies reflection {id_a}; got {sources:?}"
);
assert!(
source_set.contains(&id_b),
"cross-namespace aggregation must include ai-memory reflection {id_b}; got {sources:?}"
);
}
#[test]
fn issue_848_handle_persona_generate_null_namespace_routes_to_cross_namespace() {
let (conn, _dir) = fresh_db();
seed_reflection(
&conn,
"scoped/ns",
"single source",
"alice closes loops with audit-honest discipline",
);
let llm = StubLlm;
let gen_res = handle_persona_generate(
&conn,
&json!({"entity_id": "alice", "namespace": null}),
Some(&llm),
FeatureTier::Smart,
None,
)
.expect("null namespace must aggregate cross-namespace");
assert_eq!(gen_res["namespace_scope"], "cross_namespace");
assert_eq!(gen_res["persona"]["namespace"], "global");
}
#[test]
fn issue_848_cross_namespace_with_no_reflections_reports_any_namespace_sentinel() {
let (conn, _dir) = fresh_db();
let llm = StubLlm;
let err = handle_persona_generate(
&conn,
&json!({"entity_id": "alice"}),
Some(&llm),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(
err.contains("<any namespace>"),
"#848 — empty cross-namespace scan must reference the cross-namespace sentinel; got: {err}"
);
}
}