diff --git a/python/sglang/srt/function_call/base_format_detector.py b/python/sglang/srt/function_call/base_format_detector.py index 03a0c6e43..d4532761c 100644 --- a/python/sglang/srt/function_call/base_format_detector.py +++ b/python/sglang/srt/function_call/base_format_detector.py @@ -166,15 +166,22 @@ class BaseFormatDetector(ABC): try: try: - tool_call_pos = current_text.find(self.bot_token) - if tool_call_pos != -1: - start_idx = tool_call_pos + len(self.bot_token) - elif self.current_tool_id > 0 and current_text.startswith( + # Priority check: if we're processing a subsequent tool (current_tool_id > 0), + # first check if text starts with the tool separator. This is critical for + # parallel tool calls because the bot_token (e.g., '[') can also + # appear inside array parameters of the current tool, and we must not + # mistakenly identify that as the start of a new tool. + if self.current_tool_id > 0 and current_text.startswith( self.tool_call_separator ): start_idx = len(self.tool_call_separator) else: - start_idx = 0 + # Only search for bot_token if not processing subsequent tool + tool_call_pos = current_text.find(self.bot_token) + if tool_call_pos != -1: + start_idx = tool_call_pos + len(self.bot_token) + else: + start_idx = 0 if start_idx >= len(current_text): return StreamingParseResult() diff --git a/test/registered/function_call/test_parallel_tool_calls.py b/test/registered/function_call/test_parallel_tool_calls.py new file mode 100644 index 000000000..34aa48410 --- /dev/null +++ b/test/registered/function_call/test_parallel_tool_calls.py @@ -0,0 +1,163 @@ +import json + +""" +Test case for parallel tool call parsing. + +This test verifies that the parser correctly handles parallel tool calls +with array parameters in JSON array format. + +Scenario: +- Model outputs two parallel tool calls in JSON array format +- Both tools have array parameters (e.g., "title": ["7.8.9 H-9 ..."]) +- First tool completes with closing braces +- Second tool starts with opening brace +- The parser must correctly handle the '[' characters in array parameters + without confusing them with the JSON array start + +Expected behavior: Both tools should be parsed correctly. +""" + +import unittest + +from sglang.srt.entrypoints.openai.protocol import Function, Tool +from sglang.srt.function_call.json_array_parser import JsonArrayParser +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci(1.0, "default") + + +class TestParallelToolCalls(unittest.TestCase): + """Test case for parallel tool call parsing with array parameters.""" + + def setUp(self): + """Set up test tools and detector.""" + self.tools = [ + Tool( + type="function", + function=Function( + name="search_docs", + description="Search documents", + parameters={ + "type": "object", + "properties": { + "title": { + "type": "array", + "items": {"type": "string"}, + "description": "Document title", + } + }, + "required": ["title"], + }, + ), + ), + ] + self.detector = JsonArrayParser() + + def _accumulate_tool_calls(self, tool_calls, result): + """Helper method to accumulate tool call results from parsing output.""" + if not result.calls: + return + for call in result.calls: + if call.tool_index is None: + continue + while len(tool_calls) <= call.tool_index: + tool_calls.append({"name": "", "parameters": ""}) + if call.name: + tool_calls[call.tool_index]["name"] = call.name + if call.parameters: + tool_calls[call.tool_index]["parameters"] += call.parameters + + def test_parallel_tool_calls_with_array_parameters(self): + """ + Test parsing two parallel tool calls where both have array parameters. + + This test reproduces the specific scenario: + - Two tool calls separated by comma + - Both tools have array parameters containing '[' character + - First tool completes with '}},' + - Second tool starts with '{"name": ..., "parameters": {"title": ["' + + Expected: Both tools should be parsed correctly without errors. + """ + # Simulate more realistic streaming chunks where + # the key issue is the comma separator followed by second tool with array param + chunks = [ + "[\n", + ' {"name": "search_docs", "parameters": {"title": ["7.8.9"', + '], "filename": "doc1"}},\n', + ' {"name": "search_docs", "parameters": {"title": ', + '["4.8"], "filename": "doc2"}}', + "]", + ] + + tool_calls = [] + errors = [] + + for i, chunk in enumerate(chunks): + try: + result = self.detector.parse_streaming_increment(chunk, self.tools) + # Collect tool calls + self._accumulate_tool_calls(tool_calls, result) + + except Exception as e: + errors.append(f"Chunk {i} ({repr(chunk)}): {type(e).__name__}: {e}") + + # Verify no errors occurred + if errors: + self.fail("Errors occurred during parsing:\n" + "\n".join(errors)) + + # Verify both tool calls were parsed + self.assertEqual(len(tool_calls), 2, "Should have parsed exactly 2 tool calls") + + # Verify first tool call + self.assertEqual( + tool_calls[0]["name"], + "search_docs", + "First tool name should be search_docs", + ) + params1 = json.loads(tool_calls[0]["parameters"]) + self.assertEqual(params1["title"], ["7.8.9"], "First tool title should match") + self.assertEqual( + params1["filename"], "doc1", "First tool filename should be doc1" + ) + + # Verify second tool call + self.assertEqual( + tool_calls[1]["name"], + "search_docs", + "Second tool name should be search_docs", + ) + params2 = json.loads(tool_calls[1]["parameters"]) + self.assertEqual(params2["title"], ["4.8"], "Second tool title should match") + self.assertEqual( + params2["filename"], "doc2", "Second tool filename should be doc2" + ) + + def test_simple_parallel_tool_calls(self): + """ + Test a simpler case of two parallel tool calls with array parameters. + + This is a minimal test case that still tests the core functionality. + """ + chunks = [ + "[\n", + ' {"name": "search_docs", "parameters": {"title": ["a"]}},', + "\n", + ' {"name": "search_docs", "parameters": {"title": ["b"]}}', + "]", + ] + + tool_calls = [] + + for chunk in chunks: + result = self.detector.parse_streaming_increment(chunk, self.tools) + self._accumulate_tool_calls(tool_calls, result) + + # Should parse both tools successfully + self.assertEqual(len(tool_calls), 2, "Should parse 2 tool calls") + self.assertEqual(tool_calls[0]["name"], "search_docs") + self.assertEqual(tool_calls[1]["name"], "search_docs") + + +if __name__ == "__main__": + unittest.main()