gglib_core/domain/council/
role_catalog.rs

1//! Built-in role catalog for the orchestrator v2 hierarchical planner.
2//!
3//! A [`RoleId`] identifies a specialist role that a [`TaskNode`] can adopt.
4//! The [`RoleCatalog`] maps role names to [`RoleSpec`] entries that carry the
5//! system-prompt fragment, default tool allowlist, suggested sampling
6//! temperature, and approval policy for that role.
7//!
8//! # Built-in roles
9//!
10//! | Role id | Purpose |
11//! |---------|---------|
12//! | `researcher` | Information gathering and source retrieval |
13//! | `red-team` | Adversarial challenge and stress-testing of plans |
14//! | `fact-checker` | Verification of claims against retrieved evidence |
15//! | `writer` | First-draft prose generation |
16//! | `editor` | Revision and polish of existing drafts |
17//! | `critic` | Structured critique and gap identification |
18//! | `synthesizer` | Final integration of multiple node outputs |
19//!
20//! YAML-overridable catalogs are deferred to a future phase.
21
22use std::collections::HashMap;
23
24use serde::{Deserialize, Serialize};
25
26// =============================================================================
27// RoleId
28// =============================================================================
29
30/// Opaque identifier for a specialist role within a task-force subgraph.
31///
32/// Short, kebab-case strings are recommended (e.g. `"researcher"`, `"red-team"`).
33///
34/// # Example
35///
36/// ```rust
37/// use gglib_core::domain::council::role_catalog::RoleId;
38///
39/// let id = RoleId::new("researcher");
40/// assert_eq!(id.as_str(), "researcher");
41/// assert_eq!(id.to_string(), "researcher");
42/// ```
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub struct RoleId(pub String);
45
46impl RoleId {
47    /// Create a new [`RoleId`] from any string-like value.
48    ///
49    /// # Example
50    ///
51    /// ```rust
52    /// use gglib_core::domain::council::role_catalog::RoleId;
53    ///
54    /// let id = RoleId::new("synthesizer");
55    /// assert_eq!(id.as_str(), "synthesizer");
56    /// ```
57    pub fn new(s: impl Into<String>) -> Self {
58        Self(s.into())
59    }
60
61    /// Return the inner string slice.
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65}
66
67impl std::fmt::Display for RoleId {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.write_str(&self.0)
70    }
71}
72
73// =============================================================================
74// RoleSpec
75// =============================================================================
76
77/// Specification for a single specialist role.
78///
79/// Carried inside [`RoleCatalog`] and resolved at planning time by the
80/// hierarchical director (Phase H).
81#[derive(Debug, Clone)]
82pub struct RoleSpec {
83    /// Short, human-readable display name (title-case).
84    pub display_name: &'static str,
85    /// One-paragraph system-prompt fragment injected before the worker's goal.
86    pub system_prompt_fragment: &'static str,
87    /// Tool names this role is permitted to call by default.
88    ///
89    /// The director may widen or narrow this list per-node at plan time.
90    pub default_tool_allowlist: &'static [&'static str],
91    /// Suggested sampling temperature for this role's LLM calls (0.0–1.0).
92    pub suggested_temperature: f32,
93    /// Whether this role's output requires human approval before being passed
94    /// downstream.
95    pub requires_approval: bool,
96}
97
98// =============================================================================
99// RoleCatalog
100// =============================================================================
101
102/// Immutable map of [`RoleId`] → [`RoleSpec`] for the built-in specialist roles.
103///
104/// Construct via [`RoleCatalog::default()`]; the seven built-in roles are
105/// always present.  YAML-overridable catalogs are deferred to a future phase.
106///
107/// # Example
108///
109/// ```rust
110/// use gglib_core::domain::council::role_catalog::{RoleCatalog, RoleId};
111///
112/// let catalog = RoleCatalog::default();
113/// assert_eq!(catalog.len(), 7);
114/// assert!(catalog.get(&RoleId::new("researcher")).is_some());
115/// assert!(catalog.get(&RoleId::new("unknown-role")).is_none());
116/// ```
117pub struct RoleCatalog {
118    roles: HashMap<RoleId, RoleSpec>,
119}
120
121impl Default for RoleCatalog {
122    fn default() -> Self {
123        let mut roles: HashMap<RoleId, RoleSpec> = HashMap::with_capacity(7);
124
125        roles.insert(
126            RoleId::new("researcher"),
127            RoleSpec {
128                display_name: "Researcher",
129                system_prompt_fragment: "You are a specialist researcher. Your job is to gather \
130                    accurate, relevant information from the sources available to you. Prefer \
131                    primary sources. Cite your evidence clearly. Do not synthesise or \
132                    editorialize — return facts and summaries of what you found.",
133                default_tool_allowlist: &["web_search", "read_file"],
134                suggested_temperature: 0.3,
135                requires_approval: false,
136            },
137        );
138
139        roles.insert(
140            RoleId::new("red-team"),
141            RoleSpec {
142                display_name: "Red Team",
143                system_prompt_fragment: "You are an adversarial critic. Your job is to find \
144                    weaknesses, edge cases, and failure modes in the plan or content presented \
145                    to you. Be specific and constructive. Do not simply disagree — provide \
146                    concrete counter-arguments and evidence where possible.",
147                default_tool_allowlist: &[],
148                suggested_temperature: 0.7,
149                requires_approval: false,
150            },
151        );
152
153        roles.insert(
154            RoleId::new("fact-checker"),
155            RoleSpec {
156                display_name: "Fact Checker",
157                system_prompt_fragment: "You are a rigorous fact-checker. Your job is to verify \
158                    every claim in the content presented to you. For each claim, determine whether \
159                    it is supported, unsupported, or contradicted by available evidence. Return a \
160                    structured list of verdicts with your evidence.",
161                default_tool_allowlist: &["web_search"],
162                suggested_temperature: 0.2,
163                requires_approval: false,
164            },
165        );
166
167        roles.insert(
168            RoleId::new("writer"),
169            RoleSpec {
170                display_name: "Writer",
171                system_prompt_fragment: "You are a skilled writer. Your job is to produce a \
172                    first draft of high-quality prose based on the research and outlines provided \
173                    to you. Write clearly and engagingly. Do not pad with filler. Follow any \
174                    specified format, tone, or length constraints precisely.",
175                default_tool_allowlist: &[],
176                suggested_temperature: 0.7,
177                requires_approval: false,
178            },
179        );
180
181        roles.insert(
182            RoleId::new("editor"),
183            RoleSpec {
184                display_name: "Editor",
185                system_prompt_fragment: "You are a professional editor. Your job is to revise \
186                    and polish the draft provided to you. Improve clarity, flow, grammar, and \
187                    conciseness without changing the author's voice or intent. Return the revised \
188                    full text.",
189                default_tool_allowlist: &[],
190                suggested_temperature: 0.4,
191                requires_approval: false,
192            },
193        );
194
195        roles.insert(
196            RoleId::new("critic"),
197            RoleSpec {
198                display_name: "Critic",
199                system_prompt_fragment: "You are a structured critic. Your job is to identify \
200                    gaps, logical inconsistencies, and areas for improvement in the content \
201                    provided. Return a numbered list of specific, actionable critiques. Do not \
202                    rewrite the content — only identify what needs changing and why.",
203                default_tool_allowlist: &[],
204                suggested_temperature: 0.5,
205                requires_approval: false,
206            },
207        );
208
209        roles.insert(
210            RoleId::new("synthesizer"),
211            RoleSpec {
212                display_name: "Synthesizer",
213                system_prompt_fragment: "You are a synthesis specialist. Your job is to \
214                    integrate the outputs from multiple preceding workers into a single coherent, \
215                    well-structured response. Eliminate redundancy. Resolve contradictions by \
216                    noting them explicitly. Return a unified, polished result.",
217                default_tool_allowlist: &[],
218                suggested_temperature: 0.5,
219                requires_approval: false,
220            },
221        );
222
223        Self { roles }
224    }
225}
226
227impl RoleCatalog {
228    /// Look up a role by id.
229    ///
230    /// Returns `None` if no role with the given id exists in the catalog.
231    ///
232    /// # Example
233    ///
234    /// ```rust
235    /// use gglib_core::domain::council::role_catalog::{RoleCatalog, RoleId};
236    ///
237    /// let catalog = RoleCatalog::default();
238    /// let spec = catalog.get(&RoleId::new("writer")).unwrap();
239    /// assert_eq!(spec.display_name, "Writer");
240    /// assert!(catalog.get(&RoleId::new("nonexistent")).is_none());
241    /// ```
242    pub fn get(&self, id: &RoleId) -> Option<&RoleSpec> {
243        self.roles.get(id)
244    }
245
246    /// Return the number of roles in the catalog.
247    ///
248    /// # Example
249    ///
250    /// ```rust
251    /// use gglib_core::domain::council::role_catalog::RoleCatalog;
252    ///
253    /// let catalog = RoleCatalog::default();
254    /// assert_eq!(catalog.len(), 7);
255    /// ```
256    pub fn len(&self) -> usize {
257        self.roles.len()
258    }
259
260    /// Return `true` if the catalog contains no roles.
261    pub fn is_empty(&self) -> bool {
262        self.roles.is_empty()
263    }
264
265    /// Return an iterator over `(RoleId, RoleSpec)` pairs.
266    pub fn iter(&self) -> impl Iterator<Item = (&RoleId, &RoleSpec)> {
267        self.roles.iter()
268    }
269}
270
271// =============================================================================
272// Tests
273// =============================================================================
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn default_catalog_has_seven_roles() {
281        let catalog = RoleCatalog::default();
282        assert_eq!(catalog.len(), 7);
283    }
284
285    #[test]
286    fn all_builtin_role_ids_resolve() {
287        let catalog = RoleCatalog::default();
288        for name in [
289            "researcher",
290            "red-team",
291            "fact-checker",
292            "writer",
293            "editor",
294            "critic",
295            "synthesizer",
296        ] {
297            assert!(
298                catalog.get(&RoleId::new(name)).is_some(),
299                "built-in role '{name}' missing from catalog"
300            );
301        }
302    }
303
304    #[test]
305    fn unknown_role_returns_none() {
306        let catalog = RoleCatalog::default();
307        assert!(catalog.get(&RoleId::new("unknown")).is_none());
308    }
309
310    #[test]
311    fn researcher_spec_has_web_search_in_allowlist() {
312        let catalog = RoleCatalog::default();
313        let spec = catalog.get(&RoleId::new("researcher")).unwrap();
314        assert!(spec.default_tool_allowlist.contains(&"web_search"));
315    }
316
317    #[test]
318    fn role_id_display_matches_inner_string() {
319        let id = RoleId::new("fact-checker");
320        assert_eq!(id.to_string(), "fact-checker");
321        assert_eq!(id.as_str(), "fact-checker");
322    }
323
324    #[test]
325    fn is_empty_returns_false_for_default_catalog() {
326        let catalog = RoleCatalog::default();
327        assert!(!catalog.is_empty());
328    }
329
330    #[test]
331    fn iter_yields_seven_entries() {
332        let catalog = RoleCatalog::default();
333        assert_eq!(catalog.iter().count(), 7);
334    }
335}