1use std::fs;
4use std::path::{Path, PathBuf};
5
6use machi_types::{ErrorCode, MachiError};
7
8use crate::definition::AgentDefinition;
9
10pub const PROJECT_AGENTS_MD: &str = "AGENTS.md";
12
13pub trait PromptAssembler: Send + Sync {
15 fn assemble(&self, definition: &AgentDefinition) -> Result<String, MachiError>;
21}
22
23#[derive(Debug, Default, Clone, Copy)]
25pub struct IdentityAssembler;
26
27impl PromptAssembler for IdentityAssembler {
28 fn assemble(&self, definition: &AgentDefinition) -> Result<String, MachiError> {
29 Ok(definition.instructions.resolve())
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct ProjectPromptAssembler {
36 preamble: Option<String>,
37 separator: String,
38}
39
40impl Default for ProjectPromptAssembler {
41 fn default() -> Self {
42 Self {
43 preamble: None,
44 separator: "\n\n".into(),
45 }
46 }
47}
48
49impl ProjectPromptAssembler {
50 #[must_use]
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn from_project(cwd: impl AsRef<Path>) -> Result<Self, MachiError> {
62 let path = cwd.as_ref().join(PROJECT_AGENTS_MD);
63 Self::from_path(path)
64 }
65
66 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, MachiError> {
72 let path = path.as_ref();
73 if !path.is_file() {
74 return Ok(Self::new());
75 }
76 let raw = fs::read_to_string(path).map_err(|e| {
77 MachiError::new(
78 ErrorCode::AgentBuild,
79 format!("read {}: {e}", path.display()),
80 )
81 })?;
82 Ok(Self::with_preamble(raw))
83 }
84
85 #[must_use]
87 pub fn with_preamble(text: impl Into<String>) -> Self {
88 let t = text.into();
89 let preamble = {
90 let trimmed = t.trim();
91 if trimmed.is_empty() {
92 None
93 } else {
94 Some(trimmed.to_owned())
95 }
96 };
97 Self {
98 preamble,
99 separator: "\n\n".into(),
100 }
101 }
102
103 #[must_use]
105 pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
106 self.separator = sep.into();
107 self
108 }
109
110 #[must_use]
112 pub const fn has_preamble(&self) -> bool {
113 self.preamble.is_some()
114 }
115
116 #[must_use]
118 pub fn preamble(&self) -> Option<&str> {
119 self.preamble.as_deref()
120 }
121}
122
123impl PromptAssembler for ProjectPromptAssembler {
124 fn assemble(&self, definition: &AgentDefinition) -> Result<String, MachiError> {
125 let body = definition.instructions.resolve();
126 match &self.preamble {
127 Some(pre) if !body.is_empty() => Ok(format!("{pre}{}{body}", self.separator)),
128 Some(pre) => Ok(pre.clone()),
129 None => Ok(body),
130 }
131 }
132}
133
134#[must_use]
136pub fn agents_md_path(cwd: impl AsRef<Path>) -> PathBuf {
137 cwd.as_ref().join(PROJECT_AGENTS_MD)
138}
139
140#[cfg(test)]
141mod tests {
142 use std::io::Write;
143
144 use tempfile::tempdir;
145
146 use super::*;
147 use crate::definition::Instructions;
148
149 fn sample_def() -> AgentDefinition {
150 let mut d = AgentDefinition::new("a");
151 d.instructions = Instructions::Static("Be brief.".into());
152 d.model = "mock".into();
153 d.max_steps = 4;
154 d
155 }
156
157 #[test]
158 fn identity_returns_body() {
159 let p = IdentityAssembler.assemble(&sample_def()).expect("ok");
160 assert_eq!(p, "Be brief.");
161 }
162
163 #[test]
164 fn project_preamble_prepends() {
165 let asm = ProjectPromptAssembler::with_preamble("Project rules.");
166 let p = asm.assemble(&sample_def()).expect("ok");
167 assert!(p.starts_with("Project rules."));
168 assert!(p.contains("Be brief."));
169 }
170
171 #[test]
172 fn from_project_reads_agents_md() {
173 let dir = tempdir().expect("tmp");
174 let path = dir.path().join(PROJECT_AGENTS_MD);
175 let mut f = fs::File::create(&path).expect("create");
176 write!(f, "# Rules\n\nNo force-push.\n").expect("write");
177 let asm = ProjectPromptAssembler::from_project(dir.path()).expect("load");
178 assert!(asm.has_preamble());
179 let p = asm.assemble(&sample_def()).expect("ok");
180 assert!(p.contains("No force-push."));
181 assert!(p.contains("Be brief."));
182 }
183
184 #[test]
185 fn missing_agents_md_is_ok() {
186 let dir = tempdir().expect("tmp");
187 let asm = ProjectPromptAssembler::from_project(dir.path()).expect("load");
188 assert!(!asm.has_preamble());
189 }
190}