Skip to content

Commit 9847585

Browse files
committed
fix: invalid JSON tool call argument sanitization to prevent API errors
1 parent 73e7843 commit 9847585

File tree

2 files changed

+138
-1
lines changed

2 files changed

+138
-1
lines changed

src/agents/models/chatcmpl_converter.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@
4343
from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message
4444
from openai.types.responses.response_reasoning_item import Content, Summary
4545

46+
from .. import _debug
4647
from ..agent_output import AgentOutputSchemaBase
4748
from ..exceptions import AgentsException, UserError
4849
from ..handoffs import Handoff
4950
from ..items import TResponseInputItem, TResponseOutputItem
51+
from ..logger import logger
5052
from ..model_settings import MCPToolChoice
5153
from ..tool import FunctionTool, Tool
5254
from .fake_id import FAKE_RESPONSES_ID
@@ -55,6 +57,44 @@
5557

5658

5759
class Converter:
60+
@classmethod
61+
def _sanitize_tool_call_arguments(cls, arguments: str | None, tool_name: str) -> str:
62+
"""
63+
Validates and sanitizes tool call arguments JSON string.
64+
65+
If the arguments are invalid JSON, returns "{}" as a safe default.
66+
This prevents API errors when invalid JSON is stored in session history
67+
and later sent to APIs that strictly validate JSON (like Anthropic via litellm).
68+
69+
Args:
70+
arguments: The raw arguments string from the tool call.
71+
tool_name: The name of the tool (for logging purposes).
72+
73+
Returns:
74+
Valid JSON string. If input is invalid, returns "{}".
75+
"""
76+
if not arguments:
77+
return "{}"
78+
79+
try:
80+
# Validate JSON by parsing it
81+
json.loads(arguments)
82+
return arguments
83+
except (json.JSONDecodeError, TypeError):
84+
# If invalid JSON, return empty object as safe default
85+
# This prevents API errors while maintaining compatibility
86+
if _debug.DONT_LOG_TOOL_DATA:
87+
logger.debug(
88+
f"Invalid JSON in tool call arguments for {tool_name}, sanitizing to '{{}}'"
89+
)
90+
else:
91+
truncated_args = arguments[:100] if len(arguments) > 100 else arguments
92+
logger.debug(
93+
f"Invalid JSON in tool call arguments for {tool_name}: "
94+
f"{truncated_args}, sanitizing to '{{}}'"
95+
)
96+
return "{}"
97+
5898
@classmethod
5999
def convert_tool_choice(
60100
cls, tool_choice: Literal["auto", "required", "none"] | str | MCPToolChoice | None
@@ -524,7 +564,9 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
524564
pending_thinking_blocks = None # Clear after using
525565

526566
tool_calls = list(asst.get("tool_calls", []))
527-
arguments = func_call["arguments"] if func_call["arguments"] else "{}"
567+
arguments = cls._sanitize_tool_call_arguments(
568+
func_call.get("arguments"), func_call["name"]
569+
)
528570
new_tool_call = ChatCompletionMessageFunctionToolCallParam(
529571
id=func_call["call_id"],
530572
type="function",

tests/test_openai_chatcompletions_converter.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,101 @@ def test_tool_call_conversion():
379379
assert tool_call["function"]["arguments"] == function_call["arguments"] # type: ignore
380380

381381

382+
def test_tool_call_with_valid_json_arguments():
383+
"""
384+
Test that valid JSON arguments pass through unchanged.
385+
"""
386+
function_call = ResponseFunctionToolCallParam(
387+
id="tool1",
388+
call_id="abc",
389+
name="test_tool",
390+
arguments='{"key": "value", "number": 42}',
391+
type="function_call",
392+
)
393+
394+
messages = Converter.items_to_messages([function_call])
395+
tool_call = messages[0]["tool_calls"][0] # type: ignore
396+
assert tool_call["function"]["arguments"] == '{"key": "value", "number": 42}' # type: ignore
397+
398+
399+
def test_tool_call_with_invalid_json_arguments():
400+
"""
401+
Test that invalid JSON arguments are sanitized to "{}".
402+
This prevents API errors when invalid JSON is stored in session history
403+
and later sent to APIs that strictly validate JSON (like Anthropic via litellm).
404+
"""
405+
# Test with missing closing brace (common case)
406+
function_call = ResponseFunctionToolCallParam(
407+
id="tool1",
408+
call_id="abc",
409+
name="test_tool",
410+
arguments='{"key": "value"', # Missing closing brace
411+
type="function_call",
412+
)
413+
414+
messages = Converter.items_to_messages([function_call])
415+
tool_call = messages[0]["tool_calls"][0] # type: ignore
416+
# Invalid JSON should be sanitized to "{}"
417+
assert tool_call["function"]["arguments"] == "{}" # type: ignore
418+
419+
# Test with None
420+
function_call_none = ResponseFunctionToolCallParam(
421+
id="tool2",
422+
call_id="def",
423+
name="test_tool",
424+
arguments=None, # type: ignore
425+
type="function_call",
426+
)
427+
428+
messages_none = Converter.items_to_messages([function_call_none])
429+
tool_call_none = messages_none[0]["tool_calls"][0] # type: ignore
430+
assert tool_call_none["function"]["arguments"] == "{}" # type: ignore
431+
432+
# Test with empty string
433+
function_call_empty = ResponseFunctionToolCallParam(
434+
id="tool3",
435+
call_id="ghi",
436+
name="test_tool",
437+
arguments="",
438+
type="function_call",
439+
)
440+
441+
messages_empty = Converter.items_to_messages([function_call_empty])
442+
tool_call_empty = messages_empty[0]["tool_calls"][0] # type: ignore
443+
assert tool_call_empty["function"]["arguments"] == "{}" # type: ignore
444+
445+
446+
def test_tool_call_with_malformed_json_variants():
447+
"""
448+
Test various malformed JSON cases are all sanitized correctly.
449+
"""
450+
malformed_cases = [
451+
'{"key": "value"', # Missing closing brace
452+
'{"key": "value"}}', # Extra closing brace
453+
'{"key": "value",}', # Trailing comma
454+
'{"key":}', # Missing value
455+
'{key: "value"}', # Unquoted key
456+
'{"key": "value"', # Missing closing brace (different position)
457+
"not json at all", # Not JSON at all
458+
]
459+
460+
for i, malformed_json in enumerate(malformed_cases):
461+
function_call = ResponseFunctionToolCallParam(
462+
id=f"tool{i}",
463+
call_id=f"call{i}",
464+
name="test_tool",
465+
arguments=malformed_json,
466+
type="function_call",
467+
)
468+
469+
messages = Converter.items_to_messages([function_call])
470+
tool_call = messages[0]["tool_calls"][0] # type: ignore
471+
# All malformed JSON should be sanitized to "{}"
472+
assert tool_call["function"]["arguments"] == "{}", ( # type: ignore
473+
f"Malformed JSON '{malformed_json}' should be sanitized to '{{}}'"
474+
)
475+
476+
382477
@pytest.mark.parametrize("role", ["user", "system", "developer"])
383478
def test_input_message_with_all_roles(role: str):
384479
"""

0 commit comments

Comments
 (0)