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