fix: native tool calls broken over OpenAI Responses API#6515
fix: native tool calls broken over OpenAI Responses API#6515theCyberTech wants to merge 7 commits into
Conversation
…tool loop
is_tool_call_list() and extract_tool_call_info() only recognized
Chat-Completions-style ({"function": {...}}), Anthropic-style
({"name", "input"}), and Gemini-style tool-call shapes. The Responses
API's function_call output items are flat dicts shaped
{"id", "name", "arguments"} with no nested "function" key and no
"input" key, so they matched none of the checks.
This caused is_tool_call_list() to misclassify a genuine tool call as
a plain text answer, so the native tool loop returned the raw
tool-call list as the agent's final output instead of executing the
tool. Even after recognizing the shape, extract_tool_call_info() would
have passed an empty arguments dict, since it only read "input" for
the dict fallback.
Verified against LLM(api="responses") with tools attached: the agent
now correctly executes the tool with the parsed arguments instead of
returning the unexecuted tool-call JSON as its answer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Regression tests for is_tool_call_list() and extract_tool_call_info()
against the Responses API's flat {"id", "name", "arguments"} dict
shape, alongside existing Chat-Completions and Bedrock/Anthropic
shapes to confirm no regression there.
Confirmed these tests fail against the pre-fix version of
agent_utils.py (3 failures matching exactly the Responses API cases)
and pass against the fix in 37087b7.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… API input items _prepare_responses_params() passed non-system messages straight through as the Responses API "input" array without converting them. That's fine for plain user/assistant text (matches the API's lenient "easy input message" shape), but Chat-Completions-style assistant messages carrying "tool_calls" and "tool"-role messages have no equivalent shape in the Responses API - it expects standalone "function_call" and "function_call_output" input items instead. Sending the raw Chat-Completions shapes gets rejected with a 400 (union-type validation failure against every Responses API input item variant). This broke every multi-turn tool-calling conversation over api="responses" that doesn't rely on auto_chain/previous_response_id (i.e. the common case: resending full history each turn instead of referencing server-side state). Added _convert_message_to_responses_input_items() to translate: - assistant + tool_calls -> one function_call item per call - tool role -> function_call_output item - everything else -> passed through unchanged Verified against a real multi-turn tool-calling run: the agent now completes the full conversation and returns the actual extracted answer instead of erroring on the second turn. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughOpenAI Responses API preparation now converts Chat Completions tool-call messages into Responses API input items. Shared utilities recognize flat Responses API tool-call objects, with regression tests covering conversion, parsing, and multi-turn conversations. ChangesResponses API tool-call compatibility
Sequence Diagram(s)sequenceDiagram
participant ChatMessages
participant OpenAICompletion
participant ResponsesAPIInput
ChatMessages->>OpenAICompletion: Chat Completions messages
OpenAICompletion->>OpenAICompletion: Convert tool calls and tool results
OpenAICompletion->>ResponsesAPIInput: function_call and function_call_output items
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Pull request overview
This PR fixes native tool-calling when using LLM(api="responses") by teaching CrewAI to correctly recognize and parse Responses API tool-call shapes and by converting Chat-Completions-style tool-call message turns into valid Responses API input items for multi-turn conversations.
Changes:
- Extend
is_tool_call_list()/extract_tool_call_info()to support Responses API’s flat{"id","name","arguments"}tool-call dict shape. - Add conversion logic so assistant
tool_callsturns andtoolrole results becomefunction_call/function_call_outputinput items for Responses API requests. - Add regression tests covering both the Responses API tool-call shape parsing and multi-turn input conversion behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| lib/crewai/src/crewai/utilities/agent_utils.py | Recognize/parse Responses API flat tool-call dicts (arguments support, tool-call list detection). |
| lib/crewai/src/crewai/llms/providers/openai/completion.py | Convert Chat-Completions tool-call turns into Responses API function_call / function_call_output input items. |
| lib/crewai/tests/utilities/test_agent_utils.py | Regression tests for Responses API tool-call list detection and tool call info extraction. |
| lib/crewai/tests/llms/openai/test_openai.py | Regression tests for converting tool-call conversations into valid Responses API inputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
_convert_message_to_responses_input_items() was annotated to return list[dict[str, Any]], but the passthrough branch returns the LLMMessage argument unchanged. Lists are invariant in mypy, so a bare LLMMessage (TypedDict) isn't assignable into a list[dict[str, Any]] return - this was flagged by CI's type-checker job across all Python versions. Widened the return type (and the local list built in the tool_calls branch) to list[dict[str, Any] | LLMMessage], matching what the function actually returns. Confirmed with a local mypy run and the full openai/agent_utils test suites (176 passed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
joaomdmoura
left a comment
There was a problem hiding this comment.
Thanks — the diagnosis is right and I verified the core fix against the framework's own message flow before reviewing:
- The flat tool-call dicts come from
_extract_function_calls_from_response(), which maps the SDK'sitem.call_idinto"id"— soextract_tool_call_info()picks up the correct correlation id, and the converter'sfunction_call.call_id↔function_call_output.call_idpairing round-trips correctly. - Both history writers (
agent_executor.py/agent_utils.py) always emit CC-shapedtool_callswithcontent: Noneand string arguments, so the converter's assumptions match everything the framework itself produces. _prepare_responses_params()is the single choke point for sync and async, so streaming is covered too.- The new
is_tool_call_list/extract_tool_call_infobranches are additive and can't change CC/Bedrock/Anthropic/Gemini outcomes.
Test quality is good: exact-shape assertions, negative-space checks (no leftover CC-only keys), and still-recognized cases for the other provider shapes.
Requesting changes for two small hardening items (inline) — both matter precisely because this PR's target is OpenAI-compatible servers, where shape drift is the norm. One nit inline as well.
On the broadened is_tool_call_list classification (any dict with name+arguments): I'd accept it as-is — it's the same risk class as the pre-existing Bedrock name+input check, and only provider paths that return Python lists are exposed. Tightening isn't possible without changing the producer; if we want it airtight later, add "type": "function_call" to _extract_function_calls_from_response()'s dicts and check it — follow-up, not a blocker.
Missing test coverage matching the two inline items: assistant message with both content and tool_calls, and dict-valued arguments reaching the converter.
| """ | ||
| role = message.get("role") | ||
|
|
||
| if role == "assistant" and message.get("tool_calls"): |
There was a problem hiding this comment.
Assistant text is dropped when a message carries both content and tool_calls.
This branch returns only function_call items, discarding any assistant text. The framework's own history writers always set content: None, so no in-framework impact — but this is a general converter on the public LLM.call(messages=...) path, and Chat-Completions-style histories from external callers legitimately carry assistant text alongside tool calls ("I'll fetch that page…"). That text now silently disappears from the model's context.
Cheap fix — emit the text item first:
if role == "assistant" and message.get("tool_calls"):
items: list[dict[str, Any] | LLMMessage] = []
if message.get("content"):
items.append({"role": "assistant", "content": message["content"]})
for tool_call in message["tool_calls"]:
...Plus a test for the combined shape.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/crewai/tests/llms/openai/test_openai.py`:
- Around line 1009-1037: Update
test_openai_responses_api_preserves_assistant_content_with_tool_calls to include
a realistic top-level id on the fixture’s tool call, then assert that the
generated Responses API function_call preserves that exact ID as call_id instead
of only checking a generated call_ prefix. Keep the existing assistant content
and arguments assertions unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 789a2591-bbd4-4854-8c91-b67fbcd45cfb
📒 Files selected for processing (3)
lib/crewai/src/crewai/llms/providers/openai/completion.pylib/crewai/src/crewai/utilities/agent_utils.pylib/crewai/tests/llms/openai/test_openai.py
🚧 Files skipped from review as they are similar to previous changes (1)
- lib/crewai/src/crewai/utilities/agent_utils.py
Summary
Native tool calling was silently broken for any
LLM(api="responses")setup with tools attached — three separate, compounding bugs in the same code path:is_tool_call_list()/extract_tool_call_info()(crewai/utilities/agent_utils.py) didn't recognize the Responses API's flat{"id", "name", "arguments"}tool-call dict shape (only Chat-Completions/Anthropic/Bedrock/Gemini shapes were handled). A genuine tool call was misclassified as a plain text answer, so the agent returned the raw unexecuted tool-call JSON as its final output instead of calling the tool.extract_tool_call_info()'s dict branch only read an"input"key for arguments, never"arguments"— so the tool would have executed with empty args._prepare_responses_params()(crewai/llms/providers/openai/completion.py) passed Chat-Completions-shapedtool_calls/tool-role messages straight through as Responses APIinputitems. The Responses API has no equivalent shape for those — it expects standalonefunction_call/function_call_outputitems — so any multi-turn tool-calling conversation overapi="responses"400'd on the second turn.Reproduced against a real OpenAI-compatible self-hosted model server (vLLM) with a tool-using agent. Before the fix: the agent returned the tool-call JSON as its "answer" without ever invoking the tool. After: the agent calls the tool, gets the real result, and completes the task correctly across multiple turns.
Test plan
is_tool_call_list()/extract_tool_call_info()covering the Responses API shape, confirmed they fail against pre-fix code and pass against the fix, with no regressions in the existing 77-test suite (tests/utilities/test_agent_utils.py)_convert_message_to_responses_input_items()/_prepare_responses_params()covering assistant tool_calls, tool-role results, and a full multi-turn conversation, confirmed they fail against pre-fix code and pass against the fix, with no regressions intests/llms/openai/test_openai.py(101 passed) ortests/llms/openai_compatible/Note
Medium Risk
Touches core LLM/agent tool paths for
api="responses"; changes are targeted with regression tests and should not affect Chat Completions or other providers.Overview
Fixes native tool calling when using
LLM(api="responses")with tools—previously agents could return raw tool-call JSON instead of executing tools, or fail on follow-up turns.Responses request shaping:
_prepare_responses_paramsnow maps Chat-Completions-style history (assistanttool_calls,toolrole results) into Responses APIfunction_call/function_call_outputinput items via_convert_message_to_responses_input_items, instead of forwarding keys the API rejects on multi-turn tool loops.Agent tool-call handling:
is_tool_call_listandextract_tool_call_inforecognize the Responses API’s flat{"name", "arguments", ...}tool-call dicts (not only nestedfunction/ Bedrockinputshapes), so calls are classified and parsed with real arguments.Regression tests cover message conversion, multi-turn conversation shape, and the new dict formats in
test_openai.pyandtest_agent_utils.py.Reviewed by Cursor Bugbot for commit 6450d67. Bugbot is set up for automated code reviews on this repo. Configure here.