You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] Sync streaming ChatAgent raises an uncaught TimeoutError instead of warning when a streamed tool call exceeds tool_execution_timeout #4336
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):
Save the snippet above and run it with the interpreter that has camel installed.
Observe the sync streaming path raising TimeoutError out of agent.step().
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
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_accumulatorfor 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:
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 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)
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.
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):
# Wait for all futures to complete (or timeout)forfutureinconcurrent.futures.as_completed(
futures_map.keys(),
timeout=self.tool_execution_timeoutifself.tool_execution_timeoutelseNone,
):
function_name, tool_call_data=futures_map[future]
try:
tool_call_record=future.result() # <- no timeout argument
...
exceptconcurrent.futures.TimeoutError: # line 5081 - unreachable for a timeoutlogger.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.
Root cause is the one in the report: the timeout went to as_completed(), which raises from the iterator at the for statement, so the handler around future.result() was unreachable and the TimeoutError escaped through step().
The loop now drives the iterator with next(), catches that TimeoutError, and reports + cancels every tool still running, so the step completes and the behaviour matches _execute_tools_async_with_status_accumulator. Regression test asserts the warning and the empty record list; it fails on master with TimeoutError: 1 (of 1) futures unfinished.
One thing the patch does not change, since it is a separate decision: the executor is still joined on the way out, so a hung tool still delays the step until its thread returns. If you would rather detach it, say so and I will do it as a follow-up.
Activity
kcncb commentedon Oct 28, 2026
Fix is up: #4431.
Root cause is the one in the report: the timeout went to
as_completed(), which raises from the iterator at theforstatement, so the handler aroundfuture.result()was unreachable and theTimeoutErrorescaped throughstep().The loop now drives the iterator with
next(), catches thatTimeoutError, and reports + cancels every tool still running, so the step completes and the behaviour matches_execute_tools_async_with_status_accumulator. Regression test asserts the warning and the empty record list; it fails onmasterwithTimeoutError: 1 (of 1) futures unfinished.One thing the patch does not change, since it is a separate decision: the executor is still joined on the way out, so a hung tool still delays the step until its thread returns. If you would rather detach it, say so and I will do it as a follow-up.