diff --git a/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py b/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py index 5098f148e3b2..f3d54dbe6e33 100644 --- a/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py +++ b/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py @@ -110,7 +110,10 @@ def _parse_parameters_from_xml(self, invoke_content: str) -> dict: for param_name, param_type, param_value in param_matches: # Convert value based on type if param_type == "true": # string type - parameters[param_name] = param_value.strip() + # Taken as written: the encoder renders a string value verbatim + # between the tags, so its whitespace (a file's last newline, a + # line's indentation) is part of the value. + parameters[param_name] = param_value else: # Try to parse as JSON for other types try: diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index a427c5aa024f..8a8d00ef8fce 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -3160,6 +3160,53 @@ def test_deepseek_streaming_keeps_markdown_fences( assert parser_cls().detect_and_parse(text, sample_tools).normal_text == text +def _dsml_tool_call(tag_prefix: str, block_name: str, name: str, + string_arguments: dict[str, str]) -> str: + """A tool call as the DSML encoders render it: each string value verbatim.""" + parameters = "\n".join( + f'<{tag_prefix}parameter name="{key}" string="true">{value}' + f"" for key, value in string_arguments.items()) + return (f"<{tag_prefix}{block_name}>\n" + f'<{tag_prefix}invoke name="{name}">\n{parameters}\n' + f"\n") + + +@pytest.mark.parametrize("parser_cls,tag_prefix,block_name", [ + (DeepSeekV32Parser, "|DSML|", "function_calls"), + (DeepSeekV4Parser, "|DSML|", "tool_calls"), + (DeepSeekV41Parser, "|DSML| ", "calls"), +]) +@pytest.mark.parametrize("streaming", [False, True]) +def test_dsml_string_parameter_values_are_kept_as_written( + sample_tools: list[ChatCompletionToolsParam], + parser_cls: type[BaseToolParser], tag_prefix: str, block_name: str, + streaming: bool) -> None: + """A string value's leading and trailing whitespace is part of the value. + + An edit's ``old_string`` must match the file including its indentation, + and a written file keeps its last newline. + """ + arguments = { + "location": " indented line\n second line", + "unit": "file content\n", + } + text = _dsml_tool_call(tag_prefix, block_name, "get_weather", arguments) + + parser = parser_cls() + if streaming: + results = [ + parser.parse_streaming_increment(text[i:i + 7], sample_tools) + for i in range(0, len(text), 7) + ] + calls = [call for result in results for call in result.calls] + else: + calls = parser.detect_and_parse(text, sample_tools).calls + + assert [call.name for call in calls if call.name] == ["get_weather"] + assert json.loads("".join(call.parameters or "" + for call in calls)) == arguments + + @pytest.mark.parametrize( "parser_cls, tool_call_text", [