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