Skip to main content

ralph_proto/
hat.rs

1//! Hat types for agent personas.
2//!
3//! A hat defines how the CLI agent should behave for a given iteration.
4
5use crate::Topic;
6use serde::{Deserialize, Serialize};
7
8/// Unique identifier for a hat.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10pub struct HatId(String);
11
12impl HatId {
13    /// Creates a new hat ID.
14    pub fn new(id: impl Into<String>) -> Self {
15        Self(id.into())
16    }
17
18    /// Returns the ID as a string slice.
19    pub fn as_str(&self) -> &str {
20        &self.0
21    }
22}
23
24impl From<&str> for HatId {
25    fn from(s: &str) -> Self {
26        Self::new(s)
27    }
28}
29
30impl From<String> for HatId {
31    fn from(s: String) -> Self {
32        Self::new(s)
33    }
34}
35
36impl std::fmt::Display for HatId {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "{}", self.0)
39    }
40}
41
42/// A hat (persona) that defines agent behavior.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Hat {
45    /// Unique identifier for this hat.
46    pub id: HatId,
47
48    /// Human-readable name for the hat.
49    pub name: String,
50
51    /// Short description of the hat's purpose.
52    /// Used in the HATS table to help Ralph understand when to delegate.
53    pub description: String,
54
55    /// Topic patterns this hat subscribes to.
56    pub subscriptions: Vec<Topic>,
57
58    /// Topics this hat is expected to publish.
59    pub publishes: Vec<Topic>,
60
61    /// Instructions prepended to prompts for this hat.
62    pub instructions: String,
63}
64
65impl Hat {
66    /// Creates a new hat with the given ID and name.
67    pub fn new(id: impl Into<HatId>, name: impl Into<String>) -> Self {
68        Self {
69            id: id.into(),
70            name: name.into(),
71            description: String::new(),
72            subscriptions: Vec::new(),
73            publishes: Vec::new(),
74            instructions: String::new(),
75        }
76    }
77
78    /// Sets the description for this hat.
79    #[must_use]
80    pub fn with_description(mut self, description: impl Into<String>) -> Self {
81        self.description = description.into();
82        self
83    }
84
85    /// Creates the default hat for single-hat mode.
86    #[deprecated(note = "Use default_planner() and default_builder() instead")]
87    pub fn default_single() -> Self {
88        Self {
89            id: HatId::new("default"),
90            name: "Default".to_string(),
91            description: "Default single-hat mode handler".to_string(),
92            subscriptions: vec![Topic::new("*")],
93            publishes: vec![Topic::new("task.done")],
94            instructions: String::new(),
95        }
96    }
97
98    /// Creates the default planner hat.
99    ///
100    /// Per spec: Planner triggers on `task.start`, `task.resume`, `build.done`, `build.blocked`
101    /// and publishes `build.task`.
102    pub fn default_planner() -> Self {
103        Self {
104            id: HatId::new("planner"),
105            name: "Planner".to_string(),
106            description: "Plans and prioritizes tasks, delegates to Builder".to_string(),
107            subscriptions: vec![
108                Topic::new("task.start"),
109                Topic::new("task.resume"),
110                Topic::new("build.done"),
111                Topic::new("build.blocked"),
112            ],
113            publishes: vec![Topic::new("build.task")],
114            instructions: String::new(),
115        }
116    }
117
118    /// Creates the default builder hat.
119    ///
120    /// Per spec: Builder triggers on `build.task` and publishes
121    /// `build.done` or `build.blocked`.
122    pub fn default_builder() -> Self {
123        Self {
124            id: HatId::new("builder"),
125            name: "Builder".to_string(),
126            description: "Implements code changes, runs backpressure".to_string(),
127            subscriptions: vec![Topic::new("build.task")],
128            publishes: vec![Topic::new("build.done"), Topic::new("build.blocked")],
129            instructions: String::new(),
130        }
131    }
132
133    /// Adds a subscription to this hat.
134    #[must_use]
135    pub fn subscribe(mut self, topic: impl Into<Topic>) -> Self {
136        self.subscriptions.push(topic.into());
137        self
138    }
139
140    /// Sets the instructions for this hat.
141    #[must_use]
142    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
143        self.instructions = instructions.into();
144        self
145    }
146
147    /// Sets the topics this hat publishes.
148    #[must_use]
149    pub fn with_publishes(mut self, publishes: Vec<Topic>) -> Self {
150        self.publishes = publishes;
151        self
152    }
153
154    /// Checks if this hat is subscribed to the given topic.
155    pub fn is_subscribed(&self, topic: &Topic) -> bool {
156        self.is_subscribed_str(topic.as_str())
157    }
158
159    /// Checks if this hat is subscribed to the given topic string.
160    ///
161    /// Zero-allocation variant of `is_subscribed()` for hot paths.
162    pub fn is_subscribed_str(&self, topic: &str) -> bool {
163        self.subscriptions.iter().any(|sub| sub.matches_str(topic))
164    }
165
166    /// Checks if this hat has a specific (non-global-wildcard) subscription for the topic.
167    ///
168    /// Returns true if the hat matches via a specific pattern (e.g., `task.*`, `build.done`)
169    /// rather than a global wildcard `*`. Used for routing priority - specific subscriptions
170    /// take precedence over fallback wildcards.
171    pub fn has_specific_subscription(&self, topic: &Topic) -> bool {
172        self.subscriptions
173            .iter()
174            .any(|sub| !sub.is_global_wildcard() && sub.matches(topic))
175    }
176
177    /// Returns true if all subscriptions are global wildcards (`*`).
178    ///
179    /// Used to identify fallback handlers like Ralph.
180    pub fn is_fallback_only(&self) -> bool {
181        !self.subscriptions.is_empty() && self.subscriptions.iter().all(Topic::is_global_wildcard)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_subscription_matching() {
191        let hat = Hat::new("impl", "Implementer")
192            .subscribe("impl.*")
193            .subscribe("task.start");
194
195        assert!(hat.is_subscribed(&Topic::new("impl.done")));
196        assert!(hat.is_subscribed(&Topic::new("task.start")));
197        assert!(!hat.is_subscribed(&Topic::new("review.done")));
198    }
199
200    #[test]
201    #[allow(deprecated)]
202    fn test_default_single_hat() {
203        let hat = Hat::default_single();
204        assert!(hat.is_subscribed(&Topic::new("anything")));
205        assert!(hat.is_subscribed(&Topic::new("impl.done")));
206    }
207
208    #[test]
209    fn test_default_planner_hat() {
210        let hat = Hat::default_planner();
211        assert_eq!(hat.id.as_str(), "planner");
212        assert!(hat.is_subscribed(&Topic::new("task.start")));
213        assert!(hat.is_subscribed(&Topic::new("task.resume"))); // For ralph resume
214        assert!(hat.is_subscribed(&Topic::new("build.done")));
215        assert!(hat.is_subscribed(&Topic::new("build.blocked")));
216        assert!(!hat.is_subscribed(&Topic::new("build.task")));
217    }
218
219    #[test]
220    fn test_default_builder_hat() {
221        let hat = Hat::default_builder();
222        assert_eq!(hat.id.as_str(), "builder");
223        assert!(hat.is_subscribed(&Topic::new("build.task")));
224        assert!(!hat.is_subscribed(&Topic::new("task.start")));
225        assert!(!hat.is_subscribed(&Topic::new("build.done")));
226    }
227}