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/// Renders a SYSTEM.md template: `{{ workspace }}` becomes the
22/// workspace path, `skills` holds the discovered skills as `name`,
23/// `description`, and `path` fields, and `tools` holds the advertised
24/// tool specs as `name`, `description`, and `parameters` (the JSON
25/// schema, renderable with `tojson`) — all there for
26/// `{% for %}`/`{% if %}`. Undefined variables render empty; syntax and
27/// filter errors return with template positions.
28pub fn render_system(
29    template: &str,
30    workspace: &Path,
31    skills: &[Skill],
32    tools: &[ToolSpec],
33) -> Result<String, minijinja::Error> {
34    let context = serde_json::json!({
35        "workspace": workspace.display().to_string(),
36        "skills": skills,
37        "tools": tools,
38    });
39    render_template(template, &context)
40}
41
42/// Renders `template` over `context` — the same engine and whitespace
43/// rules as [`render_system`], with the caller's own variables. A
44/// value's own text is inserted verbatim, never re-parsed, so a
45/// section's braces survive; the template name in errors is `system`.
46pub fn render_template(
47    template: &str,
48    context: &serde_json::Value,
49) -> Result<String, minijinja::Error> {
50    let mut env = Environment::new();
51    // Block tags may sit on their own lines in a markdown template
52    // without blank-line runoff.
53    env.set_syntax(
54        minijinja::syntax::SyntaxConfig::builder()
55            .trim_blocks(true)
56            .lstrip_blocks(true)
57            .build()?,
58    );
59    env.add_template("system", template)?;
60    Ok(env
61        .get_template("system")?
62        .render(Value::from(minijinja::value::Serde(context)))?
63        .trim_end()
64        .to_owned()
65        + "\n")
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use serde_json::json;
72
73    fn skill(name: &str, description: &str) -> Skill {
74        Skill {
75            name: name.into(),
76            description: description.into(),
77            path: format!("/cfg/skills/{name}/SKILL.md").into(),
78        }
79    }
80
81    fn tool(name: &str, description: &str) -> ToolSpec {
82        ToolSpec::new(
83            name,
84            description,
85            json!({
86                "type": "object",
87                "properties": { "command": { "type": "string" } },
88                "required": ["command"]
89            }),
90        )
91    }
92
93    #[test]
94    fn render_fills_the_workspace_and_leaves_other_braces_alone() {
95        let out = render_system(
96            "work in {{ workspace }}, keep {mystery}",
97            Path::new("/ws"),
98            &[],
99            &[],
100        )
101        .unwrap();
102        assert_eq!(out, "work in /ws, keep {mystery}\n");
103    }
104
105    #[test]
106    fn a_for_loop_renders_its_body_once_per_skill() {
107        let out = render_system(
108            "{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
109            Path::new("/ws"),
110            &[skill("a", "da"), skill("b", "db")],
111            &[],
112        )
113        .unwrap();
114        assert_eq!(
115            out,
116            "- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
117        );
118    }
119
120    #[test]
121    fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
122        let out = render_system(
123            "{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
124            Path::new("/ws"),
125            &[],
126            &[tool("t", "dt")],
127        )
128        .unwrap();
129        assert_eq!(
130            out,
131            "- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
132        );
133    }
134
135    #[test]
136    fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
137        let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
138        let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
139        assert_eq!(
140            with,
141            "Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
142        );
143        let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
144        assert_eq!(without, "\n");
145    }
146
147    #[test]
148    fn a_misspelled_variable_renders_empty_not_an_error() {
149        let out = render_system(
150            "{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
151            Path::new("/ws"),
152            &[skill("s", "d")],
153            &[],
154        )
155        .unwrap();
156        assert_eq!(out, "s\n");
157    }
158
159    #[test]
160    fn a_template_error_surfaces_instead_of_rendering_prose() {
161        let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
162            .expect_err("an unclosed block is a loud error, not prose");
163        assert!(err.to_string().contains("system"), "{err}");
164    }
165
166    #[test]
167    fn a_template_without_the_block_presents_no_skills() {
168        let out = render_system(
169            "base in {{ workspace }}\n",
170            Path::new("/ws"),
171            &[skill("s", "d")],
172            &[],
173        )
174        .unwrap();
175        assert_eq!(out, "base in /ws\n");
176    }
177
178    #[test]
179    fn render_template_takes_the_callers_own_variables() {
180        let out = render_template(
181            "hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
182            &json!({ "who": "claw", "tag": "" }),
183        )
184        .unwrap();
185        assert_eq!(out, "hi claw\n");
186    }
187
188    #[test]
189    fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
190        let out = render_template(
191            "{{ section }}",
192            &json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
193        )
194        .unwrap();
195        assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
196    }
197}