#4336·camel

[BUG] Sync streaming ChatAgent raises an uncaught TimeoutError instead of warning when a streamed tool call exceeds tool_execution_timeout

Author: BlueX888Created Sep 15, 2026Updated Sep 15, 2026

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 generator of pre-built chunks.
python
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

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):

python
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:

  1. Save the snippet above and run it with the interpreter that has camel installed.
  2. Observe the sync streaming path raising TimeoutError out of agent.step().
  3. Run the equivalent astep() version with the same chunks and tool_execution_timeout=0.2 (async path), and observe it logs a warning and completes.

Traceback

pytb
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 unfinished

Minimal 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 unfinished

Expected 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:

  1. 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."
  2. The sibling async implementation does exactly that: _execute_tools_async_with_status_accumulator (camel/agents/chat_agent.py:6023, timeout handling at 6056-6100) wraps every task in asyncio.wait_for(timeout=self.tool_execution_timeout) and catches asyncio.TimeoutError to log Function timed out after {N} seconds and continue. Running the async counterpart with the same chunks and the same tool_execution_timeout=0.2 produces:
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)
  1. The sync branch's own except concurrent.futures.TimeoutError: at chat_agent.py:5081 (which logs the warning and calls future.cancel()) records the author's intent for this path.
  2. CPython documents that concurrent.futures.as_completed(..., timeout=...) raises TimeoutError from the iterator, not from future.result().

Additional context

Root cause (camel/agents/chat_agent.py:5066):

python
# 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.