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 `tojson`) — all there for
46/// `{% for %}`/`{% if %}`. Undefined variables render empty; syntax and
47/// filter errors return with template positions.
48///
49/// The optional sections ([`render_system_with_sections`]) are absent
50/// here: a template that references them renders those parts empty,
51/// exactly as a caller that passes `None` for each.
52pub fn render_system(
53    template: &str,
54    workspace: &Path,
55    skills: &[Skill],
56    tools: &[ToolSpec],
57) -> Result<String, TemplateError> {
58    render_system_with_sections(template, workspace, skills, tools, None, None, &[], None)
59}
60
61/// [`render_system`] with the two optional prompt sections: `environment`
62/// — machine facts the frontend probed where commands run — and `user` —
63/// the user's own preferences file, verbatim. Both ride the template as
64/// plain string variables (`{{ environment }}`, `{% if user %}`), so the
65/// template decides whether and where they appear; `None` renders the
66/// referencing parts empty, the same as an undefined variable.
67#[expect(
68    clippy::too_many_arguments,
69    reason = "one parameter per prompt section keeps the render's inputs flat"
70)]
71pub fn render_system_with_sections(
72    template: &str,
73    workspace: &Path,
74    skills: &[Skill],
75    tools: &[ToolSpec],
76    environment: Option<&str>,
77    user: Option<&str>,
78    mcp: &[McpServerLine],
79    vision: Option<&str>,
80) -> Result<String, TemplateError> {
81    let context = serde_json::json!({
82        "workspace": workspace.display().to_string(),
83        "skills": skills,
84        "tools": tools,
85        "environment": environment,
86        "user": user,
87        "mcp": mcp,
88        "vision": vision,
89    });
90    render_template(template, &context)
91}
92
93/// Renders `template` over `context` — the same engine and whitespace
94/// rules as [`render_system`], with the caller's own variables. A
95/// value's own text is inserted verbatim, never re-parsed, so a
96/// section's braces survive; the template name in errors is `system`.
97pub fn render_template(
98    template: &str,
99    context: &serde_json::Value,
100) -> Result<String, TemplateError> {
101    let mut env = Environment::new();
102    // Block tags may sit on their own lines in a markdown template
103    // without blank-line runoff.
104    env.set_syntax(
105        minijinja::syntax::SyntaxConfig::builder()
106            .trim_blocks(true)
107            .lstrip_blocks(true)
108            .build()?,
109    );
110    env.add_template("system", template)?;
111    Ok(env
112        .get_template("system")?
113        .render(Value::from(minijinja::value::Serde(context)))?
114        .trim_end()
115        .to_owned()
116        + "\n")
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123
124    fn skill(name: &str, description: &str) -> Skill {
125        Skill {
126            name: name.into(),
127            description: description.into(),
128            license: None,
129            path: format!("/cfg/skills/{name}/SKILL.md").into(),
130            body: "the rules".into(),
131        }
132    }
133
134    fn tool(name: &str, description: &str) -> ToolSpec {
135        ToolSpec::new(
136            name,
137            description,
138            json!({
139                "type": "object",
140                "properties": { "command": { "type": "string" } },
141                "required": ["command"]
142            }),
143        )
144    }
145
146    #[test]
147    fn render_fills_the_workspace_and_leaves_other_braces_alone() {
148        let out = render_system(
149            "work in {{ workspace }}, keep {mystery}",
150            Path::new("/ws"),
151            &[],
152            &[],
153        )
154        .unwrap();
155        assert_eq!(out, "work in /ws, keep {mystery}\n");
156    }
157
158    #[test]
159    fn a_for_loop_renders_its_body_once_per_skill() {
160        let out = render_system(
161            "{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
162            Path::new("/ws"),
163            &[skill("a", "da"), skill("b", "db")],
164            &[],
165        )
166        .unwrap();
167        assert_eq!(
168            out,
169            "- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
170        );
171    }
172
173    #[test]
174    fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
175        let out = render_system(
176            "{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
177            Path::new("/ws"),
178            &[],
179            &[tool("t", "dt")],
180        )
181        .unwrap();
182        assert_eq!(
183            out,
184            "- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
185        );
186    }
187
188    #[test]
189    fn the_mcp_section_renders_lines_and_vanishes_when_empty() {
190        let template = "{% if mcp %}MCP:\n{% for server in mcp %}- {{ server.name }} (server: {{ server.server }}): {{ server.description }}\n{% endfor %}{% endif %}";
191        let lines = vec![McpServerLine {
192            name: "atlassian".into(),
193            server: "atlassian".into(),
194            description: "Jira and Confluence".into(),
195        }];
196        let out = render_system_with_sections(
197            template,
198            Path::new("/ws"),
199            &[],
200            &[],
201            None,
202            None,
203            &lines,
204            None,
205        )
206        .unwrap();
207        assert_eq!(
208            out,
209            "MCP:\n- atlassian (server: atlassian): Jira and Confluence\n"
210        );
211        let out = render_system_with_sections(
212            template,
213            Path::new("/ws"),
214            &[],
215            &[],
216            None,
217            None,
218            &[],
219            None,
220        )
221        .unwrap();
222        // A false block leaves a single newline behind (minijinja's
223        // trim_blocks keeps it); what matters is that the content
224        // vanishes, heading included.
225        assert!(!out.contains("atlassian"), "{out:?}");
226        assert!(!out.contains("MCP:"), "{out:?}");
227    }
228
229    #[test]
230    fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
231        let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
232        let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
233        assert_eq!(
234            with,
235            "Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
236        );
237        let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
238        assert_eq!(without, "\n");
239    }
240
241    #[test]
242    fn a_misspelled_variable_renders_empty_not_an_error() {
243        let out = render_system(
244            "{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
245            Path::new("/ws"),
246            &[skill("s", "d")],
247            &[],
248        )
249        .unwrap();
250        assert_eq!(out, "s\n");
251    }
252
253    #[test]
254    fn a_template_error_surfaces_instead_of_rendering_prose() {
255        let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
256            .expect_err("an unclosed block is a loud error, not prose");
257        assert!(err.to_string().contains("system"), "{err}");
258    }
259
260    #[test]
261    fn a_template_without_the_block_presents_no_skills() {
262        let out = render_system(
263            "base in {{ workspace }}\n",
264            Path::new("/ws"),
265            &[skill("s", "d")],
266            &[],
267        )
268        .unwrap();
269        assert_eq!(out, "base in /ws\n");
270    }
271
272    #[test]
273    fn the_sections_render_where_the_template_places_them() {
274        let template = "open\n{% if user %}{{ user }}\n{% endif %}{% if environment %}{{ environment }}\n{% endif %}close";
275        let out = render_system_with_sections(
276            template,
277            Path::new("/ws"),
278            &[],
279            &[],
280            Some("- OS: linux"),
281            Some("prefer short answers"),
282            &[],
283            None,
284        )
285        .unwrap();
286        assert_eq!(out, "open\nprefer short answers\n- OS: linux\nclose\n");
287    }
288
289    #[test]
290    fn absent_sections_render_nothing_like_undefined_variables() {
291        // A `None` section serializes to minijinja's undefined: the
292        // guarding `{% if %}` stays false and the template's own lines
293        // survive alone — the same render a template that never named
294        // the sections produces.
295        let template = "open\n{% if user %}{{ user }}\n{% endif %}close";
296        let without = render_system_with_sections(
297            template,
298            Path::new("/ws"),
299            &[],
300            &[],
301            None,
302            None,
303            &[],
304            None,
305        )
306        .unwrap();
307        assert_eq!(without, "open\nclose\n");
308        assert_eq!(
309            render_system(template, Path::new("/ws"), &[], &[]).unwrap(),
310            without,
311            "render_system is the sections-less form of the same render"
312        );
313    }
314
315    #[test]
316    fn render_template_takes_the_callers_own_variables() {
317        let out = render_template(
318            "hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
319            &json!({ "who": "claw", "tag": "" }),
320        )
321        .unwrap();
322        assert_eq!(out, "hi claw\n");
323    }
324
325    #[test]
326    fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
327        let out = render_template(
328            "{{ section }}",
329            &json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
330        )
331        .unwrap();
332        assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
333    }
334}