Skip to content

add safe conditional, loop, and filter constructs to template engine #129

@cchinchilla-dev

Description

@cchinchilla-dev

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:

  • 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.

1. Constructs:

Construct Syntax Semantics
Variable {var}, {state.x}, {state.items[0].name} Existing behavior
Conditional {% if expr %}...{% elif expr %}...{% else %}...{% endif %} 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).
  • No {% macro %} / {% block %} — keep grammar small.
  • No expression evaluation outside of {% if %} / {% for %} headers.

6. Backward compatibility:

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:

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.pyTemplateError with 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_unchanged
  • test_if_renders_block_when_true
  • test_if_skips_block_when_false
  • test_if_elif_else_picks_correct_branch
  • test_for_iterates_over_list
  • test_for_loop_variable_does_not_leak
  • test_for_iterating_non_iterable_raises
  • test_filter_default_substitutes_for_missing
  • test_filter_truncate_cuts_at_length
  • test_filter_chain_applied_left_to_right
  • test_unknown_filter_raises_with_suggestion
  • test_template_error_reports_line_and_column
  • test_max_iteration_count_enforced
  • test_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

  • 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.
  • Pairs with add Pydantic-typed state schemas with validation on reads and writes #128 (typed state): when state has a schema, conditionals get type-checked at lint time.
  • Backward compatible: any existing template stays valid. The new constructs are opt-in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    coreCore engine, DAG, stateenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions