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, ScratchpadConfig};
9use ralph_proto::Hat;
10use std::collections::HashMap;
11
12const DEFAULT_SCRATCHPAD_GUARDRAIL: &str = "Fresh context each iteration - scratchpad is memory";
13const DISABLED_SCRATCHPAD_GUARDRAIL: &str =
14    "Fresh context each iteration - runtime tasks and events are memory";
15
16/// Builds instructions for custom hats.
17///
18/// Uses ghuntley methodology: numbered phases, specific verbs ("study"),
19/// subagent limits (parallel for reads, single for builds).
20#[derive(Debug)]
21pub struct InstructionBuilder {
22    core: CoreConfig,
23    /// Event metadata for deriving instructions from pub/sub contracts.
24    events: HashMap<String, EventMetadata>,
25}
26
27impl InstructionBuilder {
28    /// Creates a new instruction builder with core configuration.
29    pub fn new(core: CoreConfig) -> Self {
30        Self {
31            core,
32            events: HashMap::new(),
33        }
34    }
35
36    /// Creates a new instruction builder with event metadata for custom hats.
37    pub fn with_events(core: CoreConfig, events: HashMap<String, EventMetadata>) -> Self {
38        Self { core, events }
39    }
40
41    /// Derives instructions from a hat's pub/sub contract and event metadata.
42    ///
43    /// For each event the hat triggers on or publishes:
44    /// 1. Check event metadata for on_trigger/on_publish instructions
45    /// 2. Fall back to built-in defaults for well-known events
46    ///
47    /// This allows users to define custom events with custom behaviors,
48    /// while still getting sensible defaults for standard events.
49    fn task_planning_behavior(scratchpad: &ScratchpadConfig) -> String {
50        if !scratchpad.enabled {
51            return "Analyze the task and create a plan.".to_string();
52        }
53
54        if scratchpad.path == ScratchpadConfig::default().path {
55            "Analyze the task and create a plan in the scratchpad.".to_string()
56        } else {
57            format!(
58                "Analyze the task and create a plan in the scratchpad at `{}`.",
59                scratchpad.path
60            )
61        }
62    }
63
64    fn changes_requested_behavior(scratchpad: &ScratchpadConfig) -> String {
65        if !scratchpad.enabled {
66            return "Add fix tasks with `ralph tools task` and dispatch.".to_string();
67        }
68
69        if scratchpad.path == ScratchpadConfig::default().path {
70            "Add fix tasks to scratchpad and dispatch.".to_string()
71        } else {
72            format!(
73                "Add fix tasks to the scratchpad at `{}` and dispatch.",
74                scratchpad.path
75            )
76        }
77    }
78
79    fn guardrails(&self, scratchpad: &ScratchpadConfig) -> String {
80        self.core
81            .guardrails
82            .iter()
83            .enumerate()
84            .map(|(i, guardrail)| {
85                let guardrail = if !scratchpad.enabled && guardrail == DEFAULT_SCRATCHPAD_GUARDRAIL
86                {
87                    DISABLED_SCRATCHPAD_GUARDRAIL
88                } else {
89                    guardrail
90                };
91                format!("{}. {guardrail}", 999 + i)
92            })
93            .collect::<Vec<_>>()
94            .join("\n")
95    }
96
97    fn derive_instructions_from_contract(
98        &self,
99        hat: &Hat,
100        scratchpad: &ScratchpadConfig,
101    ) -> String {
102        let mut behaviors: Vec<String> = Vec::new();
103
104        // Derive behaviors from triggers (what this hat responds to)
105        for trigger in &hat.subscriptions {
106            let trigger_str = trigger.as_str();
107
108            // First, check event metadata
109            if let Some(meta) = self.events.get(trigger_str)
110                && !meta.on_trigger.is_empty()
111            {
112                behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
113                continue;
114            }
115
116            // Fall back to built-in defaults for well-known events
117            let default_behavior = match trigger_str {
118                "task.start" | "task.resume" => Some(Self::task_planning_behavior(scratchpad)),
119                "build.done" => {
120                    Some("Review the completed work and decide next steps.".to_string())
121                }
122                "build.blocked" => Some(
123                    "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate)."
124                        .to_string(),
125                ),
126                "build.task" => Some(
127                    "Implement the assigned task. Follow existing patterns. Run backpressure (tests/lint/typecheck/audit/coverage/specs; mutation testing when configured). Commit atomically when tests pass."
128                        .to_string(),
129                ),
130                "review.request" => Some(
131                    "Review the recent changes for correctness, tests, patterns, errors, and security."
132                        .to_string(),
133                ),
134                "review.approved" => {
135                    Some("Mark the task complete `[x]` and proceed to next task.".to_string())
136                }
137                "review.changes_requested" => Some(Self::changes_requested_behavior(scratchpad)),
138                _ => None,
139            };
140
141            if let Some(behavior) = default_behavior {
142                behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
143            }
144        }
145
146        // Derive behaviors from publishes (what this hat outputs)
147        for publish in &hat.publishes {
148            let publish_str = publish.as_str();
149
150            // First, check event metadata
151            if let Some(meta) = self.events.get(publish_str)
152                && !meta.on_publish.is_empty()
153            {
154                behaviors.push(format!(
155                    "**Publish `{}`:** {}",
156                    publish_str, meta.on_publish
157                ));
158                continue;
159            }
160
161            // Fall back to built-in defaults for well-known events
162            let default_behavior = match publish_str {
163                "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks.".to_string()),
164                "build.done" => Some("When implementation is finished and tests pass.".to_string()),
165                "build.blocked" => {
166                    Some("When stuck - include what you tried and why it failed.".to_string())
167                }
168                "review.request" => {
169                    Some("After build completion, before marking done.".to_string())
170                }
171                "review.approved" => {
172                    Some("If changes look good and meet requirements.".to_string())
173                }
174                "review.changes_requested" => {
175                    Some("If issues found - include specific feedback.".to_string())
176                }
177                _ => None,
178            };
179
180            if let Some(behavior) = default_behavior {
181                behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
182            }
183        }
184
185        // Add must-publish rule if hat has publishable events
186        if !hat.publishes.is_empty() {
187            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
188            behaviors.push(format!(
189                "You MUST publish one of: `{}` every iteration or the loop will terminate.",
190                topics.join("`, `")
191            ));
192        }
193
194        if behaviors.is_empty() {
195            "Follow the incoming event instructions.".to_string()
196        } else {
197            format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
198        }
199    }
200
201    /// Builds custom hat instructions for extended multi-agent configurations.
202    ///
203    /// Use this for hats beyond the default Ralph.
204    /// When instructions are empty, derives them from the pub/sub contract.
205    pub fn build_custom_hat(
206        &self,
207        hat: &Hat,
208        events_context: &str,
209        scratchpad: &ScratchpadConfig,
210    ) -> String {
211        let guardrails = self.guardrails(scratchpad);
212        let role_instructions = if hat.instructions.is_empty() {
213            self.derive_instructions_from_contract(hat, scratchpad)
214        } else {
215            hat.instructions.clone()
216        };
217
218        let (publish_topics, must_publish) = if hat.publishes.is_empty() {
219            (String::new(), String::new())
220        } else {
221            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
222            let topics_list = topics.join(", ");
223            let topics_backticked = format!("`{}`", topics.join("`, `"));
224            let example_topic = topics.first().copied().unwrap_or("event.name");
225
226            (
227                format!("You publish to: {}", topics_list),
228                format!(
229                    "\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.",
230                    topics_backticked, example_topic
231                ),
232            )
233        };
234
235        format!(
236            r"You are {name}. You have fresh context each iteration.
237
238### 0. ORIENTATION
239You MUST study the incoming event context.
240You MUST NOT assume work isn't done — verify first.
241
242### 0b. TOOL DISCIPLINE
243Runtime work state lives in `ralph tools task`, not in ad hoc markdown checklists.
244You MUST check `<ready-tasks>` before creating more tasks.
245If this iteration creates or discovers durable work, you MUST represent it with `ralph tools task ensure`, `start`, `close`, `reopen`, or `fail` as appropriate.
246If you are entering an unfamiliar area, you SHOULD search memories with `ralph tools memory search` before acting.
247You SHOULD assume the workflow commands are available when the loop is already running and use the task-specific command you actually need.
248The loop sets `$RALPH_BIN` to the current Ralph executable. Prefer `$RALPH_BIN emit ...` and `$RALPH_BIN tools ...` when you need a direct command form.
249Do not spend iterations on shell or tool-availability diagnosis unless the task is explicitly about the runtime environment.
250If 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.
251Keep temporary artifacts where later steps can still inspect them, such as a repo-local `logs/` directory or `/var/tmp` when needed.
252If a command fails, a dependency is missing, or you become blocked, you MUST record a `fix` memory with `ralph tools memory add`.
253If the issue is not resolved in the same iteration, you MUST fail or reopen the relevant runtime task before stopping.
254If your confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
255If this turn is likely to run longer than a few minutes, you SHOULD send a non-blocking progress update with `ralph tools interact progress`.
256
257### 1. EXECUTE
258{role_instructions}
259You MUST NOT use more than 1 subagent for build/tests.
260
261### 2. VERIFY
262You MUST run tests and verify implementation before reporting done.
263You MUST NOT report completion without evidence (test output, build success).
264You MUST NOT close tasks unless ALL conditions are met:
265- Implementation is actually complete (not partially done)
266- Tests pass (run them and verify output)
267- Build succeeds (if applicable)
268
269### 3. REPORT
270You MUST publish a result event with evidence using `ralph emit`.
271{publish_topics}{must_publish}
272
273### GUARDRAILS
274{guardrails}
275
276---
277You MUST handle these events:
278{events}",
279            name = hat.name,
280            role_instructions = role_instructions,
281            publish_topics = publish_topics,
282            must_publish = must_publish,
283            guardrails = guardrails,
284            events = events_context,
285        )
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::config::ScratchpadConfig;
293
294    fn default_builder() -> InstructionBuilder {
295        InstructionBuilder::new(CoreConfig::default())
296    }
297
298    #[test]
299    fn test_custom_hat_with_rfc2119_patterns() {
300        let builder = default_builder();
301        let hat = Hat::new("reviewer", "Code Reviewer")
302            .with_instructions("Review PRs for quality and correctness.");
303
304        let instructions = builder.build_custom_hat(
305            &hat,
306            "PR #123 ready for review",
307            &ScratchpadConfig::default(),
308        );
309
310        // Custom role with RFC2119 style identity
311        assert!(instructions.contains("Code Reviewer"));
312        assert!(instructions.contains("You have fresh context each iteration"));
313
314        // Numbered orientation phase with RFC2119
315        assert!(instructions.contains("### 0. ORIENTATION"));
316        assert!(instructions.contains("You MUST study the incoming event context"));
317        assert!(instructions.contains("You MUST NOT assume work isn't done"));
318
319        assert!(instructions.contains("### 0b. TOOL DISCIPLINE"));
320        assert!(instructions.contains("You MUST check `<ready-tasks>` before creating more tasks"));
321        assert!(
322            instructions
323                .contains("`ralph tools task ensure`, `start`, `close`, `reopen`, or `fail`")
324        );
325        assert!(instructions.contains("`ralph tools memory search`"));
326        assert!(instructions.contains("`ralph tools memory add`"));
327        assert!(instructions.contains(".ralph/agent/decisions.md"));
328        assert!(instructions.contains("ralph tools interact progress"));
329
330        // Numbered execute phase with RFC2119
331        assert!(instructions.contains("### 1. EXECUTE"));
332        assert!(instructions.contains("Review PRs for quality"));
333        assert!(instructions.contains("You MUST NOT use more than 1 subagent for build/tests"));
334
335        // Verify phase with RFC2119 (task completion verification)
336        assert!(instructions.contains("### 2. VERIFY"));
337        assert!(instructions.contains("You MUST run tests and verify implementation"));
338        assert!(instructions.contains("You MUST NOT close tasks unless"));
339
340        // Report phase with RFC2119
341        assert!(instructions.contains("### 3. REPORT"));
342        assert!(
343            instructions
344                .contains("You MUST publish a result event with evidence using `ralph emit`")
345        );
346
347        // Guardrails section with high numbers
348        assert!(instructions.contains("### GUARDRAILS"));
349        assert!(instructions.contains("999."));
350
351        // Event context is included with RFC2119 directive
352        assert!(instructions.contains("You MUST handle these events"));
353        assert!(instructions.contains("PR #123 ready for review"));
354    }
355
356    #[test]
357    fn test_custom_guardrails_injected() {
358        let scratchpad = ScratchpadConfig {
359            enabled: true,
360            path: ".workspace/plan.md".to_string(),
361        };
362        let custom_core = CoreConfig {
363            scratchpad: scratchpad.clone(),
364            specs_dir: "./specifications/".to_string(),
365            guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
366            workspace_root: std::path::PathBuf::from("."),
367        };
368        let builder = InstructionBuilder::new(custom_core);
369
370        let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
371        let instructions = builder.build_custom_hat(&hat, "context", &scratchpad);
372
373        // Custom guardrails are injected with 999+ numbering
374        assert!(instructions.contains("999. Custom rule one"));
375        assert!(instructions.contains("1000. Custom rule two"));
376    }
377
378    #[test]
379    fn test_must_publish_injected_for_explicit_instructions() {
380        use ralph_proto::Topic;
381
382        let builder = default_builder();
383        let hat = Hat::new("reviewer", "Code Reviewer")
384            .with_instructions("Review PRs for quality and correctness.")
385            .with_publishes(vec![
386                Topic::new("review.approved"),
387                Topic::new("review.changes_requested"),
388            ]);
389
390        let instructions =
391            builder.build_custom_hat(&hat, "PR #123 ready", &ScratchpadConfig::default());
392
393        // Must-publish rule should be injected even with explicit instructions (RFC2119)
394        assert!(
395            instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
396            "Must-publish rule should be injected for custom hats with publishes"
397        );
398        assert!(instructions.contains("`review.approved`"));
399        assert!(instructions.contains("`review.changes_requested`"));
400        assert!(
401            instructions.contains("Plain-language summaries do NOT count as event publication")
402        );
403        assert!(instructions.contains("You MUST stop immediately after emitting"));
404        assert!(instructions.contains("You MUST NOT end the iteration without publishing"));
405    }
406
407    #[test]
408    fn test_must_publish_not_injected_when_no_publishes() {
409        let builder = default_builder();
410        let hat = Hat::new("observer", "Silent Observer")
411            .with_instructions("Observe and log, but do not emit events.");
412
413        let instructions =
414            builder.build_custom_hat(&hat, "Observe this", &ScratchpadConfig::default());
415
416        // No must-publish rule when hat has no publishes
417        // Note: The prompt says "You MUST publish a result event" in the REPORT section,
418        // but the specific emitted-topics list should not appear
419        assert!(
420            !instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
421            "Specific must-publish list should NOT be injected when hat has no publishes"
422        );
423    }
424
425    #[test]
426    fn test_derived_behaviors_when_no_explicit_instructions() {
427        use ralph_proto::Topic;
428
429        let builder = default_builder();
430        let hat = Hat::new("builder", "Builder")
431            .subscribe("build.task")
432            .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
433
434        let instructions =
435            builder.build_custom_hat(&hat, "Implement feature X", &ScratchpadConfig::default());
436
437        // Should derive behaviors from pub/sub contract
438        assert!(instructions.contains("Derived Behaviors"));
439        assert!(instructions.contains("build.task"));
440    }
441
442    #[test]
443    fn test_derived_behaviors_keep_default_scratchpad_wording() {
444        let builder = default_builder();
445        let hat = Hat::new("planner", "Planner")
446            .subscribe("task.start")
447            .subscribe("review.changes_requested");
448
449        let instructions =
450            builder.build_custom_hat(&hat, "Plan the work", &ScratchpadConfig::default());
451
452        assert!(instructions.contains("Analyze the task and create a plan in the scratchpad."));
453        assert!(instructions.contains("Add fix tasks to scratchpad and dispatch."));
454        assert!(
455            !instructions.contains(".ralph/agent/scratchpad.md"),
456            "Default scratchpad path should not be newly injected into inherited defaults"
457        );
458    }
459
460    #[test]
461    fn test_derived_behaviors_omit_scratchpad_when_disabled() {
462        let builder = default_builder();
463        let scratchpad = ScratchpadConfig {
464            enabled: false,
465            path: ".ralph/agent/scratchpad.md".to_string(),
466        };
467        let hat = Hat::new("planner", "Planner")
468            .subscribe("task.start")
469            .subscribe("review.changes_requested");
470
471        let instructions = builder.build_custom_hat(&hat, "Plan the work", &scratchpad);
472
473        assert!(instructions.contains("Analyze the task and create a plan."));
474        assert!(instructions.contains("Add fix tasks with `ralph tools task` and dispatch."));
475        assert!(
476            !instructions.to_ascii_lowercase().contains("scratchpad"),
477            "Disabled hat prompts must not instruct agents to use scratchpad"
478        );
479    }
480
481    #[test]
482    fn test_derived_behaviors_name_custom_scratchpad_path() {
483        let builder = default_builder();
484        let scratchpad = ScratchpadConfig {
485            enabled: true,
486            path: ".ralph/agent/planner.md".to_string(),
487        };
488        let hat = Hat::new("planner", "Planner")
489            .subscribe("task.start")
490            .subscribe("review.changes_requested");
491
492        let instructions = builder.build_custom_hat(&hat, "Plan the work", &scratchpad);
493
494        assert!(instructions.contains(
495            "Analyze the task and create a plan in the scratchpad at `.ralph/agent/planner.md`."
496        ));
497        assert!(instructions.contains(
498            "Add fix tasks to the scratchpad at `.ralph/agent/planner.md` and dispatch."
499        ));
500    }
501}