Repository navigation
Description
Required prerequisites
- I have read the documentation https://camel-ai.github.io/camel/camel.html.
- I have searched the Issue Tracker and Discussions that this hasn't already been reported. (+1 or comment there if it has.)
- Consider asking first in a Discussion.
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.91a7Problem 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:
- The model hallucinates or mistypes a tool name.
- 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 theChatAgentdocstring 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 toolstool-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.pyExtra 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:
- Run the snippet above.
- 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). - 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 yieldsexternal_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 toolsas a tool result (as_execute_tooldoes atchat_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 theChatAgentdocstring (chat_agent.py:418-422) and_step_impl(chat_agent.py:3077-3089), i.e. surface it viaexternal_tool_requestsin 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_accumulatoronly queues entries withcomplete=True(chat_agent.py:5029-5034) and returns without executing or yielding anything;_stream_responsethen 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-5207sync,5366-5371async) 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
BlueX888 commentedon Sep 23, 2026
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:
Observed output:
Happy to open a PR with this fix, or to be assigned if you prefer to take it.
impartshadow commentedon Sep 24, 2026
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 founderror result (_execute_tool) and theexternal_tool_call_requestsreturn (_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 withfinish_reason == "tool_calls"yields at least one response. As a test, run the same mocked model output throughstep()withstream=Falseandstream=Truefor three names (registered, hallucinated, external). Assert the sametool_callsandexternal_tool_call_requestsand 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.