You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
SafeFormatDict + str.format_map() (src/agentloom/core/templates.py) is the entire template engine. It supports variable substitution and dotted attribute access, period. It cannot:
Conditionals: include a section only if a value is present ({% if context %}Context: {context}{% endif %}).
Loops: build a list of items for few-shot prompts ({% for example in examples %}- {example}\n{% endfor %}).
Filters: truncate, default, format dates, lower/upper, JSON-stringify ({long_text|truncate:500}, {name|default:"Anonymous"}, {date|format:"%Y-%m-%d"}).
Includes / partials: reuse a prompt fragment across workflows ({% include "prelude.txt" %}).
The CLAUDE.md rule is "no Jinja2." The intent is clear (no full templating language with arbitrary expression evaluation), but the practical consequence is that any prompt of nontrivial complexity has to be assembled in Python before being passed as workflow state — which defeats the point of declarative YAML workflows. Examples that need this immediately:
LLM-as-judge prompts that include the response only when present, with a fallback message otherwise.
Few-shot prompts with N example pairs from state, where N varies per scenario.
Multi-language prompt templates that select language-specific variants based on state.
RAG prompts that include retrieved chunks, with a graceful empty-state path.
Proposal
Add a safe, narrow, hand-rolled template engine (no Jinja2, no eval) with three constructs and a small filter library. The grammar is intentionally tiny and parseable in ~100 lines of code.
Boolean expression on state (using the same AST validator as router expressions, see #104 fix)
Loop
{% for item in iterable %}...{{ item.field }}...{% endfor %}
Iterate over a state list; loop variable is local to the block
Filter
{var | filter_name:arg1:arg2}
Apply a registered filter
2. Built-in filters:
Filter
Example
Behavior
default
{name | default:"Anonymous"}
Substitute if missing/empty
truncate
{text | truncate:500}
Cut at N chars (with … suffix)
lower / upper / strip
{name | lower}
String case ops
length
{items | length}
Length of list/dict/string
join
{items | join:", "}
Join list with separator
pluralize
{count | pluralize:"item":"items"}
Singular/plural based on count
format
{date | format:"%Y-%m-%d"}
Format datetime/date with strftime
json
{obj | json}
JSON-serialize (compact)
json_pretty
{obj | json_pretty}
JSON-serialize (indent=2)
regex_replace
{text | regex_replace:"\\s+":" "}
Substitute regex matches
3. Conditional expression grammar:
Reuses the post-fix router AST validator (the one that should result from #104's fix). The same allowlist applies — no __class__, no __call__ chains, no type builtin. This is intentional: the security boundary is shared, the implementation effort is shared, the audit surface is one place.
4. Loop grammar:
{% for item in state.examples %}
Example: {{ item.input }}
Expected: {{ item.expected }}
{% endfor %}
{{ ... }} (double-brace) inside a loop block resolves against the loop scope first, then state.
Loop variable is local — does not leak.
Iterating over non-iterables raises TemplateError at render.
Strict bound on iteration count (e.g., 10000) to prevent runaway templates.
5. No expression statements:
Important non-features (reject at parse time):
No {% set x = ... %} — templates do not mutate state.
No {% include "url" %} — only local-package includes (whitelisted directory).
Template errors include the line number, column, and the offending expression — not just "KeyError: 'x'". A typo in a long prompt should show:
TemplateError: Unknown filter 'truncat' at line 12, column 47:
Description: {text | truncat:500}
^^^^^^^^
Did you mean 'truncate'?
Scope
src/agentloom/core/templates.py — extend with conditional/loop/filter parser. Keep SafeFormatDict as the simple-variable fast path; switch to the new engine when {% %} is detected in the template.
src/agentloom/core/templates.py::FILTERS — built-in filter registry; users can register custom filters via plugin system (separate concern).
src/agentloom/steps/router.py — share the AST validator with conditional expressions.
src/agentloom/exceptions.py — TemplateError with line/column info.
examples/templating/ — examples for each construct.
docs/ — templating reference (the "no Jinja2" rule needs an updated explanation).
Why not Jinja2: Jinja2 has a large attack surface, supports arbitrary expression evaluation, and brings sandboxing concerns of its own (the SandboxedEnvironment has had CVEs). The hand-rolled engine is intentionally smaller than Jinja2 by an order of magnitude — easier to audit, no new dependency.
The conditional/loop grammar is what 90% of users would use Jinja2 for. The remaining 10% (macros, inheritance) is explicitly out of scope.
Reusing the router AST validator means one security boundary, one audit, one set of tests for what expressions can evaluate.
Description
SafeFormatDict+str.format_map()(src/agentloom/core/templates.py) is the entire template engine. It supports variable substitution and dotted attribute access, period. It cannot:{% if context %}Context: {context}{% endif %}).{% for example in examples %}- {example}\n{% endfor %}).{long_text|truncate:500},{name|default:"Anonymous"},{date|format:"%Y-%m-%d"}).{% include "prelude.txt" %}).The CLAUDE.md rule is "no Jinja2." The intent is clear (no full templating language with arbitrary expression evaluation), but the practical consequence is that any prompt of nontrivial complexity has to be assembled in Python before being passed as workflow state — which defeats the point of declarative YAML workflows. Examples that need this immediately:
Proposal
Add a safe, narrow, hand-rolled template engine (no Jinja2, no eval) with three constructs and a small filter library. The grammar is intentionally tiny and parseable in ~100 lines of code.
1. Constructs:
{var},{state.x},{state.items[0].name}{% if expr %}...{% elif expr %}...{% else %}...{% endif %}{% for item in iterable %}...{{ item.field }}...{% endfor %}{var | filter_name:arg1:arg2}2. Built-in filters:
default{name | default:"Anonymous"}truncate{text | truncate:500}…suffix)lower/upper/strip{name | lower}length{items | length}join{items | join:", "}pluralize{count | pluralize:"item":"items"}format{date | format:"%Y-%m-%d"}json{obj | json}json_pretty{obj | json_pretty}regex_replace{text | regex_replace:"\\s+":" "}3. Conditional expression grammar:
Reuses the post-fix router AST validator (the one that should result from #104's fix). The same allowlist applies — no
__class__, no__call__chains, notypebuiltin. This is intentional: the security boundary is shared, the implementation effort is shared, the audit surface is one place.4. Loop grammar:
{{ ... }}(double-brace) inside a loop block resolves against the loop scope first, then state.TemplateErrorat render.5. No expression statements:
Important non-features (reject at parse time):
{% set x = ... %}— templates do not mutate state.{% include "url" %}— only local-package includes (whitelisted directory).{% macro %}/{% block %}— keep grammar small.{% if %}/{% for %}headers.6. Backward compatibility:
{var}syntax keeps working unchanged.{% %}markers go through the existing fast path (str.format_map).SafeFormatDictlenient mode remains the default; strict mode triggered by add Pydantic-typed state schemas with validation on reads and writes #128 (typed state) or by an explicit step flag.7. Error reporting:
Template errors include the line number, column, and the offending expression — not just "KeyError: 'x'". A typo in a long prompt should show:
Scope
src/agentloom/core/templates.py— extend with conditional/loop/filter parser. KeepSafeFormatDictas the simple-variable fast path; switch to the new engine when{% %}is detected in the template.src/agentloom/core/templates.py::FILTERS— built-in filter registry; users can register custom filters via plugin system (separate concern).src/agentloom/steps/router.py— share the AST validator with conditional expressions.src/agentloom/exceptions.py—TemplateErrorwith line/column info.examples/templating/— examples for each construct.docs/— templating reference (the "no Jinja2" rule needs an updated explanation).Regression tests
test_simple_variable_substitution_unchangedtest_if_renders_block_when_truetest_if_skips_block_when_falsetest_if_elif_else_picks_correct_branchtest_for_iterates_over_listtest_for_loop_variable_does_not_leaktest_for_iterating_non_iterable_raisestest_filter_default_substitutes_for_missingtest_filter_truncate_cuts_at_lengthtest_filter_chain_applied_left_to_righttest_unknown_filter_raises_with_suggestiontest_template_error_reports_line_and_columntest_max_iteration_count_enforcedtest_no_arbitrary_expression_evaluation(security boundary tests)test_dunder_attributes_blocked_in_conditionals(shared with router fix router expression sandbox to prevent arbitrary code execution #104)Notes