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}" )