Skip to content

[BUG] Streaming ChatAgent silently drops tool calls whose name is not a registered internal tool (hallucinated names and external_tools) #4366

Open

Description

@BlueX888

Required prerequisites

What version of camel are you using?

0.2.91a7 (from source, master @ 8c791b7b9cf7deab56cb5a92818c34499af9097f)

System information

  • camel installed from source (repo checkout at commit 8c791b7b9cf7deab56cb5a92818c34499af9097f)
  • Python 3.13.15
  • macOS (arm64), openai 2.54.0
import sys, camel
print(sys.version, sys.platform)
print(camel.__version__)
# 3.13.15 (main, Aug 25 2026, 13:50:12) [Clang 22.1.3] darwin
# 0.2.91a7

Problem description

With model_config_dict={"stream": True}, a streamed tool call whose function name is not in the agent's internal tools is silently dropped. The step executes nothing, records nothing to memory (memory keeps only the system + user messages, leaving a dangling user message), surfaces no external_tool_requests, and yields 0 responses — after iteration, StreamingChatAgentResponse.msgs == [] and .info == {}.

This affects two everyday cases:

  1. The model hallucinates or mistypes a tool name.
  2. The model calls a tool registered via external_tools= / add_external_tool(). Its schema is sent to the model (_get_full_tool_schemas, camel/agents/chat_agent.py:954-961), and the ChatAgent docstring says such tools should be returned directly to the caller (chat_agent.py:418-422: "When these tools are called, the agent will directly return the request instead of processing it") — but in streaming mode the call vanishes.

Non-streaming step() on the same input behaves correctly, which shows the streaming path diverges from the intended behaviour:

  • An unknown name produces a Tool execution failed: Tool 'x' not found in registered tools tool-result message recorded to memory so the model can recover (_execute_tool, chat_agent.py:4117-4123).
  • An external tool call is returned to the caller via info["external_tool_call_requests"] (_step_impl, chat_agent.py:3077-3089).

Any user code (or society/workforce loops) that iterates agent.step() in streaming mode gets an empty stream with no error, and no way to tell a tool call was dropped.

Actual output of the snippet below (verbatim, run at commit 8c791b7b):

streaming 'hallucinated_tool': yielded 0 responses; resp.msgs=[]; resp.info={}
  schemas sent to model: ['add']
  memory: [('system', None, 'You are a helpful assistant.'), ('user', None, 'Please call the tool.')]
non-streaming 'hallucinated_tool': info[0]: {'id': 'm2', 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}, 'termination_reasons': ['stop'], 'num_tokens': 0, 'tool_calls': [ToolCallingRecord(tool_name='hallucinated_tool', args={'q': 1}, result="Tool execution failed: Tool 'hallucinated_tool' not found in registered tools", tool_call_id='call_1', images=None)], 'external_tool_call_requests': None}
streaming 'ext_tool': yielded 0 responses; resp.msgs=[]; resp.info={}
  schemas sent to model: ['ext_tool', 'add']
  memory: [('system', None, 'You are a helpful assistant.'), ('user', None, 'Please call the tool.')]
non-streaming 'ext_tool': info[0]: {'id': 'm1', 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}, 'termination_reasons': ['tool_calls'], 'num_tokens': 0, 'tool_calls': [], 'external_tool_call_requests': [ToolCallRequest(tool_name='ext_tool', args={'x': 5}, tool_call_id='call_1', extra_content=None)]}

Two independent verifications of a fuller script (same commit) observed the same behaviour, e.g.:

B hallucinated tool, streaming: responses yielded: 0 | resp.msgs==[] info=={} | memory: [('system',...),('user', None, 'Please call the tool.')]
C hallucinated tool, non-streaming: info[0]: tool_calls=[('hallucinated_tool', "Tool execution failed: Tool 'hallucinated_tool' not found in registere")]
D external tool, streaming: responses yielded: 0 | resp.msgs==[] info=={} | schemas sent to model: ['add', 'ext_tool'] | memory: [('system',...),('user',...)]
D' external tool, non-streaming: info[0]: external_tool_call_requests=[ToolCallRequest(tool_name='ext_tool', args={'x': 5}, tool_call_id='call_1', extra_content=None)]
A internal tool, streaming+content: responses yielded: 2, info[1]: tool_calls=[('add', '3')], memory has assistant 'add' + tool result '3'

i.e. streaming works fine for an internal tool name (A), and only non-internal names are dropped (B, D).

Reproducible example code

The Python snippets:

import os
import warnings

warnings.filterwarnings("ignore")
# model.run is fully mocked below; the key only passes the constructor check
os.environ.setdefault("OPENAI_API_KEY", "sk-dummy-not-used")

from unittest.mock import MagicMock

from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_chunk import (
    ChatCompletionChunk,
    ChoiceDelta,
)
from openai.types.chat.chat_completion_chunk import (
    Choice as ChunkChoice,
    ChoiceDeltaToolCall,
    ChoiceDeltaToolCallFunction,
)
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.chat.chat_completion_message_tool_call import (
    ChatCompletionMessageToolCall,
    Function,
)

from camel.agents import ChatAgent
from camel.models import ModelFactory
from camel.toolkits import FunctionTool
from camel.types import ModelPlatformType, ModelType


class _DummyTokenCounter:
    r"""Offline stand-in so no tiktoken download happens."""

    def count_tokens_from_messages(self, messages, **kwargs):
        return 0

    def count_tokens(self, text):
        return 0

    def encode(self, text):
        return []


def make_model(stream):
    model = ModelFactory.create(
        model_platform=ModelPlatformType.OPENAI,
        model_type=ModelType.GPT_5_MINI,
        model_config_dict={"stream": stream},
    )
    model._token_counter = _DummyTokenCounter()
    return model


def tool_call_chunks(name, args_json):
    r"""Stream a single tool call: the name first, then the arguments."""
    return [
        ChatCompletionChunk(
            id="m1",
            choices=[
                ChunkChoice(
                    delta=ChoiceDelta(
                        role="assistant",
                        tool_calls=[
                            ChoiceDeltaToolCall(
                                index=0,
                                id="call_1",
                                type="function",
                                function=ChoiceDeltaToolCallFunction(
                                    name=name, arguments=""
                                ),
                            )
                        ],
                    ),
                    index=0,
                    finish_reason=None,
                )
            ],
            created=1234567890,
            model="gpt-5-mini",
            object="chat.completion.chunk",
        ),
        ChatCompletionChunk(
            id="m1",
            choices=[
                ChunkChoice(
                    delta=ChoiceDelta(
                        tool_calls=[
                            ChoiceDeltaToolCall(
                                index=0,
                                function=ChoiceDeltaToolCallFunction(
                                    arguments=args_json
                                ),
                            )
                        ]
                    ),
                    index=0,
                    finish_reason="tool_calls",
                )
            ],
            created=1234567890,
            model="gpt-5-mini",
            object="chat.completion.chunk",
        ),
    ]


def tool_call_completion(name, args_json):
    return ChatCompletion(
        id="m1",
        choices=[
            Choice(
                index=0,
                finish_reason="tool_calls",
                message=ChatCompletionMessage(
                    role="assistant",
                    content="",
                    tool_calls=[
                        ChatCompletionMessageToolCall(
                            id="call_1",
                            type="function",
                            function=Function(name=name, arguments=args_json),
                        )
                    ],
                ),
            )
        ],
        created=1234567890,
        model="gpt-5-mini",
        object="chat.completion",
    )


def plain_completion(text):
    return ChatCompletion(
        id="m2",
        choices=[
            Choice(
                index=0,
                finish_reason="stop",
                message=ChatCompletionMessage(role="assistant", content=text),
            )
        ],
        created=1234567891,
        model="gpt-5-mini",
        object="chat.completion",
    )


def memory_of(agent):
    messages = agent.memory.get_context()[0]
    return [
        (
            m["role"],
            (m.get("tool_calls") or [{}])[0].get("function", {}).get("name"),
            m.get("content"),
        )
        for m in messages
    ]


def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b


def ext_tool(x: int) -> int:
    """External tool: doubles x."""
    return 2 * x


def stream_step(tool_name, args_json, external_tools=None):
    model = make_model(stream=True)

    def mock_run(*a, **k):
        def gen():
            for chunk in tool_call_chunks(tool_name, args_json):
                yield chunk

        return gen()

    model.run = MagicMock(side_effect=mock_run)
    agent = ChatAgent(
        system_message="You are a helpful assistant.",
        model=model,
        tools=[add],
        external_tools=external_tools,
        stream_accumulate=False,
    )
    schemas = [s["function"]["name"] for s in agent._get_full_tool_schemas()]
    resp = agent.step("Please call the tool.")
    finals = [r for r in resp]
    print(
        f"streaming {tool_name!r}: yielded {len(finals)} responses; "
        f"resp.msgs={resp.msgs}; resp.info={resp.info}"
    )
    print(f"  schemas sent to model: {schemas}")
    print(f"  memory: {memory_of(agent)}")


def nonstream_step(tool_name, args_json, external_tools=None):
    model = make_model(stream=False)
    calls = {"n": 0}

    def mock_run(*a, **k):
        calls["n"] += 1
        if calls["n"] == 1:
            return tool_call_completion(tool_name, args_json)
        return plain_completion("all done")

    model.run = MagicMock(side_effect=mock_run)
    agent = ChatAgent(
        system_message="You are a helpful assistant.",
        model=model,
        tools=[add],
        external_tools=external_tools,
    )
    resp = agent.step("Please call the tool.")
    info = resp.info if isinstance(resp.info, list) else [resp.info]
    print(f"non-streaming {tool_name!r}: info[0]: {info[0]}")


# 1) hallucinated tool name, streaming -> dropped
stream_step("hallucinated_tool", '{"q": 1}')
# 2) same input, non-streaming -> error result recorded, model can recover
nonstream_step("hallucinated_tool", '{"q": 1}')
# 3) external tool, streaming -> schema is sent but the call is dropped
stream_step("ext_tool", '{"x": 5}', external_tools=[FunctionTool(ext_tool)])
# 4) same input, non-streaming -> request returned to the caller
nonstream_step(
    "ext_tool", '{"x": 5}', external_tools=[FunctionTool(ext_tool)]
)

Command lines:

python repro.py

Extra dependencies:

None beyond camel's own dependencies (the openai SDK is already a camel dependency; the model backend is fully mocked, so no API key, account, or network access is needed).

Steps to reproduce:

  1. Run the snippet above.
  2. Observe the two streaming ... blocks: 0 responses yielded, resp.msgs=[], resp.info={}, and memory keeps only the system + user messages — for both the hallucinated name and the external tool (whose schema was sent to the model).
  3. Compare with the two non-streaming ... blocks on the same input: the hallucinated name yields a recorded error tool-result the model can recover from, and the external tool yields external_tool_call_requests.

Traceback

No traceback — nothing raises. The step silently yields 0 responses and ends; that silence is the bug.

Expected behavior

For a streamed tool call whose name is not a registered internal tool, streaming mode should behave like the non-streaming path on the same input:

  • Hallucinated/unknown names: record Tool 'x' not found in registered tools as a tool result (as _execute_tool does at chat_agent.py:4117-4123) so the model can recover, instead of dropping the call and ending the step with 0 responses and a dangling user message in memory.
  • External tools (name in _external_tool_schemas): return the request to the caller instead of processing it, per the ChatAgent docstring (chat_agent.py:418-422) and _step_impl (chat_agent.py:3077-3089), i.e. surface it via external_tool_requests in the streamed response's info rather than discarding it.

The basis is camel's own code: the streaming executor _execute_tool_from_stream_data already contains the same unknown-tool branch as the non-streaming _execute_tool (chat_agent.py:5202-5207 sync, 5366-5371 async — "Tool 'x' not found in registered tools" recorded as a result), and external tool schemas are deliberately sent to the model (chat_agent.py:954-961). Both intentions are defeated in streaming mode by the gate described below.

Additional context

Root cause: _accumulate_tool_calls only marks a streamed tool call complete when tool_call_entry['function']['name'] in self._internal_tools (camel/agents/chat_agent.py:4999-5003). A call for any other name (hallucinated or external) is accumulated but never marked complete, so:

  • _execute_tools_sync_with_status_accumulator only queues entries with complete=True (chat_agent.py:5029-5034) and returns without executing or yielding anything;
  • _stream_response then takes the "Stream completed without tool calls" branch (chat_agent.py:4552-4555), clears the accumulated calls, and breaks — the generator ends having yielded 0 responses and recorded nothing to memory;
  • the unknown-tool error branch inside _execute_tool_from_stream_data (chat_agent.py:5202-5207 sync, 5366-5371 async) is unreachable from this path;
  • and every streamed response hardcodes "external_tool_requests": None (_create_streaming_response_with_accumulator, chat_agent.py:6153-6168), so external calls could not be surfaced even if they completed.

Suggested direction (not a full patch): in _accumulate_tool_calls, remove the name in self._internal_tools condition from the completeness gate (complete = id + name + JSON-parseable arguments), so unknown names flow into _execute_tool_from_stream_data's existing unknown-tool branch and get the same recovery behaviour as non-streaming; separately, in the streaming loop, treat complete calls whose name is in _external_tool_schemas like _step_impl does — record the assistant tool_calls message, break the loop, and surface them via external_tool_requests in the final streamed response's info.

I'm happy to open a PR with this approach if maintainers agree on the direction.

Related: I searched the tracker and discussions and found no existing report of this. Closest items are #3782 (feature request: stream + tool call + structured output) and #2494 (closed feature request: streaming support in ChatAgent) — neither covers tool calls being dropped.

AI disclosure: this report was drafted with AI assistance (reproduction script, observed outputs, and code-path analysis); every output quoted above is from an actual run of the snippet at commit 8c791b7b.

Activity

  1. BlueX888 commented on Sep 23, 2026

    @BlueX888
    Author

    Reproduced this. Root cause: camel/agents/chat_agent.py:4999-5003 - _accumulate_tool_calls only marked a streamed tool call complete when its function name was in self._internal_tools, so a call for any other name (hallucinated or external) was never marked complete, never executed, and the streaming step ended with 0 responses and nothing recorded to memory.

    Minimal reproduction:

    python repro.py
    

    Observed output:

    streaming 'hallucinated_tool': yielded 0 responses; resp.msgs=[]; resp.info={}
    non-streaming 'hallucinated_tool': info[0]: {'id': 'm2', 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}, 'termination_reasons': ['stop'], 'num_tokens': 0, 'tool_calls': [ToolCallingRecord(tool_name='hallucinated_tool', args={'q': 1}, result="Tool execution failed: Tool 'hallucinated_tool' not found in registered tools", tool_call_id='call_1', images=None)], 'external_tool_call_requests': None}
    streaming 'ext_tool': yielded 0 responses; resp.msgs=[]; resp.info={}
    non-streaming 'ext_tool': info[0]: {'id': 'm1', 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}, 'termination_reasons': ['tool_calls'], 'num_tokens': 0, 'tool_calls': [], 'external_tool_call_requests': [ToolCallRequest(tool_name='ext_tool', args={'x': 5}, tool_call_id='call_1', extra_content=None)]}
    

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

  2. impartshadow commented on Sep 24, 2026

    @impartshadow

    Streaming ChatAgent.step() has a silent-drop failure: a tool call with a valid shape but an unrecognized disposition is neither executed, errored, nor returned, and the stream ends empty with no signal.

    My hypothesis, from the issue text alone since I haven't traced the code: the streaming tool-call path only branches on "name is in the internal tools". Anything else falls through to the loop's exit with nothing yielded. The non-streaming path has two other branches, the Tool 'x' not found error result (_execute_tool) and the external_tool_call_requests return (_step_impl), and the streaming path appears to lack both. That fits the reported symptoms: 0 responses, info == {}, and memory "leaving a dangling user message" with no assistant or tool record. The accumulated tool-call buffer was probably consumed and then discarded.

    The check that would have caught this is a disposition invariant at the boundary where the stream's tool-call accumulator is finalized. Every tool call the model emitted must end in exactly one of executed, error-recorded, or external-request. Assert len(dispositions) == len(accumulated_calls), and assert that a stream that ended with finish_reason == "tool_calls" yields at least one response. As a test, run the same mocked model output through step() with stream=False and stream=True for three names (registered, hallucinated, external). Assert the same tool_calls and external_tool_call_requests and the same memory records. Right now that parity test fails on two of the three.

    I'm uncertain about where the drop happens: the accumulator, the dispatch, or the final yield. A debug print of the accumulated calls just before dispatch would settle it. If the calls are already missing there, the bug is upstream in chunk assembly and not in dispatch.

    Advisory SFA-2026-37976A693F · Full redacted write-up: https://impartshadow.github.io/echo-site/failure-cases/bug-streaming-chatagent-silently-drops-tool-calls-wh-37976a693f.html
    If this diagnosis is useful, react 👍 to this comment; if it is wrong, react 👎 — I use the verdict to choose what I investigate next.

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