Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-template/src/lib.rs
damocles c0eb6780ec templates: the agent system prompt is a Jinja template, rendered through hive-template
system.md carries {{ label }}-style variables and an {% if not archetype %} block in place of the config:repo / config:archetype marker pairs, so prompt.rs drops its line filter, marker parsers and string replaces. The rendered prompt is byte for byte the old one for a custom agent, an archetype agent and an empty archetype name, with and without hive, swarm, docs and operators. hive-template now drops a block tag's own line.
2026-11-11 09:04:42 +01:00

101 lines
3.2 KiB
Rust

//! The one template language the workspace renders with: Jinja, through
//! `minijinja`, with an unknown variable an error rather than an empty string.
//!
//! A template is `{{ name }}` for a value and `{% if cond %}...{% endif %}` for
//! a block; a lone `{` or `}` is plain text. Callers pass their context as any
//! `Serialize` value and get back the text or a message naming what is wrong.
use std::collections::BTreeSet;
use minijinja::{Environment, UndefinedBehavior};
use serde::Serialize;
/// An environment that refuses a name the context does not hold, keeps the
/// template's trailing newline, and drops a block tag's own line (`{% if %}` on a
/// line by itself leaves no blank line behind).
fn environment() -> Environment<'static> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_keep_trailing_newline(true);
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env
}
/// `template` rendered against `context`.
///
/// # Errors
///
/// The template does not parse, or it names a variable `context` does not hold.
pub fn render(template: &str, context: &impl Serialize) -> Result<String, String> {
environment()
.render_str(template, context)
.map_err(|e| e.to_string())
}
/// The variables `template` reads that it does not define itself, such as `repo`
/// for `{{ repo }}`. Lets a caller refuse a template that names an unknown field
/// before any event renders it.
///
/// # Errors
///
/// The template does not parse.
pub fn variables(template: &str) -> Result<BTreeSet<String>, String> {
let env = environment();
let parsed = env.template_from_str(template).map_err(|e| e.to_string())?;
Ok(parsed.undeclared_variables(false).into_iter().collect())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_value_and_a_block_render() {
let ctx = json!({ "repo": "o/r", "role": "agent" });
let out = render(
"{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n",
&ctx,
);
assert_eq!(out.as_deref(), Ok("o/r (agent)"));
}
#[test]
fn a_block_tag_on_its_own_line_leaves_no_line_behind() {
let out = render(
"a\n{% if on %}\nb\n{% else %}\nc\n{% endif %}\nd\n",
&json!({ "on": false }),
);
assert_eq!(out.as_deref(), Ok("a\nc\nd\n"));
}
#[test]
fn single_braces_are_plain_text() {
assert_eq!(
render("{not a name} and { open", &json!({})).as_deref(),
Ok("{not a name} and { open")
);
}
#[test]
fn an_unknown_variable_is_an_error() {
assert!(render("{{ numbr }}", &json!({ "number": "7" })).is_err());
}
#[test]
fn variables_lists_what_the_template_reads_not_what_it_defines() {
let found = variables("{{ repo }}#{{ number }}{% set x = 1 %}{{ x }}").expect("parses");
assert_eq!(
found.into_iter().collect::<Vec<_>>(),
["number", "repo"].map(String::from)
);
}
#[test]
fn a_template_that_does_not_parse_is_refused() {
assert!(variables("{{ repo ").is_err());
assert!(render("{% if %}", &json!({})).is_err());
}
}