1use crate::{RunContext, RunMetadata};
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4
5use super::workspace::{FileBody, FileReadOptions, Workspace, WorkspacePath};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct InstructionFileSpec {
10 pub file_name: String,
12 pub max_bytes: Option<usize>,
14}
15
16impl Default for InstructionFileSpec {
17 fn default() -> Self {
18 Self {
19 file_name: "AGENTS.md".to_string(),
20 max_bytes: None,
21 }
22 }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InstructionRequest {
28 pub target_paths: Vec<WorkspacePath>,
30 pub file_specs: Vec<InstructionFileSpec>,
32 pub max_bytes: usize,
34}
35
36impl Default for InstructionRequest {
37 fn default() -> Self {
38 Self {
39 target_paths: vec![WorkspacePath::root()],
40 file_specs: vec![InstructionFileSpec::default()],
41 max_bytes: 64 * 1024,
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct InstructionBundle {
49 pub files: Vec<InstructionFile>,
52 pub warnings: Vec<String>,
54 pub truncated: bool,
56 pub metadata: RunMetadata,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct InstructionFile {
63 pub path: WorkspacePath,
65 pub content: String,
67 pub truncated: bool,
69}
70
71#[async_trait]
73pub trait InstructionResolver: Send + Sync {
74 async fn resolve(
76 &self,
77 request: InstructionRequest,
78 context: &RunContext,
79 ) -> Result<InstructionBundle, InstructionError>;
80}
81
82#[derive(Debug, Clone)]
85pub struct DefaultInstructionResolver<W> {
86 workspace: W,
87}
88
89impl<W> DefaultInstructionResolver<W> {
90 pub fn new(workspace: W) -> Self {
92 Self { workspace }
93 }
94}
95
96#[async_trait]
97impl<W> InstructionResolver for DefaultInstructionResolver<W>
98where
99 W: Workspace,
100{
101 async fn resolve(
102 &self,
103 mut request: InstructionRequest,
104 _context: &RunContext,
105 ) -> Result<InstructionBundle, InstructionError> {
106 if request.file_specs.is_empty() {
107 request.file_specs.push(InstructionFileSpec::default());
108 }
109 if request.target_paths.is_empty() {
110 request.target_paths.push(WorkspacePath::root());
111 }
112
113 let mut candidate_dirs = Vec::new();
114 for target in &request.target_paths {
115 for dir in ancestors(target) {
116 if !candidate_dirs.contains(&dir) {
117 candidate_dirs.push(dir);
118 }
119 }
120 }
121 candidate_dirs.sort();
122 candidate_dirs.dedup();
123
124 let mut files = Vec::new();
125 let mut warnings = Vec::new();
126 let mut used = 0usize;
127 let mut truncated = false;
128
129 for dir in candidate_dirs {
130 for spec in &request.file_specs {
131 let candidate = if dir.as_path().as_os_str().is_empty() {
132 WorkspacePath::parse(&spec.file_name)
133 } else {
134 dir.join(&spec.file_name)
135 }
136 .map_err(|error| InstructionError::InvalidRequest {
137 message: error.to_string(),
138 })?;
139 let remaining = request.max_bytes.saturating_sub(used);
140 if remaining == 0 {
141 truncated = true;
142 continue;
143 }
144 let max_bytes = spec.max_bytes.unwrap_or(remaining).min(remaining);
145 let content = match self
146 .workspace
147 .read_file(
148 &candidate,
149 FileReadOptions {
150 max_bytes: Some(max_bytes),
151 include_binary: false,
152 },
153 )
154 .await
155 {
156 Ok(content) => content,
157 Err(error) => {
158 let text = error.to_string();
159 if !text.contains("not found") {
160 warnings.push(format!("{}: {text}", candidate.display()));
161 }
162 continue;
163 }
164 };
165 let FileBody::Text { text, .. } = content.body else {
166 warnings.push(format!(
167 "instruction file is binary: {}",
168 candidate.display()
169 ));
170 continue;
171 };
172 used += text.len();
173 truncated |= content.truncated;
174 files.push(InstructionFile {
175 path: candidate,
176 content: text,
177 truncated: content.truncated,
178 });
179 }
180 }
181
182 Ok(InstructionBundle {
183 files,
184 warnings,
185 truncated,
186 metadata: RunMetadata::new(),
187 })
188 }
189}
190
191fn ancestors(path: &WorkspacePath) -> Vec<WorkspacePath> {
192 let display = path.display();
193 if display.is_empty() {
194 return vec![WorkspacePath::root()];
195 }
196 let parts: Vec<_> = display.split('/').collect();
197 let parent_parts = if display.ends_with('/') {
198 parts
199 } else {
200 parts[..parts.len().saturating_sub(1)].to_vec()
201 };
202 let mut dirs = vec![WorkspacePath::root()];
203 for index in 0..parent_parts.len() {
204 let joined = parent_parts[..=index].join("/");
205 if let Ok(path) = WorkspacePath::parse(joined) {
206 dirs.push(path);
207 }
208 }
209 dirs
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
214#[non_exhaustive]
215pub enum InstructionError {
216 #[error("invalid instruction request: {message}")]
218 InvalidRequest {
219 message: String,
221 },
222 #[error("instruction workspace error: {message}")]
224 Workspace {
225 message: String,
227 },
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::coding::LocalWorkspace;
234
235 fn temp_dir(tag: &str) -> std::path::PathBuf {
236 let dir = std::env::temp_dir().join(format!(
237 "molo-instruction-test-{}-{tag}",
238 std::process::id()
239 ));
240 let _ = std::fs::remove_dir_all(&dir);
241 std::fs::create_dir_all(&dir).unwrap();
242 dir
243 }
244
245 #[tokio::test]
246 async fn resolver_applies_hierarchy() {
247 let root = temp_dir("hierarchy");
248 std::fs::create_dir_all(root.join("src/nested")).unwrap();
249 std::fs::write(root.join("AGENTS.md"), "root").unwrap();
250 std::fs::write(root.join("src/AGENTS.md"), "src").unwrap();
251 let resolver = DefaultInstructionResolver::new(LocalWorkspace::new(&root).unwrap());
252 let bundle = resolver
253 .resolve(
254 InstructionRequest {
255 target_paths: vec![WorkspacePath::parse("src/nested/lib.rs").unwrap()],
256 ..InstructionRequest::default()
257 },
258 &RunContext::new("instructions"),
259 )
260 .await
261 .unwrap();
262 assert_eq!(bundle.files.len(), 2);
263 assert_eq!(bundle.files[0].content, "root");
264 assert_eq!(bundle.files[1].content, "src");
265 let _ = std::fs::remove_dir_all(root);
266 }
267}