Skip to main content

defect_agent/session/
prompt.rs

1use 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
10/// A system prompt section with a title. Each section is wrapped in a level-1 heading
11/// (`#`) and separated by a markdown horizontal rule (`---`), so the model treats each
12/// section as an independent document.
13///
14/// Convention: the injected title uses level-1 (`#`); the section body (e.g.
15/// `base_prompt` / `AGENTS.md`) should start at level-2 (`##`) and nest naturally
16/// underneath.
17struct 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
35/// Wrap each section in a level-1 heading and join them separated by `\n\n---\n\n`.
36fn 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
46/// # Errors
47///
48/// Returns an error if reading the prompt file fails or the prompt file path does not
49/// exist.
50pub 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    // Environment info: placed immediately after the base prompt (identity) and before
65    // project conventions, serving as a stable fact layer.
66    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(&sections))
102}
103
104/// Load **only** the project instruction layer (`AGENTS.md`, collected up the directory
105/// tree) as a single rendered string, or `None` if there is none.
106///
107/// This is the "project world knowledge" slice of [`resolve_system_prompt`] — deliberately
108/// excluding the base prompt, environment block, provider/model overlays, and session
109/// overlay (all of which are the *parent agent's* identity/runtime, not shareable project
110/// context). A subagent profile may opt in to this layer (`inherit_project_prompt = true`)
111/// so it gets build/test/architecture conventions without inheriting the parent's identity.
112///
113/// # Errors
114/// Propagates IO errors from reading `AGENTS.md` files (NotFound is not an error).
115pub 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(&sections))
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
142/// Loads the project prompt file. Returns a list of `(relative source path, text)` pairs
143/// — the source path is used in the section heading (`# Project Instructions (...)`),
144/// computed relative to `cwd`, falling back to the bare filename on failure. For
145/// non-default filenames, only a single location is read; for the default `AGENTS.md`,
146/// files are collected by walking up the directory tree.
147fn 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    // AGENTS.md is collected downward from the repo root along the directory tree, so
158    // source labels are computed relative to the repo root (e.g. `AGENTS.md`,
159    // `apps/web/AGENTS.md`).
160    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
173/// Label for the source path: prefer a path relative to `base`, fall back to the file
174/// name, then to the full path.
175fn 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;