Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 44 additions & 18 deletions renderers/muse_glimmer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -104,6 +103,15 @@
r'<atem:parameter\b[^>]*?\bname="(?P<key>[^"]+)"[^>]*?>(?P<value>.*?)</atem:parameter>',
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:
Expand Down Expand Up @@ -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 "<atem:invoke" not in content
and "<atem:function_calls>" 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 "<atem:invoke" in remainder or "<atem:function_calls>" in remainder:
content = remainder
elif not content:
content = "" if "to=self<|message|>" in text else text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reasoning hides malformed tool text

Medium Severity

When a tool channel cannot be dispatched and has no ATEM tags, parse_response sets content to an empty string if the completion also contains a reasoning span. That discards the reasoning-stripped leftover instead of keeping it, so the model’s tool-channel words vanish from the assistant message.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 49ada0e. Configure here.

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 ``<atem:invoke>`` in one tool-channel message."""
calls: list[ParsedToolCall] = []
last_end = 0
Expand All @@ -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:]
Expand All @@ -779,15 +807,13 @@ def _parse_calls(
calls.append(
ParsedToolCall(
raw=rest.strip(),
name=recipient,
status=ToolCallParseStatus.UNCLOSED_BLOCK,
)
)
elif not calls:
calls.append(
ParsedToolCall(
raw=body,
name=recipient,
status=ToolCallParseStatus.MALFORMED_STRUCTURE,
)
)
Expand Down
32 changes: 30 additions & 2 deletions tests/test_muse_glimmer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from functools import lru_cache

import pytest

from renderers.base import ToolCallParseStatus

MODEL = "meta-models/Muse-Glimmer-30B"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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})]),
),
}


Expand Down Expand Up @@ -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 '<atem:invoke name="get_weather">' 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():
Expand Down
19 changes: 14 additions & 5 deletions tests/test_tool_arg_type_preservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand All @@ -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
Expand Down Expand Up @@ -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": "",
Expand All @@ -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}"
)


Expand Down
Loading