oxios_kernel/persona/
manager.rs1use anyhow::Result;
7use parking_lot::RwLock;
8
9use super::store::PersonaStore;
10use super::{Persona, default_personas};
11
12#[derive(Debug)]
14pub struct PersonaManager {
15 store: PersonaStore,
16 active_persona_id: RwLock<Option<String>>,
17}
18
19impl PersonaManager {
20 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 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 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 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 pub fn set_active_persona(&self, id: &str) -> Result<()> {
54 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 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 pub fn create_default_personas(&self) {
82 let defaults = default_personas();
83 for persona in defaults {
84 if self.store.get(&persona.id).is_none() {
86 self.store.register(persona);
87 }
88 }
89 {
91 let mut active = self.active_persona_id.write();
92 if active.is_none() {
93 *active = Some("dev".to_string());
94 }
95 }
96 tracing::info!("Default personas initialized");
97 }
98
99 pub fn first_enabled(&self) -> Option<Persona> {
101 self.store.list_enabled().into_iter().next()
102 }
103
104 pub fn store(&self) -> &PersonaStore {
106 &self.store
107 }
108
109 pub fn active_persona_id(&self) -> Option<String> {
111 self.active_persona_id.read().clone()
112 }
113
114 pub fn apply_config(&self, cfg: &crate::config::PersonaConfig) {
124 if let Some(id) = self.active_persona_id()
127 && self.store.get(&id).map(|p| p.enabled).unwrap_or(false)
128 {
129 return;
130 }
131 if let Some(id) = cfg.default_persona_id.as_ref()
133 && self.store.get(id).map(|p| p.enabled).unwrap_or(false)
134 {
135 *self.active_persona_id.write() = Some(id.clone());
136 return;
137 }
138 if let Some(p) = self.store.list_enabled().into_iter().next() {
140 *self.active_persona_id.write() = Some(p.id);
141 }
142 }
143
144 pub async fn load_from_state_store(
148 &self,
149 store: &crate::state_store::StateStore,
150 ) -> Result<()> {
151 let snap = crate::persona::persistence::load_from_state_store(store)
152 .await?
153 .ok_or_else(|| anyhow::anyhow!("persona: no snapshot present"))?;
154 for p in &snap.personas {
156 self.store.register(p.clone());
157 }
158 if let Some(active) = snap.active_persona_id
159 && snap.personas.iter().any(|p| p.id == active && p.enabled)
160 {
161 *self.active_persona_id.write() = Some(active);
162 }
163 Ok(())
164 }
165
166 pub async fn persist(&self, store: &crate::state_store::StateStore) -> Result<()> {
169 let snapshot = crate::persona::persistence::PersonaSnapshot {
170 schema_version: 1,
171 active_persona_id: self.active_persona_id(),
172 personas: self.store.list_all(),
173 };
174 crate::persona::persistence::save_to_state_store(store, &snapshot).await
175 }
176
177 pub async fn set_active(
184 &self,
185 id: &str,
186 store: Option<&crate::state_store::StateStore>,
187 ) -> Result<Option<String>> {
188 let persona = self
189 .store
190 .get(id)
191 .ok_or_else(|| anyhow::anyhow!("Persona '{id}' not found"))?;
192 if !persona.enabled {
193 anyhow::bail!("Persona '{id}' is disabled");
194 }
195 *self.active_persona_id.write() = Some(id.to_string());
196 tracing::info!(persona_id = %id, name = %persona.name, "Active persona set");
197 if let Some(s) = store {
198 self.persist(s).await?;
199 }
200 Ok(Some(persona.system_prompt))
201 }
202}
203
204impl Default for PersonaManager {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215impl Clone for PersonaManager {
216 fn clone(&self) -> Self {
217 let personas: Vec<Persona> = self.store.list_all();
218 let store = PersonaStore::new();
219 store.load_from_slice(&personas);
220 Self {
221 store,
222 active_persona_id: RwLock::new(self.active_persona_id.read().clone()),
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::config::PersonaConfig;
231 use crate::state_store::StateStore;
233
234 fn make_store() -> StateStore {
235 let dir = tempfile::tempdir().unwrap();
236 StateStore::new(dir.keep()).unwrap()
237 }
238
239 #[tokio::test]
240 async fn test_load_from_state_store_round_trip() {
241 let store = make_store();
242 let pm = PersonaManager::new();
243 let custom = Persona {
245 id: "custom-1".to_string(),
246 name: "Custom".to_string(),
247 role: "custom".to_string(),
248 description: "Custom test persona".to_string(),
249 system_prompt: "You are Custom.".to_string(),
250 enabled: true,
251 model: None,
252 personality_traits: vec![],
253 };
254 pm.store().register(custom);
255 pm.set_active_persona("custom-1").unwrap();
256 pm.persist(&store).await.unwrap();
257
258 let pm2 = PersonaManager::new();
260 pm2.load_from_state_store(&store).await.unwrap();
261 assert!(pm2.store().get("custom-1").is_some());
262 assert_eq!(pm2.active_persona_id(), Some("custom-1".to_string()));
263 assert!(pm2.store().get("dev").is_some());
265 }
266
267 #[tokio::test]
268 async fn test_load_no_file_is_ok() {
269 let store = make_store();
270 let pm = PersonaManager::new();
271 let result = pm.load_from_state_store(&store).await;
272 assert!(result.is_err());
274 assert!(pm.store().get("dev").is_some());
275 }
276
277 #[tokio::test]
278 async fn test_apply_config_default_persona_id() {
279 let pm = PersonaManager::new();
280 *pm.active_persona_id.write() = None;
282 let cfg = PersonaConfig {
283 default_persona_id: Some("review".to_string()),
284 };
285 pm.apply_config(&cfg);
286 assert_eq!(pm.active_persona_id(), Some("review".to_string()));
287 }
288
289 #[tokio::test]
290 async fn test_apply_config_falls_back_to_first_enabled() {
291 let pm = PersonaManager::new();
292 *pm.active_persona_id.write() = None;
293 let cfg = PersonaConfig {
294 default_persona_id: None,
295 };
296 pm.apply_config(&cfg);
297 assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
299 }
300
301 #[tokio::test]
302 async fn test_apply_config_skips_disabled_default() {
303 let pm = PersonaManager::new();
304 *pm.active_persona_id.write() = None;
305 pm.store().set_enabled("review", false).unwrap();
307 let cfg = PersonaConfig {
308 default_persona_id: Some("review".to_string()),
309 };
310 pm.apply_config(&cfg);
311 assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
313 }
314
315 #[tokio::test]
316 async fn test_apply_config_keeps_existing_active() {
317 let pm = PersonaManager::new();
318 pm.set_active_persona("research").unwrap();
319 let cfg = PersonaConfig {
320 default_persona_id: Some("dev".to_string()),
321 };
322 pm.apply_config(&cfg);
323 assert_eq!(pm.active_persona_id(), Some("research".to_string()));
325 }
326
327 #[tokio::test]
328 async fn test_set_active_rejects_disabled() {
329 let pm = PersonaManager::new();
330 pm.store().set_enabled("review", false).unwrap();
331 let result = pm.set_active("review", None).await;
332 assert!(result.is_err());
333 assert_eq!(pm.active_persona_id(), Some("dev".to_string()));
334 }
335
336 #[tokio::test]
337 async fn test_set_active_rejects_unknown_id() {
338 let pm = PersonaManager::new();
339 let result = pm.set_active("nonexistent", None).await;
340 assert!(result.is_err());
341 }
342
343 #[tokio::test]
344 async fn test_set_active_returns_system_prompt() {
345 let pm = PersonaManager::new();
346 let prompt = pm.set_active("review", None).await.unwrap();
347 assert!(prompt.is_some());
348 assert!(prompt.unwrap().contains("Review"));
349 }
350
351 #[tokio::test]
352 async fn test_persist_round_trip_preserves_active() {
353 let store = make_store();
354 let pm = PersonaManager::new();
355 pm.set_active_persona("research").unwrap();
356 pm.persist(&store).await.unwrap();
357
358 let pm2 = PersonaManager::new();
359 pm2.load_from_state_store(&store).await.unwrap();
360 assert_eq!(pm2.active_persona_id(), Some("research".to_string()));
361 }
362}