defect_agent/session/
prompt.rs1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::session::context::RunningContext;
6use crate::session::turn::{BasePromptConfig, PromptConfig};
7
8const DEFAULT_PROMPT_FILE: &str = "AGENTS.md";
9
10struct Section {
18 title: String,
19 body: String,
20}
21
22impl Section {
23 fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
24 Self {
25 title: title.into(),
26 body: body.into(),
27 }
28 }
29
30 fn render(&self) -> String {
31 format!("# {}\n\n{}", self.title, self.body)
32 }
33}
34
35fn render_sections(sections: &[Section]) -> Option<String> {
37 (!sections.is_empty()).then(|| {
38 sections
39 .iter()
40 .map(Section::render)
41 .collect::<Vec<_>>()
42 .join("\n\n---\n\n")
43 })
44}
45
46pub fn resolve_system_prompt(
51 ctx: &RunningContext,
52 provider: &str,
53 model: &str,
54 base_prompt: &BasePromptConfig,
55 prompt: &PromptConfig,
56 session_overlay: Option<&str>,
57) -> Result<Option<String>, io::Error> {
58 let mut sections = Vec::new();
59
60 for body in load_base_prompt(base_prompt)? {
61 sections.push(Section::new("Base Prompt", body));
62 }
63
64 sections.push(Section::new("Environment", ctx.render()));
67
68 if let Some(text) = prompt.text.as_deref() {
69 sections.push(Section::new("System Instructions", text.to_owned()));
70 }
71
72 for (path, body) in load_prompt_file(ctx.cwd, &prompt.file)? {
73 let title = match path {
74 Some(path) => format!("Project Instructions ({path})"),
75 None => "Project Instructions".to_owned(),
76 };
77 sections.push(Section::new(title, body));
78 }
79
80 if let Some(provider_overlay) = prompt.provider_overlays.get(provider) {
81 sections.push(Section::new(
82 format!("Provider Notes ({provider})"),
83 provider_overlay.clone(),
84 ));
85 }
86
87 if let Some(model_overlay) = prompt.model_overlays.get(model) {
88 sections.push(Section::new(
89 format!("Model Notes ({model})"),
90 model_overlay.clone(),
91 ));
92 }
93
94 if let Some(session_overlay) = session_overlay {
95 sections.push(Section::new(
96 "Session Instructions",
97 session_overlay.to_owned(),
98 ));
99 }
100
101 Ok(render_sections(§ions))
102}
103
104pub fn load_project_prompt(cwd: &Path) -> Result<Option<String>, io::Error> {
116 let mut sections = Vec::new();
117 for (path, body) in load_prompt_file(cwd, DEFAULT_PROMPT_FILE)? {
118 let title = match path {
119 Some(path) => format!("Project Instructions ({path})"),
120 None => "Project Instructions".to_owned(),
121 };
122 sections.push(Section::new(title, body));
123 }
124 Ok(render_sections(§ions))
125}
126
127fn load_base_prompt(base_prompt: &BasePromptConfig) -> Result<Vec<String>, io::Error> {
128 let mut sections = Vec::new();
129
130 if let Some(file) = base_prompt.file.as_deref() {
131 let text = fs::read_to_string(file)?;
132 sections.push(text);
133 }
134
135 if let Some(text) = base_prompt.text.as_deref() {
136 sections.push(text.to_owned());
137 }
138
139 Ok(sections)
140}
141
142fn load_prompt_file(cwd: &Path, file: &str) -> Result<Vec<(Option<String>, String)>, io::Error> {
148 if file != DEFAULT_PROMPT_FILE {
149 let path = resolve_prompt_path(cwd, file);
150 return match fs::read_to_string(&path) {
151 Ok(text) => Ok(vec![(Some(rel_label(cwd, &path)), text)]),
152 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
153 Err(err) => Err(err),
154 };
155 }
156
157 let base = find_repo_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
161 let mut sections = Vec::new();
162 for dir in prompt_dirs(cwd) {
163 let path = dir.join(DEFAULT_PROMPT_FILE);
164 match fs::read_to_string(&path) {
165 Ok(text) => sections.push((Some(rel_label(&base, &path)), text)),
166 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
167 Err(err) => return Err(err),
168 }
169 }
170 Ok(sections)
171}
172
173fn rel_label(base: &Path, path: &Path) -> String {
176 path.strip_prefix(base)
177 .ok()
178 .map(|rel| rel.display().to_string())
179 .filter(|s| !s.is_empty())
180 .or_else(|| {
181 path.file_name()
182 .map(|name| name.to_string_lossy().into_owned())
183 })
184 .unwrap_or_else(|| path.display().to_string())
185}
186
187fn resolve_prompt_path(cwd: &Path, file: &str) -> PathBuf {
188 if file.is_empty() {
189 cwd.join(DEFAULT_PROMPT_FILE)
190 } else {
191 cwd.join(file)
192 }
193}
194
195fn prompt_dirs(cwd: &Path) -> Vec<PathBuf> {
196 let Some(repo_root) = find_repo_root(cwd) else {
197 return vec![cwd.to_path_buf()];
198 };
199
200 let mut dirs = Vec::new();
201 for dir in cwd.ancestors() {
202 dirs.push(dir.to_path_buf());
203 if dir == repo_root.as_path() {
204 break;
205 }
206 }
207 dirs.reverse();
208 dirs
209}
210
211fn find_repo_root(cwd: &Path) -> Option<PathBuf> {
212 cwd.ancestors()
213 .find(|dir| dir.join(".git").exists())
214 .map(Path::to_path_buf)
215}
216
217#[cfg(test)]
218mod tests;