Skip to main content

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 atomically when tests pass.",
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                "You MUST publish one of: `{}` every iteration 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\nYou MUST publish one of these events: {}\nYou MUST NOT end the iteration without publishing because this will terminate the loop.",
169                    topics_backticked
170                ),
171            )
172        };
173
174        format!(
175            r"You are {name}. You have fresh context each iteration.
176
177### 0. ORIENTATION
178You MUST study the incoming event context.
179You MUST NOT assume work isn't done — verify first.
180
181### 1. EXECUTE
182{role_instructions}
183You MUST NOT use more than 1 subagent for build/tests.
184
185### 2. VERIFY
186You MUST run tests and verify implementation before reporting done.
187You MUST NOT report completion without evidence (test output, build success).
188You MUST NOT close tasks unless ALL conditions are met:
189- Implementation is actually complete (not partially done)
190- Tests pass (run them and verify output)
191- Build succeeds (if applicable)
192
193### 3. REPORT
194You MUST publish a result event with evidence.
195{publish_topics}{must_publish}
196
197### GUARDRAILS
198{guardrails}
199
200---
201You MUST handle these events:
202{events}",
203            name = hat.name,
204            role_instructions = role_instructions,
205            publish_topics = publish_topics,
206            must_publish = must_publish,
207            guardrails = guardrails,
208            events = events_context,
209        )
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn default_builder(promise: &str) -> InstructionBuilder {
218        InstructionBuilder::new(promise, CoreConfig::default())
219    }
220
221    #[test]
222    fn test_custom_hat_with_rfc2119_patterns() {
223        let builder = default_builder("DONE");
224        let hat = Hat::new("reviewer", "Code Reviewer")
225            .with_instructions("Review PRs for quality and correctness.");
226
227        let instructions = builder.build_custom_hat(&hat, "PR #123 ready for review");
228
229        // Custom role with RFC2119 style identity
230        assert!(instructions.contains("Code Reviewer"));
231        assert!(instructions.contains("You have fresh context each iteration"));
232
233        // Numbered orientation phase with RFC2119
234        assert!(instructions.contains("### 0. ORIENTATION"));
235        assert!(instructions.contains("You MUST study the incoming event context"));
236        assert!(instructions.contains("You MUST NOT assume work isn't done"));
237
238        // Numbered execute phase with RFC2119
239        assert!(instructions.contains("### 1. EXECUTE"));
240        assert!(instructions.contains("Review PRs for quality"));
241        assert!(instructions.contains("You MUST NOT use more than 1 subagent for build/tests"));
242
243        // Verify phase with RFC2119 (task completion verification)
244        assert!(instructions.contains("### 2. VERIFY"));
245        assert!(instructions.contains("You MUST run tests and verify implementation"));
246        assert!(instructions.contains("You MUST NOT close tasks unless"));
247
248        // Report phase with RFC2119
249        assert!(instructions.contains("### 3. REPORT"));
250        assert!(instructions.contains("You MUST publish a result event"));
251
252        // Guardrails section with high numbers
253        assert!(instructions.contains("### GUARDRAILS"));
254        assert!(instructions.contains("999."));
255
256        // Event context is included with RFC2119 directive
257        assert!(instructions.contains("You MUST handle these events"));
258        assert!(instructions.contains("PR #123 ready for review"));
259    }
260
261    #[test]
262    fn test_custom_guardrails_injected() {
263        let custom_core = CoreConfig {
264            scratchpad: ".workspace/plan.md".to_string(),
265            specs_dir: "./specifications/".to_string(),
266            guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
267            workspace_root: std::path::PathBuf::from("."),
268        };
269        let builder = InstructionBuilder::new("DONE", custom_core);
270
271        let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
272        let instructions = builder.build_custom_hat(&hat, "context");
273
274        // Custom guardrails are injected with 999+ numbering
275        assert!(instructions.contains("999. Custom rule one"));
276        assert!(instructions.contains("1000. Custom rule two"));
277    }
278
279    #[test]
280    fn test_must_publish_injected_for_explicit_instructions() {
281        use ralph_proto::Topic;
282
283        let builder = default_builder("DONE");
284        let hat = Hat::new("reviewer", "Code Reviewer")
285            .with_instructions("Review PRs for quality and correctness.")
286            .with_publishes(vec![
287                Topic::new("review.approved"),
288                Topic::new("review.changes_requested"),
289            ]);
290
291        let instructions = builder.build_custom_hat(&hat, "PR #123 ready");
292
293        // Must-publish rule should be injected even with explicit instructions (RFC2119)
294        assert!(
295            instructions.contains("You MUST publish one of these events"),
296            "Must-publish rule should be injected for custom hats with publishes"
297        );
298        assert!(instructions.contains("`review.approved`"));
299        assert!(instructions.contains("`review.changes_requested`"));
300        assert!(instructions.contains("You MUST NOT end the iteration without publishing"));
301    }
302
303    #[test]
304    fn test_must_publish_not_injected_when_no_publishes() {
305        let builder = default_builder("DONE");
306        let hat = Hat::new("observer", "Silent Observer")
307            .with_instructions("Observe and log, but do not emit events.");
308
309        let instructions = builder.build_custom_hat(&hat, "Observe this");
310
311        // No must-publish rule when hat has no publishes
312        // Note: The prompt says "You MUST publish a result event" in the REPORT section,
313        // but the specific "You MUST publish one of these events:" list should not appear
314        assert!(
315            !instructions.contains("You MUST publish one of these events"),
316            "Specific must-publish list should NOT be injected when hat has no publishes"
317        );
318    }
319
320    #[test]
321    fn test_derived_behaviors_when_no_explicit_instructions() {
322        use ralph_proto::Topic;
323
324        let builder = default_builder("DONE");
325        let hat = Hat::new("builder", "Builder")
326            .subscribe("build.task")
327            .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
328
329        let instructions = builder.build_custom_hat(&hat, "Implement feature X");
330
331        // Should derive behaviors from pub/sub contract
332        assert!(instructions.contains("Derived Behaviors"));
333        assert!(instructions.contains("build.task"));
334    }
335}