feat(tokenizers): Jinja loop.previtem / loop.nextitem - #411
Conversation
Add loop.previtem and loop.nextitem to the Jinja evaluator's per-iteration
loop context. Both are simply omitted from the loop dict at the first/last
iteration respectively, so member access on them falls through the
evaluator's existing Undefined convention (EvalMemberAccess already returns
Undefined for an absent dict key) rather than a new sentinel or a throw --
matching Jinja2 semantics where previtem/nextitem are falsy-undefined at the
boundaries, never an error.
Also adds the "undefined" test to EvalIsTest (`x is undefined` / `x is not
undefined`), mirroring the existing IsDefinedExpr path. This is required
because Qwen3.8-27B's real chat_template.jinja checks
`preserve_thinking is undefined` in its assistant-message branch, which sits
on the path to every tool message -- without it the acceptance-criterion
test below cannot execute at all.
Tests:
- JinjaEvaluatorTests: discriminating previtem/nextitem cases (actual
neighbour values, not "always current" or "always undefined"), explicit
first/last boundary checks via `is defined`/`is not`, and the new
`is undefined` cases.
- Qwen38ChatTemplateTests (new): renders the real Qwen/Qwen3.8-27B
chat_template.jinja tool-response branch verbatim (macro + message loop
lines 1-41/88-170 of the file fetched from HF, unmodified) and asserts on
actual merge behaviour for consecutive tool_response messages, plus the
single-tool-response open+close-in-one-iteration case.
Note: the verbatim excerpt above deliberately drops the template's
unconditional reasoning-effort prelude (lines 42-87), which uses a
parenthesized tuple literal (`not in ('xhigh', 'medium', 'low')`) that the
parser does not support -- a pre-existing, unrelated gap tracked separately
as #409. The full, unmodified template still fails to parse until #409
lands, so tool calling for this model is not yet fully unblocked end-to-end;
this PR fixes a necessary condition for it, not a sufficient one.
Closes #399
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
There was a problem hiding this comment.
Pull request overview
Adds missing Jinja loop-context and test semantics needed to render real-world chat templates (notably Qwen3.8’s tool-response branch) within DotLLM.Tokenizers’ Jinja2-subset interpreter.
Changes:
- Extend
loopcontext inforevaluation withloop.previtem/loop.nextitem, omitted at boundaries to reuse the evaluator’s existingUndefinedbehavior. - Add support for the
is undefined/is not undefinedtest inEvalIsTest(aligned with existingUndefinedsentinel semantics). - Add unit + integration coverage, including a verbatim excerpt test of Qwen3.8-27B’s tool-response branch behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/DotLLM.Tokenizers/ChatTemplates/JinjaEvaluator.cs |
Implements loop.previtem/loop.nextitem via boundary key omission and adds is undefined test handling using the existing Undefined sentinel. |
tests/DotLLM.Tests.Unit/Tokenizers/ChatTemplates/JinjaEvaluatorTests.cs |
Adds focused unit tests verifying neighbor correctness and boundary undefined/defined behavior, plus is undefined/is not undefined cases. |
tests/DotLLM.Tests.Integration/Tokenizers/ChatTemplates/Qwen38ChatTemplateTests.cs |
Adds an integration test rendering Qwen3.8’s real tool-response branch excerpt and asserting correct tool-message merge behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
) Independent review of PR #411 mutation-tested the loop.previtem/nextitem suite (7 mutants) and validated against real Jinja2 3.1.6. Six of seven mutants died; two behaviors it verified empirically had no guard in the repo: - `is undefined` returning true for a legitimately null value survived the suite. The shipped code is already correct (Jinja2: a key present with value null is defined, not undefined), but nothing asserted it. Add IsUndefined_False_WhenVariableIsNull. This is what keeps a legitimately-null loop.previtem distinguishable from an omitted (undefined) one -- the whole basis of the omission-instead-of-sentinel design for those two fields. - Nested for-loops: the inner loop's own "loop" variable must not leak into the outer loop's previtem/nextitem once the inner loop finishes. Add ForLoop_NestedLoop_DoesNotLeakIntoOuterLoopPrevNextItem, asserting the outer loop's previtem/nextitem are restored correctly after an inner loop completes within the same outer iteration body. Both additive; 116/116 unit tests pass (Release). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
Update: mutation-testing follow-upIndependent review mutation-tested the
Both additive; 116/116 unit tests pass (Release). Two other findings from that review are not addressed here — filed/tracked separately, out of this issue's scope:
CI noteCI currently shows Integration Tests failing on this branch — not caused by this change. 9/12 failures are |
Summary
loop.previtem/loop.nextitemto the Jinja evaluator's per-iteration loop context (src/DotLLM.Tokenizers/ChatTemplates/JinjaEvaluator.cs). Both are simply omitted from theloopdict at the first/last iteration respectively, so member access on them falls through the evaluator's existingUndefinedconvention (EvalMemberAccessalready returnsUndefinedfor an absent dict key) instead of a new sentinel or a throw — matches Jinja2 semantics, whereprevitem/nextitemare falsy-undefined at the boundaries, never an error."undefined"test toEvalIsTest(x is undefined/x is not undefined), mirroring the existingIsDefinedExprpath. Required, not optional scope creep: Qwen3.8-27B's real template checkspreserve_thinking is undefinedin its assistant-message branch, which sits on the path to every tool message — without this the acceptance-criterion test can't execute at all.Important caveat
The verbatim template excerpt used in the new integration test deliberately drops the template's unconditional reasoning-effort prelude (lines 42-87 of the real file), which uses a parenthesized tuple literal (
not in ('xhigh', 'medium', 'low')) that the Jinja parser doesn't support yet — a pre-existing, unrelated gap, filed separately as #409.The full, unmodified Qwen3.8-27B template still fails to parse until #409 lands — so this PR fixes a necessary condition for tool calling on that model, not a sufficient one. Don't read this as "tool calling now works end-to-end for Qwen3.8-27B."
Tests
JinjaEvaluatorTests: discriminating previtem/nextitem cases (actual neighbour values — not "always current item" or "always undefined"), explicit first/last boundary checks viais defined/is not defined, and newis undefined/is not undefinedcases.Qwen38ChatTemplateTests(new,tests/DotLLM.Tests.Integration): renders the realQwen/Qwen3.8-27Bchat_template.jinjatool-response branch verbatim (therender_contentmacro + the multi-step-tool loop + the per-message render loop + generation-prompt tail — lines 1-41 and 88-170 of the file fetched from HF 2026-08-14, unmodified) and asserts on actual merge behaviour: consecutiverole == "tool"messages merge into a single<|im_start|>user ... <|im_end|>turn, and a lone tool message opens+closes in the same loop iteration.Verified
dotnet test(Release) onDotLLM.Tests.Unitfiltered toJinjaEvaluatorTests/JinjaChatTemplateTests: 114 passed, 0 failed.dotnet test(Release) onDotLLM.Tests.Integrationfiltered toChatTemplate: 6 passed, 3 skipped (the pre-existing Mach-1 tests skip because that fixture isn't staged locally — unrelated to this change), 0 failed.src/project referenced by them — no separate top-level solution file in this repo).Not verified
Closes #399