diff --git a/camel/agents/chat_agent.py b/camel/agents/chat_agent.py index ca172d4d8d..25e988998e 100644 --- a/camel/agents/chat_agent.py +++ b/camel/agents/chat_agent.py @@ -4036,7 +4036,16 @@ def _handle_batch_response( for tool_call in tool_calls: tool_name = tool_call.function.name # type: ignore[union-attr] tool_call_id = tool_call.id - args = json.loads(tool_call.function.arguments) # type: ignore[union-attr] + try: + args = json.loads(tool_call.function.arguments) # type: ignore[union-attr] + except json.JSONDecodeError: + # `arguments` may be empty or truncated on the wire. + args = {} + if not isinstance(args, dict): + # `arguments` may also be valid JSON that is not an + # object (`null`, `[]`, a bare scalar), which `args` + # cannot hold any more than a malformed payload. + args = {} extra_content = getattr(tool_call, 'extra_content', None) tool_call_request = ToolCallRequest( diff --git a/test/agents/test_chat_agent.py b/test/agents/test_chat_agent.py index da43a41611..638f865f56 100644 --- a/test/agents/test_chat_agent.py +++ b/test/agents/test_chat_agent.py @@ -44,6 +44,7 @@ BaseModelBackend, ModelFactory, OpenAIModel, + StubModel, ) from camel.terminators import ResponseWordsTerminator from camel.toolkits import ( @@ -2371,3 +2372,74 @@ def test_rate_limit_retry_respects_anthropic_error_when_installed(): from openai import RateLimitError as OpenAIRateLimitError assert _RATE_LIMIT_ERRORS == (OpenAIRateLimitError,) + + +@pytest.mark.parametrize( + "arguments", + ["", '{"city": "San Fra', "null", "[]", "123", '"ping"'], + ids=["empty", "truncated", "null", "list", "number", "string"], +) +def test_chat_agent_step_tolerates_unusable_tool_arguments(arguments): + r"""A tool call whose `arguments` string cannot fill `args` is executed. + + OpenAI-compatible servers (vLLM, Ollama, Together, ...) send + `arguments: ""` for zero-argument tools, and arguments can be truncated + on the wire. A payload may also be well-formed JSON that is not an + object (`null`, `[]`, a bare scalar), which `ToolCallRequest.args` cannot + hold either. The streaming path lets none of them escape, so the + non-streaming path must not either. + """ + + def ping() -> str: + r"""A zero-argument tool.""" + return "pong" + + model_backend_rsp_tool = ChatCompletion( + id='mock_id_123456', + choices=[ + Choice( + finish_reason='tool_calls', + index=0, + logprobs=None, + message=ChatCompletionMessage( + content=None, + role='assistant', + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id='call_mock_123456', + function=Function( + arguments=arguments, + name='ping', + ), + type='function', + ), + ], + ), + ) + ], + created=1730752528, + model='stub', + object='chat.completion', + usage=CompletionUsage( + completion_tokens=5, + prompt_tokens=10, + total_tokens=15, + ), + ) + + model = StubModel(ModelType.STUB, model_config_dict={"stream": False}) + agent = ChatAgent( + system_message="You are a help assistant.", + model=model, + tools=[ping], + max_iteration=1, + ) + agent.model_backend.run = MagicMock(return_value=model_backend_rsp_tool) + + response = agent.step("Ping the tool.") + + tool_calls = response.info['tool_calls'] + assert len(tool_calls) == 1 + assert tool_calls[0].tool_name == 'ping' + assert tool_calls[0].args == {} + assert tool_calls[0].result == 'pong'