jinja: give each for-loop iteration a fresh scope (#2018)

`{% set %}` of a non-loop variable inside a `{% for %}` body leaks
across iterations when the assignment is conditionally skipped. Each
iteration should start with a clean scope, matching standard Jinja2
semantics.

This fixes the issue with GLM-5.2 chat template when:
* turn 1 is a tool call with reasoning
* turn 2 is a tool call without reasoning

In this case, the reasoning content for turn 1 would be wrongly
duplicated to turn 2, resulting in degraded model performance.
This commit is contained in:
Yap Sok Ann 2026-06-24 13:58:36 +07:00 committed by GitHub
parent a7d35d51dc
commit 997b289d93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 22 additions and 3 deletions

View File

@ -596,11 +596,18 @@ value for_statement::execute_impl(context & ctx) {
loop_obj->insert("length", mk_val<value_int>(filtered_items.size()));
loop_obj->insert("previtem", i > 0 ? filtered_items[i - 1] : mk_val<value_undefined>("previtem"));
loop_obj->insert("nextitem", i < filtered_items.size() - 1 ? filtered_items[i + 1] : mk_val<value_undefined>("nextitem"));
scope.set_val("loop", loop_obj);
scope_update_fns[i](scope);
// Use a fresh scope for each iteration so that {% set %} variables
// (including ones assigned only conditionally inside the body) do not
// leak across iterations. This matches standard Jinja2 semantics, where
// each loop iteration starts with a clean scope. State that must
// accumulate across iterations has to use namespace(), whose mutations
// are applied to the shared object referenced from the enclosing scope.
context iter_scope(scope);
iter_scope.set_val("loop", loop_obj);
scope_update_fns[i](iter_scope);
try {
for (auto & stmt : body) {
value val = stmt->execute(scope);
value val = stmt->execute(iter_scope);
result->push_back(val);
}
} catch (const continue_statement::signal &) {

View File

@ -360,6 +360,18 @@ static void test_loops(testing & t) {
json::object(),
"012"
);
// {% set %} of a non-loop variable inside a loop body must not leak across
// iterations. When a variable is only assigned on some iterations (here via
// a conditional branch), the engine must still treat it as undefined on the
// iterations where the branch is not taken, rather than reusing the value
// from a previous iteration. This matches standard Jinja2 semantics, where
// each loop iteration starts with a fresh scope.
test_template(t, "conditional set does not leak across iterations",
"{%- for m in msgs %}{% if m.x %}{% set r = m.x %}{% endif %}[{{ r|default('-') }}]{% endfor %}",
{{"msgs", json::array({json{{"x", "a"}}, json::object(), json{{"x", "c"}}})}},
"[a][-][c]"
);
}
static void test_expressions(testing & t) {