Skip to content

fix(agents): report streamed tool timeout instead of raising TimeoutError - #4337

Open
BlueX888 wants to merge 2 commits into
camel-ai:masterfrom
BlueX888:fix/prep-sync-stream-tool-timeout-escapes
Open

BlueX888 wants to merge 2 commits into
camel-ai:masterfrom
BlueX888:fix/prep-sync-stream-tool-timeout-escapes

Conversation

@BlueX888

Copy link
Copy Markdown

Related Issue

Closes #4336

Description

In streaming mode the sync path is the only one that mishandles tool_execution_timeout: when a streamed tool call runs past the deadline, an uncaught concurrent.futures.TimeoutError propagates out of agent.step() instead of the per-tool warning that _execute_tools_async_with_status_accumulator already logs for the same input.

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

for future in concurrent.futures.as_completed(      # 5066
    futures_map.keys(),
    timeout=self.tool_execution_timeout if self.tool_execution_timeout else None,
):
    ...
    try:
        tool_call_record = future.result()          # 5077 - no timeout argument
    except concurrent.futures.TimeoutError:         # 5081 - unreachable for a timeout
        logger.warning(f"Function '{function_name}' timed out after ...")
        future.cancel()

The deadline is handed to as_completed(), and CPython raises TimeoutError from that iterator itself — i.e. at the for statement, outside the try. The handler at line 5081 therefore never runs: no warning is logged, future.cancel() is never called, no ToolCallingRecord is produced, and the exception unwinds through _stream_response / _stream and out of step(). Passing timeout to as_completed still matters, which is why the fix wraps the iteration rather than removing the deadline.

For contrast, the async branch (camel/agents/chat_agent.py:6056-6100) wraps each task in asyncio.wait_for(timeout=self.tool_execution_timeout), catches asyncio.TimeoutError, logs Function timed out after {N} seconds and continues — the intended behaviour for this option, which the constructor docstring at camel/agents/chat_agent.py:441-442 documents as a per-tool budget.

Changes

  • camel/agents/chat_agent.py: wrap the as_completed(..., timeout=...) loop in try/except concurrent.futures.TimeoutError. On timeout, log the existing warning and call future.cancel() for each future still pending, matching the async branch. Completed futures are still drained and recorded as before.
  • test/agents/test_chat_agent.py: add test_chat_agent_stream_tool_timeout_is_reported_not_raised, an offline regression test (stub model yielding two pre-built chunks carrying one slow_tool call, tool_execution_timeout=0.2) asserting that step() completes, that the timeout warning is logged, and that no tool call is recorded.

Testing

Reproduced first on unpatched master (8c791b7b) with only the new test added:

$ python -m pytest test/agents/test_chat_agent.py::test_chat_agent_stream_tool_timeout_is_reported_not_raised -x -q
>           responses = list(agent.step("use the tool"))
E                           TimeoutError: 1 (of 1) futures unfinished
/.../python3.13/concurrent/futures/_base.py:239: TimeoutError
FAILED test/agents/test_chat_agent.py::test_chat_agent_stream_tool_timeout_is_reported_not_raised
1 failed, 1 warning in 1.18s

After the fix:

$ python -m pytest test/agents/test_chat_agent.py::test_chat_agent_stream_tool_timeout_is_reported_not_raised -q
1 passed, 1 warning in 1.14s

$ python -m pytest test/agents/test_chat_agent.py -q
4 failed, 35 passed, 12 warnings in 4.41s

The 4 failures (test_tool_calling_async, test_chat_agent_creation_methods, test_chat_agent_stream_with_structured_output, test_chat_agent_async_stream_with_structured_output) also fail on unpatched master in this environment — they need a live backend / real API key. With the new test added, unpatched master gives 5 failed, 34 passed, the patched tree gives 4 failed, 35 passed; no existing test changes state.

Lint and type check, using the versions the repo pins (.pre-commit-config.yaml, pyproject.toml):

$ ruff check --no-fix camel/agents/chat_agent.py test/agents/test_chat_agent.py   # ruff 0.7.4
All checks passed!
$ ruff format --check camel/agents/chat_agent.py test/agents/test_chat_agent.py
2 files already formatted
$ mypy --namespace-packages -p camel -p test
Found 113 errors in 49 files (checked 831 source files)

The mypy count is byte-identical to unpatched master in this environment (missing optional deps such as tree_sitter, jinja2, sklearn); neither changed file reports an error.

What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

Checklist

  • I have read and agree to the AI-Generated Code Policy (required)
  • I have linked this PR to an issue (required)
  • I have checked if any dependencies need to be added or updated in pyproject.toml and run uv lock — no dependency changes
  • I have updated the tests accordingly (required for a bug fix or a new feature)
  • I have updated the documentation if needed — not needed, existing behaviour only
  • I have added examples if this is a new feature — n/a

AI assistance disclosure: this patch was drafted with AI assistance and reviewed by a human; the test output quoted above was produced by running the commands shown against this branch and unpatched master.

…rror

concurrent.futures.as_completed raises TimeoutError from its iterator, so
the except clause guarding future.result() never handled a real timeout
and the exception escaped agent.step() through the streaming path. Handle
the iterator timeout and log the per-tool timeout for pending futures,
matching the async tool execution branch.
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 04cc1b99-ccd5-4620-8f42-0228d628d2f8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

logger.info(
f"Function output: {tool_call_record.result}"
)
except concurrent.futures.TimeoutError:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outer handler is the right shape and it does stop the escape. One thing that came with the move: the inner except concurrent.futures.TimeoutError here survives, and on Python 3.11+ it is not dead code, it is reachable for the wrong reason.

concurrent.futures.TimeoutError became an alias of the builtin TimeoutError in 3.11, and TimeoutError is an OSError subclass, so a tool that hits its own network deadline lands in this branch instead of the generic one below it. I ran the handler structure from this diff (not the agent itself) at 05ad013 in clean containers, with a tool that raises TimeoutError("the tool's own network deadline"):

python:3.10-slim  -> ERROR Error executing tool 'slow_tool': the tool's own network deadline
python:3.12-slim  -> WARNING Function 'slow_tool' timed out after 30.0 seconds

pyproject is >=3.10,<3.15, so both of those ship. On 3.12 the warning names a budget the tool never exceeded, and the tool's own message is dropped.

Now that the outer handler owns the budget timeout, deleting this inner one and letting except Exception report the tool's error would keep that case accurate.

@BlueX888 BlueX888 Sep 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Python facts here check out, but the conclusion does not survive the real call path. I ran it through the agent rather than a copy of the handler, and a tool that raises TimeoutError is reported as the tool's error — the branch is never entered.

_execute_tool_from_stream_data — the callable submitted to the executor at chat_agent.py:5061 — wraps the whole tool invocation in except Exception as e: (:5185) and returns a record with {"error": error_msg}, with a second except Exception at :5242 returning None. So future.result() cannot raise for a tool failure. Only a BaseException (KeyboardInterrupt, SystemExit, asyncio.CancelledError) could escape, and TimeoutError is not one. The inner except concurrent.futures.TimeoutError at :5082 is therefore unreachable from a tool error, which is presumably why it has sat there as a catch for a future.result() call that is not given a timeout.

Confirmed by driving a streamed tool that raises TimeoutError("the tool's own network deadline") with tool_execution_timeout=30.0:

WARNING camel.camel.agents.chat_agent:chat_agent.py:5190 Error executing tool
  'tool_with_own_deadline': Execution of function tool_with_own_deadline failed
  with no arguments. Error: the tool's own network deadline

The tool's message survives and no timeout is claimed. af311904 adds that as a regression test so the reporting cannot start being swallowed unnoticed. I left :5082 alone rather than deleting it, since removing genuinely unreachable code is a separate change from this fix and would be a no-op diff here — happy to drop it if you would rather have it gone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the way I checked could not have caught it: I ran the handler shape on its own rather than the executor path, so I never saw that future.result() cannot raise here.

Reading it at af311904: _execute_tools_sync_with_status_accumulator submits only _execute_tool_from_stream_data (camel/agents/chat_agent.py:5060), and that function's whole body is a single try with except Exception at :5185 and :5242 and no raise anywhere inside it. TimeoutError is an Exception, so a tool's own deadline is caught at :5185 and comes back as a record. The branch at :5082 is not reachable from a tool error, and the outer handler you added at :5092 is the one that fires. Sorry for the noise.

Separately, your reply above posted as the literal text @/tmp/reply_4337.md, so the body did not come through. I went by the commit message.

…rror

A tool that hits its own network deadline raises TimeoutError, which is an
alias of concurrent.futures.TimeoutError on Python 3.11+, so the executor
loop's inner handler looks like it would blame the agent's tool budget and
drop the tool's own message.

It cannot: _execute_tool_from_stream_data catches every Exception it can and
returns a record, so future.result() never raises and that branch is
unreachable from a tool error. This drives a streamed tool that raises
TimeoutError well inside tool_execution_timeout and pins the reporting, so
the behaviour cannot regress silently.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants