diff --git a/renderers/parsing.py b/renderers/parsing.py index 352ae817..efc8aa62 100644 --- a/renderers/parsing.py +++ b/renderers/parsing.py @@ -447,7 +447,7 @@ def _parse_xml_tool_calls( r"]+)>\n?(.*?)\n?", block_text, re.DOTALL ): arg_name = pm.group(1) - arg_value = pm.group(2).strip() + arg_value = pm.group(2) value, used_fallback = _coerce_arg_value( arg_value, params.get(arg_name) ) @@ -597,7 +597,7 @@ def _parse_glm_tool_call( if ave == -1: structure_broke = True break - val_text = _decode(tokenizer, block[av + 1 : ave]).strip() + val_text = _decode(tokenizer, block[av + 1 : ave]) value, used_fallback = _coerce_arg_value(val_text, params.get(key)) arguments[key] = value any_json_fallback = any_json_fallback or used_fallback @@ -799,7 +799,7 @@ def _parse_hy3_tool_calls( if ave == -1: structure_broke = True break - val_text = _decode(tokenizer, arg_ids[av + 1 : ave]).strip() + val_text = _decode(tokenizer, arg_ids[av + 1 : ave]) value, used_fallback = _coerce_arg_value(val_text, params.get(key)) arguments[key] = value any_json_fallback = any_json_fallback or used_fallback @@ -978,7 +978,7 @@ def _parse_laguna_xs2_tool_calls( re.DOTALL, ): k = m.group(1).strip() - v = m.group(2).strip() + v = m.group(2) value, used_fallback = _coerce_arg_value(v, params.get(k)) arguments[k] = value any_json_fallback = any_json_fallback or used_fallback @@ -1417,7 +1417,7 @@ def parse_minimax( re.DOTALL, ): pname = pm.group(1) - pval = pm.group(2).strip() + pval = pm.group(2) value, used_fallback = _coerce_arg_value( pval, params.get(pname) ) diff --git a/tests/test_tool_arg_type_preservation.py b/tests/test_tool_arg_type_preservation.py index d8196417..9e70df18 100644 --- a/tests/test_tool_arg_type_preservation.py +++ b/tests/test_tool_arg_type_preservation.py @@ -68,15 +68,17 @@ def renderer(model, renderer_name): ] -# Each case: a single ``x`` argument whose value is a STRING that -# happens to be valid JSON of another type. With a schema declaring -# ``x: string``, the parser must return the string verbatim. -JSON_LOOKING_STRING_ARGS = [ +# String values retain their type and whitespace when the schema declares +# ``x: string``, including values that happen to be valid JSON of another type. +STRING_ARG_CASES = [ pytest.param({"x": "true"}, id="string-bool"), pytest.param({"x": "42"}, id="string-int"), pytest.param({"x": "null"}, id="string-null"), pytest.param({"x": "[1,2,3]"}, id="string-array"), pytest.param({"x": '{"k": 1}'}, id="string-object"), + pytest.param({"x": " Paris\nMünchen ☁ "}, id="string-whitespace"), + pytest.param({"x": "\n leading and trailing newline\n"}, id="string-newline"), + pytest.param({"x": ""}, id="string-empty"), ] @@ -116,7 +118,7 @@ def _extract_assistant_tokens(renderer, prompt, assistant_msg, *, tools=None): return full_ids[len(prompt_ids) :] -@pytest.mark.parametrize("args", JSON_LOOKING_STRING_ARGS) +@pytest.mark.parametrize("args", STRING_ARG_CASES) def test_string_arg_follows_parser_contract(model, renderer_name, renderer, args): """Preserve declared strings unless the reference parser decodes JSON.""" msg = { @@ -135,7 +137,7 @@ def test_string_arg_follows_parser_contract(model, renderer_name, renderer, args assert parsed.tool_calls, f"{model}: parser returned no tool_calls" got = _normalize_args(parsed.tool_calls[0].arguments) expected = ( - {key: json.loads(value) for key, value in args.items()} + {key: _normalize_args(value) for key, value in args.items()} if isinstance(renderer, MuseGlimmerRenderer) else args )