From ffd4705baa39117cad64c6b7304249e10d773c17 Mon Sep 17 00:00:00 2001 From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:02:19 +0800 Subject: [PATCH] fix(reasoning): honor Poolside template thinking defaults (#32540) Co-authored-by: Jiminator <69131491+Jiminator@users.noreply.github.com> --- .../sglang/srt/parser/template_detection.py | 91 +++++++++++- .../unit/parser/test_template_manager.py | 136 +++++++++++++++++- 2 files changed, 219 insertions(+), 8 deletions(-) diff --git a/python/sglang/srt/parser/template_detection.py b/python/sglang/srt/parser/template_detection.py index 489fa820a..ad43ef003 100644 --- a/python/sglang/srt/parser/template_detection.py +++ b/python/sglang/srt/parser/template_detection.py @@ -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("function-name") - and ctx.has_text("") + ctx.has_text("") and ctx.has_text("") + 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 diff --git a/test/registered/unit/parser/test_template_manager.py b/test/registered/unit/parser/test_template_manager.py index 85fb3a75b..ca28b4325 100644 --- a/test/registered/unit/parser/test_template_manager.py +++ b/test/registered/unit/parser/test_template_manager.py @@ -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 '' and '' tags + function-name + argument-key + value-of-argument-key + + """ + 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, [""]) + + 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 / 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" + "{{- '' + function_data.name -}}\n" + '{{- "" ~ k ~ "" -}}' + '{{- "" ~ v ~ "" -}}\n' + "{{- '' -}}" + ) + _, config, parser = self._detect(template, [""]) + 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): "", [""], "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" + "{{ name }}{{ k }}" + "{{ v }}" + ) + 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")