rhema-agent-tools 0.2.6

The shared agent toolkit on rhema-core's tool abstraction: the approval-gated shell, skills, paste expansion, dangling-call repair, the system-prompt renderer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! The system-prompt template renderer: one markdown template, Jinja.
//!
//! The template language is Jinja, which prompt templates across the
//! LLM ecosystem already speak. [`render_system`] is the standard
//! shape — `{{ workspace }}`, the discovered `skills`, the advertised
//! `tools`; [`render_template`] is the same engine over a caller's own
//! variables, so an agent with other per-dispatch values (the claw's
//! SOUL/USER sections, say) renders with the one syntax and the one
//! whitespace rules. Rendering is fallible on purpose: a template error
//! surfaces loudly at agent construction instead of silently rendering
//! half the prompt, while a misspelled variable renders empty
//! (minijinja's lenient undefined).

use std::path::Path;

use minijinja::{Environment, Value};
use rhema_core::ToolSpec;

use crate::skills::Skill;

/// The template engine's error, re-exported under a crate-local name:
/// [`render_system`] and [`render_template`] return it, and a caller
/// should not need a minijinja dependency to name it.
pub use minijinja::Error as TemplateError;

/// One wired MCP server, as the prompt's `mcp` section carries it: the
/// stub's name, the command the model types, and the user-written
/// description. Content comes from config only — an MCP server never
/// gets prose into the system prompt beyond its own yaml line.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct McpServerLine {
    /// The bash command and function name (`command_name` or the
    /// server name).
    pub name: String,
    /// The server's config name, for `mcp tools <server>` style calls.
    pub server: String,
    /// The user-written description, verbatim.
    pub description: String,
}

/// Renders a SYSTEM.md template: `{{ workspace }}` becomes the
/// workspace path, `skills` holds the discovered skills as `name`,
/// `description`, and `path` fields, and `tools` holds the advertised
/// tool specs as `name`, `description`, and `parameters` (the JSON
/// schema, renderable with `json` for prompt text or `tojson` where the
/// output feeds HTML) — all there for `{% for %}`/`{% if %}`. Undefined
/// variables render empty; syntax and filter errors return with
/// template positions.
///
/// The optional sections ([`render_system_with_sections`]) are absent
/// here: a template that references them renders those parts empty,
/// exactly as a caller that passes `None` for each.
pub fn render_system(
    template: &str,
    workspace: &Path,
    skills: &[Skill],
    tools: &[ToolSpec],
) -> Result<String, TemplateError> {
    render_system_with_sections(
        template,
        workspace,
        skills,
        tools,
        None,
        None,
        &[],
        None,
        None,
    )
}

/// [`render_system`] with the two optional prompt sections: `environment`
/// — machine facts the frontend probed where commands run — and `user` —
/// the user's own preferences file, verbatim. Both ride the template as
/// plain string variables (`{{ environment }}`, `{% if user %}`), so the
/// template decides whether and where they appear; `None` renders the
/// referencing parts empty, the same as an undefined variable. The
/// `mcp`, `vision`, and `jobs` sections ride the same way: each is text
/// a frontend provisioned, or `None` for a frontend that wired nothing.
#[expect(
    clippy::too_many_arguments,
    reason = "one parameter per prompt section keeps the render's inputs flat"
)]
pub fn render_system_with_sections(
    template: &str,
    workspace: &Path,
    skills: &[Skill],
    tools: &[ToolSpec],
    environment: Option<&str>,
    user: Option<&str>,
    mcp: &[McpServerLine],
    vision: Option<&str>,
    jobs: Option<&str>,
) -> Result<String, TemplateError> {
    let context = serde_json::json!({
        "workspace": workspace.display().to_string(),
        "skills": skills,
        "tools": tools,
        "environment": environment,
        "user": user,
        "mcp": mcp,
        "vision": vision,
        "jobs": jobs,
    });
    render_template(template, &context)
}

/// The `json` filter: a value as compact JSON, no HTML-safe escaping.
fn json(value: Value) -> Result<String, TemplateError> {
    serde_json::to_string(&value).map_err(|err| {
        TemplateError::new(
            minijinja::ErrorKind::InvalidOperation,
            "cannot serialize to JSON",
        )
        .with_source(err)
    })
}

/// Renders `template` over `context` — the same engine and whitespace
/// rules as [`render_system`], with the caller's own variables. A
/// value's own text is inserted verbatim, never re-parsed, so a
/// section's braces survive; the template name in errors is `system`.
pub fn render_template(
    template: &str,
    context: &serde_json::Value,
) -> Result<String, TemplateError> {
    let mut env = Environment::new();
    // Block tags may sit on their own lines in a markdown template
    // without blank-line runoff.
    env.set_syntax(
        minijinja::syntax::SyntaxConfig::builder()
            .trim_blocks(true)
            .lstrip_blocks(true)
            .build()?,
    );
    // `json` is `tojson` without the HTML-safe escaping: `tojson`
    // escapes `<`, `>`, `&`, and `'` as `\u003c`-style sequences so the
    // output can ride a script tag, and a prompt's schema column is not
    // HTML — the escapes cost tokens and reach the model as
    // `this session\u0027s`. The names are distinct so a user template
    // that wants the HTML-safe spelling keeps it.
    env.add_filter("json", json);
    env.add_template("system", template)?;
    Ok(env
        .get_template("system")?
        .render(Value::from(minijinja::value::Serde(context)))?
        .trim_end()
        .to_owned()
        + "\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn skill(name: &str, description: &str) -> Skill {
        Skill {
            name: name.into(),
            description: description.into(),
            license: None,
            path: format!("/cfg/skills/{name}/SKILL.md").into(),
            body: "the rules".into(),
        }
    }

    fn tool(name: &str, description: &str) -> ToolSpec {
        ToolSpec::new(
            name,
            description,
            json!({
                "type": "object",
                "properties": { "command": { "type": "string" } },
                "required": ["command"]
            }),
        )
    }

    #[test]
    fn render_fills_the_workspace_and_leaves_other_braces_alone() {
        let out = render_system(
            "work in {{ workspace }}, keep {mystery}",
            Path::new("/ws"),
            &[],
            &[],
        )
        .unwrap();
        assert_eq!(out, "work in /ws, keep {mystery}\n");
    }

    #[test]
    fn a_for_loop_renders_its_body_once_per_skill() {
        let out = render_system(
            "{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
            Path::new("/ws"),
            &[skill("a", "da"), skill("b", "db")],
            &[],
        )
        .unwrap();
        assert_eq!(
            out,
            "- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
        );
    }

    #[test]
    fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
        let out = render_system(
            "{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
            Path::new("/ws"),
            &[],
            &[tool("t", "dt")],
        )
        .unwrap();
        assert_eq!(
            out,
            "- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
        );
    }

    /// The `json` filter is `tojson` without the HTML-safe escapes: a
    /// prompt's schema column carries `'` and `<` as themselves rather
    /// than as `\u0027` and `\u003c`, and a template that embeds its
    /// output in a page can still ask for the escaped spelling.
    #[test]
    fn the_json_filter_leaves_html_characters_alone() {
        let spec = ToolSpec::new(
            "t",
            "the session's tool",
            json!({
                "type": "object",
                "properties": {
                    "model": { "type": "string", "description": "a session's <provider>/<model>" }
                }
            }),
        );
        let out = render_system(
            "{{ tools[0].parameters | json }}",
            Path::new("/ws"),
            &[],
            std::slice::from_ref(&spec),
        )
        .unwrap();
        assert!(out.contains(r#""a session's <provider>/<model>""#), "{out}");
        assert!(!out.contains("\\u0027"), "{out}");
        assert!(!out.contains("\\u003c"), "{out}");

        let escaped = render_system(
            "{{ tools[0].parameters | tojson }}",
            Path::new("/ws"),
            &[],
            &[spec],
        )
        .unwrap();
        assert!(escaped.contains("\\u0027"), "{escaped}");
    }

    #[test]
    fn the_mcp_section_renders_lines_and_vanishes_when_empty() {
        let template = "{% if mcp %}MCP:\n{% for server in mcp %}- {{ server.name }} (server: {{ server.server }}): {{ server.description }}\n{% endfor %}{% endif %}";
        let lines = vec![McpServerLine {
            name: "atlassian".into(),
            server: "atlassian".into(),
            description: "Jira and Confluence".into(),
        }];
        let out = render_system_with_sections(
            template,
            Path::new("/ws"),
            &[],
            &[],
            None,
            None,
            &lines,
            None,
            None,
        )
        .unwrap();
        assert_eq!(
            out,
            "MCP:\n- atlassian (server: atlassian): Jira and Confluence\n"
        );
        let out = render_system_with_sections(
            template,
            Path::new("/ws"),
            &[],
            &[],
            None,
            None,
            &[],
            None,
            None,
        )
        .unwrap();
        // A false block leaves a single newline behind (minijinja's
        // trim_blocks keeps it); what matters is that the content
        // vanishes, heading included.
        assert!(!out.contains("atlassian"), "{out:?}");
        assert!(!out.contains("MCP:"), "{out:?}");
    }

    #[test]
    fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
        let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
        let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
        assert_eq!(
            with,
            "Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
        );
        let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
        assert_eq!(without, "\n");
    }

    #[test]
    fn a_misspelled_variable_renders_empty_not_an_error() {
        let out = render_system(
            "{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
            Path::new("/ws"),
            &[skill("s", "d")],
            &[],
        )
        .unwrap();
        assert_eq!(out, "s\n");
    }

    #[test]
    fn a_template_error_surfaces_instead_of_rendering_prose() {
        let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
            .expect_err("an unclosed block is a loud error, not prose");
        assert!(err.to_string().contains("system"), "{err}");
    }

    #[test]
    fn a_template_without_the_block_presents_no_skills() {
        let out = render_system(
            "base in {{ workspace }}\n",
            Path::new("/ws"),
            &[skill("s", "d")],
            &[],
        )
        .unwrap();
        assert_eq!(out, "base in /ws\n");
    }

    #[test]
    fn the_sections_render_where_the_template_places_them() {
        let template = "open\n{% if user %}{{ user }}\n{% endif %}{% if environment %}{{ environment }}\n{% endif %}close";
        let out = render_system_with_sections(
            template,
            Path::new("/ws"),
            &[],
            &[],
            Some("- OS: linux"),
            Some("prefer short answers"),
            &[],
            None,
            None,
        )
        .unwrap();
        assert_eq!(out, "open\nprefer short answers\n- OS: linux\nclose\n");
    }

    #[test]
    fn absent_sections_render_nothing_like_undefined_variables() {
        // A `None` section serializes to minijinja's undefined: the
        // guarding `{% if %}` stays false and the template's own lines
        // survive alone — the same render a template that never named
        // the sections produces.
        let template = "open\n{% if user %}{{ user }}\n{% endif %}close";
        let without = render_system_with_sections(
            template,
            Path::new("/ws"),
            &[],
            &[],
            None,
            None,
            &[],
            None,
            None,
        )
        .unwrap();
        assert_eq!(without, "open\nclose\n");
        assert_eq!(
            render_system(template, Path::new("/ws"), &[], &[]).unwrap(),
            without,
            "render_system is the sections-less form of the same render"
        );
    }

    #[test]
    fn render_template_takes_the_callers_own_variables() {
        let out = render_template(
            "hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
            &json!({ "who": "claw", "tag": "" }),
        )
        .unwrap();
        assert_eq!(out, "hi claw\n");
    }

    #[test]
    fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
        let out = render_template(
            "{{ section }}",
            &json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
        )
        .unwrap();
        assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
    }
}