Skip to main content

rhema_agent_tools/
prompt.rs

1//! The system-prompt template renderer: one markdown template, Jinja.
2//!
3//! The template language is Jinja, which prompt templates across the
4//! LLM ecosystem already speak. [`render_system`] is the standard
5//! shape — `{{ workspace }}`, the discovered `skills`, the advertised
6//! `tools`; [`render_template`] is the same engine over a caller's own
7//! variables, so an agent with other per-dispatch values (the claw's
8//! SOUL/USER sections, say) renders with the one syntax and the one
9//! whitespace rules. Rendering is fallible on purpose: a template error
10//! surfaces loudly at agent construction instead of silently rendering
11//! half the prompt, while a misspelled variable renders empty
12//! (minijinja's lenient undefined).
13
14use std::path::Path;
15
16use minijinja::{Environment, Value};
17use rhema_core::ToolSpec;
18
19use crate::skills::Skill;
20
21/// The template engine's error, re-exported under a crate-local name:
22/// [`render_system`] and [`render_template`] return it, and a caller
23/// should not need a minijinja dependency to name it.
24pub use minijinja::Error as TemplateError;
25
26/// One wired MCP server, as the prompt's `mcp` section carries it: the
27/// stub's name, the command the model types, and the user-written
28/// description. Content comes from config only — an MCP server never
29/// gets prose into the system prompt beyond its own yaml line.
30#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
31pub struct McpServerLine {
32    /// The bash command and function name (`command_name` or the
33    /// server name).
34    pub name: String,
35    /// The server's config name, for `mcp tools <server>` style calls.
36    pub server: String,
37    /// The user-written description, verbatim.
38    pub description: String,
39}
40
41/// Renders a SYSTEM.md template: `{{ workspace }}` becomes the
42/// workspace path, `skills` holds the discovered skills as `name`,
43/// `description`, and `path` fields, and `tools` holds the advertised
44/// tool specs as `name`, `description`, and `parameters` (the JSON
45/// schema, renderable with `json` for prompt text or `tojson` where the
46/// output feeds HTML) — all there for `{% for %}`/`{% if %}`. Undefined
47/// variables render empty; syntax and filter errors return with
48/// template positions.
49///
50/// The optional sections ([`render_system_with_sections`]) are absent
51/// here: a template that references them renders those parts empty,
52/// exactly as a caller that passes `None` for each.
53pub fn render_system(
54    template: &str,
55    workspace: &Path,
56    skills: &[Skill],
57    tools: &[ToolSpec],
58) -> Result<String, TemplateError> {
59    render_system_with_sections(
60        template,
61        workspace,
62        skills,
63        tools,
64        None,
65        None,
66        &[],
67        None,
68        None,
69    )
70}
71
72/// [`render_system`] with the two optional prompt sections: `environment`
73/// — machine facts the frontend probed where commands run — and `user` —
74/// the user's own preferences file, verbatim. Both ride the template as
75/// plain string variables (`{{ environment }}`, `{% if user %}`), so the
76/// template decides whether and where they appear; `None` renders the
77/// referencing parts empty, the same as an undefined variable. The
78/// `mcp`, `vision`, and `jobs` sections ride the same way: each is text
79/// a frontend provisioned, or `None` for a frontend that wired nothing.
80#[expect(
81    clippy::too_many_arguments,
82    reason = "one parameter per prompt section keeps the render's inputs flat"
83)]
84pub fn render_system_with_sections(
85    template: &str,
86    workspace: &Path,
87    skills: &[Skill],
88    tools: &[ToolSpec],
89    environment: Option<&str>,
90    user: Option<&str>,
91    mcp: &[McpServerLine],
92    vision: Option<&str>,
93    jobs: Option<&str>,
94) -> Result<String, TemplateError> {
95    let context = serde_json::json!({
96        "workspace": workspace.display().to_string(),
97        "skills": skills,
98        "tools": tools,
99        "environment": environment,
100        "user": user,
101        "mcp": mcp,
102        "vision": vision,
103        "jobs": jobs,
104    });
105    render_template(template, &context)
106}
107
108/// The `json` filter: a value as compact JSON, no HTML-safe escaping.
109fn json(value: Value) -> Result<String, TemplateError> {
110    serde_json::to_string(&value).map_err(|err| {
111        TemplateError::new(
112            minijinja::ErrorKind::InvalidOperation,
113            "cannot serialize to JSON",
114        )
115        .with_source(err)
116    })
117}
118
119/// Renders `template` over `context` — the same engine and whitespace
120/// rules as [`render_system`], with the caller's own variables. A
121/// value's own text is inserted verbatim, never re-parsed, so a
122/// section's braces survive; the template name in errors is `system`.
123pub fn render_template(
124    template: &str,
125    context: &serde_json::Value,
126) -> Result<String, TemplateError> {
127    let mut env = Environment::new();
128    // Block tags may sit on their own lines in a markdown template
129    // without blank-line runoff.
130    env.set_syntax(
131        minijinja::syntax::SyntaxConfig::builder()
132            .trim_blocks(true)
133            .lstrip_blocks(true)
134            .build()?,
135    );
136    // `json` is `tojson` without the HTML-safe escaping: `tojson`
137    // escapes `<`, `>`, `&`, and `'` as `\u003c`-style sequences so the
138    // output can ride a script tag, and a prompt's schema column is not
139    // HTML — the escapes cost tokens and reach the model as
140    // `this session\u0027s`. The names are distinct so a user template
141    // that wants the HTML-safe spelling keeps it.
142    env.add_filter("json", json);
143    env.add_template("system", template)?;
144    Ok(env
145        .get_template("system")?
146        .render(Value::from(minijinja::value::Serde(context)))?
147        .trim_end()
148        .to_owned()
149        + "\n")
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use serde_json::json;
156
157    fn skill(name: &str, description: &str) -> Skill {
158        Skill {
159            name: name.into(),
160            description: description.into(),
161            license: None,
162            path: format!("/cfg/skills/{name}/SKILL.md").into(),
163            body: "the rules".into(),
164        }
165    }
166
167    fn tool(name: &str, description: &str) -> ToolSpec {
168        ToolSpec::new(
169            name,
170            description,
171            json!({
172                "type": "object",
173                "properties": { "command": { "type": "string" } },
174                "required": ["command"]
175            }),
176        )
177    }
178
179    #[test]
180    fn render_fills_the_workspace_and_leaves_other_braces_alone() {
181        let out = render_system(
182            "work in {{ workspace }}, keep {mystery}",
183            Path::new("/ws"),
184            &[],
185            &[],
186        )
187        .unwrap();
188        assert_eq!(out, "work in /ws, keep {mystery}\n");
189    }
190
191    #[test]
192    fn a_for_loop_renders_its_body_once_per_skill() {
193        let out = render_system(
194            "{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
195            Path::new("/ws"),
196            &[skill("a", "da"), skill("b", "db")],
197            &[],
198        )
199        .unwrap();
200        assert_eq!(
201            out,
202            "- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
203        );
204    }
205
206    #[test]
207    fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
208        let out = render_system(
209            "{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
210            Path::new("/ws"),
211            &[],
212            &[tool("t", "dt")],
213        )
214        .unwrap();
215        assert_eq!(
216            out,
217            "- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
218        );
219    }
220
221    /// The `json` filter is `tojson` without the HTML-safe escapes: a
222    /// prompt's schema column carries `'` and `<` as themselves rather
223    /// than as `\u0027` and `\u003c`, and a template that embeds its
224    /// output in a page can still ask for the escaped spelling.
225    #[test]
226    fn the_json_filter_leaves_html_characters_alone() {
227        let spec = ToolSpec::new(
228            "t",
229            "the session's tool",
230            json!({
231                "type": "object",
232                "properties": {
233                    "model": { "type": "string", "description": "a session's <provider>/<model>" }
234                }
235            }),
236        );
237        let out = render_system(
238            "{{ tools[0].parameters | json }}",
239            Path::new("/ws"),
240            &[],
241            std::slice::from_ref(&spec),
242        )
243        .unwrap();
244        assert!(out.contains(r#""a session's <provider>/<model>""#), "{out}");
245        assert!(!out.contains("\\u0027"), "{out}");
246        assert!(!out.contains("\\u003c"), "{out}");
247
248        let escaped = render_system(
249            "{{ tools[0].parameters | tojson }}",
250            Path::new("/ws"),
251            &[],
252            &[spec],
253        )
254        .unwrap();
255        assert!(escaped.contains("\\u0027"), "{escaped}");
256    }
257
258    #[test]
259    fn the_mcp_section_renders_lines_and_vanishes_when_empty() {
260        let template = "{% if mcp %}MCP:\n{% for server in mcp %}- {{ server.name }} (server: {{ server.server }}): {{ server.description }}\n{% endfor %}{% endif %}";
261        let lines = vec![McpServerLine {
262            name: "atlassian".into(),
263            server: "atlassian".into(),
264            description: "Jira and Confluence".into(),
265        }];
266        let out = render_system_with_sections(
267            template,
268            Path::new("/ws"),
269            &[],
270            &[],
271            None,
272            None,
273            &lines,
274            None,
275            None,
276        )
277        .unwrap();
278        assert_eq!(
279            out,
280            "MCP:\n- atlassian (server: atlassian): Jira and Confluence\n"
281        );
282        let out = render_system_with_sections(
283            template,
284            Path::new("/ws"),
285            &[],
286            &[],
287            None,
288            None,
289            &[],
290            None,
291            None,
292        )
293        .unwrap();
294        // A false block leaves a single newline behind (minijinja's
295        // trim_blocks keeps it); what matters is that the content
296        // vanishes, heading included.
297        assert!(!out.contains("atlassian"), "{out:?}");
298        assert!(!out.contains("MCP:"), "{out:?}");
299    }
300
301    #[test]
302    fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
303        let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
304        let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
305        assert_eq!(
306            with,
307            "Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
308        );
309        let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
310        assert_eq!(without, "\n");
311    }
312
313    #[test]
314    fn a_misspelled_variable_renders_empty_not_an_error() {
315        let out = render_system(
316            "{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
317            Path::new("/ws"),
318            &[skill("s", "d")],
319            &[],
320        )
321        .unwrap();
322        assert_eq!(out, "s\n");
323    }
324
325    #[test]
326    fn a_template_error_surfaces_instead_of_rendering_prose() {
327        let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
328            .expect_err("an unclosed block is a loud error, not prose");
329        assert!(err.to_string().contains("system"), "{err}");
330    }
331
332    #[test]
333    fn a_template_without_the_block_presents_no_skills() {
334        let out = render_system(
335            "base in {{ workspace }}\n",
336            Path::new("/ws"),
337            &[skill("s", "d")],
338            &[],
339        )
340        .unwrap();
341        assert_eq!(out, "base in /ws\n");
342    }
343
344    #[test]
345    fn the_sections_render_where_the_template_places_them() {
346        let template = "open\n{% if user %}{{ user }}\n{% endif %}{% if environment %}{{ environment }}\n{% endif %}close";
347        let out = render_system_with_sections(
348            template,
349            Path::new("/ws"),
350            &[],
351            &[],
352            Some("- OS: linux"),
353            Some("prefer short answers"),
354            &[],
355            None,
356            None,
357        )
358        .unwrap();
359        assert_eq!(out, "open\nprefer short answers\n- OS: linux\nclose\n");
360    }
361
362    #[test]
363    fn absent_sections_render_nothing_like_undefined_variables() {
364        // A `None` section serializes to minijinja's undefined: the
365        // guarding `{% if %}` stays false and the template's own lines
366        // survive alone — the same render a template that never named
367        // the sections produces.
368        let template = "open\n{% if user %}{{ user }}\n{% endif %}close";
369        let without = render_system_with_sections(
370            template,
371            Path::new("/ws"),
372            &[],
373            &[],
374            None,
375            None,
376            &[],
377            None,
378            None,
379        )
380        .unwrap();
381        assert_eq!(without, "open\nclose\n");
382        assert_eq!(
383            render_system(template, Path::new("/ws"), &[], &[]).unwrap(),
384            without,
385            "render_system is the sections-less form of the same render"
386        );
387    }
388
389    #[test]
390    fn render_template_takes_the_callers_own_variables() {
391        let out = render_template(
392            "hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
393            &json!({ "who": "claw", "tag": "" }),
394        )
395        .unwrap();
396        assert_eq!(out, "hi claw\n");
397    }
398
399    #[test]
400    fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
401        let out = render_template(
402            "{{ section }}",
403            &json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
404        )
405        .unwrap();
406        assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
407    }
408}