[BUG] Sync streaming ChatAgent raises an uncaught TimeoutError instead of warning when a streamed tool call exceeds tool_execution_timeout
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
runis replaced by a generator of pre-built chunks.
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.91a7Problem description
When a ChatAgent runs in streaming mode (sync step(), i.e. model_config_dict={'stream': True}) and a streamed tool call takes longer than tool_execution_timeout, the whole step aborts with an uncaught concurrent.futures.TimeoutError that propagates out of agent.step(). The intended behaviour — log a warning and continue without a tool result — never runs, and no ToolCallingRecord is recorded for that call.
The async counterpart (astep() / _astream_response()) handles the identical input correctly: it logs Function timed out after 0.2 seconds and completes the step normally. So sync and async disagree on the same input.
The constructor documents tool_execution_timeout as a per-tool budget:
camel/agents/chat_agent.py:441-442
tool_execution_timeout (Optional[float], optional): Timeout
for individual tool execution. If None, wait indefinitely.Reproducible example code
The Python snippet (self-contained, no network — the backend run is monkeypatched to yield two ChatCompletionChunks that carry a single slow_tool call):
import time
from unittest.mock import MagicMock
from openai.types.chat.chat_completion_chunk import (
ChatCompletionChunk,
Choice as ChunkChoice,
ChoiceDelta,
ChoiceDeltaToolCall,
ChoiceDeltaToolCallFunction,
)
from camel.agents import ChatAgent
from camel.models import StubModel
from camel.types import ModelType
from camel.toolkits import FunctionTool
def slow_tool() -> str:
"""A tool that takes far longer than the configured timeout."""
time.sleep(2.0)
return "done"
model = StubModel(ModelType.STUB, model_config_dict={"stream": True})
chunks = [
ChatCompletionChunk(
id="mock_stream_tool",
choices=[
ChunkChoice(
delta=ChoiceDelta(
role="assistant",
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_1",
type="function",
function=ChoiceDeltaToolCallFunction(
name="slow_tool", arguments="{}"
),
)
],
),
index=0,
finish_reason=None,
)
],
created=1234567890,
model="gpt-5-mini",
object="chat.completion.chunk",
),
ChatCompletionChunk(
id="mock_stream_tool",
choices=[
ChunkChoice(
delta=ChoiceDelta(),
index=0,
finish_reason="tool_calls",
)
],
created=1234567890,
model="gpt-5-mini",
object="chat.completion.chunk",
),
]
def mock_stream():
for chunk in chunks:
yield chunk
model.run = MagicMock(return_value=mock_stream())
agent = ChatAgent(
system_message="You are a helpful assistant.",
model=model,
tools=[FunctionTool(slow_tool)],
tool_execution_timeout=0.2,
)
try:
responses = list(agent.step("use the tool"))
print("OK: completed, last info:", responses[-1].info.get("finish_reasons"))
except Exception as e: # noqa: BLE001
import concurrent.futures
print(f"RAISED {type(e).__module__}.{type(e).__name__}: {e!r}")
print("futures.TimeoutError:", isinstance(e, concurrent.futures.TimeoutError))Extra dependencies:
openai (already a camel dependency)Steps to reproduce:
- Save the snippet above and run it with the interpreter that has camel installed.
- Observe the sync streaming path raising
TimeoutErrorout ofagent.step(). - Run the equivalent
astep()version with the same chunks andtool_execution_timeout=0.2(async path), and observe it logs a warning and completes.
Traceback
RAISED builtins.TimeoutError: TimeoutError('1 (of 1) futures unfinished')
futures.TimeoutError: True
File ".../camel/agents/chat_agent.py", line 5066, in _execute_tools_sync_with_status_accumulator
for future in concurrent.futures.as_completed(
TimeoutError: 1 (of 1) futures unfinishedMinimal CPython check showing the exception originates from the for statement itself, so the inner except future.result() handler never sees it:
OUTER from for-statement: concurrent.futures._base.TimeoutError 1 (of 1) futures unfinishedExpected behavior
A tool that exceeds tool_execution_timeout should be logged and skipped, and step() should complete — exactly as the async branch does. The sync branch already contains the handler that was clearly written for this case (logger.warning("Function '...' timed out after N seconds") + future.cancel()), it is just unreachable for a timeout.
Concrete basis:
- The public constructor docstring defines the option as a per-tool budget:
camel/agents/chat_agent.py:441-442— "Timeout for individual tool execution. If None, wait indefinitely." - The sibling async implementation does exactly that:
_execute_tools_async_with_status_accumulator(camel/agents/chat_agent.py:6023, timeout handling at6056-6100) wraps every task inasyncio.wait_for(timeout=self.tool_execution_timeout)and catchesasyncio.TimeoutErrorto logFunction timed out after {N} secondsandcontinue. Running the async counterpart with the same chunks and the sametool_execution_timeout=0.2produces:
2026-09-15 18:29:38,668 - camel.camel.agents.chat_agent - WARNING - Function timed out after 0.2 seconds
OK (async path completed without raising)- The sync branch's own
except concurrent.futures.TimeoutError:atchat_agent.py:5081(which logs the warning and callsfuture.cancel()) records the author's intent for this path. - CPython documents that
concurrent.futures.as_completed(..., timeout=...)raisesTimeoutErrorfrom the iterator, not fromfuture.result().
Additional context
Root cause (camel/agents/chat_agent.py:5066):
# Wait for all futures to complete (or timeout)
for future in concurrent.futures.as_completed(
futures_map.keys(),
timeout=self.tool_execution_timeout
if self.tool_execution_timeout
else None,
):
function_name, tool_call_data = futures_map[future]
try:
tool_call_record = future.result() # <- no timeout argument
...
except concurrent.futures.TimeoutError: # line 5081 - unreachable for a timeout
logger.warning(...)
future.cancel()The timeout is handed to as_completed(), but future.result() is called without one. CPython raises the TimeoutError from the as_completed iterator itself (at the for statement), which is outside the try; the except concurrent.futures.TimeoutError at line 5081 only guards future.result() and therefore never fires. The exception then propagates out of the generator through _stream_response/_stream and out of agent.step(). Because the handler never runs, future.cancel() is not called and no tool result is recorded. The async path is unaffected.
Fix sketch (not a full patch; happy to open a PR if that's useful): give each future its own deadline instead of relying on the iterator, e.g. future.result(timeout=max(0.0, deadline - time.monotonic())), or move the try/except so it wraps the for future in as_completed(..., timeout=...) statement, logging + cancelling and then skipping the remaining work — matching _execute_tools_async_with_status_accumulator.
Reachability: ChatAgent(model=<backend with model_config_dict={'stream': True}>, tools=[FunctionTool(slow_tool)], tool_execution_timeout=0.2) then step() → _stream (4371) → _stream_response (4450) → _process_stream_chunks_with_accumulator (4694, call site 4771) → _execute_tools_sync_with_status_accumulator (5016) → as_completed at 5066.
Related issue/PR references: searched the tracker for tool_execution_timeout, as_completed, _execute_tools_sync, TimeoutError, tool timeout — no existing report of this bug. The closest closed issues are #3782 (feature request: stream + tool call + structured output) and #4239/#4243, which are unrelated event-loop problems.
Happy to open a PR with the approach above if you'd like — 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 commands shown.
Source: camel-ai/camel