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)]
41pub struct SystemPromptBuilder {
42 tool_snippets: Vec<ToolSnippet>,
44 guidelines: Vec<String>,
46 context_files: Vec<ContextFile>,
48 skills: SkillSet,
50 custom_prompt: Option<String>,
52 append_prompt: Option<String>,
54 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 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 let mut prompt = if let Some(ref custom) = self.custom_prompt {
106 custom.clone()
108 } else {
109 self.build_default_prompt()
110 };
111
112 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 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 let skills_section = self.skills.format_for_prompt();
139 if !skills_section.is_empty() {
140 prompt.push_str(&skills_section);
141 }
142
143 prompt.push_str(&format!("\nCurrent date: {}", date));
145 prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
146
147 prompt
148 }
149
150 fn build_default_prompt(&self) -> String {
152 let mut prompt = String::new();
153
154 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 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 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 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 assert!(prompt.contains("You are a custom agent."));
241 assert!(!prompt.contains("rab, a coding agent harness"));
242 assert!(!prompt.contains("Available tools:"));
243 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 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 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 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 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 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}