Skip to main content

molo_coding/coding/
instructions.rs

1use crate::{RunContext, RunMetadata};
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4
5use super::workspace::{FileBody, FileReadOptions, Workspace, WorkspacePath};
6
7/// Instruction file candidate.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct InstructionFileSpec {
10    /// File name to look for in each ancestor directory.
11    pub file_name: String,
12    /// Maximum bytes read from this file.
13    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/// Instruction resolution request.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InstructionRequest {
28    /// Target paths used to determine ancestor search paths.
29    pub target_paths: Vec<WorkspacePath>,
30    /// Candidate instruction files.
31    pub file_specs: Vec<InstructionFileSpec>,
32    /// Maximum total bytes returned.
33    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/// Resolved project instructions.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct InstructionBundle {
49    /// Instruction file contents in application order. More-specific files
50    /// appear later.
51    pub files: Vec<InstructionFile>,
52    /// Resolver warnings.
53    pub warnings: Vec<String>,
54    /// Whether the bundle was truncated by byte budget.
55    pub truncated: bool,
56    /// Host-owned metadata.
57    pub metadata: RunMetadata,
58}
59
60/// One resolved instruction file.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct InstructionFile {
63    /// Instruction file path.
64    pub path: WorkspacePath,
65    /// File content.
66    pub content: String,
67    /// Whether content was truncated.
68    pub truncated: bool,
69}
70
71/// Resolves project instruction files.
72#[async_trait]
73pub trait InstructionResolver: Send + Sync {
74    /// Resolves project instructions.
75    async fn resolve(
76        &self,
77        request: InstructionRequest,
78        context: &RunContext,
79    ) -> Result<InstructionBundle, InstructionError>;
80}
81
82/// Default resolver that searches for `AGENTS.md`-style files from root to
83/// target parent directories.
84#[derive(Debug, Clone)]
85pub struct DefaultInstructionResolver<W> {
86    workspace: W,
87}
88
89impl<W> DefaultInstructionResolver<W> {
90    /// Constructs a resolver over a workspace.
91    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/// Instruction resolver errors.
213#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
214#[non_exhaustive]
215pub enum InstructionError {
216    /// Request is invalid.
217    #[error("invalid instruction request: {message}")]
218    InvalidRequest {
219        /// Model-safe explanation.
220        message: String,
221    },
222    /// Workspace read failed.
223    #[error("instruction workspace error: {message}")]
224    Workspace {
225        /// Model-safe explanation.
226        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}