rab/agent/
system_prompt.rs1use crate::agent::context_files::ContextFile;
12use yoagent::skills::SkillSet;
13
14use std::path::Path;
15
16#[derive(Debug, Clone)]
19pub struct ToolSnippet {
20 pub name: String,
21 pub description: String,
22}
23
24impl ToolSnippet {}
25
26#[derive(Debug, Default)]
28pub struct SystemPromptBuilder {
29 tool_snippets: Vec<ToolSnippet>,
31 guidelines: Vec<String>,
33 context_files: Vec<ContextFile>,
35 skills: SkillSet,
37 custom_prompt: Option<String>,
39 append_prompt: Option<String>,
41 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 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 let mut prompt = if let Some(ref custom) = self.custom_prompt {
93 custom.clone()
95 } else {
96 self.build_default_prompt()
97 };
98
99 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 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 let skills_section = self.skills.format_for_prompt();
126 if !skills_section.is_empty() {
127 prompt.push_str(&skills_section);
128 }
129
130 prompt.push_str(&format!("\nCurrent date: {}", date));
132 prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
133
134 prompt
135 }
136
137 fn build_default_prompt(&self) -> String {
139 let mut prompt = String::new();
140
141 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 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 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 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 assert!(prompt.contains("You are a custom agent."));
228 assert!(!prompt.contains("rab, a coding agent harness"));
229 assert!(!prompt.contains("Available tools:"));
230 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 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 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 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 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 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}