From 44da737770e4bcd9bfa27751f0a0751c9b5c06e1 Mon Sep 17 00:00:00 2001 From: soaringk <42689402+soaringk@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:48:14 +0800 Subject: [PATCH] [fix] Handle escaped characters in GLM tool call parser to prevent double serialization (#12456) --- .../srt/function_call/glm4_moe_detector.py | 37 ++++--- test/srt/test_function_call_parser.py | 103 ++++++++++++++++++ 2 files changed, 127 insertions(+), 13 deletions(-) diff --git a/python/sglang/srt/function_call/glm4_moe_detector.py b/python/sglang/srt/function_call/glm4_moe_detector.py index 301d0e0de..e66e62a11 100644 --- a/python/sglang/srt/function_call/glm4_moe_detector.py +++ b/python/sglang/srt/function_call/glm4_moe_detector.py @@ -24,13 +24,23 @@ def get_argument_type(func_name: str, arg_key: str, defined_tools: list): def parse_arguments(json_value): try: - try: - parsed_value = json.loads(json_value) - except: - parsed_value = ast.literal_eval(json_value) + parsed_value = json.loads(json_value) return parsed_value, True except: - return json_value, False + # If that fails, try wrapping it to unescape JSON characters + try: + # Wrap the value as a JSON string field + wrapped = json.loads('{"tmp": "' + json_value + '"}') + # parse the unescaped value + parsed_value = json.loads(wrapped["tmp"]) + return parsed_value, True + except: + # Final fallback to ast.literal_eval + try: + parsed_value = ast.literal_eval(json_value) + return parsed_value, True + except: + return json_value, False class Glm4MoeDetector(BaseFormatDetector): @@ -45,8 +55,13 @@ class Glm4MoeDetector(BaseFormatDetector): self.bot_token = "" self.eot_token = "" self.func_call_regex = r".*?" - self.func_detail_regex = r"([^\n]*)\n(.*)" - self.func_arg_regex = r"(.*?)\s*(.*?)" + self.func_detail_regex = re.compile( + r"(.*?)(?:\\n|\n)(.*)", re.DOTALL + ) + self.func_arg_regex = re.compile( + r"(.*?)(?:\\n|\s)*(.*?)", + re.DOTALL, + ) def has_tool_call(self, text: str) -> bool: """Check if the text contains a glm-4.5 / glm-4.6 format tool call.""" @@ -69,14 +84,10 @@ class Glm4MoeDetector(BaseFormatDetector): try: for match_result in match_result_list: # Get function name - func_detail = re.search(self.func_detail_regex, match_result, re.DOTALL) + func_detail = self.func_detail_regex.search(match_result) func_name = func_detail.group(1) func_args = func_detail.group(2) - pairs = re.findall( - r"(.*?)\s*(.*?)", - func_args, - re.DOTALL, - ) + pairs = self.func_arg_regex.findall(func_args) arguments = {} for arg_key, arg_value in pairs: arg_key = arg_key.strip() diff --git a/test/srt/test_function_call_parser.py b/test/srt/test_function_call_parser.py index b945077b9..91ff1dc3d 100644 --- a/test/srt/test_function_call_parser.py +++ b/test/srt/test_function_call_parser.py @@ -2191,6 +2191,109 @@ class TestGlm4MoeDetector(unittest.TestCase): ) self.assertEqual(self.detector._buffer, "") + def test_array_argument_with_escaped_json(self): + """Test that array arguments with escaped JSON are properly handled without double-escaping.""" + # Add a tool with array parameter + tools_with_array = [ + Tool( + type="function", + function=Function( + name="todo_write", + description="Write todos", + parameters={ + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The updated todo list", + } + }, + "required": ["todos"], + }, + ), + ), + ] + + def check_params(result): + self.assertEqual(1, len(result.calls)) + self.assertEqual("todo_write", result.calls[0].name) + params = json.loads(result.calls[0].parameters) + self.assertIsInstance(params["todos"], list) + self.assertEqual(4, len(params["todos"])) + self.assertEqual("1", params["todos"][0]["id"]) + self.assertEqual( + "Check for hard-coded issues in the backend code", + params["todos"][0]["task"], + ) + self.assertEqual("in_progress", params["todos"][0]["status"]) + self.assertEqual("2", params["todos"][1]["id"]) + self.assertEqual( + "Check for hard-coded issues in the frontend code", + params["todos"][1]["task"], + ) + self.assertEqual("pending", params["todos"][1]["status"]) + self.assertEqual("3", params["todos"][2]["id"]) + self.assertEqual( + "Check for code violating the Single Responsibility Principle", + params["todos"][2]["task"], + ) + self.assertEqual("pending", params["todos"][2]["status"]) + self.assertEqual("4", params["todos"][3]["id"]) + self.assertEqual( + "Generate a rectification proposal report", params["todos"][3]["task"] + ) + self.assertEqual("pending", params["todos"][3]["status"]) + + # Simulate the raw response from GLM-4.6 model with normal and escaped JSON in XML + result = self.detector.detect_and_parse( + """todo_write\ntodos\n[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}] +""", + tools_with_array, + ) + check_params(result) + result = self.detector.detect_and_parse( + r"""todo_write\ntodos\n[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}] +""", + tools_with_array, + ) + check_params(result) + + def check_single_todos(tool_result, expected): + self.assertEqual(1, len(tool_result.calls)) + self.assertEqual("todo_write", tool_result.calls[0].name) + params = json.loads(tool_result.calls[0].parameters) + self.assertIsInstance(params["todos"], list) + self.assertEqual(1, len(params["todos"])) + self.assertEqual("1", params["todos"][0]["id"]) + self.assertEqual(expected, params["todos"][0]["task"]) + self.assertEqual("pending", params["todos"][0]["status"]) + + # Test with escaped JSON containing backslashes in content (e.g., Windows paths) + expected_path = r"Check file at C:\Users\test.txt" + result = self.detector.detect_and_parse( + """todo_write\ntodos\n[{\"id\": \"1\", \"task\": \"Check file at C:\\\\Users\\\\test.txt\", \"status\": \"pending\"}]""", + tools_with_array, + ) + check_single_todos(result, expected_path) + result = self.detector.detect_and_parse( + r"""todo_write\ntodos\n[{\"id\": \"1\", \"task\": \"Check file at C:\\\\Users\\\\test.txt\", \"status\": \"pending\"}]""", + tools_with_array, + ) + check_single_todos(result, expected_path) + + # Should contain literal \n, not actual newline + expected_output = r"Print \n to see newline" + result = self.detector.detect_and_parse( + """todo_write\ntodos\n[{\"id\": \"1\", \"task\": \"Print \\\\n to see newline\",\"status\": \"pending\"}]""", + tools_with_array, + ) + check_single_todos(result, expected_output) + result = self.detector.detect_and_parse( + r"""todo_write\ntodos\n[{\"id\": \"1\", \"task\": \"Print \\\\n to see newline\",\"status\": \"pending\"}]""", + tools_with_array, + ) + check_single_todos(result, expected_output) + class TestJsonArrayParser(unittest.TestCase): def setUp(self):