Skip to content

fix(agents): handle non-internal tool calls in streaming mode - #4369

Open
BlueX888 wants to merge 2 commits into
camel-ai:masterfrom
BlueX888:fix/prep-streaming-drops-non-internal-tool-calls
Open

BlueX888 wants to merge 2 commits into
camel-ai:masterfrom
BlueX888:fix/prep-streaming-drops-non-internal-tool-calls

Conversation

@BlueX888

Copy link
Copy Markdown

Related Issue

Closes #4366

Description

In streaming mode, a tool call whose function name is not in _internal_tools was silently dropped: the streaming step executed nothing, recorded nothing to memory, surfaced no external_tool_requests, and yielded 0 responses. This affected both hallucinated tool names and tools registered via external_tools= / add_external_tool(), whose schemas are sent to the model (_get_full_tool_schemas, camel/agents/chat_agent.py:954) and which the ChatAgent docstring says should be returned directly to the caller instead of processed.

Root cause: _accumulate_tool_calls only marked a streamed tool call complete when its function name was in self._internal_tools (pre-fix camel/agents/chat_agent.py:4999-5003). A call for any other name was accumulated but never marked complete, so:

  • the sync executor queued only complete=True entries and returned without executing or yielding anything;
  • the streaming loop then took the "Stream completed without tool calls" branch, cleared the accumulated calls, and ended — 0 responses yielded, nothing recorded to memory (a dangling user message);
  • the unknown-tool branch inside _execute_tool_from_stream_data (pre-fix camel/agents/chat_agent.py:5202 sync, 5366 async), which records a recoverable Tool 'x' not found in registered tools tool result just like the non-streaming _execute_tool (camel/agents/chat_agent.py:4117-4123), was unreachable;
  • external tool calls could never be surfaced because every streamed response hardcoded "external_tool_requests": None.

Changes (all in camel/agents/chat_agent.py, sync and async paths kept in lockstep):

  • _accumulate_tool_calls (chat_agent.py:4915): a tool call is now complete when it has id + name + JSON-parseable arguments, regardless of the name being a registered internal tool. Unknown names now flow into the existing unknown-tool branch of _execute_tool_from_stream_data (and its async twin), which records a recoverable error tool result and lets the model continue, matching the non-streaming path.
  • Sync and async executors (chat_agent.py:5036, 6080): complete calls whose name is in _external_tool_schemas are collected as ToolCallRequest objects instead of being executed. The sync executor now records the assistant message with all complete tool calls before its "nothing to execute" early return (the async executor already did), so an all-external turn still records the assistant tool-call message.
  • The sync and async stream chunk processors (chat_agent.py:4708, 5865) thread the collected requests through and no longer emit a final content response when external tool calls were requested (that response would duplicate the one carrying the requests).
  • _stream_response / _astream_response (chat_agent.py:4533, 5596): when external tool calls were requested, yield a final response carrying info["external_tool_requests"] and end the step so the caller can execute them, mirroring _step_impl (camel/agents/chat_agent.py:3077-3118). Mixed turns (internal + external tools in one response) execute and record the internal tools, then surface the external requests and stop — same as non-streaming.

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 in this PR
  • I have updated the tests accordingly (required for a bug fix or a new feature)
  • I have updated the documentation if needed — no documentation change needed: no public API change, streaming now follows the external-tools behavior the docs already describe
  • I have added examples if this is a new feature — not a new feature

Testing

Added 4 regression tests to test/agents/test_chat_agent.py (sync + async, hallucinated name / external tool). They are fully offline: the model backend is mocked and only a dummy OPENAI_API_KEY is needed for the constructor-time check.

New tests on unpatched master (8c791b7b) — all 4 fail, reproducing the bug:

$ OPENAI_API_KEY=dummy python -m pytest test/agents/test_chat_agent.py -k "stream_unknown_tool or stream_external_tool"
>       assert len(responses) > 0, "Streaming step dropped the unknown tool call"
E       AssertionError: Streaming step dropped the unknown tool call
E       assert 0 > 0
>       assert len(responses) > 0, "Streaming step dropped the external tool call"
E       AssertionError: Streaming step dropped the external tool call
E       assert 0 > 0
FAILED test/agents/test_chat_agent.py::test_chat_agent_stream_unknown_tool_records_error_result
FAILED test/agents/test_chat_agent.py::test_chat_agent_stream_external_tool_returns_requests
FAILED test/agents/test_chat_agent.py::test_chat_agent_async_stream_unknown_tool_records_error_result
FAILED test/agents/test_chat_agent.py::test_chat_agent_async_stream_external_tool_returns_requests
================= 4 failed, 38 deselected, 2 warnings in 1.66s =================

Same tests on this branch:

$ OPENAI_API_KEY=dummy python -m pytest test/agents/test_chat_agent.py -k "stream_unknown_tool or stream_external_tool" -v
test/agents/test_chat_agent.py::test_chat_agent_stream_unknown_tool_records_error_result PASSED [ 25%]
test/agents/test_chat_agent.py::test_chat_agent_stream_external_tool_returns_requests PASSED [ 50%]
test/agents/test_chat_agent.py::test_chat_agent_async_stream_unknown_tool_records_error_result PASSED [ 75%]
test/agents/test_chat_agent.py::test_chat_agent_async_stream_external_tool_returns_requests PASSED [100%]
================= 4 passed, 38 deselected, 2 warnings in 1.34s =================

Full module suite on this branch:

$ OPENAI_API_KEY=dummy python -m pytest test/agents/test_chat_agent.py
================== 4 failed, 38 passed, 13 warnings in 7.32s ==================

The 4 failures are pre-existing and environmental (they need a live OpenAI API key / ANTHROPIC_API_KEY; re-running them on unpatched master 8c791b7b fails identically: test_tool_calling_async, test_chat_agent_creation_methods, test_chat_agent_stream_with_structured_output, test_chat_agent_async_stream_with_structured_output). The 34 previously-passing tests still pass, plus the 4 new ones.

Lint/type checks on the changed files, using the versions pinned in .pre-commit-config.yaml:

$ 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
$ codespell camel/agents/chat_agent.py test/agents/test_chat_agent.py            # codespell 2.4.1
(no output)
$ mypy --namespace-packages -p camel -p test -p apps
(no errors in camel/agents/chat_agent.py or test/agents/test_chat_agent.py)

The reproduction script from the issue also now shows streaming matching non-streaming for every scenario: a hallucinated name records Tool 'hallucinated_tool' not found in registered tools as a tool result in memory and the model recovers on the next iteration; an external tool call is returned via external_tool_requests with the assistant tool-call message recorded and the step ended.

@coderabbitai

coderabbitai Bot commented Sep 24, 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: 137339c8-a91a-478c-8dad-c20cfa50589b

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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Comment thread camel/agents/chat_agent.py Outdated
tool_calls_to_execute.append(tool_call_data)

# Record the assistant message with ALL tool calls
self._record_assistant_tool_calls_message(accumulated_tool_calls)

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.

Ran this against 21928d4 in a clean container, python:3.12-slim, core deps only.

_execute_tools_sync_with_status_accumulator (and the async version at line 6094) call _record_assistant_tool_calls_message(accumulated_tool_calls) with no content arg, so the recorded assistant message always has empty content. The non-streaming path threads it explicitly (step(), response_content from output_messages[0].content), so a turn where the model says something before calling a tool keeps that text in memory there but not here.

Repro: mocked stream with a content delta ("Let me double that for you.") then an external tool call, through agent.step().

=== agent.chat_history (persisted memory) ===
assistant | content: '' | tool_calls: [{'id': 'call_mock_ext', ...}]

The caller-facing response does carry the content (the first yielded partial), it's only the persisted memory entry that drops it, so a later turn never sees what the model said before calling the tool.

Fix would be threading content_accumulator.get_full_content() into both call sites and passing it as content= to _record_assistant_tool_calls_message.

In streaming mode, a tool call whose function name is not in
_internal_tools was never marked complete, so it was silently
dropped: unknown tools produced no tool-result or error message in
memory and the step yielded no responses, and external tool calls
were never surfaced even though their schemas are sent to the model.

- _accumulate_tool_calls no longer requires the function name to be
  a registered internal tool to mark the call complete; completeness
  is id, name and parseable arguments, so the existing unknown-tool
  branch of _execute_tool_from_stream_data (and its async twin) is
  reachable again and records a recoverable error tool result, like
  the non-streaming path.
- Streamed calls for tools in _external_tool_schemas are collected
  as ToolCallRequest objects instead of being executed, recorded in
  the assistant message, returned via info['external_tool_requests']
  in a final response, and the step ends so the caller can execute
  them, mirroring _step_impl.
The streaming tool-execution paths called
_record_assistant_tool_calls_message with no content, so the assistant
message persisted in memory for a turn with tool calls always had empty
content: text streamed before the tool call survived in the
caller-facing response but was lost in memory, while the non-streaming
path keeps it by threading the response content into the recorded
message.

- _execute_tools_sync_with_status_accumulator now receives the content
  accumulator from the stream-processing loop, like its async sibling.
- Both the sync and the async executors pass
  content_accumulator.get_full_content() as the content of the recorded
  assistant tool call message.
@BlueX888
BlueX888 force-pushed the fix/prep-streaming-drops-non-internal-tool-calls branch from 21928d4 to 4e220e8 Compare October 17, 2026 01:53
@BlueX888

Copy link
Copy Markdown
Author

Thanks @AmirF194 for the clean container verification — addressed in 4e220e8: both streaming executors now pass content=content_accumulator.get_full_content() to _record_assistant_tool_calls_message (the sync executor also receives the accumulator from _process_stream_chunks_with_accumulator, mirroring its async sibling), matching how the non-streaming path threads response_content. Added four regression tests (sync/async × internal/external tool call) that mock a content delta followed by a tool call and assert the persisted chat_history entry keeps the text; on unpatched main the internal variants reproduce exactly the empty-content behavior you showed. The branch is also rebased onto current main.

This branch has not been deployed

No deployments
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] Streaming ChatAgent silently drops tool calls whose name is not a registered internal tool (hallucinated names and external_tools)

2 participants