Skip to main content

oxios_kernel/persona/
manager.rs

1//! Persona manager: coordinates persona-aware execution.
2//!
3//! The PersonaManager manages persona lifecycle and provides
4//! the active persona for orchestrator and agent runtime.
5//!
6//! RFC-039 completion: the manager owns an optional `StateStore` and a
7//! reseed callback so that **every** `set_active` call — from the HTTP API,
8//! the `PersonaTool`, the Gateway, or the delete handler — automatically
9//! persists to disk and re-seeds the intent engine.  There is one code path;
10//! callers cannot accidentally skip persistence or re-seeding.
11
12use std::sync::Arc;
13
14use anyhow::Result;
15use parking_lot::RwLock;
16
17use super::store::PersonaStore;
18use super::{Persona, default_personas};
19use crate::state_store::StateStore;
20
21/// Manages persona lifecycle and coordinates persona-aware execution.
22pub struct PersonaManager {
23    store: PersonaStore,
24    active_persona_id: RwLock<Option<String>>,
25    /// Optional shared `StateStore` for automatic persistence.
26    /// When `None`, persistence calls are no-ops (tests, ephemeral runs).
27    state_store: Option<Arc<StateStore>>,
28    /// Callback invoked after a successful `set_active` to re-seed the
29    /// intent engine's `system_prompt`.  Stored on the manager (not
30    /// `PersonaApi`) so ephemeral `PersonaApi` instances in `PersonaTool`
31    /// cannot lose it.
32    #[allow(clippy::type_complexity)]
33    reseed_callback: RwLock<Option<Arc<dyn Fn(Option<String>) + Send + Sync>>>,
34}
35
36impl std::fmt::Debug for PersonaManager {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("PersonaManager")
39            .field("active_persona_id", &self.active_persona_id.read())
40            .field("state_store", &self.state_store.is_some())
41            .field("reseed_callback", &self.reseed_callback.read().is_some())
42            .finish()
43    }
44}
45
46impl PersonaManager {
47    /// Creates a new persona manager with default personas.
48    pub fn new() -> Self {
49        let store = PersonaStore::new();
50        let manager = Self {
51            store,
52            active_persona_id: RwLock::new(None),
53            state_store: None,
54            reseed_callback: RwLock::new(None),
55        };
56        manager.create_default_personas();
57        manager
58    }
59
60    /// Creates a new persona manager, optionally loading from existing data.
61    pub fn with_defaults(personas: Vec<Persona>) -> Self {
62        let store = PersonaStore::new();
63        store.load_from_slice(&personas);
64        let this = Self {
65            store,
66            active_persona_id: RwLock::new(None),
67            state_store: None,
68            reseed_callback: RwLock::new(None),
69        };
70        // Set the first enabled persona as active by default.
71        if let Some(first) = this.store.list_enabled().into_iter().next() {
72            *this.active_persona_id.write() = Some(first.id);
73        }
74        this
75    }
76
77    /// Builder: attach a shared `StateStore`.
78    pub fn with_state_store(mut self, store: Arc<StateStore>) -> Self {
79        self.state_store = Some(store);
80        self
81    }
82
83    /// Set the callback that re-seeds the intent engine's system_prompt.
84    /// Called automatically by `set_active`.
85    pub fn set_reseed_callback(&self, cb: Option<Arc<dyn Fn(Option<String>) + Send + Sync>>) {
86        *self.reseed_callback.write() = cb;
87    }
88
89    /// Returns the current active persona, if any.
90    pub fn get_active_persona(&self) -> Option<Persona> {
91        let active_id = self.active_persona_id.read().clone();
92        active_id.and_then(|id| self.store.get(&id))
93    }
94
95    /// Returns the system prompt for the active persona.
96    /// Falls back to a default prompt if no active persona.
97    pub fn active_system_prompt(&self) -> String {
98        self.get_active_persona()
99            .map(|p| p.system_prompt.clone())
100            .unwrap_or_else(|| {
101                "You are a helpful AI assistant that follows the Ouroboros methodology: \
102                 specify before you build, evaluate before you ship."
103                    .to_string()
104            })
105    }
106
107    /// Creates the default personas (Dev, Review, Research, Architect, Mentor,
108    /// Ops, Security, Writer, Planner).
109    pub fn create_default_personas(&self) {
110        let defaults = default_personas();
111        for persona in defaults {
112            // Only register if not already present.
113            if self.store.get(&persona.id).is_none() {
114                self.store.register(persona);
115            }
116        }
117        // Set first persona as active if none is set.
118        {
119            let mut active = self.active_persona_id.write();
120            if active.is_none() {
121                *active = Some("dev".to_string());
122            }
123        }
124        tracing::info!("Default personas initialized");
125    }
126
127    /// Returns the first enabled persona, for wiring into OuroborosEngine.
128    pub fn first_enabled(&self) -> Option<Persona> {
129        self.store.list_enabled().into_iter().next()
130    }
131
132    /// Returns the persona store for direct access.
133    pub fn store(&self) -> &PersonaStore {
134        &self.store
135    }
136
137    /// Returns the ID of the active persona.
138    pub fn active_persona_id(&self) -> Option<String> {
139        self.active_persona_id.read().clone()
140    }
141
142    // ── RFC-039 ────────────────────────────────────────────────────────────
143
144    /// Active persona 의 우선순위 결정:
145    ///   1. StateStore `index.json` 의 `active_persona_id` (enabled 면 적용)
146    ///   2. `PersonaConfig.default_persona_id` (enabled 면 적용)
147    ///   3. store 의 첫 번째 enabled
148    ///   4. None
149    ///
150    /// `&self` — `Arc<PersonaManager>` 뒤에서 호출됨.
151    pub fn apply_config(&self, cfg: &crate::config::PersonaConfig) {
152        // 우선순위 1: 기존 active_persona_id 가 enabled 면 유지
153        // (load_from_state_store 가 이미 StateStore 의 active_persona_id 를 박았음).
154        if let Some(id) = self.active_persona_id()
155            && self.store.get(&id).map(|p| p.enabled).unwrap_or(false)
156        {
157            return;
158        }
159        // 우선순위 2: config.default_persona_id
160        if let Some(id) = cfg.default_persona_id.as_ref()
161            && self.store.get(id).map(|p| p.enabled).unwrap_or(false)
162        {
163            *self.active_persona_id.write() = Some(id.clone());
164            return;
165        }
166        // 우선순위 3: 첫 번째 enabled
167        if let Some(p) = self.store.list_enabled().into_iter().next() {
168            *self.active_persona_id.write() = Some(p.id);
169        }
170    }
171
172    /// StateStore 에서 페르소나 + active_persona_id 를 로드.
173    /// 손상·부재 시 silent fallback 하지 않고 `Result::Err` 로 전파.
174    /// 호출자는 defaults 가 이미 new() 로 박혀 있음을 알고 있어야 함.
175    pub async fn load_from_state_store(&self, store: &StateStore) -> Result<()> {
176        let snap = crate::persona::persistence::load_from_state_store(store)
177            .await?
178            .ok_or_else(|| anyhow::anyhow!("persona: no snapshot present"))?;
179        // store 가 이미 기본 페르소나를 들고 있어도 디스크가 우선.
180        for p in &snap.personas {
181            self.store.register(p.clone());
182        }
183        if let Some(active) = snap.active_persona_id
184            && snap.personas.iter().any(|p| p.id == active && p.enabled)
185        {
186            *self.active_persona_id.write() = Some(active);
187        }
188        Ok(())
189    }
190
191    /// StateStore 에 페르소나 + active_persona_id 를 저장.
192    /// `state_store` 가 설정되지 않은 경우 no-op (`Ok(())`).
193    /// 메모리 상태는 유지, IO 실패는 `Result::Err` 로 전파.
194    pub async fn persist(&self) -> Result<()> {
195        let Some(ref store) = self.state_store else {
196            return Ok(());
197        };
198        let snapshot = crate::persona::persistence::PersonaSnapshot {
199            schema_version: 1,
200            active_persona_id: self.active_persona_id(),
201            personas: self.store.list_all(),
202        };
203        crate::persona::persistence::save_to_state_store(store, &snapshot).await
204    }
205
206    /// 글로벌 활성 페르소나 변경.
207    ///
208    /// 단일 진리 경로: 슬롯 변경 → persist (내부 StateStore) → reseed (callback).
209    /// 모든 호출자 (HTTP, PersonaTool, Gateway, delete handler) 가 이 메서드를
210    /// 거치므로 영속화 누락이나 intent engine 미재시드가 발생하지 않는다.
211    ///
212    /// 새 system_prompt 를 `Ok(Some(prompt))` 로 반환.
213    ///
214    /// `&self` — interior mutability (Arc 뒤 호출 가능).
215    pub async fn set_active(&self, id: &str) -> Result<Option<String>> {
216        let persona = self
217            .store
218            .get(id)
219            .ok_or_else(|| anyhow::anyhow!("Persona '{id}' not found"))?;
220        if !persona.enabled {
221            anyhow::bail!("Persona '{id}' is disabled");
222        }
223        *self.active_persona_id.write() = Some(id.to_string());
224        tracing::info!(persona_id = %id, name = %persona.name, "Active persona set");
225
226        // Persist (no-op if no state_store).
227        if let Err(e) = self.persist().await {
228            tracing::warn!(error = %e, "persona set_active: persist failed");
229        }
230
231        // Re-seed intent engine if callback is set.
232        let prompt = persona.system_prompt.clone();
233        if let Some(ref cb) = *self.reseed_callback.read() {
234            cb(Some(prompt.clone()));
235        }
236        Ok(Some(prompt))
237    }
238}
239
240impl Default for PersonaManager {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246impl Clone for PersonaManager {
247    fn clone(&self) -> Self {
248        let personas: Vec<Persona> = self.store.list_all();
249        let store = PersonaStore::new();
250        store.load_from_slice(&personas);
251        Self {
252            store,
253            active_persona_id: RwLock::new(self.active_persona_id.read().clone()),
254            // Arc clone — shares the same underlying store/callback.
255            state_store: self.state_store.clone(),
256            reseed_callback: RwLock::new(self.reseed_callback.read().clone()),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::config::PersonaConfig;
265
266    fn make_store() -> Arc<StateStore> {
267        let dir = tempfile::tempdir().unwrap();
268        Arc::new(StateStore::new(dir.keep()).unwrap())
269    }
270
271    #[tokio::test]
272    async fn test_load_from_state_store_round_trip() {
273        let store = make_store();
274        let pm = PersonaManager::new().with_state_store(store.clone());
275        // Create a custom persona, persist, then load into a fresh manager.
276        let custom = Persona {
277            id: "custom-1".to_string(),
278            name: "Custom".to_string(),
279            role: "custom".to_string(),
280            description: "Custom test persona".to_string(),
281            system_prompt: "You are Custom.".to_string(),
282            enabled: true,
283            model: None,
284            personality_traits: vec![],
285        };
286        pm.store().register(custom);
287        pm.set_active("custom-1").await.unwrap();
288        pm.persist().await.unwrap();
289
290        // Fresh manager — load from disk.
291        let pm2 = PersonaManager::new().with_state_store(store.clone());
292        pm2.load_from_state_store(&store).await.unwrap();
293        assert!(pm2.store().get("custom-1").is_some());
294        assert_eq!(pm2.active_persona_id(), Some("custom-1".to_string()));
295        // Defaults should also still be present (new() created them).
296        assert!(pm2.store().get("dev").is_some());
297    }
298
299    #[tokio::test]
300    async fn test_load_no_file_is_ok() {
301        let store = make_store();
302        let pm = PersonaManager::new().with_state_store(store.clone());
303        let result = pm.load_from_state_store(&store).await;
304        // No file → Err (we require a snapshot). But defaults from new() remain.
305        assert!(result.is_err());
306        assert!(pm.store().get("dev").is_some());
307    }
308
309    #[tokio::test]
310    async fn test_apply_config_default_persona_id() {
311        let pm = PersonaManager::new();
312        // Clear active so apply_config must pick from config.
313        *pm.active_persona_id.write() = None;
314        let cfg = PersonaConfig {
315            default_persona_id: Some("review".to_string()),
316        };
317        pm.apply_config(&cfg);
318        assert_eq!(pm.active_persona_id(), Some("review".to_string()));
319    }
320
321    #[tokio::test]
322    async fn test_apply_config_falls_back_to_first_enabled() {
323        let pm = PersonaManager::new();
324        *pm.active_persona_id.write() = None;
325        let cfg = PersonaConfig {
326            default_persona_id: None,
327        };
328        pm.apply_config(&cfg);
329        // First enabled persona is "dev" (order: dev, review, research).
330        assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
331    }
332
333    #[tokio::test]
334    async fn test_apply_config_skips_disabled_default() {
335        let pm = PersonaManager::new();
336        *pm.active_persona_id.write() = None;
337        // "review" is enabled by default; disable it.
338        pm.store().set_enabled("review", false).unwrap();
339        let cfg = PersonaConfig {
340            default_persona_id: Some("review".to_string()),
341        };
342        pm.apply_config(&cfg);
343        // review is disabled → fall back to first enabled = dev.
344        assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
345    }
346
347    #[tokio::test]
348    async fn test_apply_config_keeps_existing_active() {
349        let pm = PersonaManager::new();
350        pm.set_active("research").await.unwrap();
351        let cfg = PersonaConfig {
352            default_persona_id: Some("dev".to_string()),
353        };
354        pm.apply_config(&cfg);
355        // Existing active (research, enabled) wins over config default.
356        assert_eq!(pm.active_persona_id(), Some("research".to_string()));
357    }
358
359    #[tokio::test]
360    async fn test_set_active_rejects_disabled() {
361        let pm = PersonaManager::new();
362        pm.store().set_enabled("review", false).unwrap();
363        let result = pm.set_active("review").await;
364        assert!(result.is_err());
365        assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
366    }
367
368    #[tokio::test]
369    async fn test_set_active_rejects_unknown_id() {
370        let pm = PersonaManager::new();
371        let result = pm.set_active("nonexistent").await;
372        assert!(result.is_err());
373    }
374
375    #[tokio::test]
376    async fn test_set_active_returns_system_prompt() {
377        let pm = PersonaManager::new();
378        let prompt = pm.set_active("review").await.unwrap();
379        assert!(prompt.is_some());
380        assert!(prompt.unwrap().contains("Review"));
381    }
382
383    #[tokio::test]
384    async fn test_set_active_fires_reseed_callback() {
385        let pm = PersonaManager::new();
386        let received = Arc::new(parking_lot::Mutex::new(None::<String>));
387        let received_cb = received.clone();
388        pm.set_reseed_callback(Some(Arc::new(move |prompt| {
389            *received_cb.lock() = prompt;
390        })));
391        pm.set_active("review").await.unwrap();
392        assert!(received.lock().as_ref().unwrap().contains("Review"));
393    }
394
395    #[tokio::test]
396    async fn test_set_active_no_callback_still_works() {
397        let pm = PersonaManager::new();
398        // No callback set — should not panic.
399        let result = pm.set_active("research").await;
400        assert!(result.is_ok());
401    }
402
403    #[tokio::test]
404    async fn test_set_active_persists_when_store_set() {
405        let store = make_store();
406        let pm = PersonaManager::new().with_state_store(store.clone());
407        pm.set_active("research").await.unwrap();
408
409        // Fresh manager loads the persisted active.
410        let pm2 = PersonaManager::new().with_state_store(store.clone());
411        pm2.load_from_state_store(&store).await.unwrap();
412        assert_eq!(pm2.active_persona_id(), Some("research".to_string()));
413    }
414
415    #[tokio::test]
416    async fn test_set_active_no_persist_without_store() {
417        let pm = PersonaManager::new();
418        // No state_store — persist is no-op, should succeed.
419        pm.set_active("research").await.unwrap();
420        assert_eq!(pm.active_persona_id(), Some("research".to_string()));
421    }
422
423    #[tokio::test]
424    async fn test_persist_round_trip_preserves_active() {
425        let store = make_store();
426        let pm = PersonaManager::new().with_state_store(store.clone());
427        pm.set_active("research").await.unwrap();
428        pm.persist().await.unwrap();
429
430        let pm2 = PersonaManager::new().with_state_store(store.clone());
431        pm2.load_from_state_store(&store).await.unwrap();
432        assert_eq!(pm2.active_persona_id(), Some("research".to_string()));
433    }
434
435    #[tokio::test]
436    async fn test_clone_preserves_state_store_and_callback() {
437        let store = make_store();
438        let pm = PersonaManager::new().with_state_store(store.clone());
439        let received = Arc::new(parking_lot::Mutex::new(None::<String>));
440        let received_cb = received.clone();
441        pm.set_reseed_callback(Some(Arc::new(move |prompt| {
442            *received_cb.lock() = prompt;
443        })));
444
445        let cloned = pm.clone();
446        // Cloned manager should persist and reseed via the shared Arcs.
447        cloned.set_active("research").await.unwrap();
448        assert!(received.lock().is_some());
449    }
450}