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) {
81 let defaults = default_personas();
82 for persona in defaults {
83 if self.store.get(&persona.id).is_none() {
85 self.store.register(persona);
86 }
87 }
88 {
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 pub fn first_enabled(&self) -> Option<Persona> {
100 self.store.list_enabled().into_iter().next()
101 }
102
103 pub fn store(&self) -> &PersonaStore {
105 &self.store
106 }
107
108 pub fn active_persona_id(&self) -> Option<String> {
110 self.active_persona_id.read().clone()
111 }
112
113 pub fn apply_config(&self, cfg: &crate::config::PersonaConfig) {
123 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 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 if let Some(p) = self.store.list_enabled().into_iter().next() {
139 *self.active_persona_id.write() = Some(p.id);
140 }
141 }
142
143 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 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 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 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
203impl 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 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 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 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 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 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 *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 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 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 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 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}