[Bugfix] Guard tool call argument JSON parsing in chat message postprocessing (#48922)

Signed-off-by: VBS2004 <venkatbalaji2004@gmail.com> Co-authored-by: Ben Browning <56071+bbrowning@users.noreply.github.com>

Venkat Balaji S committed Aug 27, 2026 at 01:56 UTC 161ffd37d207e7def333f07f650e90c67b8bd43b
2 files changed +154 -3
tests/entrypoints/unit_tests/test_chat_utils.py
+117
@@ -2806,3 +2806,120 @@ async def test_resolve_items_does_not_leak_tasks_on_partial_failure():
2806 f"resolve_items left {len(leaked_tasks)} task(s) running after "
2807 f"raising: {leaked_tasks}"
2808 )
2809 +
2810 +
2811 +def _assistant_tool_call(arguments, name="write"):
2812 + return [
2813 + {
2814 + "role": "assistant",
2815 + "content": "",
2816 + "tool_calls": [
2817 + {
2818 + "id": "call_0",
2819 + "type": "function",
2820 + "function": {"name": name, "arguments": arguments},
2821 + }
2822 + ],
2823 + }
2824 + ]
2825 +
2826 +
2827 +def _assistant_tool_calls(calls):
2828 + return [
2829 + {
2830 + "role": "assistant",
2831 + "content": "",
2832 + "tool_calls": calls,
2833 + }
2834 + ]
2835 +
2836 +
2837 +def test_tool_call_arguments_dict_passthrough(caplog):
2838 + messages = _assistant_tool_call({"a": 1})
2839 + _postprocess_messages(messages)
2840 + args = messages[0]["tool_calls"][0]["function"]["arguments"]
2841 + assert args == {"a": 1}
2842 + assert not caplog.records
2843 +
2844 +
2845 +def test_tool_call_arguments_valid_object_string(caplog):
2846 + messages = _assistant_tool_call('{"a": 1}')
2847 + _postprocess_messages(messages)
2848 + args = messages[0]["tool_calls"][0]["function"]["arguments"]
2849 + assert args == {"a": 1}
2850 + assert not caplog.records
2851 +
2852 +
2853 +def test_tool_call_arguments_malformed_json_small(caplog):
2854 + bad = '{"cmd": "mkdir -p /tmp/mmadtest && cat > /tmp/mmadtest'
2855 + messages = _assistant_tool_call(bad, name="exec")
2856 + _postprocess_messages(messages)
2857 + args = messages[0]["tool_calls"][0]["function"]["arguments"]
2858 + assert args == {}
2859 +
2860 + assert len(caplog.records) == 1
2861 + assert "exec" in caplog.records[0].message
2862 + assert "coercing to an empty object" in caplog.records[0].message
2863 +
2864 +
2865 +def test_tool_call_arguments_malformed_json_large(caplog):
2866 + bad = '{"filepath": "src/main.py", "contents": "' + ("x" * 18000)
2867 + messages = _assistant_tool_call(bad, name="write")
2868 + _postprocess_messages(messages)
2869 + args = messages[0]["tool_calls"][0]["function"]["arguments"]
2870 + assert args == {}
2871 +
2872 + assert len(caplog.records) == 1
2873 + assert "write" in caplog.records[0].message
2874 + assert "coercing to an empty object" in caplog.records[0].message
2875 +
2876 +
2877 +@pytest.mark.parametrize("non_obj", ["[]", "42", "true", '"hello"', "null"])
2878 +def test_tool_call_arguments_valid_json_non_object(caplog, non_obj):
2879 + messages = _assistant_tool_call(non_obj, name="bad_tool")
2880 + _postprocess_messages(messages)
2881 + args = messages[0]["tool_calls"][0]["function"]["arguments"]
2882 + assert args == {}
2883 +
2884 + if non_obj == "null":
2885 + # null parses to None, so no warning is emitted according to requirements
2886 + assert not caplog.records
2887 + else:
2888 + assert len(caplog.records) == 1
2889 + assert "bad_tool" in caplog.records[0].message
2890 + assert "not a JSON object" in caplog.records[0].message
2891 +
2892 +
2893 +@pytest.mark.parametrize("missing", [None, ""])
2894 +def test_tool_call_arguments_missing_or_empty(caplog, missing):
2895 + messages = _assistant_tool_call(missing)
2896 + if missing is None:
2897 + del messages[0]["tool_calls"][0]["function"]["arguments"]
2898 + _postprocess_messages(messages)
2899 + args = messages[0]["tool_calls"][0]["function"]["arguments"]
2900 + assert args == {}
2901 + assert not caplog.records
2902 +
2903 +
2904 +def test_tool_call_arguments_multiple_independent(caplog):
2905 + calls = [
2906 + {
2907 + "id": "call_0",
2908 + "type": "function",
2909 + "function": {"name": "good", "arguments": '{"a": 1}'},
2910 + },
2911 + {
2912 + "id": "call_1",
2913 + "type": "function",
2914 + "function": {"name": "bad", "arguments": '{"cmd": "'},
2915 + },
2916 + ]
2917 + messages = _assistant_tool_calls(calls)
2918 + _postprocess_messages(messages)
2919 +
2920 + tool_calls = messages[0]["tool_calls"]
2921 + assert tool_calls[0]["function"]["arguments"] == {"a": 1}
2922 + assert tool_calls[1]["function"]["arguments"] == {}
2923 +
2924 + assert len(caplog.records) == 1
2925 + assert "bad" in caplog.records[0].message
vllm/entrypoints/chat_utils.py
+37 -3
@@ -1944,9 +1944,43 @@ def _postprocess_messages(messages: list[ConversationMessage]) -> None:
1944
1945 # if arguments is None or empty string, set to {}
1946 if content := function.get("arguments"):
1947 - if not isinstance(content, (dict, list)):
1948 - parsed = json.loads(content)
1949 - function["arguments"] = parsed if parsed is not None else {}
1947 + if isinstance(content, dict):
1948 + parsed = content
1949 + else:
1950 + if isinstance(content, str):
1951 + try:
1952 + parsed = json.loads(content)
1953 + except json.JSONDecodeError:
1954 + # A malformed `arguments` string lives in
1955 + # conversation history, so failing the request
1956 + # here would fail every subsequent turn too and
1957 + # leave the conversation unrecoverable. Coerce
1958 + # to an empty object so the turn can proceed.
1959 + logger.warning(
1960 + "Tool call %r has arguments that are not valid "
1961 + "JSON (%d chars); coercing to an empty object "
1962 + "so the conversation can continue.",
1963 + function.get("name"),
1964 + len(content),
1965 + )
1966 + parsed = None
1967 + else:
1968 + parsed = content
1969 +
1970 + if not isinstance(parsed, dict):
1971 + if parsed is not None:
1972 + # Valid JSON, but not an object (e.g. "[]",
1973 + # "42", "true").
1974 + # Chat templates require a mapping.
1975 + logger.warning(
1976 + "Tool call %r arguments decoded to %s, not a "
1977 + "JSON object; coercing to an empty object.",
1978 + function.get("name"),
1979 + type(parsed).__name__,
1980 + )
1981 + parsed = {}
1982 +
1983 + function["arguments"] = parsed
1984 else:
1985 function["arguments"] = {}
1986