mlua_swarm/operator/render.rs
1//! `system_prompt` template rendering for the Operator path.
2//!
3//! Lets the agent.md body (`AgentDef.profile.system_prompt`) carry
4//! Jinja2-compatible syntax (`{{ directive }}` / `{% if intent %}` /
5//! `{{ x | upper }}` and so on). The caller passes
6//! `TaskSpec.initial_directive` as JSON and its fields expand into the
7//! template slots.
8//!
9//! ## Engine choice
10//!
11//! minijinja (maintained by Armin Ronacher, the Jinja2 author — a light
12//! dependency). Two defaults are forced up front to avoid classic traps:
13//!
14//! - `auto_escape = None`. HTML auto-escape is off; for LLM prompts,
15//! turning `<` / `>` into `<` / `>` corrupts the prompt.
16//! - `UndefinedBehavior::Strict`. A typo'd variable would otherwise
17//! silently render as an empty string; in a production prompt
18//! template we want that to fail loud.
19//!
20//! ## Syntax available inside the agent.md body (Jinja2-compatible, per
21//! minijinja v2)
22//!
23//! ```text
24//! Variables: {{ directive }} / {{ slot.nested }} / {{ items[0] }}
25//! Filters: {{ name | upper }} / {{ x | default("fallback") }} / {{ s | length }}
26//! Branch: {% if intent %}...{% elif other %}...{% else %}...{% endif %}
27//! Loop: {% for x in items %}{{ x }},{% endfor %}
28//! Comment: {# note #}
29//! Raw: {% raw %}{{ literal }}{% endraw %}
30//! ```
31//!
32//! Macros, `include`, and inheritance are not available here — this
33//! layer performs a flat render over one string handed in by the caller,
34//! and does not support multi-template composition. If we ever need
35//! that, adding a source loader to `Environment` is a carry.
36//!
37//! ## Slot names (the variables the caller can reference in the
38//! template)
39//!
40//! `slots_from_prompt(prompt: &str)` builds the slot map:
41//!
42//! - When `prompt` is a **JSON object**, the object's top-level keys
43//! become the slot names — for example
44//! `r#"{"directive":"X","intent":"fix"}"#` exposes `{{ directive }}`
45//! and `{{ intent }}`.
46//! - When `prompt` is **anything else** (a plain string, a number, an
47//! array, an already-stringified JSON), it is wrapped as
48//! `{"directive": <the prompt itself>}`; only `{{ directive }}` is
49//! available.
50//!
51//! To expose additional slots, the caller (whoever assembles
52//! `TaskSpec.initial_directive`) passes a JSON object. Conventions:
53//!
54//! - `directive` (effectively required) — the main task instruction;
55//! the plain-prompt fallback also lives here.
56//! - `intent` — task kind / classification hint (optional; used in
57//! `if` branches).
58//! - `context` — additional context (optional).
59//! - Beyond those, agent.md authors are free to add whatever slots the
60//! template needs.
61//!
62//! ## Errors
63//!
64//! - Undefined variable → `RenderError::Template` (strict mode).
65//! - Syntax error → `RenderError::Template`.
66//! - On the `OperatorSpawner` path this is wrapped in
67//! `SpawnError::Internal("render system_prompt: ...")` and propagated
68//! — no silent fallback, fail loud.
69
70use minijinja::{Environment, UndefinedBehavior, Value};
71use std::collections::BTreeSet;
72use thiserror::Error;
73
74/// Render errors. Anything from minijinja is wrapped as `Template`.
75#[derive(Debug, Error)]
76pub enum RenderError {
77 /// minijinja syntax errors, undefined-variable errors, runtime
78 /// errors, and the like.
79 #[error("template render failed: {0}")]
80 Template(String),
81}
82
83impl From<minijinja::Error> for RenderError {
84 fn from(e: minijinja::Error) -> Self {
85 RenderError::Template(format!("{e:#}"))
86 }
87}
88
89/// Shared `Environment` construction for every entry point in this module
90/// (`render_system` / `template_variables`) — keeps the `auto_escape` /
91/// `UndefinedBehavior` settings from drifting apart between the two.
92fn build_env() -> Environment<'static> {
93 let mut env = Environment::new();
94 env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
95 env.set_undefined_behavior(UndefinedBehavior::Strict);
96 env
97}
98
99/// Render a `system_prompt` template in strict mode with auto-escape
100/// disabled.
101///
102/// `slots` is any JSON value. When it is an object, the top-level keys
103/// are exposed as variables — `{{ directive }}` reads `slots.directive`.
104/// When it is not an object, this function binds the value under a
105/// single variable named `value`, reachable as `{{ value }}`.
106pub fn render_system(template: &str, slots: &serde_json::Value) -> Result<String, RenderError> {
107 let env = build_env();
108
109 let tmpl = env.template_from_str(template)?;
110 let value = Value::from_serialize(slots);
111 let rendered = if let serde_json::Value::Object(_) = slots {
112 tmpl.render(value)?
113 } else {
114 // A non-object is bound as the single variable `value`.
115 tmpl.render(minijinja::context! { value => value })?
116 };
117 Ok(rendered)
118}
119
120/// If `prompt` is a JSON object, treat it as the slot map; otherwise
121/// wrap it as `{"directive": prompt}`. Corresponds to the
122/// `initial_directive` intake convention on the caller side
123/// (`OperatorSpawner`). Takes `Value` directly (issue #18): `Value`
124/// flows end-to-end through `EngineState.prompts` and
125/// `Engine::fetch_prompt`; parsing a JSON literal `String` back into
126/// an `Object` is no longer required at this boundary.
127pub fn slots_from_prompt(prompt: &serde_json::Value) -> serde_json::Value {
128 match prompt {
129 v @ serde_json::Value::Object(_) => v.clone(),
130 other => serde_json::json!({ "directive": other }),
131 }
132}
133
134/// Enumerate the template variables `template` requires but never binds
135/// itself (e.g. `{% for %}`/`{% set %}`-declared names are excluded).
136///
137/// Uses the same `Environment` configuration as [`render_system`] (shared
138/// via [`build_env`], so the two entry points cannot drift apart) and
139/// minijinja 2.21's `Template::undeclared_variables(false)`. A syntax
140/// error surfaces here (via `template_from_str`'s `Err`) rather than as an
141/// empty set — `undeclared_variables` itself silently returns an empty
142/// `HashSet` when it fails to re-parse the (already-compiled) source, so
143/// the syntax check must happen at `template_from_str` time, before that
144/// call.
145pub fn template_variables(template: &str) -> Result<BTreeSet<String>, RenderError> {
146 let env = build_env();
147 let tmpl = env.template_from_str(template)?;
148 Ok(tmpl.undeclared_variables(false).into_iter().collect())
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use serde_json::json;
155
156 #[test]
157 fn expands_simple_variable() {
158 let out = render_system("hello {{ directive }}", &json!({ "directive": "world" }))
159 .expect("render ok");
160 assert_eq!(out, "hello world");
161 }
162
163 #[test]
164 fn supports_if_branch() {
165 let tmpl = "{% if intent %}intent={{ intent }}{% else %}no-intent{% endif %}";
166 let with = render_system(tmpl, &json!({ "intent": "fix-bug" })).unwrap();
167 assert_eq!(with, "intent=fix-bug");
168 let without = render_system(tmpl, &json!({ "intent": null })).unwrap();
169 assert_eq!(without, "no-intent");
170 }
171
172 #[test]
173 fn supports_filter() {
174 let out = render_system("{{ name | upper }}", &json!({ "name": "shi" })).unwrap();
175 assert_eq!(out, "SHI");
176 }
177
178 #[test]
179 fn undefined_variable_errors_strict() {
180 let err = render_system("hello {{ missing }}", &json!({ "directive": "x" }))
181 .expect_err("strict undef must fail");
182 let msg = format!("{err}");
183 assert!(
184 msg.contains("undefined") || msg.contains("missing"),
185 "expected strict undef error, got: {msg}"
186 );
187 }
188
189 #[test]
190 fn syntax_error_returns_err() {
191 let err = render_system("hello {{ unclosed", &json!({})).expect_err("syntax error");
192 let msg = format!("{err}");
193 assert!(
194 msg.contains("syntax") || msg.contains("unexpected"),
195 "got: {msg}"
196 );
197 }
198
199 #[test]
200 fn html_chars_not_escaped() {
201 // For LLM prompt use, escaping `<` / `>` / `&` corrupts the prompt.
202 let out = render_system("{{ snippet }}", &json!({ "snippet": "<tag>&" })).unwrap();
203 assert_eq!(out, "<tag>&");
204 }
205
206 #[test]
207 fn supports_for_loop() {
208 let tmpl = "{% for x in xs %}{{ x }},{% endfor %}";
209 let out = render_system(tmpl, &json!({ "xs": ["a", "b", "c"] })).unwrap();
210 assert_eq!(out, "a,b,c,");
211 }
212
213 #[test]
214 fn slots_from_prompt_object() {
215 let v = slots_from_prompt(&json!({"directive":"do-X","intent":"fix"}));
216 assert_eq!(v["directive"], "do-X");
217 assert_eq!(v["intent"], "fix");
218 }
219
220 #[test]
221 fn slots_from_prompt_plain_string() {
222 let v = slots_from_prompt(&json!("just a plain instruction"));
223 assert_eq!(v["directive"], "just a plain instruction");
224 }
225
226 #[test]
227 fn slots_from_prompt_json_array_falls_back_to_directive() {
228 // A top-level array is not an object, so fall back to wrapping in `directive`.
229 let v = slots_from_prompt(&json!(["a", "b"]));
230 assert_eq!(v["directive"], json!(["a", "b"]));
231 }
232
233 // ────────────────────────────────────────────────────────────────
234 // template_variables (subtask-1, explain-agent)
235 // ────────────────────────────────────────────────────────────────
236
237 #[test]
238 fn template_variables_lists_two_undeclared_vars() {
239 let vars = template_variables("hello {{ directive }}, mode={{ mode }}").expect("parse ok");
240 let expected: BTreeSet<String> = ["directive", "mode"]
241 .into_iter()
242 .map(String::from)
243 .collect();
244 assert_eq!(vars, expected);
245 }
246
247 #[test]
248 fn template_variables_no_vars_is_empty() {
249 let vars = template_variables("hello, world").expect("parse ok");
250 assert!(vars.is_empty());
251 }
252
253 #[test]
254 fn template_variables_syntax_error_is_err() {
255 let err = template_variables("hello {{ unclosed").expect_err("syntax error");
256 let msg = format!("{err}");
257 assert!(
258 msg.contains("syntax") || msg.contains("unexpected"),
259 "got: {msg}"
260 );
261 }
262
263 #[test]
264 fn template_variables_for_loop_bound_var_not_enumerated() {
265 // `x` is bound by the `{% for %}`, so it must not appear — only
266 // `items` (the collection the loop iterates over) is undeclared.
267 let vars =
268 template_variables("{% for x in items %}{{ x }},{% endfor %}").expect("parse ok");
269 let expected: BTreeSet<String> = ["items"].into_iter().map(String::from).collect();
270 assert_eq!(vars, expected);
271 }
272}