Skip to main content

machi_agent/
prompt.rs

1//! Optional system-prompt assembly (project `AGENTS.md`, host preambles).
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use machi_types::{ErrorCode, MachiError};
7
8use crate::definition::AgentDefinition;
9
10/// Default project instruction file relative to a workspace root.
11pub const PROJECT_AGENTS_MD: &str = "AGENTS.md";
12
13/// Assembles the final system prompt for an agent definition.
14pub trait PromptAssembler: Send + Sync {
15    /// Produce the system prompt string for `definition`.
16    ///
17    /// # Errors
18    ///
19    /// I/O or validation failures.
20    fn assemble(&self, definition: &AgentDefinition) -> Result<String, MachiError>;
21}
22
23/// Identity assembler: uses only the definition’s resolved instructions.
24#[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/// Prepend optional project preamble (e.g. `AGENTS.md`) to definition instructions.
34#[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    /// Empty preamble (equivalent to [`IdentityAssembler`] for assembly content).
51    #[must_use]
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Load `AGENTS.md` from `cwd` when present (missing file is not an error).
57    ///
58    /// # Errors
59    ///
60    /// Read failures when the file exists but cannot be read.
61    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    /// Load preamble from an explicit path when present.
67    ///
68    /// # Errors
69    ///
70    /// Read failures when the path exists.
71    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    /// Use an explicit preamble string (trimmed; empty → none).
86    #[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    /// Override separator between preamble and agent body.
104    #[must_use]
105    pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
106        self.separator = sep.into();
107        self
108    }
109
110    /// Whether a non-empty preamble is configured.
111    #[must_use]
112    pub const fn has_preamble(&self) -> bool {
113        self.preamble.is_some()
114    }
115
116    /// Preamble text when set.
117    #[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/// Resolve AGENTS.md path under a project root (does not read).
135#[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}