diff --git a/renderers/muse_glimmer.py b/renderers/muse_glimmer.py
index ec9bb006..779c1a78 100644
--- a/renderers/muse_glimmer.py
+++ b/renderers/muse_glimmer.py
@@ -51,7 +51,6 @@
should_rerender_for_thinking_retention,
)
from renderers.configs import MuseGlimmerRendererConfig
-from renderers.parsing import _build_param_type_index, _coerce_arg_value
_IMAGE_TYPES = ("image", "image_url")
_UNSUPPORTED_MEDIA_TYPES = ("video", "video_url", "audio", "audio_url", "input_audio")
@@ -104,6 +103,15 @@
r']*?\bname="(?P[^"]+)"[^>]*?>(?P.*?)',
re.DOTALL,
)
+_STRIP_REASONING_RE = re.compile(
+ r"(?:<\|start\|>assistant\s*)?to=self<\|message\|>.*?<\|eom\|>", re.DOTALL
+)
+_STRIP_OPEN_REASONING_RE = re.compile(
+ r"(?:<\|start\|>assistant\s*)?to=self<\|message\|>"
+ r"(?:(?!<\|eom\|>)(?!to=[^\s<]+<\|message\|>).)*"
+ r"(?=to=[^\s<]+<\|message\|>|$)",
+ re.DOTALL,
+)
def _json(value: Any) -> str:
@@ -723,23 +731,42 @@ def cut(h: re.Match) -> int:
reasoning = (
"\n".join(p for p in reasoning_parts if p) if reasoning_parts else None
)
- param_types = _build_param_type_index(tools)
+ registered = {_function(tool)["name"] for tool in tools or []}
calls = [
c
for recipient, body, closed in tool_bodies
- for c in self._parse_calls(recipient, body, param_types)
+ for c in self._parse_calls(body, registered)
]
+ content = "".join(content_parts)
+ if (
+ messages
+ and messages[-1][0] == "user"
+ and not messages[-1][2]
+ and not any(call.name for call in calls)
+ and content.endswith("\n")
+ and "" not in content
+ ):
+ # vLLM's non-streaming content regex ends at `$`, before one final LF.
+ content = content[:-1]
+ if tool_bodies and not any(call.name for call in calls):
+ # Stock vLLM preserves malformed tool markup as content when no
+ # complete invocation can be dispatched; it does not invent a call.
+ remainder = _STRIP_REASONING_RE.sub("", text)
+ remainder = _STRIP_OPEN_REASONING_RE.sub("", remainder)
+ if "" in remainder:
+ content = remainder
+ elif not content:
+ content = "" if "to=self<|message|>" in text else text
return ParsedResponse(
- content="".join(content_parts),
+ content=content,
reasoning_content=reasoning,
tool_calls=calls,
reasoning_complete=reasoning_complete,
)
@staticmethod
- def _parse_calls(
- recipient: str, body: str, param_types: dict[str, dict[str, dict[str, Any]]]
- ) -> list[ParsedToolCall]:
+ def _parse_calls(body: str, registered: set[str]) -> list[ParsedToolCall]:
"""Every ```` in one tool-channel message."""
calls: list[ParsedToolCall] = []
last_end = 0
@@ -754,23 +781,24 @@ def _parse_calls(
)
)
continue
- schema = param_types.get(name, {})
+ if name not in registered:
+ head, sep, tail = name.partition(".")
+ if sep and head == tail and head in registered:
+ name = head
arguments: dict[str, Any] = {}
- fallback = False
for p in _PARAM_RE.finditer(m.group("body")):
- value, used = _coerce_arg_value(
- p.group("value"), schema.get(p.group("key"))
- )
+ value = p.group("value")
+ try:
+ value = json.loads(value)
+ except (json.JSONDecodeError, ValueError):
+ pass
arguments[p.group("key")] = value
- fallback = fallback or used
calls.append(
ParsedToolCall(
raw=m.group(0),
name=name,
arguments=arguments,
- status=ToolCallParseStatus.INVALID_JSON
- if fallback
- else ToolCallParseStatus.OK,
+ status=ToolCallParseStatus.OK,
)
)
rest = body[last_end:]
@@ -779,7 +807,6 @@ def _parse_calls(
calls.append(
ParsedToolCall(
raw=rest.strip(),
- name=recipient,
status=ToolCallParseStatus.UNCLOSED_BLOCK,
)
)
@@ -787,7 +814,6 @@ def _parse_calls(
calls.append(
ParsedToolCall(
raw=body,
- name=recipient,
status=ToolCallParseStatus.MALFORMED_STRUCTURE,
)
)
diff --git a/tests/test_muse_glimmer.py b/tests/test_muse_glimmer.py
index 961cfed3..500898ee 100644
--- a/tests/test_muse_glimmer.py
+++ b/tests/test_muse_glimmer.py
@@ -17,7 +17,6 @@
from functools import lru_cache
import pytest
-
from renderers.base import ToolCallParseStatus
MODEL = "meta-models/Muse-Glimmer-30B"
@@ -365,6 +364,22 @@ def test_consecutive_assistant_messages_continue_the_turn():
("Let me think.", "The answer is 4.", []),
),
"answer-only": (" to=user<|message|>Hello there!", (None, "Hello there!", [])),
+ "answer-final-newline": (" to=user<|message|>Hello\n", (None, "Hello", [])),
+ "answer-final-two-newlines": (
+ " to=user<|message|>Hello\n\n",
+ (None, "Hello\n", []),
+ ),
+ "answer-final-space-newline": (" to=user<|message|>Hello \n", (None, "Hello ", [])),
+ "closed-answer-keeps-newline": (
+ " to=user<|message|>Hello\n<|eom|>",
+ (None, "Hello\n", []),
+ ),
+ "tool-and-answer-keeps-newline": (
+ " to=get_weather<|message|>"
+ + _invoke("get_weather", ("city", "Paris"))
+ + "<|eom|><|start|>assistant to=user<|message|>Hello\n",
+ (None, "Hello\n", [("get_weather", {"city": "Paris"})]),
+ ),
"tool-call": (
" to=self<|message|>Need weather.<|eom|><|start|>assistant to=get_weather<|message|>"
+ _invoke(
@@ -413,6 +428,16 @@ def test_consecutive_assistant_messages_continue_the_turn():
"<|start|>assistant to=user<|message|>Done.",
("First.\nSecond.", "Done.", []),
),
+ "doubled-bare-name": (
+ " to=get_weather<|message|>"
+ + _invoke("get_weather.get_weather", ("city", "Paris")),
+ (None, "", [("get_weather", {"city": "Paris"})]),
+ ),
+ "string-schema-does-not-override-scalar-decoding": (
+ " to=get_weather<|message|>"
+ + _invoke("get_weather", ("city", "123"), ("code", "false")),
+ (None, "", [("get_weather", {"city": 123, "code": False})]),
+ ),
}
@@ -520,14 +545,17 @@ def test_unfinished_tool_call_is_unclosed():
)
)
assert [c.status for c in parsed.tool_calls] == [ToolCallParseStatus.UNCLOSED_BLOCK]
+ assert parsed.tool_calls[0].name is None
+ assert '' in parsed.content
def test_tool_channel_without_invoke_is_malformed():
_, renderer = _load()
parsed = renderer.parse_response(_ids(" to=get_weather<|message|>Paris please"))
assert [(c.name, c.status) for c in parsed.tool_calls] == [
- ("get_weather", ToolCallParseStatus.MALFORMED_STRUCTURE)
+ (None, ToolCallParseStatus.MALFORMED_STRUCTURE)
]
+ assert parsed.content == " to=get_weather<|message|>Paris please"
def test_prefilled_reasoning_prompt_continues_reasoning():
diff --git a/tests/test_tool_arg_type_preservation.py b/tests/test_tool_arg_type_preservation.py
index aa3d15bf..ec12a3e2 100644
--- a/tests/test_tool_arg_type_preservation.py
+++ b/tests/test_tool_arg_type_preservation.py
@@ -13,6 +13,9 @@
(Qwen3) and section-JSON (Kimi K2) parsers sidestep the bug because
their wire format quotes strings; both serve as controls.
+Muse Glimmer follows vLLM's schema-independent JSON decoding instead, so
+its cases assert decoded values even when the schema declares a string.
+
Originally raised by Robin (Poolside) on PR #21.
"""
@@ -25,6 +28,8 @@
import pytest
from parity import models_for
+from renderers.muse_glimmer import MuseGlimmerRenderer
+
# (HuggingFace model name, renderer name). Two JSON-shaped controls
# (string types already preserved by the wire format) + five XML-style
@@ -110,9 +115,8 @@ def _extract_assistant_tokens(renderer, prompt, assistant_msg, *, tools=None):
@pytest.mark.parametrize("args", JSON_LOOKING_STRING_ARGS)
-def test_string_arg_preserves_type(model, renderer_name, renderer, args):
- """Tool-call args of declared type ``str`` must round-trip as ``str``,
- not get re-parsed as bool/int/null/list/dict by the parser."""
+def test_string_arg_follows_parser_contract(model, renderer_name, renderer, args):
+ """Preserve declared strings unless the reference parser decodes JSON."""
msg = {
"role": "assistant",
"content": "",
@@ -128,8 +132,13 @@ def test_string_arg_preserves_type(model, renderer_name, renderer, args):
assert parsed.tool_calls, f"{model}: parser returned no tool_calls"
got = _normalize_args(parsed.tool_calls[0].arguments)
- assert got == args, (
- f"{model}: tool-arg type drift — sent {args!r}, parser returned {got!r}"
+ expected = (
+ {key: json.loads(value) for key, value in args.items()}
+ if isinstance(renderer, MuseGlimmerRenderer)
+ else args
+ )
+ assert got == expected, (
+ f"{model}: tool-arg type drift — expected {expected!r}, parser returned {got!r}"
)