fix(reasoning): honor Poolside template thinking defaults (#32540)
Co-authored-by: Jiminator <69131491+Jiminator@users.noreply.github.com>
This commit is contained in:
co-authored by
Jiminator
parent
d0e69d3881
commit
ffd4705baa
@@ -26,6 +26,7 @@ from typing import Callable, Optional, Tuple
|
||||
|
||||
import jinja2
|
||||
import jinja2.ext
|
||||
import jinja2.nodes
|
||||
import jinja2.sandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,6 +72,72 @@ class ReasoningToggleConfig:
|
||||
return self.special_case == "always"
|
||||
|
||||
|
||||
class _GenerationTagExtension(jinja2.ext.Extension):
|
||||
"""Parse-only support for the ``{% generation %}`` blocks emitted by
|
||||
transformers chat templates (assistant token masking). Needed so the
|
||||
toggle-default parser below can build an AST for templates that use it."""
|
||||
|
||||
tags = {"generation"}
|
||||
|
||||
def parse(self, parser):
|
||||
lineno = next(parser.stream).lineno
|
||||
body = parser.parse_statements(("name:endgeneration",), drop_needle=True)
|
||||
return jinja2.nodes.Scope(body).set_lineno(lineno)
|
||||
|
||||
|
||||
def _has_toggle_default_assignment(
|
||||
ctx: TemplateDetectionContext, param: str, default: bool
|
||||
) -> bool:
|
||||
"""True if the template applies Jinja's ``default`` filter (or its ``d``
|
||||
alias) to ``param`` with working-toggle semantics and the given default,
|
||||
e.g. ``{%- set enable_thinking = enable_thinking | default(false) -%}``
|
||||
(Laguna-XS-2.1) or ``default(true)`` (Laguna-S-2.1).
|
||||
|
||||
Matches on the parsed ``Filter`` node, so comments, ``{% raw %}`` blocks,
|
||||
and string literals cannot shadow a live assignment. Boolean-mode handling:
|
||||
``default(x, false)`` is equivalent to ``default(x)``; ``default(false,
|
||||
true)`` still maps False -> False and True -> True, so it is a working
|
||||
default-off toggle; only ``default(true, true)`` collapses the toggle
|
||||
(an explicit False is replaced by True) and is not matched.
|
||||
"""
|
||||
try:
|
||||
env = jinja2.Environment(
|
||||
extensions=[jinja2.ext.loopcontrols, _GenerationTagExtension]
|
||||
)
|
||||
tree = env.parse(ctx.template)
|
||||
except jinja2.TemplateError:
|
||||
return False
|
||||
except Exception:
|
||||
# e.g. RecursionError on a pathologically nested template; detection
|
||||
# must never take down server startup (mirrors
|
||||
# detect_inline_system_support).
|
||||
return False
|
||||
for node in tree.find_all(jinja2.nodes.Filter):
|
||||
if node.name not in ("default", "d") or node.dyn_args or node.dyn_kwargs:
|
||||
continue
|
||||
if not isinstance(node.node, jinja2.nodes.Name) or node.node.name != param:
|
||||
continue
|
||||
args = list(node.args)
|
||||
kwargs = {kw.key: kw.value for kw in node.kwargs}
|
||||
if not args or not isinstance(args[0], jinja2.nodes.Const):
|
||||
continue
|
||||
default_value = args[0].value
|
||||
if not isinstance(default_value, bool):
|
||||
continue
|
||||
boolean_arg = args[1] if len(args) > 1 else kwargs.get("boolean")
|
||||
if boolean_arg is not None and not isinstance(boolean_arg, jinja2.nodes.Const):
|
||||
continue
|
||||
boolean_mode = bool(boolean_arg.value) if boolean_arg is not None else False
|
||||
if boolean_mode and default_value:
|
||||
# default(true, true): any falsy value is replaced by True, so an
|
||||
# explicit enable_thinking=false still renders thinking on -- the
|
||||
# assignment is not a working toggle.
|
||||
continue
|
||||
if default_value is default:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning mode rules (detect toggle config from template)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -103,7 +170,8 @@ REASONING_MODE_RULES = (
|
||||
r"{%\s*if\s+not\s+enable_thinking\s+is\s+defined\s*%}.*?"
|
||||
r"{%\s*set\s+enable_thinking\s*=\s*(?:false|False)\s*%}",
|
||||
re.DOTALL,
|
||||
),
|
||||
)
|
||||
or _has_toggle_default_assignment(ctx, "enable_thinking", False),
|
||||
),
|
||||
DetectionRule(
|
||||
name="nemotron_3_super_low_effort",
|
||||
@@ -134,7 +202,8 @@ REASONING_MODE_RULES = (
|
||||
or ctx.has_pattern(
|
||||
r"enable_thinking\s+is\s+not\s+defined\s+or\s+enable_thinking"
|
||||
)
|
||||
or ctx.has_pattern(r"namespace\([^)]*enable_thinking\s*=\s*true"),
|
||||
or ctx.has_pattern(r"namespace\([^)]*enable_thinking\s*=\s*true")
|
||||
or _has_toggle_default_assignment(ctx, "enable_thinking", True),
|
||||
),
|
||||
DetectionRule(
|
||||
name="explicit_thinking_default_false",
|
||||
@@ -143,7 +212,8 @@ REASONING_MODE_RULES = (
|
||||
r"{%\s*if\s+not\s+thinking\s+is\s+defined\s*%}.*?"
|
||||
r"{%\s*set\s+thinking\s*=\s*(?:false|False)\s*%}",
|
||||
re.DOTALL,
|
||||
),
|
||||
)
|
||||
or _has_toggle_default_assignment(ctx, "thinking", False),
|
||||
),
|
||||
DetectionRule(
|
||||
name="thinking_default_true",
|
||||
@@ -160,7 +230,8 @@ REASONING_MODE_RULES = (
|
||||
r"thinking\s+is\s+defined\s+and\s+(?:thinking\s+is\s+false|not\s+thinking)"
|
||||
)
|
||||
or ctx.has_pattern(r"thinking\s+is\s+not\s+defined\s+or\s+thinking")
|
||||
or ctx.has_pattern(r"namespace\([^)]*thinking\s*=\s*true"),
|
||||
or ctx.has_pattern(r"namespace\([^)]*thinking\s*=\s*true")
|
||||
or _has_toggle_default_assignment(ctx, "thinking", True),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -269,10 +340,16 @@ def _is_hunyuan(ctx):
|
||||
|
||||
def _is_poolside_v1(ctx):
|
||||
has_poolside_tool_format = (
|
||||
ctx.has_text("unescaped XML-like object")
|
||||
and ctx.has_text("<tool_call>function-name")
|
||||
and ctx.has_text("<arg_key>")
|
||||
ctx.has_text("<arg_key>")
|
||||
and ctx.has_text("<arg_value>")
|
||||
and (
|
||||
# Laguna-XS.2 spells out the tool-call format in prose;
|
||||
# Laguna-S-2.1 does not, so also key on the tool preamble both
|
||||
# template families share. The Poolside identity must not depend
|
||||
# on the enable_thinking default, which differs between families.
|
||||
ctx.has_text("unescaped XML-like object")
|
||||
or ctx.has_text("All available function signatures are listed below")
|
||||
)
|
||||
)
|
||||
return has_poolside_tool_format or (
|
||||
ctx.reasoning_config
|
||||
|
||||
@@ -90,6 +90,126 @@ class TestTemplateManagerReasoningDetection(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(parser, "interns1")
|
||||
|
||||
def test_poolside_v1_detects_variant_template_defaults(self):
|
||||
tool_format = """
|
||||
return an unescaped XML-like object with function name and arguments
|
||||
within '<tool_call>' and '</tool_call>' tags
|
||||
<tool_call>function-name
|
||||
<arg_key>argument-key</arg_key>
|
||||
<arg_value>value-of-argument-key</arg_value>
|
||||
</tool_call>
|
||||
"""
|
||||
for default, expected in (("false", False), ("true", True)):
|
||||
with self.subTest(default=default):
|
||||
template = (
|
||||
"{%- set enable_thinking = enable_thinking | default("
|
||||
f"{default}) -%}}\n{tool_format}"
|
||||
)
|
||||
_, config, parser = self._detect(template, ["<tool_call>"])
|
||||
|
||||
self.assertEqual(
|
||||
config,
|
||||
ReasoningToggleConfig(
|
||||
toggle_param="enable_thinking", default_enabled=expected
|
||||
),
|
||||
)
|
||||
self.assertEqual(parser, "poolside_v1")
|
||||
|
||||
def test_poolside_v1_laguna_s21_shaped_template(self):
|
||||
# Laguna-S-2.1's real template defaults enable_thinking on and lacks
|
||||
# the XS-style prose describing the tool-call format; only the tool
|
||||
# preamble and the <arg_key>/<arg_value> markup identify the family.
|
||||
template = (
|
||||
"{%- set enable_thinking = enable_thinking | default(true) -%}\n"
|
||||
"You may call functions to assist with the user query.\n"
|
||||
"All available function signatures are listed below:\n"
|
||||
"{{- '<tool_call>' + function_data.name -}}\n"
|
||||
'{{- "<arg_key>" ~ k ~ "</arg_key>" -}}'
|
||||
'{{- "<arg_value>" ~ v ~ "</arg_value>" -}}\n'
|
||||
"{{- '</tool_call>' -}}"
|
||||
)
|
||||
_, config, parser = self._detect(template, ["<tool_call>"])
|
||||
self.assertEqual(
|
||||
config,
|
||||
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
|
||||
)
|
||||
self.assertEqual(parser, "poolside_v1")
|
||||
|
||||
def test_enable_thinking_default_filter_variants(self):
|
||||
cases = [
|
||||
# Jinja's documented alias for the default filter.
|
||||
("{%- set enable_thinking = enable_thinking | d(true) -%}", True),
|
||||
# No whitespace around the pipe.
|
||||
("{% set enable_thinking = enable_thinking|default(false) %}", False),
|
||||
# An explicit boolean=false second argument is equivalent to the
|
||||
# one-argument form.
|
||||
(
|
||||
"{%- set enable_thinking = enable_thinking"
|
||||
" | default(true, false) -%}",
|
||||
True,
|
||||
),
|
||||
# Boolean mode with a false default still maps False -> False and
|
||||
# True -> True, so it is a working default-off toggle.
|
||||
(
|
||||
"{%- set enable_thinking = enable_thinking"
|
||||
" | default(false, true) -%}",
|
||||
False,
|
||||
),
|
||||
(
|
||||
"{%- set enable_thinking = enable_thinking"
|
||||
" | default(false, boolean=true) -%}",
|
||||
False,
|
||||
),
|
||||
# The filter also counts outside a set-assignment (gemma-4 style).
|
||||
("{%- if enable_thinking | default(false) -%}x{%- endif -%}", False),
|
||||
# A commented-out assignment must not shadow the live one.
|
||||
(
|
||||
"{# {%- set enable_thinking = enable_thinking | default(false) -%} #}\n"
|
||||
"{%- set enable_thinking = enable_thinking | default(true) -%}",
|
||||
True,
|
||||
),
|
||||
# Neither must a raw block or a string literal.
|
||||
(
|
||||
"{% raw %}{% set enable_thinking = enable_thinking"
|
||||
" | default(false) %}{% endraw %}\n"
|
||||
"{%- set enable_thinking = enable_thinking | default(true) -%}",
|
||||
True,
|
||||
),
|
||||
]
|
||||
for template, expected in cases:
|
||||
with self.subTest(template=template):
|
||||
_, config, _ = self._detect(template, [])
|
||||
self.assertEqual(
|
||||
config,
|
||||
ReasoningToggleConfig(
|
||||
toggle_param="enable_thinking", default_enabled=expected
|
||||
),
|
||||
)
|
||||
|
||||
def test_pathological_template_does_not_raise(self):
|
||||
# A template jinja cannot parse (RecursionError from deep nesting) must
|
||||
# degrade to no detection, not crash template loading.
|
||||
template = (
|
||||
"{% if a %}" * 5000 + "x" + "{% endif %}" * 5000 + "\n"
|
||||
"{%- set enable_thinking = enable_thinking | default(true) -%}"
|
||||
)
|
||||
_, config = detect_reasoning_pattern(template)
|
||||
self.assertIsNone(config)
|
||||
|
||||
def test_enable_thinking_collapsing_default_not_a_toggle(self):
|
||||
# default(true, true) / default(true, boolean=true) replace any falsy
|
||||
# value with True, so an explicit enable_thinking=false still renders
|
||||
# thinking on; the assignment is not a working toggle and must stay
|
||||
# undetected.
|
||||
for template in (
|
||||
"{%- set enable_thinking = enable_thinking | default(true, true) -%}",
|
||||
"{%- set enable_thinking = enable_thinking"
|
||||
" | default(true, boolean=true) -%}",
|
||||
):
|
||||
with self.subTest(template=template):
|
||||
_, config, _ = self._detect(template, [])
|
||||
self.assertIsNone(config)
|
||||
|
||||
def test_nemotron_detects_uppercase_true_assignment(self):
|
||||
template = """
|
||||
{% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
|
||||
@@ -225,7 +345,7 @@ class TestTemplateDetectionRuleMatrix(unittest.TestCase):
|
||||
"</tool_call>",
|
||||
["<tool_call>"],
|
||||
"poolside_v1",
|
||||
None,
|
||||
"enable_thinking",
|
||||
),
|
||||
(
|
||||
"lfm2_not_deepseek_r1_from_history_cleanup",
|
||||
@@ -379,6 +499,20 @@ class TestToolCallParserDetection(unittest.TestCase):
|
||||
tcp = detect_tool_call_parser(template, tok, config, force)
|
||||
return rp, tcp
|
||||
|
||||
def test_poolside_s21_shape_resolves_poolside_tool_call_parser(self):
|
||||
# Regression: with default(true) the S-2.1 shape must not fall through
|
||||
# to the qwen tool-call parser, which cannot read Poolside's XML-KV
|
||||
# tool format.
|
||||
template = (
|
||||
"{%- set enable_thinking = enable_thinking | default(true) -%}\n"
|
||||
"All available function signatures are listed below:\n"
|
||||
"<tool_call>{{ name }}<arg_key>{{ k }}</arg_key>"
|
||||
"<arg_value>{{ v }}</arg_value></tool_call>"
|
||||
)
|
||||
force, config = detect_reasoning_pattern(template)
|
||||
result = detect_tool_call_parser(template, _DummyTokenizer([]), config, force)
|
||||
self.assertEqual(result, "poolside_v1")
|
||||
|
||||
def test_qwen3_detects_qwen_tool_call_parser(self):
|
||||
rp, tcp = self._detect_all("Qwen/Qwen3-0.6B")
|
||||
self.assertEqual(rp, "qwen3")
|
||||
|
||||
Reference in New Issue
Block a user