# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Tests for the engine-based Qwen3 tool call parser. These validate that the engine-driven parser correctly handles Qwen3 XML-style tool calls. """ import json from unittest.mock import MagicMock import pytest from tests.parser.engine.conftest import make_mock_tokenizer from tests.parser.engine.streaming_helpers import ( collect_content, collect_function_name, collect_tool_arguments, simulate_tool_streaming, ) from vllm.parser.engine.parser_engine import ParserEngine from vllm.parser.qwen3 import ( TOOL_CALL_END, TOOL_CALL_START, qwen3_config, ) @pytest.fixture def mock_tokenizer(): return make_mock_tokenizer( { TOOL_CALL_START: 100, TOOL_CALL_END: 101, } ) @pytest.fixture def parser(mock_tokenizer): return ParserEngine( mock_tokenizer, parser_engine_config=qwen3_config(thinking=False), ) class TestNonStreaming: def test_no_tool_calls(self, parser, mock_request): result = parser.extract_tool_calls( "This is a regular response without any tool calls.", mock_request, ) assert result.tools_called is False assert result.tool_calls == [] assert result.content == ("This is a regular response without any tool calls.") def test_single_tool_call(self, parser, mock_request): text = ( "\n" "\n" "Tokyo\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert len(result.tool_calls) == 1 assert result.tool_calls[0].function.name == "get_weather" args = json.loads(result.tool_calls[0].function.arguments) assert args == {"city": "Tokyo"} def test_parallel_tool_calls(self, parser, mock_request): text = ( "\n" "\n" "Tokyo\n" "\n" "" "\n" "\n" "Asia/Tokyo\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert len(result.tool_calls) == 2 assert result.tool_calls[0].function.name == "get_weather" assert result.tool_calls[1].function.name == "get_time" args0 = json.loads(result.tool_calls[0].function.arguments) assert args0 == {"city": "Tokyo"} args1 = json.loads(result.tool_calls[1].function.arguments) assert args1 == {"timezone": "Asia/Tokyo"} def test_various_data_types(self, parser, mock_request): text = ( "\n\n" "hello\n" "42\n" "3.14\n" "true\n" "null\n" '["a", "b", "c"]\n' '{"nested": "value"}\n' "\n" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True args = json.loads(result.tool_calls[0].function.arguments) assert args["string_field"] == "hello" assert args["int_field"] == "42" assert args["float_field"] == "3.14" assert args["bool_field"] == "true" assert args["null_field"] == "null" assert args["array_field"] == '["a", "b", "c"]' assert args["object_field"] == '{"nested": "value"}' def test_empty_arguments(self, parser, mock_request): text = "\n\n\n" result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert result.tool_calls[0].function.name == "refresh" args = json.loads(result.tool_calls[0].function.arguments) assert args == {} def test_surrounding_text(self, parser, mock_request): text = ( "Let me check the weather for you.\n\n" "\n\n" "Tokyo\n" "\n\n\n" "I will get that information." ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert result.content is not None assert "Let me check the weather" in result.content assert result.tool_calls[0].function.name == "get_weather" def test_escaped_strings(self, parser, mock_request): text = ( "\n\n" 'He said "hello"\n' "C:\\Users\\file.txt\n" "line1\nline2\n" "\n" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True args = json.loads(result.tool_calls[0].function.arguments) assert args["quoted"] == 'He said "hello"' assert args["path"] == "C:\\Users\\file.txt" assert args["newline"] == "line1\nline2" def test_multiple_parameters(self, parser, mock_request): text = ( "\n\n" "vllm parsing\n" "10\n" "false\n" "\n" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True args = json.loads(result.tool_calls[0].function.arguments) assert args == { "query": "vllm parsing", "limit": "10", "exact_match": "false", } def test_multiline_param_values(self, parser, mock_request): """Parameter values spanning multiple lines.""" text = ( "\n" "\n" "\n" "ls -la /tmp\n" "\n" "\n" "List files in /tmp directory\n" "\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert len(result.tool_calls) == 1 assert result.tool_calls[0].function.name == "Bash" args = json.loads(result.tool_calls[0].function.arguments) assert args["command"] == "ls -la /tmp" assert args["description"] == "List files in /tmp directory" def test_multiline_two_tool_calls(self, parser, mock_request): """Two tool calls with multi-line parameter values (bug report).""" text = ( "\n" "\n" "\n" "find /workspace -name '*.py' | head -20\n" "\n" "\n" "Find Python files\n" "\n" "\n" "" "\n" "\n" "\n" "/workspace/main.py\n" "\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert len(result.tool_calls) == 2 assert result.tool_calls[0].function.name == "Bash" assert result.tool_calls[1].function.name == "Read" args0 = json.loads(result.tool_calls[0].function.arguments) assert "find /workspace" in args0["command"] assert "Find Python files" in args0["description"] args1 = json.loads(result.tool_calls[1].function.arguments) assert "/workspace/main.py" in args1["file_path"] def test_consecutive_tool_calls_without_tool_end(self, parser, mock_request): text = ( "\n" "\n" "Tokyo\n" "\n" "\n" "\n" "Paris\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True assert len(result.tool_calls) == 2 args0 = json.loads(result.tool_calls[0].function.arguments) assert args0 == {"city": "Tokyo"} args1 = json.loads(result.tool_calls[1].function.arguments) assert args1 == {"city": "Paris"} def test_nested_json_array_parameter(self, parser, mock_request): text = ( "\n" "\n" "" '[{"question": "Pick a color",' ' "multiSelect": false, "answer": null}]' "\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) assert result.tools_called is True args = json.loads(result.tool_calls[0].function.arguments) assert args == { "questions": '[{"question": "Pick a color",' ' "multiSelect": false, "answer": null}]', } class TestStreaming: def test_basic_streaming(self, parser, mock_request): chunks = [ "\n", "\n", "Tokyo", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) name = collect_function_name(results) assert name == "get_weather" args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert parsed == {"city": "Tokyo"} def test_streaming_multi_param(self, parser, mock_request): chunks = [ "\n", "\n", "Tokyo\n", "celsius\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) name = collect_function_name(results) assert name == "get_weather" args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert parsed == {"city": "Tokyo", "unit": "celsius"} def test_streaming_args_arrive_incrementally(self, parser, mock_request): """Arguments must stream as intermediate deltas, not batch at tool-end.""" chunks = [ "\n", "\n", "Tokyo\n", "celsius\n", "5\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) arg_deltas: list[str] = [] for delta, _ in results: if delta and delta.tool_calls: for tc in delta.tool_calls: if tc.function and tc.function.arguments: arg_deltas.append(tc.function.arguments) assert len(arg_deltas) > 1, ( f"Expected arguments across multiple deltas, got {len(arg_deltas)}: " f"{arg_deltas}" ) concatenated = "".join(arg_deltas) parsed = json.loads(concatenated) assert parsed == {"city": "Tokyo", "unit": "celsius", "days": "5"} def test_streaming_long_string_arg_before_parameter_end(self, parser, mock_request): """Long string arguments should stream before the closing parameter tag.""" chunks = [ "\n", "\n", "", "Artificial intelligence has rapidly transformed the way ", "developers build dynamic applications with external tools.", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) pre_close_arg_deltas: list[str] = [] all_arg_deltas: list[str] = [] for idx, (delta, _) in enumerate(results): if delta and delta.tool_calls: for tc in delta.tool_calls: if tc.function and tc.function.arguments: all_arg_deltas.append(tc.function.arguments) if idx < 5: pre_close_arg_deltas.append(tc.function.arguments) assert len(pre_close_arg_deltas) > 1, ( "Expected long string arguments to stream incrementally before " f", got {pre_close_arg_deltas}" ) partial_args = "".join(pre_close_arg_deltas) assert partial_args.startswith('{"content": "Artificial intelligence') assert partial_args.endswith("external tools.") assert not partial_args.endswith('"}') all_args = "".join(all_arg_deltas) assert json.loads(all_args) == { "content": ( "Artificial intelligence has rapidly transformed the way " "developers build dynamic applications with external tools." ) } def test_streaming_text_before_tool(self, parser, mock_request): chunks = [ "Let me check ", "the weather. ", "\n", "\n", "Tokyo\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) assert collect_content(results).strip().startswith("Let me check") def test_streaming_empty_args(self, parser, mock_request): chunks = [ "\n", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) name = collect_function_name(results) assert name == "refresh" def test_streaming_split_parameter_tag(self, parser, mock_request): """Parameter tag split across chunks.""" chunks = [ "\n", "\n", "Alice", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) name = collect_function_name(results) assert name == "test" args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert parsed["name"] == "Alice" def test_streaming_split_next_parameter_tag_is_buffered(self, parser, mock_request): """A split opening parameter tag must not leak into previous value.""" chunks = [ "\n", "\n", "hello ", "10\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) args_after_partial_tag = collect_tool_arguments(results[:4]) assert "\n", "\n", "42\n", "true\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) args_text = collect_tool_arguments(results) if args_text: parsed = json.loads(args_text) assert parsed["count"] == "42" assert parsed["active"] == "true" def test_streaming_parallel_calls(self, parser, mock_request): chunks = [ "\n", "\n", "Tokyo\n", "\n", "", "\n", "\n", "JST\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) names = [] for delta, _ in results: if delta and delta.tool_calls: for tc in delta.tool_calls: if tc.function and tc.function.name: names.append(tc.function.name) assert "get_weather" in names assert "get_time" in names def test_streaming_value_split_across_chunks(self, parser, mock_request): """Parameter value split across multiple chunks.""" chunks = [ "\n", "\n", "hello ", "world", " test\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert parsed["query"] == "hello world test" def test_streaming_split_tool_call_tag(self, parser, mock_request): """ arrives as a single special token; the rest of the content is split into fine-grained chunks.""" chunks = [ "\n", "\n", "1", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) name = collect_function_name(results) assert name == "test" args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert parsed["x"] == "1" def test_char_by_char_streaming(self, mock_request): """Feed text character-by-character to test lexer robustness. Uses a tokenizer without special token IDs because char-by-char delivery only occurs when the tokenizer splits the tag across multiple sub-word tokens (i.e., no dedicated special token). """ tokenizer = MagicMock() tokenizer.encode.return_value = [1, 2, 3] tokenizer.get_vocab.return_value = {} tokenizer.decode.side_effect = lambda ids: "".join( chr(i) if i < 128 else f"<{i}>" for i in ids ) no_tid_parser = ParserEngine( tokenizer, parser_engine_config=qwen3_config(thinking=False) ) full_text = ( "\n" "\n" "hi\n" "\n" "" ) chunks = list(full_text) results = simulate_tool_streaming(no_tid_parser, mock_request, chunks) name = collect_function_name(results) assert name == "echo" args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert parsed == {"msg": "hi"} def test_streaming_multiline_param_values(self, parser, mock_request): """Multi-line parameter values in streaming mode.""" chunks = [ "\n", "\n", "\n", "ls -la /tmp\n", "\n", "\n", "List files\n", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) name = collect_function_name(results) assert name == "Bash" args_text = collect_tool_arguments(results) assert args_text parsed = json.loads(args_text) assert "ls -la /tmp" in parsed["command"] assert "List files" in parsed["description"] def test_streaming_multiline_two_tool_calls(self, parser, mock_request): """Two tool calls with multi-line values — matches bug report.""" chunks = [ "\n", "\n", "\n", "find /workspace -name '*.py' | head -20\n", "\n", "\n", "Find Python files\n", "\n", "\n", "", "\n", "\n", "\n", "/workspace/main.py\n", "\n", "\n", "", ] results = simulate_tool_streaming(parser, mock_request, chunks) names = [] for delta, _ in results: if delta and delta.tool_calls: for tc in delta.tool_calls: if tc.function and tc.function.name: names.append(tc.function.name) assert "Bash" in names assert "Read" in names class TestArgConverter: """Direct tests for the Qwen3 arg_converter with multi-line values.""" def test_multiline_param_values(self): from vllm.parser.qwen3 import ( _qwen3_arg_converter, ) raw = ( "\n" "ls -la /tmp\n" "\n" "\n" "List files\n" "\n" ) result = json.loads(_qwen3_arg_converter(raw, partial=False)) assert result["command"] == "ls -la /tmp" assert result["description"] == "List files" def test_two_multiline_params(self): from vllm.parser.qwen3 import ( _qwen3_arg_converter, ) raw = ( "\nfoo\nbar\n\n" "\nbaz\nqux\n\n" ) result = json.loads(_qwen3_arg_converter(raw, partial=False)) assert result["a"] == "foo\nbar" assert result["b"] == "baz\nqux" def test_partial_multiline(self): from vllm.parser.qwen3 import ( _qwen3_arg_converter, ) raw = "\nls -la\n\npartial value" result = json.loads(_qwen3_arg_converter(raw, partial=True)) assert result["command"] == "ls -la" assert result["desc"] == "partial value" def test_partial_value_with_angle_bracket(self): from vllm.parser.qwen3 import ( _qwen3_arg_converter, ) raw = "x<5" result = json.loads(_qwen3_arg_converter(raw, partial=True)) assert result == {"expr": "x<5"} def test_partial_value_with_angle_bracket_and_complete_param(self): from vllm.parser.qwen3 import ( _qwen3_arg_converter, ) raw = "Tokyo\nx<5" result = json.loads(_qwen3_arg_converter(raw, partial=True)) assert result == {"city": "Tokyo", "expr": "x<5"} class TestSchemaAwareTypeCoercion: """Verify that _fix_arg_types corrects miscoerced values using the tool schema.""" @pytest.fixture def tools(self): from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) return [ ChatCompletionToolsParam( type="function", function={ "name": "TaskUpdate", "parameters": { "type": "object", "properties": { "taskId": {"type": "string"}, "count": {"type": "integer"}, "ratio": {"type": "number"}, "flag": {"type": "string"}, }, }, }, ) ] @pytest.fixture def parser_with_tools(self, mock_tokenizer, tools): return ParserEngine( mock_tokenizer, tools=tools, parser_engine_config=qwen3_config(thinking=False), ) def test_string_param_not_coerced_to_int(self, parser_with_tools, mock_request): text = ( "\n" "\n" "1\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) assert args["taskId"] == "1" assert isinstance(args["taskId"], str) def test_string_param_not_coerced_to_bool(self, parser_with_tools, mock_request): text = ( "\n" "\n" "true\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["flag"] == "true" assert isinstance(args["flag"], str) def test_int_param_still_coerced(self, parser_with_tools, mock_request): text = ( "\n" "\n" "42\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["count"] == 42 assert isinstance(args["count"], int) def test_no_tools_keeps_strings(self, parser, mock_request): text = ( "\n" "\n" "1\n" "\n" "" ) result = parser.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["taskId"] == "1" assert isinstance(args["taskId"], str) def test_streaming_string_param_not_coerced(self, parser_with_tools, mock_request): chunks = [ "\n", "\n", "1\n", "\n", "", ] results = simulate_tool_streaming(parser_with_tools, mock_request, chunks) args_str = collect_tool_arguments(results) args = json.loads(args_str) assert args["taskId"] == "1" assert isinstance(args["taskId"], str) class TestAnyOfTypeCoercion: """Verify that _fix_arg_types handles union types (anyOf/oneOf).""" @pytest.fixture def tools_with_anyof(self): from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) return [ ChatCompletionToolsParam( type="function", function={ "name": "set_config", "parameters": { "type": "object", "properties": { "port": { "anyOf": [ {"type": "string"}, {"type": "null"}, ], }, "count": {"type": "integer"}, }, }, }, ) ] @pytest.fixture def parser_with_anyof(self, mock_tokenizer, tools_with_anyof): return ParserEngine( mock_tokenizer, tools=tools_with_anyof, parser_engine_config=qwen3_config(thinking=False), ) def test_anyof_string_param_not_coerced(self, parser_with_anyof, mock_request): """A param with anyOf including 'string' must not be coerced to integer.""" text = ( "\n" "\n" "8080\n" "\n" "" ) result = parser_with_anyof.extract_tool_calls(text, mock_request) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) assert args["port"] == "8080" class TestSchemaCoercionBoolNumberNull: """Verify that _fix_arg_types coerces string values to non-string schema types using coerce_to_schema_type.""" @pytest.fixture def tools(self): from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) return [ ChatCompletionToolsParam( type="function", function={ "name": "configure", "parameters": { "type": "object", "properties": { "enabled": {"type": "boolean"}, "ratio": {"type": "number"}, "count": {"type": "integer"}, "value": {"type": ["integer", "null"]}, "label": {"type": "string"}, }, }, }, ) ] @pytest.fixture def parser_with_tools(self, mock_tokenizer, tools): return ParserEngine( mock_tokenizer, tools=tools, parser_engine_config=qwen3_config(thinking=False), ) def test_bool_param_coerced(self, parser_with_tools, mock_request): text = ( "\n" "\n" "true\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["enabled"] is True assert isinstance(args["enabled"], bool) def test_number_param_whole_normalized(self, parser_with_tools, mock_request): text = ( "\n" "\n" "5.0\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["ratio"] == 5 assert isinstance(args["ratio"], int) def test_number_param_fractional(self, parser_with_tools, mock_request): text = ( "\n" "\n" "3.14\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["ratio"] == pytest.approx(3.14) assert isinstance(args["ratio"], float) def test_null_coerced_when_in_schema(self, parser_with_tools, mock_request): text = ( "\n" "\n" "null\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["value"] is None def test_null_stays_string_without_null_schema( self, parser_with_tools, mock_request ): text = ( "\n" "\n" "null\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["label"] == "null" assert isinstance(args["label"], str) def test_streaming_bool_param_coerced(self, parser_with_tools, mock_request): chunks = [ "\n", "\n", "true\n", "\n", "", ] results = simulate_tool_streaming(parser_with_tools, mock_request, chunks) args_str = collect_tool_arguments(results) args = json.loads(args_str) assert args["enabled"] is True assert isinstance(args["enabled"], bool) def test_streaming_number_param_coerced(self, parser_with_tools, mock_request): chunks = [ "\n", "\n", "3.14\n", "\n", "", ] results = simulate_tool_streaming(parser_with_tools, mock_request, chunks) args_str = collect_tool_arguments(results) args = json.loads(args_str) assert args["ratio"] == pytest.approx(3.14) assert isinstance(args["ratio"], float) def test_streaming_matches_non_streaming_comprehensive( self, parser_with_tools, mock_request ): text = ( "\n" "\n" "true\n" "5.0\n" "42\n" "null\n" "hello\n" "\n" "" ) non_stream = parser_with_tools.extract_tool_calls(text, mock_request) ns_args = json.loads(non_stream.tool_calls[0].function.arguments) chunks = [line + "\n" for line in text.split("\n") if line] results = simulate_tool_streaming(parser_with_tools, mock_request, chunks) s_args = json.loads(collect_tool_arguments(results)) assert s_args == ns_args assert ns_args == { "enabled": True, "ratio": 5, "count": 42, "value": None, "label": "hello", } class TestNestedSchemaCoercion: """Verify that _fix_arg_types recurses into nested objects and arrays.""" @pytest.fixture def tools(self): from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) return [ ChatCompletionToolsParam( type="function", function={ "name": "search", "parameters": { "type": "object", "properties": { "query": {"type": "string"}, "filters": { "type": "object", "properties": { "language": {"type": "string"}, "min_stars": {"type": "integer"}, }, }, "tags": { "type": "array", "items": {"type": "string"}, }, "limits": { "type": "array", "items": {"type": "integer"}, }, "verbose": {"type": "boolean"}, }, }, }, ), ChatCompletionToolsParam( type="function", function={ "name": "AskUserQuestion", "parameters": { "type": "object", "properties": { "questions": { "type": "array", "items": { "type": "object", "properties": { "question": {"type": "string"}, "multiSelect": { "type": "boolean", }, "answer": { "type": ["string", "null"], }, }, }, }, }, }, }, ), ] @pytest.fixture def parser_with_tools(self, mock_tokenizer, tools): return ParserEngine( mock_tokenizer, tools=tools, parser_engine_config=qwen3_config(thinking=False), ) def test_nested_object_coerced(self, parser_with_tools, mock_request): text = ( "\n" "\n" '{"language": "python",' ' "min_stars": 100}\n' "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["filters"] == {"language": "python", "min_stars": 100} assert isinstance(args["filters"]["min_stars"], int) def test_nested_array_items_coerced(self, parser_with_tools, mock_request): text = ( "\n" "\n" "[10, 20, 30]\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["limits"] == [10, 20, 30] assert all(isinstance(v, int) for v in args["limits"]) def test_nested_string_array_not_coerced(self, parser_with_tools, mock_request): text = ( "\n" "\n" '["ml", "42"]\n' "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) assert args["tags"] == ["ml", "42"] assert all(isinstance(v, str) for v in args["tags"]) def test_array_of_objects_with_bool_and_null_coerced( self, parser_with_tools, mock_request ): text = ( "\n" "\n" "" '[{"question": "Pick a color",' ' "multiSelect": false, "answer": null}]' "\n" "\n" "" ) result = parser_with_tools.extract_tool_calls(text, mock_request) args = json.loads(result.tool_calls[0].function.arguments) questions = args["questions"] assert isinstance(questions, list) assert len(questions) == 1 assert questions[0]["question"] == "Pick a color" assert questions[0]["multiSelect"] is False assert questions[0]["answer"] is None def test_streaming_array_of_objects_with_bool_and_null_coerced( self, parser_with_tools, mock_request ): chunks = [ "\n", "\n", '[{"question": "Pick a color",', ' "multiSelect": false, "answer": null}]', "\n", "\n", "", ] results = simulate_tool_streaming(parser_with_tools, mock_request, chunks) args_str = collect_tool_arguments(results) args = json.loads(args_str) questions = args["questions"] assert isinstance(questions, list) assert len(questions) == 1 assert questions[0]["question"] == "Pick a color" assert questions[0]["multiSelect"] is False assert questions[0]["answer"] is None