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
6use anyhow::Result;
7use parking_lot::RwLock;
8
9use super::store::PersonaStore;
10use super::{Persona, default_personas};
11
12/// Manages persona lifecycle and coordinates persona-aware execution.
13#[derive(Debug)]
14pub struct PersonaManager {
15    store: PersonaStore,
16    active_persona_id: RwLock<Option<String>>,
17}
18
19impl PersonaManager {
20    /// Creates a new persona manager with default personas.
21    pub fn new() -> Self {
22        let store = PersonaStore::new();
23        let manager = Self {
24            store,
25            active_persona_id: RwLock::new(None),
26        };
27        manager.create_default_personas();
28        manager
29    }
30
31    /// Creates a new persona manager, optionally loading from existing data.
32    pub fn with_defaults(personas: Vec<Persona>) -> Self {
33        let store = PersonaStore::new();
34        store.load_from_slice(&personas);
35        let this = Self {
36            store,
37            active_persona_id: RwLock::new(None),
38        };
39        // Set the first enabled persona as active by default.
40        if let Some(first) = this.store.list_enabled().into_iter().next() {
41            *this.active_persona_id.write() = Some(first.id);
42        }
43        this
44    }
45
46    /// Returns the current active persona, if any.
47    pub fn get_active_persona(&self) -> Option<Persona> {
48        let active_id = self.active_persona_id.read().clone();
49        active_id.and_then(|id| self.store.get(&id))
50    }
51
52    /// Sets the active persona by ID.
53    pub fn set_active_persona(&self, id: &str) -> Result<()> {
54        // Verify the persona exists and is enabled.
55        let persona = self
56            .store
57            .get(id)
58            .ok_or_else(|| anyhow::anyhow!("Persona '{id}' not found"))?;
59        if !persona.enabled {
60            anyhow::bail!("Persona '{id}' is disabled");
61        }
62        *self.active_persona_id.write() = Some(id.to_string());
63        tracing::info!(persona_id = %id, name = %persona.name, "Active persona set");
64        Ok(())
65    }
66
67    /// Returns the system prompt for the active persona.
68    /// Falls back to a default prompt if no active persona.
69    pub fn active_system_prompt(&self) -> String {
70        self.get_active_persona()
71            .map(|p| p.system_prompt.clone())
72            .unwrap_or_else(|| {
73                "You are a helpful AI assistant that follows the Ouroboros methodology: \
74                 specify before you build, evaluate before you ship."
75                    .to_string()
76            })
77    }
78
79    /// Creates the three default personas (Dev, Review, Research).
80    pub fn create_default_personas(&self) {
81        let defaults = default_personas();
82        for persona in defaults {
83            // Only register if not already present.
84            if self.store.get(&persona.id).is_none() {
85                self.store.register(persona);
86            }
87        }
88        // Set first persona as active if none is set.
89        {
90            let mut active = self.active_persona_id.write();
91            if active.is_none() {
92                *active = Some("dev".to_string());
93            }
94        }
95        tracing::info!("Default personas initialized");
96    }
97
98    /// Returns the first enabled persona, for wiring into OuroborosEngine.
99    pub fn first_enabled(&self) -> Option<Persona> {
100        self.store.list_enabled().into_iter().next()
101    }
102
103    /// Returns the persona store for direct access.
104    pub fn store(&self) -> &PersonaStore {
105        &self.store
106    }
107
108    /// Returns the ID of the active persona.
109    pub fn active_persona_id(&self) -> Option<String> {
110        self.active_persona_id.read().clone()
111    }
112
113    // ── RFC-039 ────────────────────────────────────────────────────────────
114
115    /// Active persona 의 우선순위 결정:
116    ///   1. StateStore `index.json` 의 `active_persona_id` (enabled 면 적용)
117    ///   2. `PersonaConfig.default_persona_id` (enabled 면 적용)
118    ///   3. store 의 첫 번째 enabled
119    ///   4. None
120    ///
121    /// `&self` — `Arc<PersonaManager>` 뒤에서 호출됨.
122    pub fn apply_config(&self, cfg: &crate::config::PersonaConfig) {
123        // 우선순위 1: 기존 active_persona_id 가 enabled 면 유지
124        // (load_from_state_store 가 이미 StateStore 의 active_persona_id 를 박았음).
125        if let Some(id) = self.active_persona_id()
126            && self.store.get(&id).map(|p| p.enabled).unwrap_or(false)
127        {
128            return;
129        }
130        // 우선순위 2: config.default_persona_id
131        if let Some(id) = cfg.default_persona_id.as_ref()
132            && self.store.get(id).map(|p| p.enabled).unwrap_or(false)
133        {
134            *self.active_persona_id.write() = Some(id.clone());
135            return;
136        }
137        // 우선순위 3: 첫 번째 enabled
138        if let Some(p) = self.store.list_enabled().into_iter().next() {
139            *self.active_persona_id.write() = Some(p.id);
140        }
141    }
142
143    /// StateStore 에서 페르소나 + active_persona_id 를 로드.
144    /// 손상·부재 시 silent fallback 하지 않고 `Result::Err` 로 전파.
145    /// 호출자는 defaults 가 이미 new() 로 박혀 있음을 알고 있어야 함.
146    pub async fn load_from_state_store(
147        &self,
148        store: &crate::state_store::StateStore,
149    ) -> Result<()> {
150        let snap = crate::persona::persistence::load_from_state_store(store)
151            .await?
152            .ok_or_else(|| anyhow::anyhow!("persona: no snapshot present"))?;
153        // store 가 이미 기본 페르소나를 들고 있어도 디스크가 우선.
154        for p in &snap.personas {
155            self.store.register(p.clone());
156        }
157        if let Some(active) = snap.active_persona_id
158            && snap.personas.iter().any(|p| p.id == active && p.enabled)
159        {
160            *self.active_persona_id.write() = Some(active);
161        }
162        Ok(())
163    }
164
165    /// StateStore 에 페르소나 + active_persona_id 를 저장.
166    /// 메모리 상태는 유지, IO 실패는 Result 로 전파.
167    pub async fn persist(&self, store: &crate::state_store::StateStore) -> Result<()> {
168        let snapshot = crate::persona::persistence::PersonaSnapshot {
169            schema_version: 1,
170            active_persona_id: self.active_persona_id(),
171            personas: self.store.list_all(),
172        };
173        crate::persona::persistence::save_to_state_store(store, &snapshot).await
174    }
175
176    /// 글로벌 활성 페르소나 변경. 성공 시 슬롯 변경 + StateStore flush.
177    /// 새 system_prompt 를 `Ok(Some(prompt))` 로 반환 — 호출자가
178    /// `IntentEngine::set_persona_prompt` 로 직접 재시드 (kernel ↔ ouroboros
179    /// 의존성 방향 회피).
180    ///
181    /// `&self` — interior mutability (Arc 뒤 호출 가능).
182    pub async fn set_active(
183        &self,
184        id: &str,
185        store: Option<&crate::state_store::StateStore>,
186    ) -> Result<Option<String>> {
187        let persona = self
188            .store
189            .get(id)
190            .ok_or_else(|| anyhow::anyhow!("Persona '{id}' not found"))?;
191        if !persona.enabled {
192            anyhow::bail!("Persona '{id}' is disabled");
193        }
194        *self.active_persona_id.write() = Some(id.to_string());
195        tracing::info!(persona_id = %id, name = %persona.name, "Active persona set");
196        if let Some(s) = store {
197            self.persist(s).await?;
198        }
199        Ok(Some(persona.system_prompt))
200    }
201}
202
203// IntentReseed 트레이트는 의도적으로 두지 않는다 — 위 set_active docstring 참조.
204// (oxios-ouroboros 는 oxios-kernel 에 의존하지 않으므로 트레이트를 kernel 안에
205//  둘 수 없음. 대신 set_active 가 새 system_prompt 를 Ok(Some(prompt)) 로
206//  반환하고, 호출자가 IntentEngine::set_persona_prompt 를 직접 호출.)
207
208impl Default for PersonaManager {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214impl Clone for PersonaManager {
215    fn clone(&self) -> Self {
216        let personas: Vec<Persona> = self.store.list_all();
217        let store = PersonaStore::new();
218        store.load_from_slice(&personas);
219        Self {
220            store,
221            active_persona_id: RwLock::new(self.active_persona_id.read().clone()),
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::config::PersonaConfig;
230    // PersonaSnapshot is used indirectly via persistence module
231    use crate::state_store::StateStore;
232
233    fn make_store() -> StateStore {
234        let dir = tempfile::tempdir().unwrap();
235        StateStore::new(dir.keep()).unwrap()
236    }
237
238    #[tokio::test]
239    async fn test_load_from_state_store_round_trip() {
240        let store = make_store();
241        let pm = PersonaManager::new();
242        // Create a custom persona, persist, then load into a fresh manager.
243        let custom = Persona {
244            id: "custom-1".to_string(),
245            name: "Custom".to_string(),
246            role: "custom".to_string(),
247            description: "Custom test persona".to_string(),
248            system_prompt: "You are Custom.".to_string(),
249            enabled: true,
250            model: None,
251            personality_traits: vec![],
252        };
253        pm.store().register(custom);
254        pm.set_active_persona("custom-1").unwrap();
255        pm.persist(&store).await.unwrap();
256
257        // Fresh manager — load from disk.
258        let pm2 = PersonaManager::new();
259        pm2.load_from_state_store(&store).await.unwrap();
260        assert!(pm2.store().get("custom-1").is_some());
261        assert_eq!(pm2.active_persona_id(), Some("custom-1".to_string()));
262        // Defaults should also still be present (new() created them).
263        assert!(pm2.store().get("dev").is_some());
264    }
265
266    #[tokio::test]
267    async fn test_load_no_file_is_ok() {
268        let store = make_store();
269        let pm = PersonaManager::new();
270        let result = pm.load_from_state_store(&store).await;
271        // No file → Err (we require a snapshot). But defaults from new() remain.
272        assert!(result.is_err());
273        assert!(pm.store().get("dev").is_some());
274    }
275
276    #[tokio::test]
277    async fn test_apply_config_default_persona_id() {
278        let pm = PersonaManager::new();
279        // Clear active so apply_config must pick from config.
280        *pm.active_persona_id.write() = None;
281        let cfg = PersonaConfig {
282            default_persona_id: Some("review".to_string()),
283        };
284        pm.apply_config(&cfg);
285        assert_eq!(pm.active_persona_id(), Some("review".to_string()));
286    }
287
288    #[tokio::test]
289    async fn test_apply_config_falls_back_to_first_enabled() {
290        let pm = PersonaManager::new();
291        *pm.active_persona_id.write() = None;
292        let cfg = PersonaConfig {
293            default_persona_id: None,
294        };
295        pm.apply_config(&cfg);
296        // First enabled persona is "dev" (order: dev, review, research).
297        assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
298    }
299
300    #[tokio::test]
301    async fn test_apply_config_skips_disabled_default() {
302        let pm = PersonaManager::new();
303        *pm.active_persona_id.write() = None;
304        // "review" is enabled by default; disable it.
305        pm.store().set_enabled("review", false).unwrap();
306        let cfg = PersonaConfig {
307            default_persona_id: Some("review".to_string()),
308        };
309        pm.apply_config(&cfg);
310        // review is disabled → fall back to first enabled = dev.
311        assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
312    }
313
314    #[tokio::test]
315    async fn test_apply_config_keeps_existing_active() {
316        let pm = PersonaManager::new();
317        pm.set_active_persona("research").unwrap();
318        let cfg = PersonaConfig {
319            default_persona_id: Some("dev".to_string()),
320        };
321        pm.apply_config(&cfg);
322        // Existing active (research, enabled) wins over config default.
323        assert_eq!(pm.active_persona_id(), Some("research".to_string()));
324    }
325
326    #[tokio::test]
327    async fn test_set_active_rejects_disabled() {
328        let pm = PersonaManager::new();
329        pm.store().set_enabled("review", false).unwrap();
330        let result = pm.set_active("review", None).await;
331        assert!(result.is_err());
332        assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
333    }
334
335    #[tokio::test]
336    async fn test_set_active_rejects_unknown_id() {
337        let pm = PersonaManager::new();
338        let result = pm.set_active("nonexistent", None).await;
339        assert!(result.is_err());
340    }
341
342    #[tokio::test]
343    async fn test_set_active_returns_system_prompt() {
344        let pm = PersonaManager::new();
345        let prompt = pm.set_active("review", None).await.unwrap();
346        assert!(prompt.is_some());
347        assert!(prompt.unwrap().contains("Review"));
348    }
349
350    #[tokio::test]
351    async fn test_persist_round_trip_preserves_active() {
352        let store = make_store();
353        let pm = PersonaManager::new();
354        pm.set_active_persona("research").unwrap();
355        pm.persist(&store).await.unwrap();
356
357        let pm2 = PersonaManager::new();
358        pm2.load_from_state_store(&store).await.unwrap();
359        assert_eq!(pm2.active_persona_id(), Some("research".to_string()));
360    }
361}