Skip to content

[BUG] Non-streaming ChatAgent crashes with raw JSONDecodeError on empty or truncated tool-call arguments #4338

Closed as not planned

Description

@BlueX888

What version of camel are you using?

0.2.91a7

System information

  • Installed from source (editable), commit 8c791b7b9cf7deab56cb5a92818c34499af9097f (master)
  • Python 3.12.14
  • macOS (Darwin 25.6.0)
  • No network needed for the reproduction: the model backend's run is replaced by a mock returning a pre-built ChatCompletion.
import sys, camel
print(sys.version, sys.platform)
print(camel.__version__)
3.12.14 (main, Aug 25 2026, 13:50:33) [Clang 22.1.3] darwin
0.2.91a7

Problem description

With a non-streaming model backend (the default), a tool call whose arguments string is empty or truncated raises a raw json.JSONDecodeError straight out of ChatAgent.step(). The tool is never executed, the agent loop aborts, and the caller gets a JSON decoder error with no indication that it came from a model tool call.

This is not a malformed-input edge case. OpenAI-compatible servers (vLLM, Ollama, Together, and others) commonly emit arguments: "" for zero-argument tools, and the OpenAI Chat Completions specification types function.arguments as a plain str with no JSON guarantee. camel's own streaming path treats an unparseable arguments as an expected input and falls back to the raw string; the non-streaming path does not.

The same payload is tolerated on the streaming path (ChatAgent.step() with model_config_dict={'stream': True} completes normally), so sync-non-streaming and sync-streaming disagree on identical input.

Reproducible example code

The Python snippet (self-contained, no network — model.run is monkeypatched to return a ChatCompletion carrying one tool call with the given arguments string):

from unittest.mock import MagicMock

from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice as FullChoice
from openai.types.chat.chat_completion_message_tool_call import (
    ChatCompletionMessageToolCall,
    Function,
)
from openai.types.completion_usage import CompletionUsage

from camel.agents import ChatAgent
from camel.models import StubModel
from camel.types import ModelType


def ping() -> str:
    """Zero-argument tool."""
    return "pong"


def completion(arguments: str) -> ChatCompletion:
    return ChatCompletion(
        id="mock_response_id",
        choices=[
            FullChoice(
                finish_reason="tool_calls",
                index=0,
                logprobs=None,
                message=ChatCompletionMessage(
                    role="assistant",
                    content=None,
                    tool_calls=[
                        ChatCompletionMessageToolCall(
                            id="call_1",
                            type="function",
                            function=Function(name="ping", arguments=arguments),
                        )
                    ],
                ),
            )
        ],
        created=123456789,
        model="stub",
        object="chat.completion",
        usage=CompletionUsage(
            completion_tokens=5, prompt_tokens=10, total_tokens=15
        ),
    )


for args_text in ("{}", "", '{"city": "San Fra'):
    model = StubModel(ModelType.STUB, model_config_dict={"stream": False})
    agent = ChatAgent(
        system_message="You are a helpful assistant.",
        model=model,
        tools=[ping],
        max_iteration=1,
    )
    model.run = MagicMock(return_value=completion(args_text))
    agent.model_backend.run = model.run
    try:
        resp = agent.step("ping please")
        print(f"arguments={args_text!r}: OK -> {resp.info['tool_calls']}")
    except Exception as e:  # noqa: BLE001
        print(f"arguments={args_text!r}: RAISED {type(e).__name__}: {e}")

Extra dependencies:

openai (already a camel dependency)

Steps to reproduce:

  1. Save the snippet above and run it with the interpreter that has camel installed.
  2. Observe that arguments='{}' executes the tool and returns a ToolCallingRecord.
  3. Observe that arguments='' and the truncated arguments='{"city": "San Fra' both raise json.JSONDecodeError out of agent.step().
  4. Run the same payloads against a StubModel(..., model_config_dict={"stream": True}) agent and observe the streaming path completes without raising.

Actual output (verbatim, as observed in two independent runs):

arguments='{}': OK -> [ToolCallingRecord(tool_name='ping', args={}, result='pong', tool_call_id='call_1', images=None)]
arguments='': RAISED JSONDecodeError: Expecting value: line 1 column 1 (char 0)
arguments='{"city": "San Fra': RAISED JSONDecodeError: Unterminated string starting at: line 1 column 10 (char 9)

The streaming path on the same payloads, for comparison:

== streaming ChatAgent.step() ==
arguments='': no exception; type=StreamingChatAgentResponse; msg=None; info_keys=[]; tool_calls=None

The default configuration (a ChatAgent built with no stream key at all, model_config_dict: {}) behaves like the non-streaming case above:

(default config, no `stream` key) model_config_dict: {} -> RAISED JSONDecodeError Expecting value: line 1 column 1 (char 0)

Traceback

Traceback (most recent call last):
  File "<string>", line 11, in <module>
  File ".../camel/agents/chat_agent.py", line 2957, in step
    return self._step_impl(input_message, response_format)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../camel/agents/chat_agent.py", line 3035, in _step_impl
    response = self._get_model_response(
               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../camel/agents/chat_agent.py", line 3714, in _get_model_response
    return self._handle_batch_response(response)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../camel/agents/chat_agent.py", line 4039, in _handle_batch_response
    args = json.loads(tool_call.function.arguments)  # type: ignore[union-attr]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.12/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File ".../python3.12/json/decoder.py", line 338, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File ".../python3.12/json/decoder.py", line 356, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Expected behavior

A tool call whose arguments string cannot be parsed as JSON should be surfaced as a normal, recoverable condition — the tool is invoked with the raw string (or with {} for the empty string), or the step reports a tool-call error — not abort step() with a bare json.JSONDecodeError. The streaming path already defines exactly this contract.

Concrete basis:

  1. Sibling branch that does it right: the streaming accumulator wraps the identical parse in a json.JSONDecodeError handler and falls back to the raw string — camel/agents/chat_agent.py:5049-5051:

    try:
        args = json.loads(tool_call_data['function']['arguments'])
    except json.JSONDecodeError:
        args = tool_call_data['function']['arguments']

    repeated at camel/agents/chat_agent.py:6047-6049. The guard is deliberate, and it treats a parse failure as expected input rather than an invariant violation.

  2. Repo-internal contract elsewhere: camel/agents/_utils.py:130-134 catches json.JSONDecodeError and returns None with a logged error; camel/agents/chat_agent.py:5006-5011 annotates the same failure with the comment "Arguments not complete yet"; camel/agents/chat_agent.py:2096 catches (json.JSONDecodeError, ValueError, TypeError) for tool-call arguments.

  3. The OpenAI specification types the field as an unvalidated string. On the NON-streaming ChatCompletionMessageToolCall.function.arguments field (openai/openai-openapi openapi.yaml, L34499-34506): "The arguments to call the function with, as generated by the model in JSON format. Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. Validate the arguments in your code before calling your function." The field is typed string with no JSON guarantee, so an empty or truncated arguments is a documented expected input.

Additional context

Root cause (camel/agents/chat_agent.py:4039, in _handle_batch_response):

args = json.loads(tool_call.function.arguments)  # type: ignore[union-attr]

This is the only unguarded json.loads over a model-supplied arguments string in the non-streaming batch path. _handle_batch_response is called from _get_model_response (chat_agent.py:3714), which is called from _step_impl (chat_agent.py:3035), so the JSONDecodeError escapes ChatAgent.step() before any tool is executed and before any ToolCallingRecord is recorded. Every sibling site that parses the same field — the two streaming accumulators at 5049-5051 and 6047-6049, and _utils.extract_tool_call at 115-133 — already catches json.JSONDecodeError.

Reachability: any ChatAgent(tools=[...], model=<non-streaming OpenAI-compatible backend>) (the default configuration) whose endpoint returns arguments: "" for a zero-argument tool, or a truncated argument string, hits line 4039 on the first tool call.

Fix sketch (not a full patch; happy to open a PR if that's useful): mirror the streaming guard at line 4039,

try:
    args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
    args = tool_call.function.arguments   # or {} for the empty string

which brings the non-streaming path in line with chat_agent.py:5049-5053 and 6047-6050.

Related issue/PR references: searched the tracker for JSONDecodeError, arguments, empty arguments, tool_call arguments parse, vLLM tool on both issues and PRs — no existing report of this bug. The closest hits are #2098 ("[BUG] vLLM tool schema doesn't accept strict") and #1827 ("[BUG] Verify and fix Vllm tool call"), both closed and unrelated to argument parsing; #1976 is a closed feature request about FunctionCallingMessage.to_openai_assistant_message. There is an unrelated open issue in the same file, #4336, about the streaming tool_execution_timeout path (_execute_tools_sync_with_status_accumulator, line 5066) — it does not touch _handle_batch_response and does not overlap with this fix.

Happy to open a PR with the approach above once this is confirmed — or happy to be assigned.

AI assistance disclosure: this report was drafted with AI assistance and reviewed/edited by a human; the reproduction was executed and the output above is real, unedited output from the runs shown.

Activity

  1. BlueX888 commented on Sep 16, 2026

    @BlueX888
    Author

    Reproduced this. Root cause: camel/agents/chat_agent.py:4039 -- _handle_batch_response parses tool-call arguments with a bare json.loads(tool_call.function.arguments), while the streaming path at camel/agents/chat_agent.py:5050 wraps the identical call in try/except json.JSONDecodeError and falls back to the raw string.

    Minimal reproduction:

    python3 repro_4338.py  # the self-contained snippet from the issue body; no network, model.run monkeypatched
    

    Observed output:

    arguments='{}': OK -> [ToolCallingRecord(tool_name='ping', args={}, result='pong', tool_call_id='call_1', images=None)]
    arguments='': RAISED JSONDecodeError: Expecting value: line 1 column 1 (char 0)
    arguments='{"city": "San Fra': RAISED JSONDecodeError: Unterminated string starting at: line 1 column 10 (char 9)
    (default config, model_config_dict={}, no stream key) arguments='': RAISED JSONDecodeError: Expecting value: line 1 column 1 (char 0)
    

    Happy to open a PR with this fix, or to be assigned if you prefer to take it.

  2. fengju0213 commented on Sep 18, 2026

    @fengju0213
    Collaborator

    Thanks for the report and PR. Surfacing errors from invalid tool-call arguments is expected behavior. We will close issue #4338 and PR #4339.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions