1use crate::agent::context_files::ContextFile;
12use crate::agent::skills::{Skill, format_skills_for_prompt};
13
14use std::path::Path;
15
16#[derive(Debug, Clone)]
18pub struct ToolSnippet {
19 pub name: String,
20 pub description: String,
21}
22
23#[derive(Debug, Default)]
38pub struct SystemPromptBuilder {
39 tool_snippets: Vec<ToolSnippet>,
41 guidelines: Vec<String>,
43 context_files: Vec<ContextFile>,
45 skills: Vec<Skill>,
47 custom_prompt: Option<String>,
49 append_prompt: Option<String>,
51 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 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 let mut prompt = if let Some(ref custom) = self.custom_prompt {
103 custom.clone()
105 } else {
106 self.build_default_prompt()
107 };
108
109 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 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 let skills_section = format_skills_for_prompt(&self.skills);
136 if !skills_section.is_empty() {
137 prompt.push_str(&skills_section);
138 }
139
140 prompt.push_str(&format!("\nCurrent date: {}", date));
142 prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
143
144 prompt
145 }
146
147 fn build_default_prompt(&self) -> String {
149 let mut prompt = String::new();
150
151 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 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 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 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 assert!(prompt.contains("You are a custom agent."));
238 assert!(!prompt.contains("rab, a coding agent harness"));
239 assert!(!prompt.contains("Available tools:"));
240 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 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 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 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 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 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}