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 has_read_tool: bool,
39 custom_prompt: Option<String>,
41 append_prompt: Option<String>,
43 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 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 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 prompt.push_str(&format!("\nCurrent date: {}", date));
156 prompt.push_str(&format!("\nCurrent working directory: {}", prompt_cwd));
157
158 prompt
159 }
160
161 fn build_default_prompt(&self) -> String {
163 let mut prompt = String::new();
164
165 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 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 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 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 assert!(prompt.contains("You are a custom agent."));
252 assert!(!prompt.contains("rab, a coding agent harness"));
253 assert!(!prompt.contains("Available tools:"));
254 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 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 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 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 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 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}