[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