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}