rhema_agent_tools/
prompt.rs1use std::path::Path;
15
16use minijinja::{Environment, Value};
17use rhema_core::ToolSpec;
18
19use crate::skills::Skill;
20
21pub 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
42pub fn render_template(
47 template: &str,
48 context: &serde_json::Value,
49) -> Result<String, minijinja::Error> {
50 let mut env = Environment::new();
51 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}