Skip to main content

agentd/context/
prompt.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **system-prompt template** — the one place the agent's standing context
3//! is assembled, and the surface an operator overrides.
4//!
5//! The grammar is deliberately tiny: interpolation plus two block forms.
6//!
7//! ```text
8//! {{ expr }}                      interpolate
9//! {{#if expr}} … {{else}} … {{/if}}
10//! {{#each expr}} … {{/each}}      `this` is the element, `@index` its position
11//! {{! comment }}
12//! ```
13//!
14//! Everything hard about *expressions* — field access, comparisons, macros,
15//! functions — is delegated to CEL, which agentd already ships. Resolution is
16//! **path first, CEL second**: `{{instance}}` and `{{#each services}}` are bare
17//! lookups that work in any build, and only a real expression
18//! (`take(services, 16)`, `size(peers) > 0`) needs `--features cel`. That
19//! keeps the default template — and most custom ones — working without the
20//! feature, and a CEL-needing template is refused at CONFIG LOAD with the
21//! feature message rather than mis-rendering at turn time.
22//!
23//! ## Why the default template is ordered the way it is
24//!
25//! Providers cache on the literal prefix of a request (Anthropic's prompt
26//! caching, OpenAI's automatic prefix caching). A block that changes between
27//! turns invalidates the cache for **everything after it**, so the default
28//! template is ordered from most stable to most volatile: persona and
29//! instruction (change only on reload), then configuration-derived sections
30//! (workflows, services, streams, templates), then live state (peers with
31//! their instance children, parked signals, memory keys). Put volatile
32//! content last in a custom template for the same reason.
33
34use crate::engine::template::{Data, lookup};
35use serde_json::Value;
36
37/// Nesting cap — a prompt template is a document, not a program.
38const MAX_DEPTH: usize = 8;
39/// Rendered-output cap: a runaway `{{#each}}` must not be able to blow the
40/// context window (or the bill) on its own.
41const MAX_OUTPUT: usize = 256 * 1024;
42
43/// One parsed node.
44#[derive(Debug, Clone, PartialEq)]
45enum Node {
46    Text(String),
47    /// `{{ expr }}`
48    Interp(String),
49    /// `{{#if expr}} … {{else}} … {{/if}}`
50    If {
51        expr: String,
52        then: Vec<Node>,
53        otherwise: Vec<Node>,
54    },
55    /// `{{#each expr}} … {{/each}}`
56    Each {
57        expr: String,
58        body: Vec<Node>,
59    },
60}
61
62/// A compiled template. Parsing is done once (at config load); rendering is
63/// per turn.
64#[derive(Debug, Clone, PartialEq)]
65pub struct Template {
66    nodes: Vec<Node>,
67    /// Every root identifier the template reads — used to fail closed on a
68    /// typo'd reference at load instead of rendering a silent empty hole.
69    pub roots: Vec<String>,
70    /// Whether any expression needs CEL (i.e. is not a bare path).
71    pub needs_cel: bool,
72}
73
74impl Template {
75    /// Parse + validate. Errors name the problem, never a byte offset alone.
76    pub fn parse(src: &str) -> Result<Template, String> {
77        let mut p = Parser {
78            s: src,
79            i: 0,
80            depth: 0,
81        };
82        let nodes = p.block(None)?;
83        if p.i < src.len() {
84            return Err(format!(
85                "unexpected {:?} — a closing tag with no opening block",
86                &src[p.i..(p.i + 20).min(src.len())]
87            ));
88        }
89        let mut t = Template {
90            nodes,
91            roots: Vec::new(),
92            needs_cel: false,
93        };
94        let mut roots = Vec::new();
95        let mut needs_cel = false;
96        collect(&t.nodes, &mut roots, &mut needs_cel);
97        roots.sort();
98        roots.dedup();
99        t.roots = roots;
100        t.needs_cel = needs_cel;
101        Ok(t)
102    }
103
104    /// Does the template read `name` anywhere? (`{{instruction}}` guard.)
105    pub fn reads(&self, name: &str) -> bool {
106        self.roots.iter().any(|r| r == name)
107    }
108
109    /// Render against `data`. A missing path renders empty rather than
110    /// failing the turn — the load-time root check is what catches typos, and
111    /// an absent-at-runtime value (no peers yet) is normal, not an error.
112    pub fn render(&self, data: &Data) -> Result<String, String> {
113        let mut out = String::new();
114        render_nodes(&self.nodes, data, &mut out, 0)?;
115        Ok(out)
116    }
117}
118
119struct Parser<'a> {
120    s: &'a str,
121    i: usize,
122    depth: usize,
123}
124
125impl<'a> Parser<'a> {
126    /// Parse until EOF or one of `stop` (`{{/if}}`, `{{/each}}`, `{{else}}`).
127    fn block(&mut self, stop: Option<&[&str]>) -> Result<Vec<Node>, String> {
128        if self.depth > MAX_DEPTH {
129            return Err(format!("template nests deeper than {MAX_DEPTH} blocks"));
130        }
131        let mut out = Vec::new();
132        loop {
133            let Some(start) = self.s[self.i..].find("{{") else {
134                if self.i < self.s.len() {
135                    out.push(Node::Text(self.s[self.i..].to_string()));
136                    self.i = self.s.len();
137                }
138                if stop.is_some() {
139                    return Err("unclosed block: expected a closing tag".into());
140                }
141                return Ok(out);
142            };
143            let start = self.i + start;
144            if start > self.i {
145                out.push(Node::Text(self.s[self.i..start].to_string()));
146            }
147            let after = start + 2;
148            let Some(rel) = self.s[after..].find("}}") else {
149                return Err("unterminated `{{` — every tag needs a closing `}}`".into());
150            };
151            let raw = self.s[after..after + rel].trim().to_string();
152            let next = after + rel + 2;
153            // A closing/else tag ends this block — the caller consumes it.
154            if let Some(stops) = stop
155                && stops.iter().any(|s| *s == raw)
156            {
157                self.i = start;
158                return Ok(out);
159            }
160            self.i = next;
161            if raw.starts_with('!') {
162                continue; // comment
163            }
164            if let Some(expr) = raw.strip_prefix("#if ") {
165                let expr = expr.trim().to_string();
166                self.depth += 1;
167                let then = self.block(Some(&["else", "/if"]))?;
168                let otherwise = if self.peek_tag() == Some("else".into()) {
169                    self.consume_tag();
170                    self.block(Some(&["/if"]))?
171                } else {
172                    Vec::new()
173                };
174                self.expect_tag("/if")?;
175                self.depth -= 1;
176                out.push(Node::If {
177                    expr,
178                    then,
179                    otherwise,
180                });
181                continue;
182            }
183            if let Some(expr) = raw.strip_prefix("#each ") {
184                let expr = expr.trim().to_string();
185                self.depth += 1;
186                let body = self.block(Some(&["/each"]))?;
187                self.expect_tag("/each")?;
188                self.depth -= 1;
189                out.push(Node::Each { expr, body });
190                continue;
191            }
192            if raw.starts_with('/') || raw == "else" {
193                return Err(format!(
194                    "{{{{{raw}}}}} is a closing tag with no opening block"
195                ));
196            }
197            if raw.starts_with('#') {
198                return Err(format!(
199                    "unknown block tag {{{{{raw}}}}} — the template language has `#if` and `#each` only"
200                ));
201            }
202            if raw.is_empty() {
203                return Err("empty `{{}}` tag".into());
204            }
205            out.push(Node::Interp(raw));
206        }
207    }
208
209    fn peek_tag(&self) -> Option<String> {
210        let rest = &self.s[self.i..];
211        let after = rest.strip_prefix("{{")?;
212        let end = after.find("}}")?;
213        Some(after[..end].trim().to_string())
214    }
215
216    fn consume_tag(&mut self) {
217        if let Some(rest) = self.s[self.i..].strip_prefix("{{")
218            && let Some(end) = rest.find("}}")
219        {
220            self.i += 2 + end + 2;
221        }
222    }
223
224    fn expect_tag(&mut self, tag: &str) -> Result<(), String> {
225        match self.peek_tag() {
226            Some(t) if t == tag => {
227                self.consume_tag();
228                Ok(())
229            }
230            _ => Err(format!("expected {{{{{tag}}}}}")),
231        }
232    }
233}
234
235/// Root identifiers + whether any expression is more than a bare path.
236fn collect(nodes: &[Node], roots: &mut Vec<String>, needs_cel: &mut bool) {
237    for n in nodes {
238        match n {
239            Node::Text(_) => {}
240            Node::Interp(e) => note(e, roots, needs_cel),
241            Node::If {
242                expr,
243                then,
244                otherwise,
245            } => {
246                note(expr, roots, needs_cel);
247                collect(then, roots, needs_cel);
248                collect(otherwise, roots, needs_cel);
249            }
250            Node::Each { expr, body } => {
251                note(expr, roots, needs_cel);
252                collect(body, roots, needs_cel);
253            }
254        }
255    }
256}
257
258fn note(expr: &str, roots: &mut Vec<String>, needs_cel: &mut bool) {
259    match bare_path(expr) {
260        Some(p) => {
261            let root = p.split(['.', '[']).next().unwrap_or(p).to_string();
262            // `this` / `@index` are block-scoped, not template roots.
263            if root != "this" && !root.starts_with('@') {
264                roots.push(root);
265            }
266        }
267        None => *needs_cel = true,
268    }
269}
270
271/// A bare path (`a`, `a.b.c`, `this.name`) — no operators, calls or literals.
272fn bare_path(expr: &str) -> Option<&str> {
273    let e = expr.trim();
274    if e.is_empty() {
275        return None;
276    }
277    let ok = e
278        .chars()
279        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '@');
280    if ok && !e.starts_with('.') && !e.ends_with('.') {
281        Some(e)
282    } else {
283        None
284    }
285}
286
287fn render_nodes(nodes: &[Node], data: &Data, out: &mut String, depth: usize) -> Result<(), String> {
288    if depth > MAX_DEPTH {
289        return Err("template recursion exceeded".into());
290    }
291    for n in nodes {
292        if out.len() > MAX_OUTPUT {
293            return Err(format!(
294                "rendered prompt exceeds {MAX_OUTPUT} bytes — narrow an `{{{{#each}}}}`"
295            ));
296        }
297        match n {
298            Node::Text(t) => out.push_str(t),
299            Node::Interp(e) => {
300                let v = eval(e, data)?;
301                out.push_str(&stringify(&v));
302            }
303            Node::If {
304                expr,
305                then,
306                otherwise,
307            } => {
308                let v = eval(expr, data)?;
309                let branch = if truthy(&v) { then } else { otherwise };
310                render_nodes(branch, data, out, depth + 1)?;
311            }
312            Node::Each { expr, body } => {
313                let v = eval(expr, data)?;
314                let items: Vec<Value> = match v {
315                    Value::Array(a) => a,
316                    Value::Null => Vec::new(),
317                    // Iterating an object yields its values, which is what an
318                    // author means by "each server in mcp.servers".
319                    Value::Object(o) => o.into_values().collect(),
320                    other => vec![other],
321                };
322                for (i, item) in items.iter().enumerate() {
323                    let mut scoped = data.clone();
324                    scoped.insert("this".into(), item.clone());
325                    scoped.insert("@index".into(), Value::from(i as u64));
326                    render_nodes(body, &scoped, out, depth + 1)?;
327                }
328            }
329        }
330    }
331    Ok(())
332}
333
334/// Path first, CEL second. A missing path is empty (see `Template::render`).
335fn eval(expr: &str, data: &Data) -> Result<Value, String> {
336    if let Some(p) = bare_path(expr) {
337        return Ok(lookup(p, data).unwrap_or(Value::Null));
338    }
339    let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
340    crate::cel::eval_value(expr, &vars).map_err(|e| format!("{expr:?}: {e}"))
341}
342
343fn stringify(v: &Value) -> String {
344    match v {
345        Value::String(s) => s.clone(),
346        Value::Null => String::new(),
347        other => other.to_string(),
348    }
349}
350
351/// CEL-ish truthiness, extended so `{{#if services}}` means "non-empty".
352fn truthy(v: &Value) -> bool {
353    match v {
354        Value::Null => false,
355        Value::Bool(b) => *b,
356        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
357        Value::String(s) => !s.is_empty(),
358        Value::Array(a) => !a.is_empty(),
359        Value::Object(o) => !o.is_empty(),
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367
368    fn data(pairs: &[(&str, Value)]) -> Data {
369        pairs
370            .iter()
371            .map(|(k, v)| ((*k).to_string(), v.clone()))
372            .collect()
373    }
374
375    #[test]
376    fn interpolation_and_paths() {
377        let t = Template::parse("You are {{instance}} ({{agent.mode}}).").unwrap();
378        let d = data(&[
379            ("instance", json!("beacon")),
380            ("agent", json!({"mode": "daemon"})),
381        ]);
382        assert_eq!(t.render(&d).unwrap(), "You are beacon (daemon).");
383        assert!(!t.needs_cel, "bare paths need no CEL");
384        assert_eq!(t.roots, vec!["agent".to_string(), "instance".to_string()]);
385    }
386
387    #[test]
388    fn each_iterates_with_this_and_index() {
389        let t = Template::parse("{{#each xs}}{{@index}}:{{this.n}} {{/each}}").unwrap();
390        let d = data(&[("xs", json!([{"n": "a"}, {"n": "b"}]))]);
391        assert_eq!(t.render(&d).unwrap(), "0:a 1:b ");
392    }
393
394    #[test]
395    fn each_over_empty_and_absent_renders_nothing() {
396        let t = Template::parse("[{{#each xs}}x{{/each}}]").unwrap();
397        assert_eq!(t.render(&data(&[("xs", json!([]))])).unwrap(), "[]");
398        assert_eq!(t.render(&data(&[])).unwrap(), "[]");
399    }
400
401    #[test]
402    fn if_else_uses_emptiness_as_falsy() {
403        let t = Template::parse("{{#if xs}}some{{else}}none{{/if}}").unwrap();
404        assert_eq!(t.render(&data(&[("xs", json!([1]))])).unwrap(), "some");
405        assert_eq!(t.render(&data(&[("xs", json!([]))])).unwrap(), "none");
406        assert_eq!(t.render(&data(&[("xs", json!(""))])).unwrap(), "none");
407        assert_eq!(t.render(&data(&[])).unwrap(), "none");
408    }
409
410    #[test]
411    fn nested_blocks_compose() {
412        let t = Template::parse(
413            "{{#each svc}}- {{this.name}}{{#if this.tags}} [{{this.tags}}]{{/if}}\n{{/each}}",
414        )
415        .unwrap();
416        let d = data(&[(
417            "svc",
418            json!([{"name":"billing","tags":["sensitive"]},{"name":"docs"}]),
419        )]);
420        assert_eq!(
421            t.render(&d).unwrap(),
422            "- billing [[\"sensitive\"]]\n- docs\n"
423        );
424    }
425
426    #[test]
427    fn comments_are_dropped() {
428        let t = Template::parse("a{{! not rendered }}b").unwrap();
429        assert_eq!(t.render(&data(&[])).unwrap(), "ab");
430    }
431
432    #[test]
433    fn malformed_templates_are_refused_at_parse() {
434        for (src, want) in [
435            ("{{#each xs}}oops", "unclosed block"),
436            ("{{ unterminated", "unterminated"),
437            ("{{/each}}", "closing tag with no opening block"),
438            ("{{#while x}}{{/while}}", "unknown block tag"),
439            ("{{}}", "empty"),
440        ] {
441            let e = Template::parse(src).unwrap_err();
442            assert!(e.contains(want), "{src:?} → {e:?} (wanted {want:?})");
443        }
444    }
445
446    #[test]
447    fn expressions_are_flagged_as_needing_cel() {
448        let bare = Template::parse("{{#each services}}{{this.name}}{{/each}}").unwrap();
449        assert!(!bare.needs_cel);
450        let expr = Template::parse("{{#each take(services, 3)}}x{{/each}}").unwrap();
451        assert!(expr.needs_cel, "a call is not a bare path");
452        assert!(
453            Template::parse("{{#if size(peers) > 0}}y{{/if}}")
454                .unwrap()
455                .needs_cel
456        );
457    }
458
459    #[test]
460    fn the_instruction_guard_can_see_the_reference() {
461        assert!(
462            Template::parse("## I\n{{instruction}}")
463                .unwrap()
464                .reads("instruction")
465        );
466        assert!(
467            !Template::parse("nothing here")
468                .unwrap()
469                .reads("instruction")
470        );
471    }
472
473    #[test]
474    fn a_runaway_each_is_capped_not_unbounded() {
475        let t = Template::parse("{{#each xs}}{{this}}{{/each}}").unwrap();
476        let big: Vec<Value> = (0..60_000).map(|_| json!("0123456789")).collect();
477        let e = t.render(&data(&[("xs", Value::Array(big))])).unwrap_err();
478        assert!(e.contains("exceeds"), "{e}");
479    }
480
481    #[test]
482    fn nesting_beyond_the_cap_is_refused() {
483        let src = "{{#if a}}".repeat(MAX_DEPTH + 2) + &"{{/if}}".repeat(MAX_DEPTH + 2);
484        assert!(Template::parse(&src).unwrap_err().contains("nests deeper"));
485    }
486}