Skip to main content

room_plugin_agent/
personalities.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6/// A named agent personality preset.
7///
8/// Personalities define everything needed to spawn an agent: model, tool
9/// restrictions, prompt, and naming conventions. They are resolved from
10/// user-defined TOML files (`~/.room/personalities/<name>.toml`) first,
11/// then built-in defaults compiled into the binary.
12#[derive(Debug, Clone, Deserialize)]
13pub struct Personality {
14    pub personality: PersonalityCore,
15    #[serde(default)]
16    pub tools: ToolConfig,
17    #[serde(default)]
18    pub prompt: PromptConfig,
19    #[serde(default)]
20    pub naming: NamingConfig,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct PersonalityCore {
25    pub name: String,
26    pub description: String,
27    #[serde(default = "default_model")]
28    pub model: String,
29}
30
31fn default_model() -> String {
32    "sonnet".to_owned()
33}
34
35#[derive(Debug, Clone, Default, Deserialize)]
36pub struct ToolConfig {
37    #[serde(default)]
38    pub allow: Vec<String>,
39    #[serde(default)]
40    pub disallow: Vec<String>,
41    #[serde(default)]
42    pub allow_all: bool,
43}
44
45#[derive(Debug, Clone, Default, Deserialize)]
46pub struct PromptConfig {
47    #[serde(default)]
48    pub template: String,
49}
50
51#[derive(Debug, Clone, Default, Deserialize)]
52pub struct NamingConfig {
53    #[serde(default)]
54    pub name_pool: Vec<String>,
55}
56
57impl Personality {
58    /// Generate a username for this personality.
59    ///
60    /// If a name pool is configured and `used_names` doesn't exhaust it,
61    /// picks an unused name from the pool. Otherwise falls back to
62    /// `<personality>-<short-uuid>`.
63    pub fn generate_username(&self, used_names: &[String]) -> String {
64        let prefix = &self.personality.name;
65
66        // Try name pool first
67        for name in &self.naming.name_pool {
68            let candidate = format!("{prefix}-{name}");
69            if !used_names.iter().any(|u| u == &candidate) {
70                return candidate;
71            }
72        }
73
74        // Fallback: short UUID
75        let short = &uuid::Uuid::new_v4().to_string()[..8];
76        format!("{prefix}-{short}")
77    }
78}
79
80// ── Built-in personalities ──────────────────────────────────────────────────
81
82fn builtin_coder() -> Personality {
83    Personality {
84        personality: PersonalityCore {
85            name: "coder".to_owned(),
86            description: "Development agent — reads, writes, tests, commits".to_owned(),
87            model: "opus".to_owned(),
88        },
89        tools: ToolConfig::default(),
90        prompt: PromptConfig {
91            template: "You are a development agent. Your workflow:\n\
92                1. Poll the taskboard for available tasks\n\
93                2. Claim a task and announce your plan\n\
94                3. Implement, test, and open a PR\n\
95                4. Announce completion and return to idle"
96                .to_owned(),
97        },
98        naming: NamingConfig {
99            name_pool: vec![
100                "anna".to_owned(),
101                "kai".to_owned(),
102                "nova".to_owned(),
103                "zara".to_owned(),
104                "leo".to_owned(),
105                "mika".to_owned(),
106            ],
107        },
108    }
109}
110
111fn builtin_reviewer() -> Personality {
112    Personality {
113        personality: PersonalityCore {
114            name: "reviewer".to_owned(),
115            description: "PR reviewer — read-only code access, gh commands".to_owned(),
116            model: "sonnet".to_owned(),
117        },
118        tools: ToolConfig {
119            disallow: vec!["Write".to_owned(), "Edit".to_owned()],
120            ..Default::default()
121        },
122        prompt: PromptConfig {
123            template: "You are a code reviewer. Focus on correctness, test coverage, \
124                and adherence to the project's coding standards. Use `gh pr` commands \
125                to leave reviews."
126                .to_owned(),
127        },
128        naming: NamingConfig {
129            name_pool: vec!["alice".to_owned(), "bob".to_owned(), "charlie".to_owned()],
130        },
131    }
132}
133
134fn builtin_scout() -> Personality {
135    Personality {
136        personality: PersonalityCore {
137            name: "scout".to_owned(),
138            description: "Codebase explorer — search and summarize only".to_owned(),
139            model: "haiku".to_owned(),
140        },
141        tools: ToolConfig {
142            disallow: vec!["Write".to_owned(), "Edit".to_owned(), "Bash".to_owned()],
143            ..Default::default()
144        },
145        prompt: PromptConfig {
146            template: "You are a codebase explorer. Search and summarize code, \
147                answer questions about architecture and patterns. Do not modify files."
148                .to_owned(),
149        },
150        naming: NamingConfig {
151            name_pool: vec!["hawk".to_owned(), "owl".to_owned(), "fox".to_owned()],
152        },
153    }
154}
155
156fn builtin_qa() -> Personality {
157    Personality {
158        personality: PersonalityCore {
159            name: "qa".to_owned(),
160            description: "Test writer — finds coverage gaps, writes tests".to_owned(),
161            model: "sonnet".to_owned(),
162        },
163        tools: ToolConfig::default(),
164        prompt: PromptConfig {
165            template: "You are a QA agent. Your workflow:\n\
166                1. Identify test coverage gaps\n\
167                2. Write unit and integration tests\n\
168                3. Ensure all tests pass\n\
169                4. Open a PR with the new tests"
170                .to_owned(),
171        },
172        naming: NamingConfig {
173            name_pool: vec!["tara".to_owned(), "reo".to_owned(), "juno".to_owned()],
174        },
175    }
176}
177
178fn builtin_coordinator() -> Personality {
179    Personality {
180        personality: PersonalityCore {
181            name: "coordinator".to_owned(),
182            description: "BA/triage — reads code, manages issues, coordinates".to_owned(),
183            model: "sonnet".to_owned(),
184        },
185        tools: ToolConfig {
186            disallow: vec!["Write".to_owned(), "Edit".to_owned()],
187            ..Default::default()
188        },
189        prompt: PromptConfig {
190            template: "You are a coordinator agent. Triage issues, manage the taskboard, \
191                review plans, and coordinate work across agents. Do not modify code directly."
192                .to_owned(),
193        },
194        naming: NamingConfig {
195            name_pool: vec!["sage".to_owned(), "atlas".to_owned()],
196        },
197    }
198}
199
200/// Returns the built-in personality defaults compiled into the binary.
201pub fn builtin_personalities() -> HashMap<String, Personality> {
202    let mut map = HashMap::new();
203    for p in [
204        builtin_coder(),
205        builtin_reviewer(),
206        builtin_scout(),
207        builtin_qa(),
208        builtin_coordinator(),
209    ] {
210        map.insert(p.personality.name.clone(), p);
211    }
212    map
213}
214
215/// Returns the list of all known personality names (built-in + user-defined).
216pub fn all_personality_names() -> Vec<String> {
217    let mut names: Vec<String> = builtin_personalities().keys().cloned().collect();
218
219    // Add user-defined personalities from ~/.room/personalities/
220    if let Some(dir) = personalities_dir() {
221        if let Ok(entries) = std::fs::read_dir(&dir) {
222            for entry in entries.flatten() {
223                let path = entry.path();
224                if path.extension().is_some_and(|e| e == "toml") {
225                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
226                        if !names.contains(&stem.to_owned()) {
227                            names.push(stem.to_owned());
228                        }
229                    }
230                }
231            }
232        }
233    }
234
235    names.sort();
236    names
237}
238
239/// Resolve a personality by name.
240///
241/// Resolution order:
242/// 1. User-defined TOML at `~/.room/personalities/<name>.toml`
243/// 2. Built-in defaults compiled into the binary
244///
245/// User-defined TOML files fully replace built-ins with the same name.
246pub fn resolve_personality(name: &str) -> Option<Personality> {
247    // 1. Try user-defined TOML
248    if let Some(dir) = personalities_dir() {
249        let toml_path = dir.join(format!("{name}.toml"));
250        if let Some(p) = load_personality_toml(&toml_path) {
251            return Some(p);
252        }
253    }
254
255    // 2. Try built-in
256    builtin_personalities().remove(name)
257}
258
259/// Load a personality from a TOML file, returning None on any error.
260fn load_personality_toml(path: &Path) -> Option<Personality> {
261    let content = std::fs::read_to_string(path).ok()?;
262    toml::from_str(&content).ok()
263}
264
265/// Returns the personality directory path (`~/.room/personalities/`).
266fn personalities_dir() -> Option<PathBuf> {
267    dirs_path().map(|d| d.join("personalities"))
268}
269
270/// Returns `~/.room` base path.
271fn dirs_path() -> Option<PathBuf> {
272    std::env::var("HOME")
273        .ok()
274        .map(|h| PathBuf::from(h).join(".room"))
275}
276
277// ── Tests ─────────────────────────────────────────────────────────────────────
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn builtin_personalities_has_all_five() {
285        let builtins = builtin_personalities();
286        assert!(builtins.contains_key("coder"));
287        assert!(builtins.contains_key("reviewer"));
288        assert!(builtins.contains_key("scout"));
289        assert!(builtins.contains_key("qa"));
290        assert!(builtins.contains_key("coordinator"));
291        assert_eq!(builtins.len(), 5);
292    }
293
294    #[test]
295    fn builtin_coder_has_opus_model() {
296        let builtins = builtin_personalities();
297        let coder = &builtins["coder"];
298        assert_eq!(coder.personality.model, "opus");
299    }
300
301    #[test]
302    fn builtin_reviewer_disallows_write_edit() {
303        let builtins = builtin_personalities();
304        let reviewer = &builtins["reviewer"];
305        assert!(reviewer.tools.disallow.contains(&"Write".to_owned()));
306        assert!(reviewer.tools.disallow.contains(&"Edit".to_owned()));
307    }
308
309    #[test]
310    fn builtin_scout_disallows_write_edit_bash() {
311        let builtins = builtin_personalities();
312        let scout = &builtins["scout"];
313        assert!(scout.tools.disallow.contains(&"Write".to_owned()));
314        assert!(scout.tools.disallow.contains(&"Edit".to_owned()));
315        assert!(scout.tools.disallow.contains(&"Bash".to_owned()));
316    }
317
318    #[test]
319    fn generate_username_from_name_pool() {
320        let p = builtin_coder();
321        let username = p.generate_username(&[]);
322        assert!(username.starts_with("coder-"));
323        // Should be a name from the pool, not a UUID
324        assert!(
325            p.naming
326                .name_pool
327                .iter()
328                .any(|n| username == format!("coder-{n}")),
329            "expected name from pool, got: {username}"
330        );
331    }
332
333    #[test]
334    fn generate_username_skips_used_names() {
335        let p = builtin_coder();
336        // Mark first name as used
337        let first_name = format!("coder-{}", p.naming.name_pool[0]);
338        let username = p.generate_username(&[first_name.clone()]);
339        assert_ne!(username, first_name);
340        assert!(username.starts_with("coder-"));
341    }
342
343    #[test]
344    fn generate_username_fallback_to_uuid_when_pool_exhausted() {
345        let p = builtin_reviewer();
346        // Exhaust the pool
347        let used: Vec<String> = p
348            .naming
349            .name_pool
350            .iter()
351            .map(|n| format!("reviewer-{n}"))
352            .collect();
353        let username = p.generate_username(&used);
354        assert!(username.starts_with("reviewer-"));
355        // Should be 8-char hex UUID suffix
356        let suffix = username.strip_prefix("reviewer-").unwrap();
357        assert_eq!(suffix.len(), 8);
358    }
359
360    #[test]
361    fn generate_username_empty_pool_uses_uuid() {
362        let mut p = builtin_coder();
363        p.naming.name_pool.clear();
364        let username = p.generate_username(&[]);
365        assert!(username.starts_with("coder-"));
366        let suffix = username.strip_prefix("coder-").unwrap();
367        assert_eq!(suffix.len(), 8);
368    }
369
370    #[test]
371    fn toml_deserialization_roundtrip() {
372        let toml_str = r#"
373[personality]
374name = "custom"
375description = "A custom personality"
376model = "opus"
377
378[tools]
379disallow = ["Bash"]
380
381[prompt]
382template = "You are a custom agent."
383
384[naming]
385name_pool = ["alpha", "beta"]
386"#;
387        let p: Personality = toml::from_str(toml_str).unwrap();
388        assert_eq!(p.personality.name, "custom");
389        assert_eq!(p.personality.description, "A custom personality");
390        assert_eq!(p.personality.model, "opus");
391        assert_eq!(p.tools.disallow, vec!["Bash"]);
392        assert!(p.tools.allow.is_empty());
393        assert!(!p.tools.allow_all);
394        assert_eq!(p.prompt.template, "You are a custom agent.");
395        assert_eq!(p.naming.name_pool, vec!["alpha", "beta"]);
396    }
397
398    #[test]
399    fn toml_deserialization_minimal() {
400        let toml_str = r#"
401[personality]
402name = "minimal"
403description = "Minimal personality"
404"#;
405        let p: Personality = toml::from_str(toml_str).unwrap();
406        assert_eq!(p.personality.name, "minimal");
407        assert_eq!(p.personality.model, "sonnet"); // default
408        assert!(p.tools.disallow.is_empty());
409        assert!(p.tools.allow.is_empty());
410        assert!(p.prompt.template.is_empty());
411        assert!(p.naming.name_pool.is_empty());
412    }
413
414    #[test]
415    fn toml_deserialization_allow_all() {
416        let toml_str = r#"
417[personality]
418name = "unrestricted"
419description = "No tool restrictions"
420
421[tools]
422allow_all = true
423"#;
424        let p: Personality = toml::from_str(toml_str).unwrap();
425        assert!(p.tools.allow_all);
426    }
427
428    #[test]
429    fn resolve_personality_returns_builtin() {
430        let p = resolve_personality("coder").unwrap();
431        assert_eq!(p.personality.name, "coder");
432        assert_eq!(p.personality.model, "opus");
433    }
434
435    #[test]
436    fn resolve_personality_returns_none_for_unknown() {
437        assert!(resolve_personality("nonexistent-personality-xyz").is_none());
438    }
439
440    #[test]
441    fn resolve_personality_user_toml_overrides_builtin() {
442        let dir = tempfile::tempdir().unwrap();
443        let personalities_dir = dir.path().join("personalities");
444        std::fs::create_dir_all(&personalities_dir).unwrap();
445
446        let toml_content = r#"
447[personality]
448name = "coder"
449description = "Custom coder override"
450model = "haiku"
451"#;
452        std::fs::write(personalities_dir.join("coder.toml"), toml_content).unwrap();
453
454        // Test loading directly from the TOML file
455        let p = load_personality_toml(&personalities_dir.join("coder.toml")).unwrap();
456        assert_eq!(p.personality.name, "coder");
457        assert_eq!(p.personality.model, "haiku");
458        assert_eq!(p.personality.description, "Custom coder override");
459    }
460
461    #[test]
462    fn all_personality_names_includes_builtins() {
463        let names = all_personality_names();
464        assert!(names.contains(&"coder".to_owned()));
465        assert!(names.contains(&"reviewer".to_owned()));
466        assert!(names.contains(&"scout".to_owned()));
467        assert!(names.contains(&"qa".to_owned()));
468        assert!(names.contains(&"coordinator".to_owned()));
469    }
470
471    #[test]
472    fn all_personality_names_sorted() {
473        let names = all_personality_names();
474        let mut sorted = names.clone();
475        sorted.sort();
476        assert_eq!(names, sorted);
477    }
478}