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    /// Whether the `read` tool is available (controls whether skills section is shown).
38    has_read_tool: bool,
39    /// Custom system prompt (replaces default). From SYSTEM.md or `--system-prompt`.
40    custom_prompt: Option<String>,
41    /// Text to append to the system prompt. From APPEND_SYSTEM.md or `--append-system-prompt`.
42    append_prompt: Option<String>,
43    /// Working directory.
44    cwd: Option<String>,
45}
46
47impl SystemPromptBuilder {
48    pub fn new() -> Self {
49        Self {
50            has_read_tool: true,
51            ..Default::default()
52        }
53    }
54
55    pub fn tool_snippets(mut self, snippets: Vec<ToolSnippet>) -> Self {
56        self.tool_snippets = snippets;
57        self
58    }
59
60    pub fn guidelines(mut self, guidelines: Vec<String>) -> Self {
61        self.guidelines = guidelines;
62        self
63    }
64
65    pub fn context_files(mut self, files: Vec<ContextFile>) -> Self {
66        self.context_files = files;
67        self
68    }
69
70    pub fn skills(mut self, skills: SkillSet) -> Self {
71        self.skills = skills;
72        self
73    }
74
75    pub fn has_read_tool(mut self, has: bool) -> Self {
76        self.has_read_tool = has;
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        // Pi-compatible: only show skills section if read tool is available
136        // (skills are loaded via read tool by the model).
137        if self.has_read_tool && !self.skills.is_empty() {
138            prompt.push_str(
139                "\n\nThe following skills provide specialized instructions for specific tasks.\n",
140            );
141            prompt.push_str(
142                "Use the read tool to load a skill\'s file when the task matches its description.\n",
143            );
144            prompt.push_str(
145                "When a skill file references a relative path, resolve it against the skill directory "
146            );
147            prompt.push_str(
148                "(parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\n",
149            );
150            prompt.push('\n');
151            prompt.push_str(&self.skills.format_for_prompt());
152        }
153
154        // ── 5. Date and working directory ─────────────────────────
155        prompt.push_str(&format!("\nCurrent date: {}", date));
156        prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
157
158        prompt
159    }
160
161    /// Build the default system prompt (used when no custom_prompt is set).
162    fn build_default_prompt(&self) -> String {
163        let mut prompt = String::new();
164
165        // Identity
166        prompt.push_str(
167            "You are an expert coding assistant operating inside rab, a coding agent harness. \
168             You help users by reading files, executing commands, editing code, and writing new files.\n\n",
169        );
170
171        // Available tools
172        prompt.push_str("Available tools:\n");
173        if self.tool_snippets.is_empty() {
174            prompt.push_str("(none)\n");
175        } else {
176            for snippet in &self.tool_snippets {
177                prompt.push_str(&format!("- {}: {}\n", snippet.name, snippet.description));
178            }
179        }
180
181        // Custom tools note
182        prompt.push_str(
183            "\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n",
184        );
185
186        // Guidelines
187        prompt.push_str("\nGuidelines:\n");
188
189        let has_bash = self.tool_snippets.iter().any(|t| t.name == "bash");
190        let has_grep = self.tool_snippets.iter().any(|t| t.name == "grep");
191        let has_find = self.tool_snippets.iter().any(|t| t.name == "find");
192        let has_ls = self.tool_snippets.iter().any(|t| t.name == "ls");
193
194        if has_bash && !has_grep && !has_find && !has_ls {
195            prompt.push_str("- Use bash for file operations like ls, rg, find\n");
196        }
197
198        for guideline in &self.guidelines {
199            let trimmed = guideline.trim();
200            if !trimmed.is_empty() {
201                prompt.push_str(&format!("- {}\n", trimmed));
202            }
203        }
204
205        prompt.push_str("- Be concise in your responses\n");
206        prompt.push_str("- Show file paths clearly when working with files\n");
207
208        prompt
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::agent::context_files::ContextFile;
216
217    fn make_snippet(name: &str, desc: &str) -> ToolSnippet {
218        ToolSnippet {
219            name: name.to_string(),
220            description: desc.to_string(),
221        }
222    }
223
224    #[test]
225    fn test_default_prompt_has_tools_and_guidelines() {
226        let prompt = SystemPromptBuilder::new()
227            .tool_snippets(vec![
228                make_snippet("read", "Read file contents"),
229                make_snippet("bash", "Execute bash commands"),
230            ])
231            .guidelines(vec!["Use careful paths".to_string()])
232            .build();
233
234        assert!(prompt.contains("rab, a coding agent harness"));
235        assert!(prompt.contains("read: Read file contents"));
236        assert!(prompt.contains("bash: Execute bash commands"));
237        assert!(prompt.contains("Use careful paths"));
238        assert!(prompt.contains("Be concise in your responses"));
239        assert!(prompt.contains("Current date:"));
240        assert!(prompt.contains("Current working directory:"));
241    }
242
243    #[test]
244    fn test_custom_prompt_replaces_default() {
245        let prompt = SystemPromptBuilder::new()
246            .custom_prompt(Some("You are a custom agent.".to_string()))
247            .tool_snippets(vec![make_snippet("read", "Read files")])
248            .build();
249
250        // Custom prompt replaces default
251        assert!(prompt.contains("You are a custom agent."));
252        assert!(!prompt.contains("rab, a coding agent harness"));
253        assert!(!prompt.contains("Available tools:"));
254        // But context and date still appended
255        assert!(prompt.contains("Current date:"));
256    }
257
258    #[test]
259    fn test_append_prompt() {
260        let prompt = SystemPromptBuilder::new()
261            .append_prompt(Some("Additional instructions.".to_string()))
262            .build();
263
264        assert!(prompt.contains("Additional instructions."));
265    }
266
267    #[test]
268    fn test_project_context() {
269        let files = vec![ContextFile {
270            path: "/home/user/project/AGENTS.md".into(),
271            content: "# Project rules\n- be tidy".to_string(),
272        }];
273
274        let prompt = SystemPromptBuilder::new().context_files(files).build();
275
276        assert!(prompt.contains("<project_context>"));
277        assert!(prompt.contains("<project_instructions path=\"/home/user/project/AGENTS.md\">"));
278        assert!(prompt.contains("# Project rules\n- be tidy"));
279        assert!(prompt.contains("</project_instructions>"));
280        assert!(prompt.contains("</project_context>"));
281    }
282
283    #[test]
284    fn test_multiple_context_files() {
285        let files = vec![
286            ContextFile {
287                path: "/home/user/.rab/agent/AGENTS.md".into(),
288                content: "# Global".to_string(),
289            },
290            ContextFile {
291                path: "/home/user/project/AGENTS.md".into(),
292                content: "# Project".to_string(),
293            },
294        ];
295
296        let prompt = SystemPromptBuilder::new().context_files(files).build();
297
298        // Both should appear
299        assert!(prompt.contains("# Global"));
300        assert!(prompt.contains("# Project"));
301    }
302
303    #[test]
304    fn test_skills_section_empty() {
305        let prompt = SystemPromptBuilder::new().skills(SkillSet::empty()).build();
306        assert!(!prompt.contains("<available_skills>"));
307    }
308
309    #[test]
310    fn test_date_and_cwd_at_end() {
311        let prompt = SystemPromptBuilder::new()
312            .cwd(Path::new("/home/user/project"))
313            .build();
314
315        let lines: Vec<&str> = prompt.lines().collect();
316        // Last two lines should be date and cwd
317        assert!(lines[lines.len() - 2].starts_with("Current date:"));
318        assert_eq!(
319            lines[lines.len() - 1],
320            "Current working directory: /home/user/project"
321        );
322    }
323
324    #[test]
325    fn test_no_tools_shows_none() {
326        let prompt = SystemPromptBuilder::new().build();
327        assert!(prompt.contains("Available tools:\n(none)"));
328    }
329
330    #[test]
331    fn test_bash_without_grep_find_ls() {
332        let prompt = SystemPromptBuilder::new()
333            .tool_snippets(vec![make_snippet("bash", "Execute bash")])
334            .build();
335
336        assert!(prompt.contains("Use bash for file operations like ls, rg, find"));
337    }
338
339    #[test]
340    fn test_bash_with_grep() {
341        let prompt = SystemPromptBuilder::new()
342            .tool_snippets(vec![
343                make_snippet("bash", "Execute bash"),
344                make_snippet("grep", "Search text"),
345            ])
346            .build();
347
348        // Should NOT add the bash-for-files guideline since grep is available
349        assert!(!prompt.contains("Use bash for file operations like ls, rg, find"));
350    }
351
352    #[test]
353    fn test_custom_prompt_still_gets_context_and_skills() {
354        let files = vec![ContextFile {
355            path: "/project/AGENTS.md".into(),
356            content: "# Rules".to_string(),
357        }];
358
359        let prompt = SystemPromptBuilder::new()
360            .custom_prompt(Some("Custom base.".to_string()))
361            .context_files(files)
362            .skills(SkillSet::empty())
363            .build();
364
365        assert!(prompt.starts_with("Custom base."));
366        assert!(prompt.contains("<project_instructions"));
367        assert!(prompt.contains("Current date:"));
368    }
369
370    #[test]
371    fn test_full_build_integration() {
372        let files = vec![ContextFile {
373            path: "/home/user/project/AGENTS.md".into(),
374            content: "# Project rules".to_string(),
375        }];
376
377        let prompt = SystemPromptBuilder::new()
378            .tool_snippets(vec![
379                make_snippet("read", "Read file contents"),
380                make_snippet("edit", "Make precise edits"),
381                make_snippet("bash", "Execute bash commands"),
382                make_snippet("write", "Create or overwrite files"),
383            ])
384            .guidelines(vec![
385                "Use the edit tool for precise changes with exact text matching".to_string(),
386            ])
387            .context_files(files)
388            .skills(SkillSet::empty())
389            .cwd(Path::new("/home/user/project"))
390            .build();
391
392        // Verify structure
393        assert!(prompt.starts_with("You are an expert coding assistant"));
394        assert!(prompt.contains("Available tools:"));
395        assert!(prompt.contains("- read: Read file contents"));
396        assert!(prompt.contains("Guidelines:"));
397        assert!(prompt.contains("Make precise edits"));
398        assert!(prompt.contains("<project_context>"));
399        assert!(prompt.contains("# Project rules"));
400        assert!(prompt.ends_with("/home/user/project"));
401
402        // Verify order: guidelines before context before skills before date
403        let guidelines_pos = prompt.find("Guidelines:").unwrap();
404        let context_pos = prompt.find("<project_context>").unwrap();
405        let date_pos = prompt.find("Current date:").unwrap();
406
407        assert!(context_pos > guidelines_pos);
408        assert!(date_pos > context_pos);
409    }
410}