oxios_kernel/persona/
manager.rs1use 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
21pub struct PersonaManager {
23 store: PersonaStore,
24 active_persona_id: RwLock<Option<String>>,
25 state_store: Option<Arc<StateStore>>,
28 #[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 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 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 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 pub fn with_state_store(mut self, store: Arc<StateStore>) -> Self {
79 self.state_store = Some(store);
80 self
81 }
82
83 pub fn set_reseed_callback(&self, cb: Option<Arc<dyn Fn(Option<String>) + Send + Sync>>) {
86 *self.reseed_callback.write() = cb;
87 }
88
89 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 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 pub fn create_default_personas(&self) {
110 let defaults = default_personas();
111 for persona in defaults {
112 if self.store.get(&persona.id).is_none() {
114 self.store.register(persona);
115 }
116 }
117 {
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 pub fn first_enabled(&self) -> Option<Persona> {
129 self.store.list_enabled().into_iter().next()
130 }
131
132 pub fn store(&self) -> &PersonaStore {
134 &self.store
135 }
136
137 pub fn active_persona_id(&self) -> Option<String> {
139 self.active_persona_id.read().clone()
140 }
141
142 pub fn apply_config(&self, cfg: &crate::config::PersonaConfig) {
152 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 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 if let Some(p) = self.store.list_enabled().into_iter().next() {
168 *self.active_persona_id.write() = Some(p.id);
169 }
170 }
171
172 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 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 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 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 if let Err(e) = self.persist().await {
228 tracing::warn!(error = %e, "persona set_active: persist failed");
229 }
230
231 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 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 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 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 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 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 *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 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 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 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 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 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 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 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.set_active("research").await.unwrap();
448 assert!(received.lock().is_some());
449 }
450}