ralph_core/
instructions.rs

1//! Instruction builder for Ralph agent prompts.
2//!
3//! Builds ghuntley-style prompts with numbered phases:
4//! - 0a, 0b: Orientation (study specs, study context)
5//! - 1, 2, 3: Workflow phases
6//! - 999+: Guardrails (higher = more important)
7
8use crate::config::{CoreConfig, EventMetadata};
9use ralph_proto::Hat;
10use std::collections::HashMap;
11
12/// Builds instructions for custom hats.
13///
14/// Uses ghuntley methodology: numbered phases, specific verbs ("study"),
15/// subagent limits (parallel for reads, single for builds).
16#[derive(Debug)]
17pub struct InstructionBuilder {
18    core: CoreConfig,
19    /// Event metadata for deriving instructions from pub/sub contracts.
20    events: HashMap<String, EventMetadata>,
21}
22
23impl InstructionBuilder {
24    /// Creates a new instruction builder with core configuration.
25    #[allow(unused_variables)]
26    pub fn new(completion_promise: impl Into<String>, core: CoreConfig) -> Self {
27        Self {
28            core,
29            events: HashMap::new(),
30        }
31    }
32
33    /// Creates a new instruction builder with event metadata for custom hats.
34    #[allow(unused_variables)]
35    pub fn with_events(
36        completion_promise: impl Into<String>,
37        core: CoreConfig,
38        events: HashMap<String, EventMetadata>,
39    ) -> Self {
40        Self { core, events }
41    }
42
43    /// Derives instructions from a hat's pub/sub contract and event metadata.
44    ///
45    /// For each event the hat triggers on or publishes:
46    /// 1. Check event metadata for on_trigger/on_publish instructions
47    /// 2. Fall back to built-in defaults for well-known events
48    ///
49    /// This allows users to define custom events with custom behaviors,
50    /// while still getting sensible defaults for standard events.
51    fn derive_instructions_from_contract(&self, hat: &Hat) -> String {
52        let mut behaviors: Vec<String> = Vec::new();
53
54        // Derive behaviors from triggers (what this hat responds to)
55        for trigger in &hat.subscriptions {
56            let trigger_str = trigger.as_str();
57
58            // First, check event metadata
59            if let Some(meta) = self.events.get(trigger_str)
60                && !meta.on_trigger.is_empty()
61            {
62                behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
63                continue;
64            }
65
66            // Fall back to built-in defaults for well-known events
67            let default_behavior = match trigger_str {
68                "task.start" | "task.resume" => {
69                    Some("Analyze the task and create a plan in the scratchpad.")
70                }
71                "build.done" => Some("Review the completed work and decide next steps."),
72                "build.blocked" => Some(
73                    "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate).",
74                ),
75                "build.task" => Some(
76                    "Implement the assigned task. Follow existing patterns. Run backpressure (tests/checks). Commit when done.",
77                ),
78                "review.request" => Some(
79                    "Review the recent changes for correctness, tests, patterns, errors, and security.",
80                ),
81                "review.approved" => Some("Mark the task complete `[x]` and proceed to next task."),
82                "review.changes_requested" => Some("Add fix tasks to scratchpad and dispatch."),
83                _ => None,
84            };
85
86            if let Some(behavior) = default_behavior {
87                behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
88            }
89        }
90
91        // Derive behaviors from publishes (what this hat outputs)
92        for publish in &hat.publishes {
93            let publish_str = publish.as_str();
94
95            // First, check event metadata
96            if let Some(meta) = self.events.get(publish_str)
97                && !meta.on_publish.is_empty()
98            {
99                behaviors.push(format!(
100                    "**Publish `{}`:** {}",
101                    publish_str, meta.on_publish
102                ));
103                continue;
104            }
105
106            // Fall back to built-in defaults for well-known events
107            let default_behavior = match publish_str {
108                "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks."),
109                "build.done" => Some("When implementation is finished and tests pass."),
110                "build.blocked" => Some("When stuck - include what you tried and why it failed."),
111                "review.request" => Some("After build completion, before marking done."),
112                "review.approved" => Some("If changes look good and meet requirements."),
113                "review.changes_requested" => Some("If issues found - include specific feedback."),
114                _ => None,
115            };
116
117            if let Some(behavior) = default_behavior {
118                behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
119            }
120        }
121
122        // Add must-publish rule if hat has publishable events
123        if !hat.publishes.is_empty() {
124            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
125            behaviors.push(format!(
126                "**IMPORTANT:** Every iteration MUST publish one of: `{}` or the loop will terminate.",
127                topics.join("`, `")
128            ));
129        }
130
131        if behaviors.is_empty() {
132            "Follow the incoming event instructions.".to_string()
133        } else {
134            format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
135        }
136    }
137
138    /// Builds custom hat instructions for extended multi-agent configurations.
139    ///
140    /// Use this for hats beyond the default Ralph.
141    /// When instructions are empty, derives them from the pub/sub contract.
142    pub fn build_custom_hat(&self, hat: &Hat, events_context: &str) -> String {
143        let guardrails = self
144            .core
145            .guardrails
146            .iter()
147            .enumerate()
148            .map(|(i, g)| format!("{}. {g}", 999 + i))
149            .collect::<Vec<_>>()
150            .join("\n");
151
152        let role_instructions = if hat.instructions.is_empty() {
153            self.derive_instructions_from_contract(hat)
154        } else {
155            hat.instructions.clone()
156        };
157
158        let (publish_topics, must_publish) = if hat.publishes.is_empty() {
159            (String::new(), String::new())
160        } else {
161            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
162            let topics_list = topics.join(", ");
163            let topics_backticked = format!("`{}`", topics.join("`, `"));
164
165            (
166                format!("You publish to: {}", topics_list),
167                format!(
168                    "\n\n**You MUST publish one of these events:** {}\nFailure to publish will terminate the loop.",
169                    topics_backticked
170                ),
171            )
172        };
173
174        format!(
175            r"You are {name}. Fresh context each iteration.
176
177### 0. ORIENTATION
178Study the incoming event context.
179Don't assume work isn't done—verify first.
180
181### 1. EXECUTE
182{role_instructions}
183Only 1 subagent for build/tests.
184
185### 2. REPORT
186Publish result event with evidence.
187{publish_topics}{must_publish}
188
189### GUARDRAILS
190{guardrails}
191
192---
193INCOMING:
194{events}",
195            name = hat.name,
196            role_instructions = role_instructions,
197            publish_topics = publish_topics,
198            must_publish = must_publish,
199            guardrails = guardrails,
200            events = events_context,
201        )
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn default_builder(promise: &str) -> InstructionBuilder {
210        InstructionBuilder::new(promise, CoreConfig::default())
211    }
212
213    #[test]
214    fn test_custom_hat_with_ghuntley_patterns() {
215        let builder = default_builder("DONE");
216        let hat = Hat::new("reviewer", "Code Reviewer")
217            .with_instructions("Review PRs for quality and correctness.");
218
219        let instructions = builder.build_custom_hat(&hat, "PR #123 ready for review");
220
221        // Custom role with ghuntley style identity
222        assert!(instructions.contains("Code Reviewer"));
223        assert!(instructions.contains("Fresh context each iteration"));
224
225        // Numbered orientation phase
226        assert!(instructions.contains("### 0. ORIENTATION"));
227        assert!(instructions.contains("Study the incoming event context"));
228        assert!(instructions.contains("Don't assume work isn't done"));
229
230        // Numbered execute phase
231        assert!(instructions.contains("### 1. EXECUTE"));
232        assert!(instructions.contains("Review PRs for quality"));
233        assert!(instructions.contains("Only 1 subagent for build/tests"));
234
235        // Report phase
236        assert!(instructions.contains("### 2. REPORT"));
237
238        // Guardrails section with high numbers
239        assert!(instructions.contains("### GUARDRAILS"));
240        assert!(instructions.contains("999."));
241
242        // Event context is included
243        assert!(instructions.contains("PR #123 ready for review"));
244    }
245
246    #[test]
247    fn test_custom_guardrails_injected() {
248        let custom_core = CoreConfig {
249            scratchpad: ".workspace/plan.md".to_string(),
250            specs_dir: "./specifications/".to_string(),
251            guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
252        };
253        let builder = InstructionBuilder::new("DONE", custom_core);
254
255        let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
256        let instructions = builder.build_custom_hat(&hat, "context");
257
258        // Custom guardrails are injected with 999+ numbering
259        assert!(instructions.contains("999. Custom rule one"));
260        assert!(instructions.contains("1000. Custom rule two"));
261    }
262
263    #[test]
264    fn test_must_publish_injected_for_explicit_instructions() {
265        use ralph_proto::Topic;
266
267        let builder = default_builder("DONE");
268        let hat = Hat::new("reviewer", "Code Reviewer")
269            .with_instructions("Review PRs for quality and correctness.")
270            .with_publishes(vec![
271                Topic::new("review.approved"),
272                Topic::new("review.changes_requested"),
273            ]);
274
275        let instructions = builder.build_custom_hat(&hat, "PR #123 ready");
276
277        // Must-publish rule should be injected even with explicit instructions
278        assert!(
279            instructions.contains("You MUST publish one of these events"),
280            "Must-publish rule should be injected for custom hats with publishes"
281        );
282        assert!(instructions.contains("`review.approved`"));
283        assert!(instructions.contains("`review.changes_requested`"));
284        assert!(instructions.contains("Failure to publish will terminate the loop"));
285    }
286
287    #[test]
288    fn test_must_publish_not_injected_when_no_publishes() {
289        let builder = default_builder("DONE");
290        let hat = Hat::new("observer", "Silent Observer")
291            .with_instructions("Observe and log, but do not emit events.");
292
293        let instructions = builder.build_custom_hat(&hat, "Observe this");
294
295        // No must-publish rule when hat has no publishes
296        assert!(
297            !instructions.contains("You MUST publish"),
298            "Must-publish rule should NOT be injected when hat has no publishes"
299        );
300    }
301
302    #[test]
303    fn test_derived_behaviors_when_no_explicit_instructions() {
304        use ralph_proto::Topic;
305
306        let builder = default_builder("DONE");
307        let hat = Hat::new("builder", "Builder")
308            .subscribe("build.task")
309            .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
310
311        let instructions = builder.build_custom_hat(&hat, "Implement feature X");
312
313        // Should derive behaviors from pub/sub contract
314        assert!(instructions.contains("Derived Behaviors"));
315        assert!(instructions.contains("build.task"));
316    }
317}