Skip to main content

bamboo_engine/runtime/context/
instruction.rs

1//! Instruction layer: loads instruction files (AGENTS.md, CLAUDE.md) from workspace.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use bamboo_config::paths;
7
8pub const INSTRUCTION_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_INSTRUCTION_CONTEXT_START -->";
9pub const INSTRUCTION_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_INSTRUCTION_CONTEXT_END -->";
10
11const INSTRUCTION_FILE_NAMES: [&str; 2] = ["AGENTS.md", "CLAUDE.md"];
12const MAX_INSTRUCTION_BYTES_PER_FILE: usize = 32 * 1024;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct InstructionFile {
16    pub path: PathBuf,
17    pub display_path: String,
18    pub content: String,
19}
20
21fn read_trimmed_file(path: &Path) -> Option<String> {
22    let metadata = match std::fs::symlink_metadata(path) {
23        Ok(metadata) => metadata,
24        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
25        Err(error) => {
26            tracing::warn!(
27                error_kind = ?error.kind(),
28                "failed to inspect workspace instruction file"
29            );
30            return None;
31        }
32    };
33    if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
34        tracing::warn!("ignoring non-regular or symlinked workspace instruction file");
35        return None;
36    }
37    let bytes = match std::fs::read(path) {
38        Ok(bytes) => bytes,
39        Err(error) => {
40            tracing::warn!(
41                error_kind = ?error.kind(),
42                "failed to read workspace instruction file"
43            );
44            return None;
45        }
46    };
47    let truncated = if bytes.len() > MAX_INSTRUCTION_BYTES_PER_FILE {
48        &bytes[..MAX_INSTRUCTION_BYTES_PER_FILE]
49    } else {
50        &bytes
51    };
52    let content = String::from_utf8_lossy(truncated).trim().to_string();
53    (!content.is_empty()).then_some(content)
54}
55
56fn canonical_dir_or_self(path: &Path) -> Option<PathBuf> {
57    let candidate = if path.is_dir() {
58        path.to_path_buf()
59    } else {
60        path.parent()?.to_path_buf()
61    };
62    std::fs::canonicalize(candidate).ok()
63}
64
65fn scoped_dirs(start: &Path) -> Vec<PathBuf> {
66    let mut leaf_to_root = Vec::new();
67    let Some(mut current) = canonical_dir_or_self(start) else {
68        return leaf_to_root;
69    };
70    let canonical_start = current.clone();
71    let mut found_workspace_boundary = false;
72
73    loop {
74        leaf_to_root.push(current.clone());
75        if current.join(".git").exists() {
76            found_workspace_boundary = true;
77            break;
78        }
79        let Some(parent) = current.parent() else {
80            break;
81        };
82        if parent == current {
83            break;
84        }
85        current = parent.to_path_buf();
86    }
87    if !found_workspace_boundary {
88        return vec![canonical_start];
89    }
90    leaf_to_root.reverse();
91    leaf_to_root
92}
93
94pub fn collect_instruction_files(workspace_path: &Path) -> Vec<InstructionFile> {
95    let mut files = Vec::new();
96
97    let mut seen = HashSet::new();
98    for dir in scoped_dirs(workspace_path) {
99        for file_name in INSTRUCTION_FILE_NAMES {
100            let candidate = dir.join(file_name);
101            if !seen.insert(candidate.clone()) {
102                continue;
103            }
104            let Some(content) = read_trimmed_file(&candidate) else {
105                continue;
106            };
107            files.push(InstructionFile {
108                display_path: paths::path_to_display_string(&candidate),
109                path: candidate,
110                content,
111            });
112        }
113    }
114
115    files
116}
117
118pub fn build_instruction_prompt_context(workspace_path: &str) -> Option<String> {
119    let workspace_path = workspace_path.trim();
120    if workspace_path.is_empty() {
121        return None;
122    }
123
124    let files = collect_instruction_files(Path::new(workspace_path));
125    if files.is_empty() {
126        return None;
127    }
128
129    let mut sections = Vec::new();
130    sections.push(
131        "Repository instruction layer loaded from workspace policy files. Treat these as authoritative project guardrails and follow them in addition to the base system prompt. When a request in this conversation conflicts with them, the repository policy takes precedence: do not override it just because the user asks in passing — surface the conflict and keep following the policy unless the user explicitly and knowingly directs you to override a specific rule. Only the base system prompt and higher-priority system/developer/safety directives outrank these files.".to_string(),
132    );
133
134    // Provider-visible source identities are workspace-relative. Absolute host
135    // paths belong only to the dedicated Workspace block and must not be
136    // duplicated by the instruction overlay.
137    let source_root = files
138        .first()
139        .and_then(|file| file.path.parent())
140        .map(Path::to_path_buf);
141    for file in files {
142        let source = source_root
143            .as_deref()
144            .and_then(|root| file.path.strip_prefix(root).ok())
145            .filter(|relative| !relative.as_os_str().is_empty())
146            .unwrap_or_else(|| {
147                Path::new(
148                    file.path
149                        .file_name()
150                        .and_then(|value| value.to_str())
151                        .unwrap_or("INSTRUCTION.md"),
152                )
153            });
154        sections.push(format!(
155            "## {}\nSource: {}\n\n{}",
156            file.path
157                .file_name()
158                .and_then(|value| value.to_str())
159                .unwrap_or("INSTRUCTION.md"),
160            paths::path_to_display_string(source),
161            file.content
162        ));
163    }
164
165    let body = sections.join("\n\n");
166    Some(format!(
167        "{INSTRUCTION_CONTEXT_START_MARKER}\n{body}\n{INSTRUCTION_CONTEXT_END_MARKER}"
168    ))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn build_instruction_prompt_context_collects_workspace_and_ancestor_files() {
177        let root = tempfile::tempdir().expect("temp dir");
178        let nested = root.path().join("nested/project");
179        std::fs::create_dir_all(root.path().join(".git")).expect("git marker");
180        std::fs::create_dir_all(&nested).expect("nested dir");
181        std::fs::write(root.path().join("AGENTS.md"), "root agents").expect("agents");
182        std::fs::write(root.path().join("CLAUDE.md"), "root claude").expect("claude");
183
184        let context = build_instruction_prompt_context(nested.to_string_lossy().as_ref())
185            .expect("instruction context should exist");
186
187        assert!(context.contains(INSTRUCTION_CONTEXT_START_MARKER));
188        assert!(context.contains("root agents"));
189        assert!(context.contains("root claude"));
190        assert!(context.contains("AGENTS.md"));
191        assert!(context.contains("CLAUDE.md"));
192    }
193
194    #[test]
195    fn build_instruction_prompt_context_returns_none_when_no_files_exist() {
196        let root = tempfile::tempdir().expect("temp dir");
197        assert!(build_instruction_prompt_context(root.path().to_string_lossy().as_ref()).is_none());
198    }
199
200    #[test]
201    fn instructions_are_root_to_leaf_and_stop_at_git_workspace_boundary() {
202        let outer = tempfile::tempdir().expect("temp dir");
203        std::fs::write(outer.path().join("AGENTS.md"), "outside").expect("outside");
204        let repo = outer.path().join("repo");
205        let nested = repo.join("src/feature");
206        std::fs::create_dir_all(repo.join(".git")).expect("git marker");
207        std::fs::create_dir_all(&nested).expect("nested");
208        std::fs::write(repo.join("AGENTS.md"), "root rule").expect("root");
209        std::fs::write(repo.join("src/AGENTS.md"), "nested rule").expect("nested rule");
210
211        let files = collect_instruction_files(&nested);
212        let contents: Vec<_> = files.iter().map(|file| file.content.as_str()).collect();
213        assert_eq!(contents, vec!["root rule", "nested rule"]);
214        assert!(files.iter().all(|file| !file.content.contains("outside")));
215    }
216
217    #[cfg(unix)]
218    #[test]
219    fn symlinked_instruction_file_is_not_followed() {
220        use std::os::unix::fs::symlink;
221        let root = tempfile::tempdir().expect("temp dir");
222        std::fs::create_dir_all(root.path().join(".git")).expect("git marker");
223        let outside = tempfile::NamedTempFile::new().expect("outside");
224        std::fs::write(outside.path(), "outside policy").expect("write outside");
225        symlink(outside.path(), root.path().join("AGENTS.md")).expect("symlink");
226        assert!(collect_instruction_files(root.path()).is_empty());
227    }
228
229    #[test]
230    fn git_worktree_file_is_a_workspace_boundary() {
231        let outer = tempfile::tempdir().expect("outer");
232        std::fs::write(outer.path().join("AGENTS.md"), "outside").expect("outside");
233        let worktree = outer.path().join(".bamboo/worktree/task");
234        std::fs::create_dir_all(worktree.join("src")).expect("worktree");
235        std::fs::write(worktree.join(".git"), "gitdir: /repo/.git/worktrees/task")
236            .expect("git file");
237        std::fs::write(worktree.join("AGENTS.md"), "worktree root").expect("agents");
238
239        let files = collect_instruction_files(&worktree.join("src"));
240        assert_eq!(files.len(), 1);
241        assert_eq!(files[0].content, "worktree root");
242    }
243}