Skip to content
49 changes: 47 additions & 2 deletions lib/crewai/src/crewai/llms/providers/openai/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,49 @@ async def _acall_responses(
response_model=response_model,
)

def _convert_message_to_responses_input_items(
self, message: LLMMessage
) -> list[dict[str, Any] | LLMMessage]:
"""Convert a Chat-Completions-style message into Responses API input items.

The Responses API has no message shape for an assistant turn carrying
``tool_calls`` or for a ``tool`` role reply - those become standalone
``function_call`` / ``function_call_output`` input items instead. Plain
user/assistant text messages pass through unchanged (accepted as-is by
the Responses API's lenient "easy input message" shape).
"""
role = message.get("role")

if role == "assistant" and message.get("tool_calls"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

items: list[dict[str, Any] | LLMMessage] = []
if message.get("content"):
items.append({"role": "assistant", "content": message["content"]})
for tool_call in message["tool_calls"]:
function = tool_call.get("function", {})
args = function.get("arguments", "")
items.append(
{
"type": "function_call",
"call_id": tool_call.get("id") or f"call_{id(tool_call)}",
"name": function.get("name", ""),
"arguments": args
if isinstance(args, str)
else json.dumps(args),
}
)
return items

if role == "tool":
return [
{
"type": "function_call_output",
"call_id": message.get("tool_call_id", ""),
"output": message.get("content") or "",
}
]

return [message]

def _prepare_responses_params(
self,
messages: list[LLMMessage],
Expand All @@ -683,7 +726,7 @@ def _prepare_responses_params(
- Internally-tagged tool format (flat structure)
"""
instructions: str | None = self.instructions
input_messages: list[LLMMessage] = []
input_messages: list[Any] = []
Comment thread
theCyberTech marked this conversation as resolved.

for message in messages:
if message.get("role") == "system":
Expand All @@ -694,7 +737,9 @@ def _prepare_responses_params(
else:
instructions = content_str
else:
input_messages.append(message)
input_messages.extend(
self._convert_message_to_responses_input_items(message)
)

# Prepend reasoning items for ZDR (zero-data-retention) chaining when configured
final_input: list[Any] = []
Expand Down
18 changes: 17 additions & 1 deletion lib/crewai/src/crewai/utilities/agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1238,7 +1238,14 @@ def extract_tool_call_info(
)
func_info = tool_call.get("function", {})
func_name = func_info.get("name", "") or tool_call.get("name", "")
func_args = func_info.get("arguments") or tool_call.get("input") or {}
# OpenAI Responses API function_call items are flat dicts using
# "arguments" (not "input") with no nested "function" key.
func_args = (
func_info.get("arguments")
or tool_call.get("arguments")
or tool_call.get("input")
or {}
)
return call_id, sanitize_tool_name(func_name), func_args
return None

Expand Down Expand Up @@ -1270,6 +1277,15 @@ def is_tool_call_list(response: list[Any]) -> bool:
# Bedrock-style
if isinstance(first_item, dict) and "name" in first_item and "input" in first_item:
return True
# OpenAI Responses API-style (flat dict, no nested "function" key). This
# intentionally accepts the same broad shape as the Bedrock check above;
# only provider paths that return lists reach this classifier.
if (
isinstance(first_item, dict)
and "name" in first_item
and "arguments" in first_item
):
return True
Comment thread
theCyberTech marked this conversation as resolved.
# Gemini-style
if hasattr(first_item, "function_call") and first_item.function_call:
return True
Expand Down
134 changes: 134 additions & 0 deletions lib/crewai/tests/llms/openai/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,140 @@ def test_openai_responses_api_with_system_message_extraction():
assert result.isupper() or "HELLO" in result.upper()


def test_openai_responses_api_converts_assistant_tool_calls_message():
"""Regression: assistant messages carrying tool_calls (Chat-Completions
shape) must become standalone function_call input items, since the
Responses API has no message shape for an assistant tool-call turn.
"""
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")

messages = [
{"role": "user", "content": "Fetch https://example.com"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "fetch_page",
"arguments": '{"url": "https://example.com"}',
},
}
],
},
]

params = llm._prepare_responses_params(messages)

assert params["input"][0] == {"role": "user", "content": "Fetch https://example.com"}
assert params["input"][1] == {
"type": "function_call",
"call_id": "call_abc123",
"name": "fetch_page",
"arguments": '{"url": "https://example.com"}',
}


def test_openai_responses_api_preserves_assistant_content_with_tool_calls():
"""Assistant text must be retained when it accompanies tool calls."""
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")

messages = [
{
"role": "assistant",
"content": "I'll fetch that page now.",
"tool_calls": [
{
"type": "function",
"function": {
"name": "fetch_page",
"arguments": {"url": "https://example.com"},
},
}
],
}
]

params = llm._prepare_responses_params(messages)

assert params["input"][0] == {
"role": "assistant",
"content": "I'll fetch that page now.",
}
assert params["input"][1]["type"] == "function_call"
assert params["input"][1]["call_id"].startswith("call_")
assert params["input"][1]["arguments"] == '{"url": "https://example.com"}'
Comment thread
theCyberTech marked this conversation as resolved.


def test_openai_responses_api_converts_tool_result_message():
"""Regression: tool-role messages (Chat-Completions shape) must become
function_call_output input items for the Responses API.
"""
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")

messages = [
{
"role": "tool",
"tool_call_id": "call_abc123",
"name": "fetch_page",
"content": "<html>page text</html>",
},
]

params = llm._prepare_responses_params(messages)

assert params["input"] == [
{
"type": "function_call_output",
"call_id": "call_abc123",
"output": "<html>page text</html>",
}
]


def test_openai_responses_api_multi_turn_tool_conversation_shape():
"""Regression: a full multi-turn tool-calling conversation (user ->
assistant tool_calls -> tool result) must convert entirely into valid
Responses API input items, with no leftover Chat-Completions-only keys
("tool_calls", "tool_call_id") that the Responses API would reject.
"""
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")

messages = [
{"role": "user", "content": "Fetch https://example.com"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "fetch_page",
"arguments": '{"url": "https://example.com"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"name": "fetch_page",
"content": "<html>page text</html>",
},
]

params = llm._prepare_responses_params(messages)

for item in params["input"]:
assert "tool_calls" not in item
assert "tool_call_id" not in item
assert params["input"][1]["type"] == "function_call"
assert params["input"][2]["type"] == "function_call_output"


@pytest.mark.vcr()
def test_openai_responses_api_streaming():
"""Test Responses API with streaming enabled."""
Expand Down
84 changes: 84 additions & 0 deletions lib/crewai/tests/utilities/test_agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
_split_messages_into_chunks,
convert_tools_to_openai_schema,
execute_single_native_tool_call,
extract_tool_call_info,
is_tool_call_list,
NativeToolCallResult,
parse_tool_call_args,
summarize_messages,
Expand Down Expand Up @@ -981,6 +983,88 @@ def test_parallel_summarize_preserves_files(self) -> None:
assert "report.pdf" in summary_msg["files"]


class TestIsToolCallListResponsesApiShape:
"""Regression tests: OpenAI Responses API tool-call dicts must be recognized.

Responses API function_call output items are flat dicts shaped
{"id", "name", "arguments"} - no nested "function" key, and "arguments"
instead of Anthropic/Bedrock-style "input".
"""

def test_responses_api_dict_is_recognized_as_tool_call(self) -> None:
response = [
{
"id": "call_abc123",
"name": "fetch_page",
"arguments": '{"url": "https://example.com"}',
}
]
assert is_tool_call_list(response) is True

def test_plain_text_answer_not_misclassified(self) -> None:
assert is_tool_call_list(["just a string, not a tool call"]) is False

def test_empty_list_returns_false(self) -> None:
assert is_tool_call_list([]) is False

def test_chat_completions_style_still_recognized(self) -> None:
response = [{"function": {"name": "fetch_page", "arguments": "{}"}}]
assert is_tool_call_list(response) is True

def test_bedrock_anthropic_style_still_recognized(self) -> None:
response = [{"name": "fetch_page", "input": {"url": "https://example.com"}}]
assert is_tool_call_list(response) is True


class TestExtractToolCallInfoResponsesApiShape:
"""Regression tests: extract_tool_call_info must parse Responses API dicts."""

def test_responses_api_dict_extracts_real_arguments(self) -> None:
tool_call = {
"id": "call_abc123",
"name": "fetch_page",
"arguments": '{"url": "https://example.com"}',
}
result = extract_tool_call_info(tool_call)
assert result is not None
call_id, func_name, func_args = result
assert call_id == "call_abc123"
assert func_name == "fetch_page"
assert func_args == '{"url": "https://example.com"}'

def test_responses_api_dict_does_not_return_empty_args(self) -> None:
tool_call = {
"id": "call_xyz",
"name": "fetch_page",
"arguments": '{"url": "https://example.com"}',
}
_, _, func_args = extract_tool_call_info(tool_call)
assert func_args != {}

def test_bedrock_anthropic_style_still_uses_input(self) -> None:
tool_call = {"name": "fetch_page", "input": {"url": "https://example.com"}}
_, func_name, func_args = extract_tool_call_info(tool_call)
assert func_name == "fetch_page"
assert func_args == {"url": "https://example.com"}

def test_chat_completions_style_still_uses_nested_function(self) -> None:
tool_call = {
"id": "call_1",
"function": {"name": "fetch_page", "arguments": "{}"},
}
_, func_name, func_args = extract_tool_call_info(tool_call)
assert func_name == "fetch_page"
assert func_args == "{}"

def test_non_dict_unrecognized_shape_returns_none(self) -> None:
assert extract_tool_call_info("just a string") is None

def test_unrecognized_dict_shape_returns_empty_name_and_args(self) -> None:
call_id, func_name, func_args = extract_tool_call_info({"unrelated": "data"})
assert func_name == ""
assert func_args == {}


class TestParseToolCallArgs:
"""Unit tests for parse_tool_call_args."""

Expand Down
Loading