Skip to main content

rab/agent/
system_prompt.rs

1/// System prompt construction.
2///
3/// Mirrors pi's `buildSystemPrompt()` in system-prompt.ts.
4///
5/// Layers (in order):
6/// 1. Default prompt (tool announcements + guidelines) — replaced if custom_prompt is set
7/// 2. Append prompt (always appended, whether custom or default)
8/// 3. Project context (<project_context> wrapping AGENTS.md/CLAUDE.md files)
9/// 4. Skills (<available_skills> XML block)
10/// 5. Current date + working directory (always last)
11use crate::agent::context_files::ContextFile;
12use crate::agent::skills::{Skill, format_skills_for_prompt};
13
14use std::path::Path;
15
16/// A one-line description of a tool for the "Available tools" section.
17#[derive(Debug, Clone)]
18pub struct ToolSnippet {
19    pub name: String,
20    pub description: String,
21}
22
23/// Builder for constructing the full system prompt.
24///
25/// Usage:
26/// ```ignore
27/// let prompt = SystemPromptBuilder::new()
28///     .tool_snippets(tool_snippets)
29///     .guidelines(guidelines)
30///     .context_files(context_files)
31///     .skills(skills)
32///     .custom_prompt(custom_system_md)
33///     .append_prompt(append_system_md)
34///     .cwd(&cwd)
35///     .build();
36/// ```
37#[derive(Debug, Default)]
38pub struct SystemPromptBuilder {
39    /// Tool one-liners for "Available tools" section.
40    tool_snippets: Vec<ToolSnippet>,
41    /// Extra guideline bullets beyond the standard ones.
42    guidelines: Vec<String>,
43    /// Context files (AGENTS.md / CLAUDE.md) wrapped in `<project_context>`.
44    context_files: Vec<ContextFile>,
45    /// Skills formatted as `<available_skills>` XML.
46    skills: Vec<Skill>,
47    /// Custom system prompt (replaces default). From SYSTEM.md or `--system-prompt`.
48    custom_prompt: Option<String>,
49    /// Text to append to the system prompt. From APPEND_SYSTEM.md or `--append-system-prompt`.
50    append_prompt: Option<String>,
51    /// Working directory.
52    cwd: Option<String>,
53}
54
55impl SystemPromptBuilder {
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    pub fn tool_snippets(mut self, snippets: Vec<ToolSnippet>) -> Self {
61        self.tool_snippets = snippets;
62        self
63    }
64
65    pub fn guidelines(mut self, guidelines: Vec<String>) -> Self {
66        self.guidelines = guidelines;
67        self
68    }
69
70    pub fn context_files(mut self, files: Vec<ContextFile>) -> Self {
71        self.context_files = files;
72        self
73    }
74
75    pub fn skills(mut self, skills: Vec<Skill>) -> Self {
76        self.skills = skills;
77        self
78    }
79
80    pub fn custom_prompt(mut self, prompt: Option<String>) -> Self {
81        self.custom_prompt = prompt;
82        self
83    }
84
85    pub fn append_prompt(mut self, prompt: Option<String>) -> Self {
86        self.append_prompt = prompt;
87        self
88    }
89
90    pub fn cwd(mut self, cwd: &Path) -> Self {
91        self.cwd = Some(cwd.to_string_lossy().replace('\\', "/"));
92        self
93    }
94
95    /// Build the final system prompt string.
96    pub fn build(&self) -> String {
97        let now = chrono::Utc::now();
98        let date = now.format("%Y-%m-%d").to_string();
99        let prompt_cwd = self.cwd.clone().unwrap_or_else(|| String::from("/unknown"));
100
101        // ── 1. Default or custom prompt ────────────────────────────
102        let mut prompt = if let Some(ref custom) = self.custom_prompt {
103            // Custom prompt replaces default entirely
104            custom.clone()
105        } else {
106            self.build_default_prompt()
107        };
108
109        // ── 2. Append prompt ──────────────────────────────────────
110        if let Some(ref append) = self.append_prompt
111            && !append.is_empty()
112        {
113            prompt.push('\n');
114            prompt.push('\n');
115            prompt.push_str(append);
116        }
117
118        // ── 3. Project context (AGENTS.md / CLAUDE.md) ────────────
119        if !self.context_files.is_empty() {
120            prompt.push_str("\n\n<project_context>\n\n");
121            prompt.push_str("Project-specific instructions and guidelines:\n\n");
122
123            for cf in &self.context_files {
124                let path_str = cf.path.to_string_lossy();
125                prompt.push_str(&format!(
126                    "<project_instructions path=\"{}\">\n{}\n</project_instructions>\n\n",
127                    path_str, cf.content
128                ));
129            }
130
131            prompt.push_str("</project_context>\n");
132        }
133
134        // ── 4. Skills ─────────────────────────────────────────────
135        let skills_section = format_skills_for_prompt(&self.skills);
136        if !skills_section.is_empty() {
137            prompt.push_str(&skills_section);
138        }
139
140        // ── 5. Date and working directory ─────────────────────────
141        prompt.push_str(&format!("\nCurrent date: {}", date));
142        prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
143
144        prompt
145    }
146
147    /// Build the default system prompt (used when no custom_prompt is set).
148    fn build_default_prompt(&self) -> String {
149        let mut prompt = String::new();
150
151        // Identity
152        prompt.push_str(
153            "You are an expert coding assistant operating inside rab, a coding agent harness. \
154             You help users by reading files, executing commands, editing code, and writing new files.\n\n",
155        );
156
157        // Available tools
158        prompt.push_str("Available tools:\n");
159        if self.tool_snippets.is_empty() {
160            prompt.push_str("(none)\n");
161        } else {
162            for snippet in &self.tool_snippets {
163                prompt.push_str(&format!("- {}: {}\n", snippet.name, snippet.description));
164            }
165        }
166
167        // Custom tools note
168        prompt.push_str(
169            "\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n",
170        );
171
172        // Guidelines
173        prompt.push_str("\nGuidelines:\n");
174
175        let has_bash = self.tool_snippets.iter().any(|t| t.name == "bash");
176        let has_grep = self.tool_snippets.iter().any(|t| t.name == "grep");
177        let has_find = self.tool_snippets.iter().any(|t| t.name == "find");
178        let has_ls = self.tool_snippets.iter().any(|t| t.name == "ls");
179
180        if has_bash && !has_grep && !has_find && !has_ls {
181            prompt.push_str("- Use bash for file operations like ls, rg, find\n");
182        }
183
184        for guideline in &self.guidelines {
185            let trimmed = guideline.trim();
186            if !trimmed.is_empty() {
187                prompt.push_str(&format!("- {}\n", trimmed));
188            }
189        }
190
191        prompt.push_str("- Be concise in your responses\n");
192        prompt.push_str("- Show file paths clearly when working with files\n");
193
194        prompt
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::agent::context_files::ContextFile;
202
203    fn make_snippet(name: &str, desc: &str) -> ToolSnippet {
204        ToolSnippet {
205            name: name.to_string(),
206            description: desc.to_string(),
207        }
208    }
209
210    #[test]
211    fn test_default_prompt_has_tools_and_guidelines() {
212        let prompt = SystemPromptBuilder::new()
213            .tool_snippets(vec![
214                make_snippet("read", "Read file contents"),
215                make_snippet("bash", "Execute bash commands"),
216            ])
217            .guidelines(vec!["Use careful paths".to_string()])
218            .build();
219
220        assert!(prompt.contains("rab, a coding agent harness"));
221        assert!(prompt.contains("read: Read file contents"));
222        assert!(prompt.contains("bash: Execute bash commands"));
223        assert!(prompt.contains("Use careful paths"));
224        assert!(prompt.contains("Be concise in your responses"));
225        assert!(prompt.contains("Current date:"));
226        assert!(prompt.contains("Current working directory:"));
227    }
228
229    #[test]
230    fn test_custom_prompt_replaces_default() {
231        let prompt = SystemPromptBuilder::new()
232            .custom_prompt(Some("You are a custom agent.".to_string()))
233            .tool_snippets(vec![make_snippet("read", "Read files")])
234            .build();
235
236        // Custom prompt replaces default
237        assert!(prompt.contains("You are a custom agent."));
238        assert!(!prompt.contains("rab, a coding agent harness"));
239        assert!(!prompt.contains("Available tools:"));
240        // But context and date still appended
241        assert!(prompt.contains("Current date:"));
242    }
243
244    #[test]
245    fn test_append_prompt() {
246        let prompt = SystemPromptBuilder::new()
247            .append_prompt(Some("Additional instructions.".to_string()))
248            .build();
249
250        assert!(prompt.contains("Additional instructions."));
251    }
252
253    #[test]
254    fn test_project_context() {
255        let files = vec![ContextFile {
256            path: "/home/user/project/AGENTS.md".into(),
257            content: "# Project rules\n- be tidy".to_string(),
258        }];
259
260        let prompt = SystemPromptBuilder::new().context_files(files).build();
261
262        assert!(prompt.contains("<project_context>"));
263        assert!(prompt.contains("<project_instructions path=\"/home/user/project/AGENTS.md\">"));
264        assert!(prompt.contains("# Project rules\n- be tidy"));
265        assert!(prompt.contains("</project_instructions>"));
266        assert!(prompt.contains("</project_context>"));
267    }
268
269    #[test]
270    fn test_multiple_context_files() {
271        let files = vec![
272            ContextFile {
273                path: "/home/user/.rab/agent/AGENTS.md".into(),
274                content: "# Global".to_string(),
275            },
276            ContextFile {
277                path: "/home/user/project/AGENTS.md".into(),
278                content: "# Project".to_string(),
279            },
280        ];
281
282        let prompt = SystemPromptBuilder::new().context_files(files).build();
283
284        // Both should appear
285        assert!(prompt.contains("# Global"));
286        assert!(prompt.contains("# Project"));
287    }
288
289    #[test]
290    fn test_skills_section() {
291        let skills = vec![Skill {
292            name: "code-review".to_string(),
293            description: "Reviews code for bugs".to_string(),
294            file_path: "/home/user/.rab/agent/skills/code-review/SKILL.md".into(),
295            base_dir: "/home/user/.rab/agent/skills/code-review".into(),
296            disable_model_invocation: false,
297        }];
298
299        let prompt = SystemPromptBuilder::new().skills(skills).build();
300
301        assert!(prompt.contains("<available_skills>"));
302        assert!(prompt.contains("<name>code-review</name>"));
303        assert!(prompt.contains("</available_skills>"));
304    }
305
306    #[test]
307    fn test_date_and_cwd_at_end() {
308        let prompt = SystemPromptBuilder::new()
309            .cwd(Path::new("/home/user/project"))
310            .build();
311
312        let lines: Vec<&str> = prompt.lines().collect();
313        // Last two lines should be date and cwd
314        assert!(lines[lines.len() - 2].starts_with("Current date:"));
315        assert_eq!(
316            lines[lines.len() - 1],
317            "Current working directory: /home/user/project"
318        );
319    }
320
321    #[test]
322    fn test_no_tools_shows_none() {
323        let prompt = SystemPromptBuilder::new().build();
324        assert!(prompt.contains("Available tools:\n(none)"));
325    }
326
327    #[test]
328    fn test_bash_without_grep_find_ls() {
329        let prompt = SystemPromptBuilder::new()
330            .tool_snippets(vec![make_snippet("bash", "Execute bash")])
331            .build();
332
333        assert!(prompt.contains("Use bash for file operations like ls, rg, find"));
334    }
335
336    #[test]
337    fn test_bash_with_grep() {
338        let prompt = SystemPromptBuilder::new()
339            .tool_snippets(vec![
340                make_snippet("bash", "Execute bash"),
341                make_snippet("grep", "Search text"),
342            ])
343            .build();
344
345        // Should NOT add the bash-for-files guideline since grep is available
346        assert!(!prompt.contains("Use bash for file operations like ls, rg, find"));
347    }
348
349    #[test]
350    fn test_custom_prompt_still_gets_context_and_skills() {
351        let files = vec![ContextFile {
352            path: "/project/AGENTS.md".into(),
353            content: "# Rules".to_string(),
354        }];
355
356        let skills = vec![Skill {
357            name: "test-skill".to_string(),
358            description: "Test".to_string(),
359            file_path: "/tmp/SKILL.md".into(),
360            base_dir: "/tmp".into(),
361            disable_model_invocation: false,
362        }];
363
364        let prompt = SystemPromptBuilder::new()
365            .custom_prompt(Some("Custom base.".to_string()))
366            .context_files(files)
367            .skills(skills)
368            .build();
369
370        assert!(prompt.starts_with("Custom base."));
371        assert!(prompt.contains("<project_instructions"));
372        assert!(prompt.contains("<available_skills>"));
373        assert!(prompt.contains("Current date:"));
374    }
375
376    #[test]
377    fn test_full_build_integration() {
378        let files = vec![ContextFile {
379            path: "/home/user/project/AGENTS.md".into(),
380            content: "# Project rules".to_string(),
381        }];
382
383        let skills = vec![Skill {
384            name: "code-review".to_string(),
385            description: "Review code".to_string(),
386            file_path: "/home/user/.rab/agent/skills/code-review/SKILL.md".into(),
387            base_dir: "/home/user/.rab/agent/skills/code-review".into(),
388            disable_model_invocation: false,
389        }];
390
391        let prompt = SystemPromptBuilder::new()
392            .tool_snippets(vec![
393                make_snippet("read", "Read file contents"),
394                make_snippet("edit", "Make precise edits"),
395                make_snippet("bash", "Execute bash commands"),
396                make_snippet("write", "Create or overwrite files"),
397            ])
398            .guidelines(vec![
399                "Use the edit tool for precise changes with exact text matching".to_string(),
400            ])
401            .context_files(files)
402            .skills(skills)
403            .cwd(Path::new("/home/user/project"))
404            .build();
405
406        // Verify structure
407        assert!(prompt.starts_with("You are an expert coding assistant"));
408        assert!(prompt.contains("Available tools:"));
409        assert!(prompt.contains("- read: Read file contents"));
410        assert!(prompt.contains("Guidelines:"));
411        assert!(prompt.contains("Make precise edits"));
412        assert!(prompt.contains("<project_context>"));
413        assert!(prompt.contains("# Project rules"));
414        assert!(prompt.contains("<available_skills>"));
415        assert!(prompt.contains("<name>code-review</name>"));
416        assert!(prompt.ends_with("/home/user/project"));
417
418        // Verify order: guidelines before context before skills before date
419        let guidelines_pos = prompt.find("Guidelines:").unwrap();
420        let context_pos = prompt.find("<project_context>").unwrap();
421        let skills_pos = prompt.find("<available_skills>").unwrap();
422        let date_pos = prompt.find("Current date:").unwrap();
423
424        assert!(guidelines_pos < context_pos);
425        assert!(context_pos < skills_pos);
426        assert!(skills_pos < date_pos);
427    }
428}