The bug that took two days to find
The prompt looked fine in the template file. Nothing obviously broken.
You are a helpful assistant. The user's name is {{ user_name }}.
Greet them warmly and help with their question.
The variable was populated from a user profile lookup. Normally. But the lookup had an early return path. When the profile was not found, the dictionary passed to the template renderer was missing the user_name key entirely.
The default Jinja2 behavior, and the default for most template engines, is to render a missing variable as an empty string. But minijinja, by default, renders undefined variables as the literal string undefined. Some engines render nothing. Some render the raw {{ user_name }} text.
In this case the model received:
You are a helpful assistant. The user's name is {{ user_name }}.
Greet them warmly and help with their question.
And the model answered helpfully: "Hello {{ user_name }}! How can I assist you today?"
The system prompt was stored in a variable. The response was displayed in a chat UI. Nobody read the system prompt. The AI greeted users with a literal template placeholder for two days before a QA engineer noticed "Hello {{ user_name }}" in a screenshot.
The fix was one line: validate that all template variables are present before rendering. But that line did not exist anywhere in the codebase.
agentprompt-rs is the library I wanted to have had that day. Strict mode is not a flag you opt into. It is the default.
The shape of the fix
Add to Cargo.toml:
[dependencies]
agentprompt-rs = "0.1"
Define a system prompt template:
use agentprompt_rs::{PromptTemplate, Messages};
use std::collections::HashMap;
let system_tpl = PromptTemplate::system("You are helping {{ user_name }}. Topic: {{ topic }}.");
let mut vars = HashMap::new();
vars.insert("user_name", "Alice");
// note: "topic" is not set
let result = system_tpl.render(&vars);
// Err(MissingVariable("topic"))
Strict mode catches the gap before the request is built.
With all variables present:
let mut vars = HashMap::new();
vars.insert("user_name", "Alice");
vars.insert("topic", "Rust async runtimes");
let message = system_tpl.render(&vars)?;
// Message { role: "system", content: "You are helping Alice. Topic: Rust async runtimes." }
For multi-turn prompts, build a full message array:
let mut builder = Messages::new();
builder.add_system("You are a code reviewer. Language: {{ lang }}.")?;
builder.add_user("Review this: {{ code }}")?;
let mut vars = HashMap::new();
vars.insert("lang", "Rust");
vars.insert("code", "fn main() { println!(\"hello\"); }");
let messages = builder.render_all(&vars)?;
// Vec<Message> ready to pass to an Anthropic or OpenAI API call
The ? propagates on the first missing variable. No silent gaps.
What it does NOT do
- It does not make HTTP requests. You build the messages array. You send it yourself.
- It does not manage conversation history. It renders templates into messages. Tracking turns is your job.
- It does not validate that your prompt makes semantic sense. It validates that variables are present, not that the prompt is good.
- It does not hot-reload templates from disk. Templates are compiled at call time from strings you provide.
Inside the lib: strict mode as the default
The crate wraps minijinja, a Rust-native Jinja2-compatible engine. minijinja itself has configurable undefined behavior. agentprompt-rs sets UndefinedBehavior::Strict in the engine configuration before any template is rendered.
This means any access to an undefined variable raises a minijinja::Error immediately, which the crate converts to agentprompt_rs::Error::MissingVariable(name).
The design choice is intentional. A template engine for prose documents should probably render nothing for missing variables. A template engine for LLM prompts should not. An LLM prompt with a silently missing variable is a silent correctness failure. The model still answers. The answer is wrong. No exception is raised. No log line is emitted. You find out when a user complains or a screenshot leaks.
Making strict mode the default means the failure is at render time, not at model-output review time. It moves the error detection from "hours or days after the fact" to "at the point the bad render would have happened."
If you genuinely need lenient behavior for some use case, you can construct a PromptTemplate with PromptTemplate::lenient(role, template_str). Lenient mode renders missing variables as empty strings. The method name makes the intent explicit in code review.
When this is useful
You have prompt templates with user-supplied or runtime-computed variables. Account names, selected topics, retrieved documents, tool outputs pasted into a follow-up prompt. Any path where the variable might not be set.
You want to catch that at render time, not at QA time.
You are building a multi-step agent where the output of one step is templated into the next step's prompt. If a step produced no output or produced a None, you want that to fail the render, not silently pass an empty value to the model.
You want role-aware separation. System, user, and assistant templates are separate objects. The role is encoded in the template, not passed as a separate argument at render time. This keeps the message structure consistent across different render call sites.
When NOT to use it
If your prompts are static strings with no variables, you do not need this. Build your Message structs directly.
If you are doing very heavy dynamic template composition (partials, macros, template inheritance), minijinja supports those features but agentprompt-rs does not expose them through its wrapper API. Use minijinja directly in that case.
If you need to render templates at very high throughput (thousands per second in a hot loop), benchmark first. minijinja is fast, but adding a HashMap lookup and error conversion on every render has some overhead.
Install
[dependencies]
agentprompt-rs = "0.1"
GitHub: MukundaKatta/agentprompt-rs
Requires Rust stable. No unsafe code. Dependencies: minijinja, serde, serde_json.
Siblings
| Lib | Boundary | Repo |
|---|---|---|
| prompt-template-version | Version-pin your templates so a prompt change has a name and a hash | MukundaKatta/prompt-template-version |
| llm-content-blocks-rs | Build the Anthropic content blocks the rendered messages slot into | MukundaKatta/llm-content-blocks-rs |
| agentvet-rs | Validate tool args before they go into the template variables | MukundaKatta/agentvet-rs |
| agentsnap-rs | Snapshot the rendered message array for regression tests | MukundaKatta/agentsnap-rs |
A common stack looks like this: agentvet-rs validates the input values, agentprompt-rs renders them into typed messages, llm-content-blocks-rs wraps those messages into Anthropic content block format, and agentsnap-rs snapshots the final payload so you know when the rendered output changes.
prompt-template-version sits alongside this. It gives each named prompt a content hash. When the template string changes, the hash changes. You can assert in tests that the template version your code references matches the deployed template. That catches the case where a template was edited without updating the call site.
What's next
A render_checked variant that accepts a struct implementing a trait rather than a HashMap, so the type checker can confirm all required variables are present at compile time rather than runtime. Rust's type system is the right place for this check. The current HashMap approach is flexible but pushes the error to runtime. A typed render surface would push it to compile time for the common case.
An optional #[derive(TemplateVars)] macro that generates the trait impl from a struct definition, so you define your template variables once as a struct and get both the type safety and the render call without manual trait implementation.
The library is at 0.1.0. Both of those are on the roadmap.
Part of the Hermes Agent Challenge sprint. The full agent-stack series is at MukundaKatta on GitHub.
Top comments (0)