bamboo_engine/runtime/context/
instruction.rs1use 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!("Failed to inspect instruction file {:?}: {}", path, error);
27 return None;
28 }
29 };
30 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
31 tracing::warn!(
32 "Ignoring non-regular or symlinked instruction file: {:?}",
33 path
34 );
35 return None;
36 }
37 let bytes = match std::fs::read(path) {
38 Ok(bytes) => bytes,
39 Err(error) => {
40 tracing::warn!("Failed to read instruction file {:?}: {}", path, error);
41 return None;
42 }
43 };
44 let truncated = if bytes.len() > MAX_INSTRUCTION_BYTES_PER_FILE {
45 &bytes[..MAX_INSTRUCTION_BYTES_PER_FILE]
46 } else {
47 &bytes
48 };
49 let content = String::from_utf8_lossy(truncated).trim().to_string();
50 (!content.is_empty()).then_some(content)
51}
52
53fn canonical_dir_or_self(path: &Path) -> Option<PathBuf> {
54 let candidate = if path.is_dir() {
55 path.to_path_buf()
56 } else {
57 path.parent()?.to_path_buf()
58 };
59 std::fs::canonicalize(candidate).ok()
60}
61
62fn scoped_dirs(start: &Path) -> Vec<PathBuf> {
63 let mut leaf_to_root = Vec::new();
64 let Some(mut current) = canonical_dir_or_self(start) else {
65 return leaf_to_root;
66 };
67 let canonical_start = current.clone();
68 let mut found_workspace_boundary = false;
69
70 loop {
71 leaf_to_root.push(current.clone());
72 if current.join(".git").exists() {
73 found_workspace_boundary = true;
74 break;
75 }
76 let Some(parent) = current.parent() else {
77 break;
78 };
79 if parent == current {
80 break;
81 }
82 current = parent.to_path_buf();
83 }
84 if !found_workspace_boundary {
85 return vec![canonical_start];
86 }
87 leaf_to_root.reverse();
88 leaf_to_root
89}
90
91pub fn collect_instruction_files(workspace_path: &Path) -> Vec<InstructionFile> {
92 let mut files = Vec::new();
93
94 let mut seen = HashSet::new();
95 for dir in scoped_dirs(workspace_path) {
96 for file_name in INSTRUCTION_FILE_NAMES {
97 let candidate = dir.join(file_name);
98 if !seen.insert(candidate.clone()) {
99 continue;
100 }
101 let Some(content) = read_trimmed_file(&candidate) else {
102 continue;
103 };
104 files.push(InstructionFile {
105 display_path: paths::path_to_display_string(&candidate),
106 path: candidate,
107 content,
108 });
109 }
110 }
111
112 files
113}
114
115pub fn build_instruction_prompt_context(workspace_path: &str) -> Option<String> {
116 let workspace_path = workspace_path.trim();
117 if workspace_path.is_empty() {
118 return None;
119 }
120
121 let files = collect_instruction_files(Path::new(workspace_path));
122 if files.is_empty() {
123 return None;
124 }
125
126 let mut sections = Vec::new();
127 sections.push(
128 "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(),
129 );
130
131 for file in files {
132 sections.push(format!(
133 "## {}\nSource: {}\n\n{}",
134 file.path
135 .file_name()
136 .and_then(|value| value.to_str())
137 .unwrap_or("INSTRUCTION.md"),
138 file.display_path,
139 file.content
140 ));
141 }
142
143 let body = sections.join("\n\n");
144 Some(format!(
145 "{INSTRUCTION_CONTEXT_START_MARKER}\n{body}\n{INSTRUCTION_CONTEXT_END_MARKER}"
146 ))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn build_instruction_prompt_context_collects_workspace_and_ancestor_files() {
155 let root = tempfile::tempdir().expect("temp dir");
156 let nested = root.path().join("nested/project");
157 std::fs::create_dir_all(root.path().join(".git")).expect("git marker");
158 std::fs::create_dir_all(&nested).expect("nested dir");
159 std::fs::write(root.path().join("AGENTS.md"), "root agents").expect("agents");
160 std::fs::write(root.path().join("CLAUDE.md"), "root claude").expect("claude");
161
162 let context = build_instruction_prompt_context(nested.to_string_lossy().as_ref())
163 .expect("instruction context should exist");
164
165 assert!(context.contains(INSTRUCTION_CONTEXT_START_MARKER));
166 assert!(context.contains("root agents"));
167 assert!(context.contains("root claude"));
168 assert!(context.contains("AGENTS.md"));
169 assert!(context.contains("CLAUDE.md"));
170 }
171
172 #[test]
173 fn build_instruction_prompt_context_returns_none_when_no_files_exist() {
174 let root = tempfile::tempdir().expect("temp dir");
175 assert!(build_instruction_prompt_context(root.path().to_string_lossy().as_ref()).is_none());
176 }
177
178 #[test]
179 fn instructions_are_root_to_leaf_and_stop_at_git_workspace_boundary() {
180 let outer = tempfile::tempdir().expect("temp dir");
181 std::fs::write(outer.path().join("AGENTS.md"), "outside").expect("outside");
182 let repo = outer.path().join("repo");
183 let nested = repo.join("src/feature");
184 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
185 std::fs::create_dir_all(&nested).expect("nested");
186 std::fs::write(repo.join("AGENTS.md"), "root rule").expect("root");
187 std::fs::write(repo.join("src/AGENTS.md"), "nested rule").expect("nested rule");
188
189 let files = collect_instruction_files(&nested);
190 let contents: Vec<_> = files.iter().map(|file| file.content.as_str()).collect();
191 assert_eq!(contents, vec!["root rule", "nested rule"]);
192 assert!(files.iter().all(|file| !file.content.contains("outside")));
193 }
194
195 #[cfg(unix)]
196 #[test]
197 fn symlinked_instruction_file_is_not_followed() {
198 use std::os::unix::fs::symlink;
199 let root = tempfile::tempdir().expect("temp dir");
200 std::fs::create_dir_all(root.path().join(".git")).expect("git marker");
201 let outside = tempfile::NamedTempFile::new().expect("outside");
202 std::fs::write(outside.path(), "outside policy").expect("write outside");
203 symlink(outside.path(), root.path().join("AGENTS.md")).expect("symlink");
204 assert!(collect_instruction_files(root.path()).is_empty());
205 }
206
207 #[test]
208 fn git_worktree_file_is_a_workspace_boundary() {
209 let outer = tempfile::tempdir().expect("outer");
210 std::fs::write(outer.path().join("AGENTS.md"), "outside").expect("outside");
211 let worktree = outer.path().join(".bamboo/worktree/task");
212 std::fs::create_dir_all(worktree.join("src")).expect("worktree");
213 std::fs::write(worktree.join(".git"), "gitdir: /repo/.git/worktrees/task")
214 .expect("git file");
215 std::fs::write(worktree.join("AGENTS.md"), "worktree root").expect("agents");
216
217 let files = collect_instruction_files(&worktree.join("src"));
218 assert_eq!(files.len(), 1);
219 assert_eq!(files[0].content, "worktree root");
220 }
221}