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    pub fn new(core: CoreConfig) -> Self {
26        Self {
27            core,
28            events: HashMap::new(),
29        }
30    }
31
32    /// Creates a new instruction builder with event metadata for custom hats.
33    pub fn with_events(core: CoreConfig, events: HashMap<String, EventMetadata>) -> Self {
34        Self { core, events }
35    }
36
37    /// Derives instructions from a hat's pub/sub contract and event metadata.
38    ///
39    /// For each event the hat triggers on or publishes:
40    /// 1. Check event metadata for on_trigger/on_publish instructions
41    /// 2. Fall back to built-in defaults for well-known events
42    ///
43    /// This allows users to define custom events with custom behaviors,
44    /// while still getting sensible defaults for standard events.
45    fn derive_instructions_from_contract(&self, hat: &Hat) -> String {
46        let mut behaviors: Vec<String> = Vec::new();
47
48        // Derive behaviors from triggers (what this hat responds to)
49        for trigger in &hat.subscriptions {
50            let trigger_str = trigger.as_str();
51
52            // First, check event metadata
53            if let Some(meta) = self.events.get(trigger_str)
54                && !meta.on_trigger.is_empty()
55            {
56                behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
57                continue;
58            }
59
60            // Fall back to built-in defaults for well-known events
61            let default_behavior = match trigger_str {
62                "task.start" | "task.resume" => {
63                    Some("Analyze the task and create a plan in the scratchpad.")
64                }
65                "build.done" => Some("Review the completed work and decide next steps."),
66                "build.blocked" => Some(
67                    "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate).",
68                ),
69                "build.task" => Some(
70                    "Implement the assigned task. Follow existing patterns. Run backpressure (tests/lint/typecheck/audit/coverage/specs; mutation testing when configured). Commit atomically when tests pass.",
71                ),
72                "review.request" => Some(
73                    "Review the recent changes for correctness, tests, patterns, errors, and security.",
74                ),
75                "review.approved" => Some("Mark the task complete `[x]` and proceed to next task."),
76                "review.changes_requested" => Some("Add fix tasks to scratchpad and dispatch."),
77                _ => None,
78            };
79
80            if let Some(behavior) = default_behavior {
81                behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
82            }
83        }
84
85        // Derive behaviors from publishes (what this hat outputs)
86        for publish in &hat.publishes {
87            let publish_str = publish.as_str();
88
89            // First, check event metadata
90            if let Some(meta) = self.events.get(publish_str)
91                && !meta.on_publish.is_empty()
92            {
93                behaviors.push(format!(
94                    "**Publish `{}`:** {}",
95                    publish_str, meta.on_publish
96                ));
97                continue;
98            }
99
100            // Fall back to built-in defaults for well-known events
101            let default_behavior = match publish_str {
102                "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks."),
103                "build.done" => Some("When implementation is finished and tests pass."),
104                "build.blocked" => Some("When stuck - include what you tried and why it failed."),
105                "review.request" => Some("After build completion, before marking done."),
106                "review.approved" => Some("If changes look good and meet requirements."),
107                "review.changes_requested" => Some("If issues found - include specific feedback."),
108                _ => None,
109            };
110
111            if let Some(behavior) = default_behavior {
112                behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
113            }
114        }
115
116        // Add must-publish rule if hat has publishable events
117        if !hat.publishes.is_empty() {
118            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
119            behaviors.push(format!(
120                "You MUST publish one of: `{}` every iteration or the loop will terminate.",
121                topics.join("`, `")
122            ));
123        }
124
125        if behaviors.is_empty() {
126            "Follow the incoming event instructions.".to_string()
127        } else {
128            format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
129        }
130    }
131
132    /// Builds custom hat instructions for extended multi-agent configurations.
133    ///
134    /// Use this for hats beyond the default Ralph.
135    /// When instructions are empty, derives them from the pub/sub contract.
136    pub fn build_custom_hat(&self, hat: &Hat, events_context: &str) -> String {
137        let guardrails = self
138            .core
139            .guardrails
140            .iter()
141            .enumerate()
142            .map(|(i, g)| format!("{}. {g}", 999 + i))
143            .collect::<Vec<_>>()
144            .join("\n");
145
146        let role_instructions = if hat.instructions.is_empty() {
147            self.derive_instructions_from_contract(hat)
148        } else {
149            hat.instructions.clone()
150        };
151
152        let (publish_topics, must_publish) = if hat.publishes.is_empty() {
153            (String::new(), String::new())
154        } else {
155            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
156            let topics_list = topics.join(", ");
157            let topics_backticked = format!("`{}`", topics.join("`, `"));
158            let example_topic = topics.first().copied().unwrap_or("event.name");
159
160            (
161                format!("You publish to: {}", topics_list),
162                format!(
163                    "\n\nYou MUST emit exactly ONE of these events via `ralph emit \"<topic>\" \"<summary>\"`: {}\nUse `ralph emit \"{}\" \"<summary>\"` as the pattern.\nPlain-language summaries do NOT count as event publication.\nYou MUST stop immediately after emitting.\nYou MUST NOT end the iteration without publishing because this will terminate the loop.",
164                    topics_backticked, example_topic
165                ),
166            )
167        };
168
169        format!(
170            r"You are {name}. You have fresh context each iteration.
171
172### 0. ORIENTATION
173You MUST study the incoming event context.
174You MUST NOT assume work isn't done — verify first.
175
176### 0b. TOOL DISCIPLINE
177Runtime work state lives in `ralph tools task`, not in ad hoc markdown checklists.
178You MUST check `<ready-tasks>` before creating more tasks.
179If this iteration creates or discovers durable work, you MUST represent it with `ralph tools task ensure`, `start`, `close`, `reopen`, or `fail` as appropriate.
180If you are entering an unfamiliar area, you SHOULD search memories with `ralph tools memory search` before acting.
181You SHOULD assume the workflow commands are available when the loop is already running and use the task-specific command you actually need.
182The loop sets `$RALPH_BIN` to the current Ralph executable. Prefer `$RALPH_BIN emit ...` and `$RALPH_BIN tools ...` when you need a direct command form.
183Do not spend iterations on shell or tool-availability diagnosis unless the task is explicitly about the runtime environment.
184If a command's stdout is empty or terse, verify the intended side effect in the task/event state or in the files and artifacts the command should have changed.
185Keep temporary artifacts where later steps can still inspect them, such as a repo-local `logs/` directory or `/var/tmp` when needed.
186If a command fails, a dependency is missing, or you become blocked, you MUST record a `fix` memory with `ralph tools memory add`.
187If the issue is not resolved in the same iteration, you MUST fail or reopen the relevant runtime task before stopping.
188If your confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
189If this turn is likely to run longer than a few minutes, you SHOULD send a non-blocking progress update with `ralph tools interact progress`.
190
191### 1. EXECUTE
192{role_instructions}
193You MUST NOT use more than 1 subagent for build/tests.
194
195### 2. VERIFY
196You MUST run tests and verify implementation before reporting done.
197You MUST NOT report completion without evidence (test output, build success).
198You MUST NOT close tasks unless ALL conditions are met:
199- Implementation is actually complete (not partially done)
200- Tests pass (run them and verify output)
201- Build succeeds (if applicable)
202
203### 3. REPORT
204You MUST publish a result event with evidence using `ralph emit`.
205{publish_topics}{must_publish}
206
207### GUARDRAILS
208{guardrails}
209
210---
211You MUST handle these events:
212{events}",
213            name = hat.name,
214            role_instructions = role_instructions,
215            publish_topics = publish_topics,
216            must_publish = must_publish,
217            guardrails = guardrails,
218            events = events_context,
219        )
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn default_builder() -> InstructionBuilder {
228        InstructionBuilder::new(CoreConfig::default())
229    }
230
231    #[test]
232    fn test_custom_hat_with_rfc2119_patterns() {
233        let builder = default_builder();
234        let hat = Hat::new("reviewer", "Code Reviewer")
235            .with_instructions("Review PRs for quality and correctness.");
236
237        let instructions = builder.build_custom_hat(&hat, "PR #123 ready for review");
238
239        // Custom role with RFC2119 style identity
240        assert!(instructions.contains("Code Reviewer"));
241        assert!(instructions.contains("You have fresh context each iteration"));
242
243        // Numbered orientation phase with RFC2119
244        assert!(instructions.contains("### 0. ORIENTATION"));
245        assert!(instructions.contains("You MUST study the incoming event context"));
246        assert!(instructions.contains("You MUST NOT assume work isn't done"));
247
248        assert!(instructions.contains("### 0b. TOOL DISCIPLINE"));
249        assert!(instructions.contains("You MUST check `<ready-tasks>` before creating more tasks"));
250        assert!(
251            instructions
252                .contains("`ralph tools task ensure`, `start`, `close`, `reopen`, or `fail`")
253        );
254        assert!(instructions.contains("`ralph tools memory search`"));
255        assert!(instructions.contains("`ralph tools memory add`"));
256        assert!(instructions.contains(".ralph/agent/decisions.md"));
257        assert!(instructions.contains("ralph tools interact progress"));
258
259        // Numbered execute phase with RFC2119
260        assert!(instructions.contains("### 1. EXECUTE"));
261        assert!(instructions.contains("Review PRs for quality"));
262        assert!(instructions.contains("You MUST NOT use more than 1 subagent for build/tests"));
263
264        // Verify phase with RFC2119 (task completion verification)
265        assert!(instructions.contains("### 2. VERIFY"));
266        assert!(instructions.contains("You MUST run tests and verify implementation"));
267        assert!(instructions.contains("You MUST NOT close tasks unless"));
268
269        // Report phase with RFC2119
270        assert!(instructions.contains("### 3. REPORT"));
271        assert!(
272            instructions
273                .contains("You MUST publish a result event with evidence using `ralph emit`")
274        );
275
276        // Guardrails section with high numbers
277        assert!(instructions.contains("### GUARDRAILS"));
278        assert!(instructions.contains("999."));
279
280        // Event context is included with RFC2119 directive
281        assert!(instructions.contains("You MUST handle these events"));
282        assert!(instructions.contains("PR #123 ready for review"));
283    }
284
285    #[test]
286    fn test_custom_guardrails_injected() {
287        let custom_core = CoreConfig {
288            scratchpad: ".workspace/plan.md".to_string(),
289            specs_dir: "./specifications/".to_string(),
290            guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
291            workspace_root: std::path::PathBuf::from("."),
292        };
293        let builder = InstructionBuilder::new(custom_core);
294
295        let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
296        let instructions = builder.build_custom_hat(&hat, "context");
297
298        // Custom guardrails are injected with 999+ numbering
299        assert!(instructions.contains("999. Custom rule one"));
300        assert!(instructions.contains("1000. Custom rule two"));
301    }
302
303    #[test]
304    fn test_must_publish_injected_for_explicit_instructions() {
305        use ralph_proto::Topic;
306
307        let builder = default_builder();
308        let hat = Hat::new("reviewer", "Code Reviewer")
309            .with_instructions("Review PRs for quality and correctness.")
310            .with_publishes(vec![
311                Topic::new("review.approved"),
312                Topic::new("review.changes_requested"),
313            ]);
314
315        let instructions = builder.build_custom_hat(&hat, "PR #123 ready");
316
317        // Must-publish rule should be injected even with explicit instructions (RFC2119)
318        assert!(
319            instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
320            "Must-publish rule should be injected for custom hats with publishes"
321        );
322        assert!(instructions.contains("`review.approved`"));
323        assert!(instructions.contains("`review.changes_requested`"));
324        assert!(
325            instructions.contains("Plain-language summaries do NOT count as event publication")
326        );
327        assert!(instructions.contains("You MUST stop immediately after emitting"));
328        assert!(instructions.contains("You MUST NOT end the iteration without publishing"));
329    }
330
331    #[test]
332    fn test_must_publish_not_injected_when_no_publishes() {
333        let builder = default_builder();
334        let hat = Hat::new("observer", "Silent Observer")
335            .with_instructions("Observe and log, but do not emit events.");
336
337        let instructions = builder.build_custom_hat(&hat, "Observe this");
338
339        // No must-publish rule when hat has no publishes
340        // Note: The prompt says "You MUST publish a result event" in the REPORT section,
341        // but the specific emitted-topics list should not appear
342        assert!(
343            !instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
344            "Specific must-publish list should NOT be injected when hat has no publishes"
345        );
346    }
347
348    #[test]
349    fn test_derived_behaviors_when_no_explicit_instructions() {
350        use ralph_proto::Topic;
351
352        let builder = default_builder();
353        let hat = Hat::new("builder", "Builder")
354            .subscribe("build.task")
355            .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
356
357        let instructions = builder.build_custom_hat(&hat, "Implement feature X");
358
359        // Should derive behaviors from pub/sub contract
360        assert!(instructions.contains("Derived Behaviors"));
361        assert!(instructions.contains("build.task"));
362    }
363}