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