Skip to main content

netsky_core/
prompt.rs

1//! Prompt loading, templating, and addendum layering.
2//!
3//! Base prompt + per-agent stanza are embedded at compile time via
4//! `include_str!` from the top-level `prompts/` directory. The cwd
5//! addendum is read at runtime. Everything is strictly appended — no
6//! overrides. See `briefs/netsky-rewrite-v1.md` for the contract.
7//!
8//! Templating is intentionally minimal: we substitute a small set of
9//! named variables (`{{ n }}`, `{{ agent_name }}`, `{{ cwd }}`). No
10//! conditionals, no loops. After substitution we assert no `{{`
11//! remains — an unsubstituted placeholder is a render bug, not a
12//! silent passthrough.
13
14use std::path::Path;
15
16use crate::agent::AgentId;
17use crate::consts::{CWD_ADDENDUM_AGENT0, CWD_ADDENDUM_AGENTINFINITY, CWD_ADDENDUM_CLONE_EXT};
18
19// Relative path is resolved against the file doing the `include_str!`
20// (this file, at src/crates/netsky-core/src/prompt.rs). The `../../../../`
21// backs out to the repo root, where `prompts/` lives.
22const BASE_TEMPLATE: &str = include_str!("../prompts/base.md");
23const AGENT0_STANZA: &str = include_str!("../prompts/agent0.md");
24const CLONE_STANZA: &str = include_str!("../prompts/clone.md");
25const AGENTINFINITY_STANZA: &str = include_str!("../prompts/agentinfinity.md");
26
27const SEPARATOR: &str = "\n\n---\n\n";
28
29/// Template variables made available to the render layer.
30#[derive(Debug, Clone)]
31pub struct PromptContext {
32    pub agent: AgentId,
33    pub cwd: String,
34}
35
36impl PromptContext {
37    pub fn new(agent: AgentId, cwd: impl Into<String>) -> Self {
38        Self {
39            agent,
40            cwd: cwd.into(),
41        }
42    }
43
44    /// Each template variable paired with its rendered value. Stringified
45    /// uniformly (including `n`) to avoid the arithmetic-on-string trap
46    /// that Tera-style typed contexts enabled.
47    fn bindings(&self) -> Vec<(&'static str, String)> {
48        vec![
49            ("agent_name", self.agent.name()),
50            ("n", self.agent.env_n()),
51            ("cwd", self.cwd.clone()),
52        ]
53    }
54}
55
56#[derive(Debug)]
57pub enum PromptError {
58    Io(std::io::Error),
59    UnsubstitutedPlaceholders { count: usize, preview: String },
60}
61
62impl std::fmt::Display for PromptError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Io(e) => write!(f, "io error reading addendum: {e}"),
66            Self::UnsubstitutedPlaceholders { count, preview } => write!(
67                f,
68                "template render left {count} unsubstituted placeholder(s): {preview}"
69            ),
70        }
71    }
72}
73
74impl std::error::Error for PromptError {
75    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
76        match self {
77            Self::Io(e) => Some(e),
78            _ => None,
79        }
80    }
81}
82
83impl From<std::io::Error> for PromptError {
84    fn from(e: std::io::Error) -> Self {
85        Self::Io(e)
86    }
87}
88
89fn stanza_for(agent: AgentId) -> &'static str {
90    match agent {
91        AgentId::Agent0 => AGENT0_STANZA,
92        AgentId::Clone(_) => CLONE_STANZA,
93        AgentId::Agentinfinity => AGENTINFINITY_STANZA,
94    }
95}
96
97/// Return the filename of the cwd addendum for `agent`: `0.md`,
98/// `agentinfinity.md`, or `<N>.md` for clones.
99fn cwd_addendum_filename(agent: AgentId) -> String {
100    match agent {
101        AgentId::Agent0 => CWD_ADDENDUM_AGENT0.to_string(),
102        AgentId::Agentinfinity => CWD_ADDENDUM_AGENTINFINITY.to_string(),
103        AgentId::Clone(n) => format!("{n}{CWD_ADDENDUM_CLONE_EXT}"),
104    }
105}
106
107/// Resolve which addendum file to read for `agent`. Consults
108/// `netsky.toml` `[addendum]` first; falls back to the conventional
109/// filename (`0.md` / `<N>.md` / `agentinfinity.md`) at the root of
110/// `cwd`. The TOML path is interpreted relative to `cwd` unless it
111/// starts with `/` (absolute) or `~/` (home-relative).
112///
113/// Per `briefs/netsky-config-design.md` section 3, this lets the owner
114/// split per-machine context out of the repo-tracked `0.md` (which is
115/// shared across machines) into machine-specific files under `addenda/`
116/// without touching code. Missing TOML or missing field = today's
117/// behavior unchanged.
118fn resolve_addendum_path(agent: AgentId, cwd: &Path) -> std::path::PathBuf {
119    use crate::config::Config;
120
121    let configured = Config::load_from(&cwd.join("netsky.toml"))
122        .ok()
123        .flatten()
124        .and_then(|cfg| cfg.addendum)
125        .and_then(|a| match agent {
126            AgentId::Agent0 => a.agent0,
127            AgentId::Agentinfinity => a.agentinfinity,
128            AgentId::Clone(_) => a.clone_default,
129        });
130
131    match configured {
132        Some(p) if p.starts_with('/') => std::path::PathBuf::from(p),
133        Some(p) if p.starts_with("~/") => {
134            if let Some(home) = dirs::home_dir() {
135                home.join(p.trim_start_matches("~/"))
136            } else {
137                cwd.join(p)
138            }
139        }
140        Some(p) => cwd.join(p),
141        None => cwd.join(cwd_addendum_filename(agent)),
142    }
143}
144
145/// Read the cwd addendum for `agent` from `cwd`. Returns `None` if the
146/// file doesn't exist (missing is fine — addenda are optional). Path
147/// resolution consults `netsky.toml` `[addendum]` first per
148/// [`resolve_addendum_path`].
149fn read_cwd_addendum(agent: AgentId, cwd: &Path) -> Result<Option<String>, std::io::Error> {
150    let path = resolve_addendum_path(agent, cwd);
151    match std::fs::read_to_string(&path) {
152        Ok(s) => Ok(Some(s)),
153        Err(e) => match e.kind() {
154            // Both "no such file" and "cwd isn't even a directory" mean
155            // simply: no addendum here. Missing is the common case.
156            std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory => Ok(None),
157            _ => Err(e),
158        },
159    }
160}
161
162/// Substitute `{{ name }}` (tolerant of inner whitespace) for each
163/// binding in `body`. Intentionally does NOT recurse, so replacement
164/// values containing `{{ }}` stay literal.
165fn apply_bindings(body: &str, bindings: &[(&'static str, String)]) -> String {
166    let mut out = body.to_string();
167    for (name, value) in bindings {
168        // Cover the two spellings we use in templates: `{{ name }}` and
169        // `{{name}}`. Tera tolerated arbitrary whitespace; we only need
170        // the two canonical forms — pick up the third if anyone ever
171        // writes `{{name }}` or `{{ name}}`.
172        for placeholder in [
173            format!("{{{{ {name} }}}}"),
174            format!("{{{{{name}}}}}"),
175            format!("{{{{ {name}}}}}"),
176            format!("{{{{{name} }}}}"),
177        ] {
178            out = out.replace(&placeholder, value);
179        }
180    }
181    out
182}
183
184/// After render, `{{` should not appear anywhere. If it does, someone
185/// added a new template variable without wiring it into PromptContext.
186fn assert_fully_rendered(body: &str) -> Result<(), PromptError> {
187    let count = body.matches("{{").count();
188    if count == 0 {
189        return Ok(());
190    }
191    let preview = body
192        .match_indices("{{")
193        .take(3)
194        .map(|(i, _)| {
195            let end = body.len().min(i + 32);
196            body[i..end].to_string()
197        })
198        .collect::<Vec<_>>()
199        .join(" | ");
200    Err(PromptError::UnsubstitutedPlaceholders { count, preview })
201}
202
203/// Render the full system prompt for `agent` from its `cwd`:
204/// base + `---` + per-agent stanza + `---` + cwd addendum (if present).
205pub fn render_prompt(ctx: PromptContext, cwd: &Path) -> Result<String, PromptError> {
206    let agent = ctx.agent;
207    let bindings = ctx.bindings();
208
209    let base = apply_bindings(BASE_TEMPLATE, &bindings);
210    let stanza = apply_bindings(stanza_for(agent), &bindings);
211
212    let mut out = String::with_capacity(base.len() + stanza.len() + 128);
213    out.push_str(base.trim_end());
214    out.push_str(SEPARATOR);
215    out.push_str(stanza.trim_end());
216
217    if let Some(addendum) = read_cwd_addendum(agent, cwd)? {
218        let trimmed = addendum.trim();
219        if !trimmed.is_empty() {
220            out.push_str(SEPARATOR);
221            out.push_str(trimmed);
222        }
223    }
224    out.push('\n');
225
226    assert_fully_rendered(&out)?;
227    Ok(out)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::path::PathBuf;
234
235    fn ctx_for(agent: AgentId) -> PromptContext {
236        PromptContext::new(agent, "/tmp/netsky-test")
237    }
238
239    #[test]
240    fn renders_all_agents_without_addendum() {
241        let nowhere = PathBuf::from("/dev/null/does-not-exist");
242        for agent in [
243            AgentId::Agent0,
244            AgentId::Clone(1),
245            AgentId::Clone(8),
246            AgentId::Agentinfinity,
247        ] {
248            let out = render_prompt(ctx_for(agent), &nowhere).unwrap();
249            assert!(!out.is_empty(), "empty prompt for {agent}");
250            assert!(out.contains("---"), "missing separator for {agent}");
251            assert!(!out.contains("{{"), "unsubstituted placeholder for {agent}");
252        }
253    }
254
255    #[test]
256    fn clone_prompt_substitutes_n() {
257        let nowhere = PathBuf::from("/dev/null/does-not-exist");
258        let out = render_prompt(ctx_for(AgentId::Clone(5)), &nowhere).unwrap();
259        assert!(out.contains("agent5"));
260        assert!(!out.contains("{{ n }}"));
261    }
262
263    #[test]
264    fn cwd_addendum_is_appended() {
265        let tmp = tempfile::tempdir().unwrap();
266        std::fs::write(tmp.path().join("0.md"), "USER POLICY HERE").unwrap();
267        let out = render_prompt(ctx_for(AgentId::Agent0), tmp.path()).unwrap();
268        assert!(out.contains("USER POLICY HERE"));
269    }
270
271    #[test]
272    fn render_rejects_unsubstituted_placeholder() {
273        let body = "hello {{ unknown_var }} world";
274        let err = assert_fully_rendered(body).unwrap_err();
275        match err {
276            PromptError::UnsubstitutedPlaceholders { count, .. } => assert_eq!(count, 1),
277            _ => panic!("wrong error variant"),
278        }
279    }
280
281    #[test]
282    fn bindings_stringify_uniformly() {
283        // agent0 = "0", clone = "5", agentinfinity = "infinity" — all strings.
284        let b0 = PromptContext::new(AgentId::Agent0, "/").bindings();
285        let b5 = PromptContext::new(AgentId::Clone(5), "/").bindings();
286        let binf = PromptContext::new(AgentId::Agentinfinity, "/").bindings();
287        assert_eq!(lookup(&b0, "n"), "0");
288        assert_eq!(lookup(&b5, "n"), "5");
289        assert_eq!(lookup(&binf, "n"), "infinity");
290    }
291
292    fn lookup(bindings: &[(&'static str, String)], key: &str) -> String {
293        bindings.iter().find(|(k, _)| *k == key).unwrap().1.clone()
294    }
295
296    #[test]
297    fn netsky_toml_addendum_overrides_default_path() {
298        // Owner splits 0.md into addenda/0-personal.md; netsky.toml
299        // routes agent0 to the new path. The default 0.md MUST be
300        // ignored when the TOML override is set.
301        let tmp = tempfile::tempdir().unwrap();
302        std::fs::write(tmp.path().join("0.md"), "OLD POLICY").unwrap();
303        std::fs::create_dir_all(tmp.path().join("addenda")).unwrap();
304        std::fs::write(tmp.path().join("addenda/0-personal.md"), "NEW POLICY").unwrap();
305        std::fs::write(
306            tmp.path().join("netsky.toml"),
307            "schema_version = 1\n[addendum]\nagent0 = \"addenda/0-personal.md\"\n",
308        )
309        .unwrap();
310
311        let out = render_prompt(ctx_for(AgentId::Agent0), tmp.path()).unwrap();
312        assert!(
313            out.contains("NEW POLICY"),
314            "TOML override should pick up addenda/0-personal.md"
315        );
316        assert!(
317            !out.contains("OLD POLICY"),
318            "TOML override should bypass the legacy 0.md fallback"
319        );
320    }
321
322    #[test]
323    fn missing_netsky_toml_falls_back_to_legacy_addendum() {
324        // No netsky.toml at all -> read 0.md as before.
325        let tmp = tempfile::tempdir().unwrap();
326        std::fs::write(tmp.path().join("0.md"), "LEGACY ADDENDUM").unwrap();
327        let out = render_prompt(ctx_for(AgentId::Agent0), tmp.path()).unwrap();
328        assert!(out.contains("LEGACY ADDENDUM"));
329    }
330
331    #[test]
332    fn netsky_toml_without_addendum_section_falls_back() {
333        // netsky.toml present but no [addendum] section -> still 0.md.
334        let tmp = tempfile::tempdir().unwrap();
335        std::fs::write(tmp.path().join("0.md"), "FALLBACK POLICY").unwrap();
336        std::fs::write(
337            tmp.path().join("netsky.toml"),
338            "schema_version = 1\n[owner]\nname = \"Alice\"\n",
339        )
340        .unwrap();
341        let out = render_prompt(ctx_for(AgentId::Agent0), tmp.path()).unwrap();
342        assert!(
343            out.contains("FALLBACK POLICY"),
344            "no [addendum] section should fall back to default filename"
345        );
346    }
347
348    #[test]
349    fn netsky_toml_addendum_absolute_path_used_as_is() {
350        let tmp = tempfile::tempdir().unwrap();
351        let abs_addendum = tmp.path().join("absolute-addendum.md");
352        std::fs::write(&abs_addendum, "ABSOLUTE POLICY").unwrap();
353        std::fs::write(
354            tmp.path().join("netsky.toml"),
355            format!(
356                "schema_version = 1\n[addendum]\nagent0 = \"{}\"\n",
357                abs_addendum.display()
358            ),
359        )
360        .unwrap();
361        let out = render_prompt(ctx_for(AgentId::Agent0), tmp.path()).unwrap();
362        assert!(out.contains("ABSOLUTE POLICY"));
363    }
364}