1use std::path::Path;
15
16use crate::agent::AgentId;
17use crate::consts::{CWD_ADDENDUM_AGENT0, CWD_ADDENDUM_AGENTINFINITY, CWD_ADDENDUM_CLONE_EXT};
18
19const 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#[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 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
97fn 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
107fn 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
145fn 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 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory => Ok(None),
157 _ => Err(e),
158 },
159 }
160}
161
162fn apply_bindings(body: &str, bindings: &[(&'static str, String)]) -> String {
166 let mut out = body.to_string();
167 for (name, value) in bindings {
168 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
184fn 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
203pub 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 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 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 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 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}