From 120f2ef6e782dcc10ccb5de1fde8992e0310c5b3 Mon Sep 17 00:00:00 2001 From: lorenzejay Date: Wed, 25 Feb 2026 15:26:43 -0800 Subject: [PATCH] feat: implement strip_openai_specific_schema_fields utility * Added a new utility function to remove OpenAI-specific fields from schemas, ensuring compatibility with Gemini and Bedrock APIs. * Updated BedrockCompletion and GeminiCompletion classes to utilize this function for cleaning parameters before API calls. * Introduced tests to validate the functionality of the new utility, ensuring it correctly strips incompatible fields while preserving required properties. --- .../llms/providers/bedrock/completion.py | 4 + .../llms/providers/gemini/completion.py | 6 + .../crewai/utilities/pydantic_schema_utils.py | 29 +++ .../tests/agents/test_native_tool_calling.py | 165 ++++++++++++++++++ ..._with_optional_fields_no_schema_error.yaml | 127 ++++++++++++++ ..._with_optional_fields_no_schema_error.yaml | 161 +++++++++++++++++ .../tests/utilities/test_agent_utils.py | 99 +++++++++++ 7 files changed, 591 insertions(+) create mode 100644 lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_bedrock_mcp_tool_with_optional_fields_no_schema_error.yaml create mode 100644 lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_gemini_mcp_tool_with_optional_fields_no_schema_error.yaml diff --git a/lib/crewai/src/crewai/llms/providers/bedrock/completion.py b/lib/crewai/src/crewai/llms/providers/bedrock/completion.py index c707be3af..6f3f9780b 100644 --- a/lib/crewai/src/crewai/llms/providers/bedrock/completion.py +++ b/lib/crewai/src/crewai/llms/providers/bedrock/completion.py @@ -1948,6 +1948,9 @@ class BedrockCompletion(BaseLLM): ) -> list[ConverseToolTypeDef]: """Convert CrewAI tools to Converse API format following AWS specification.""" from crewai.llms.providers.utils.common import safe_tool_conversion + from crewai.utilities.pydantic_schema_utils import ( + strip_openai_specific_schema_fields, + ) converse_tools: list[ConverseToolTypeDef] = [] @@ -1961,6 +1964,7 @@ class BedrockCompletion(BaseLLM): } if parameters and isinstance(parameters, dict): + parameters = strip_openai_specific_schema_fields(parameters) input_schema: ToolInputSchema = {"json": parameters} tool_spec["inputSchema"] = input_schema diff --git a/lib/crewai/src/crewai/llms/providers/gemini/completion.py b/lib/crewai/src/crewai/llms/providers/gemini/completion.py index bd634c8dc..324a5ac35 100644 --- a/lib/crewai/src/crewai/llms/providers/gemini/completion.py +++ b/lib/crewai/src/crewai/llms/providers/gemini/completion.py @@ -529,10 +529,16 @@ class GeminiCompletion(BaseLLM): gemini_tools = [] from crewai.llms.providers.utils.common import safe_tool_conversion + from crewai.utilities.pydantic_schema_utils import ( + strip_openai_specific_schema_fields, + ) for tool in tools: name, description, parameters = safe_tool_conversion(tool, "Gemini") + if parameters: + parameters = strip_openai_specific_schema_fields(parameters) + function_declaration = types.FunctionDeclaration( name=name, description=description, diff --git a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py index 4548ab9ce..8fe9d3679 100644 --- a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py +++ b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py @@ -417,6 +417,35 @@ def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]: return schema +def strip_openai_specific_schema_fields(schema: dict[str, Any]) -> dict[str, Any]: + """Remove OpenAI strict-mode fields that are incompatible with other providers. + + OpenAI strict mode requires ``additionalProperties: false`` on every object + and all properties listed in ``required``. Gemini and Bedrock reject these + fields with schema validation errors, so we strip them before handing off to + non-OpenAI providers. + + Specifically removes (recursively): + - ``additionalProperties`` — not supported by Gemini / Bedrock tool schemas + + Args: + schema: JSON schema dictionary (mutated in place and returned). + + Returns: + The modified schema. + """ + if isinstance(schema, dict): + schema.pop("additionalProperties", None) + for value in schema.values(): + if isinstance(value, dict): + strip_openai_specific_schema_fields(value) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + strip_openai_specific_schema_fields(item) + return schema + + def generate_model_description( model: type[BaseModel], *, diff --git a/lib/crewai/tests/agents/test_native_tool_calling.py b/lib/crewai/tests/agents/test_native_tool_calling.py index 558c34bb1..60c7abd54 100644 --- a/lib/crewai/tests/agents/test_native_tool_calling.py +++ b/lib/crewai/tests/agents/test_native_tool_calling.py @@ -559,6 +559,39 @@ class TestAnthropicNativeToolCalling: _assert_tools_overlapped() +_MCP_OPTIONAL_FIELDS_TOOL_DEFS = [ + { + "name": "filtered_search", + "description": ( + "Search a knowledge base. Pass null for any filter you don't need." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query string", + }, + "filter_type": { + "anyOf": [ + {"type": "string", "enum": ["news", "blog", "docs"]}, + {"type": "null"}, + ], + "default": None, + "description": "Optional content-type filter", + }, + "page_id": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "description": "Optional page UUID", + }, + }, + "required": ["query"], + }, + } +] + + # ============================================================================= # Google/Gemini Provider Tests # ============================================================================= @@ -796,6 +829,9 @@ class TestAzureNativeToolCalling: _assert_tools_overlapped() + + + # ============================================================================= # Bedrock Provider Tests # ============================================================================= @@ -929,6 +965,135 @@ class TestNativeToolCallingBehavior: assert hasattr(llm, "supports_function_calling") assert llm.supports_function_calling() is True + @pytest.mark.vcr() + def test_gemini_mcp_tool_with_optional_fields_no_schema_error(self) -> None: + """MCP tools with Optional fields must not cause Gemini schema validation errors. + + Regression test for https://github.com/crewAIInc/crewAI/issues/4472. + + Before the fix, generate_model_description() added additionalProperties:false + to the tool schema. Gemini rejects this field, raising: + "value at properties. must be a list" + + With the fix, strip_openai_specific_schema_fields() removes it before the + schema is sent to the Gemini API. + + To record the cassette: + RUN_LIVE_GEMINI_TESTS=true GOOGLE_API_KEY= \\ + uv run pytest tests/agents/test_native_tool_calling.py \\ + ::TestGeminiNativeToolCalling::test_gemini_mcp_tool_with_optional_fields_no_schema_error \\ + --record-mode=once -v + """ + from unittest.mock import AsyncMock, patch + + from crewai.mcp.config import MCPServerStdio + + stdio_config = MCPServerStdio(command="python", args=["fake_mcp_server.py"]) + + agent = Agent( + role="Knowledge Researcher", + goal="Search a knowledge base and return relevant results", + backstory="You are a helpful researcher who uses the filtered_search tool.", + mcps=[stdio_config], + llm=LLM(model="gemini/gemini-2.5-flash"), + verbose=False, + max_iter=3, + ) + + with patch("crewai.agent.core.MCPClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.list_tools = AsyncMock( + return_value=_MCP_OPTIONAL_FIELDS_TOOL_DEFS + ) + mock_client.connected = False + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.call_tool = AsyncMock( + return_value="Search results: Introduction to agents, Tool integration guide" + ) + mock_client_class.return_value = mock_client + + task = Task( + description=( + "Search the knowledge base for information about CrewAI agents. " + "No filters needed — just a general search." + ), + expected_output="A brief summary of relevant search results.", + agent=agent, + ) + + crew = Crew(agents=[agent], tasks=[task]) + result = crew.kickoff() + + assert result is not None + assert result.raw is not None + + @pytest.mark.vcr() + def test_bedrock_mcp_tool_with_optional_fields_no_schema_error(self) -> None: + """MCP tools with Optional fields must not cause Bedrock schema validation errors. + + Regression test for https://github.com/crewAIInc/crewAI/issues/4472. + + Before the fix, generate_model_description() added additionalProperties:false + to the tool schema. The Bedrock Converse API rejects this field, raising: + "JSON schema is invalid" + + With the fix, strip_openai_specific_schema_fields() removes it before the + schema is sent to Bedrock. + + Note: MCPClient is mocked — no Bedrock credentials are needed to run this + test. The LLM call is replayed from the VCR cassette. + + To record the cassette: + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 \\ + uv run pytest tests/agents/test_native_tool_calling.py \\ + ::TestNativeToolCallingBehavior::test_bedrock_mcp_tool_with_optional_fields_no_schema_error \\ + --record-mode=once -v + """ + from unittest.mock import AsyncMock, patch + + from crewai.mcp.config import MCPServerStdio + + stdio_config = MCPServerStdio(command="python", args=["fake_mcp_server.py"]) + + agent = Agent( + role="Knowledge Researcher", + goal="Search a knowledge base and return relevant results", + backstory="You are a helpful researcher who uses the filtered_search tool.", + mcps=[stdio_config], + llm=LLM(model="bedrock/anthropic.claude-3-haiku-20240307-v1:0"), + verbose=False, + max_iter=3, + ) + + with patch("crewai.agent.core.MCPClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.list_tools = AsyncMock( + return_value=_MCP_OPTIONAL_FIELDS_TOOL_DEFS + ) + mock_client.connected = False + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.call_tool = AsyncMock( + return_value="Search results: Introduction to agents, Tool integration guide" + ) + mock_client_class.return_value = mock_client + + task = Task( + description=( + "Search the knowledge base for information about CrewAI agents. " + "No filters needed — just a general search." + ), + expected_output="A brief summary of relevant search results.", + agent=agent, + ) + + crew = Crew(agents=[agent], tasks=[task]) + result = crew.kickoff() + + assert result is not None + assert result.raw is not None + # ============================================================================= # Token Usage Tests diff --git a/lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_bedrock_mcp_tool_with_optional_fields_no_schema_error.yaml b/lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_bedrock_mcp_tool_with_optional_fields_no_schema_error.yaml new file mode 100644 index 000000000..84e20a697 --- /dev/null +++ b/lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_bedrock_mcp_tool_with_optional_fields_no_schema_error.yaml @@ -0,0 +1,127 @@ +interactions: +- request: + body: '{"messages": [{"role": "user", "content": [{"text": "\nCurrent Task: Search + the knowledge base for information about CrewAI agents. No filters needed \u2014 + just a general search.\n\nThis is the expected criteria for your final answer: + A brief summary of relevant search results.\nyou MUST return the actual complete + content as the final answer, not a summary."}]}], "inferenceConfig": {"stopSequences": + ["\nObservation:"]}, "system": [{"text": "You are Knowledge Researcher. You + are a helpful researcher who uses the filtered_search tool.\nYour personal goal + is: Search a knowledge base and return relevant results"}], "toolConfig": {"tools": + [{"toolSpec": {"name": "python_fake_mcp_server_py_filtered_search", "description": + "Search a knowledge base. Pass null for any filter you don''t need.", "inputSchema": + {"json": {"properties": {"query": {"description": "Search query string", "title": + "Query", "type": "string"}, "filter_type": {"anyOf": [{"type": "string"}, {"type": + "object"}, {"type": "null"}], "default": null, "description": "Optional content-type + filter", "title": "Filter Type"}, "page_id": {"anyOf": [{"type": "string"}, + {"type": "object"}, {"type": "null"}], "default": null, "description": "Optional + page UUID", "title": "Page Id"}}, "required": ["query", "filter_type", "page_id"], + "type": "object"}}}}]}}' + headers: + Content-Length: + - '1324' + Content-Type: + - !!binary | + YXBwbGljYXRpb24vanNvbg== + User-Agent: + - X-USER-AGENT-XXX + amz-sdk-invocation-id: + - AMZ-SDK-INVOCATION-ID-XXX + amz-sdk-request: + - !!binary | + YXR0ZW1wdD0x + authorization: + - AUTHORIZATION-XXX + x-amz-date: + - X-AMZ-DATE-XXX + method: POST + uri: https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/converse + response: + body: + string: '{"metrics":{"latencyMs":1322},"output":{"message":{"content":[{"text":"Okay, + let''s search the knowledge base for information about CrewAI agents:"},{"toolUse":{"input":{"query":"CrewAI + agents","filter_type":null,"page_id":null},"name":"python_fake_mcp_server_py_filtered_search","toolUseId":"tooluse_KjK6AV9oUkSLKCHlXDoc05"}}],"role":"assistant"}},"stopReason":"tool_use","usage":{"inputTokens":572,"outputTokens":125,"serverToolUsage":{},"totalTokens":697}}' + headers: + Connection: + - keep-alive + Content-Length: + - '458' + Content-Type: + - application/json + Date: + - Wed, 25 Feb 2026 23:26:08 GMT + x-amzn-RequestId: + - X-AMZN-REQUESTID-XXX + status: + code: 200 + message: OK +- request: + body: '{"messages": [{"role": "user", "content": [{"text": "\nCurrent Task: Search + the knowledge base for information about CrewAI agents. No filters needed \u2014 + just a general search.\n\nThis is the expected criteria for your final answer: + A brief summary of relevant search results.\nyou MUST return the actual complete + content as the final answer, not a summary."}]}, {"role": "assistant", "content": + [{"toolUse": {"toolUseId": "tooluse_KjK6AV9oUkSLKCHlXDoc05", "name": "python_fake_mcp_server_py_filtered_search", + "input": {}}}]}, {"role": "user", "content": [{"toolResult": {"toolUseId": "tooluse_KjK6AV9oUkSLKCHlXDoc05", + "content": [{"text": "Search results: Introduction to agents, Tool integration + guide"}]}}]}, {"role": "user", "content": [{"text": "Analyze the tool result. + If requirements are met, provide the Final Answer. Otherwise, call the next + tool. Deliver only the answer without meta-commentary."}]}], "inferenceConfig": + {"stopSequences": ["\nObservation:"]}, "system": [{"text": "You are Knowledge + Researcher. You are a helpful researcher who uses the filtered_search tool.\nYour + personal goal is: Search a knowledge base and return relevant results"}], "toolConfig": + {"tools": [{"toolSpec": {"name": "python_fake_mcp_server_py_filtered_search", + "description": "Search a knowledge base. Pass null for any filter you don''t + need.", "inputSchema": {"json": {"properties": {"query": {"description": "Search + query string", "title": "Query", "type": "string"}, "filter_type": {"anyOf": + [{"type": "string"}, {"type": "object"}, {"type": "null"}], "default": null, + "description": "Optional content-type filter", "title": "Filter Type"}, "page_id": + {"anyOf": [{"type": "string"}, {"type": "object"}, {"type": "null"}], "default": + null, "description": "Optional page UUID", "title": "Page Id"}}, "required": + ["query", "filter_type", "page_id"], "type": "object"}}}}]}}' + headers: + Content-Length: + - '1873' + Content-Type: + - !!binary | + YXBwbGljYXRpb24vanNvbg== + User-Agent: + - X-USER-AGENT-XXX + amz-sdk-invocation-id: + - AMZ-SDK-INVOCATION-ID-XXX + amz-sdk-request: + - !!binary | + YXR0ZW1wdD0x + authorization: + - AUTHORIZATION-XXX + x-amz-date: + - X-AMZ-DATE-XXX + method: POST + uri: https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/converse + response: + body: + string: '{"metrics":{"latencyMs":2372},"output":{"message":{"content":[{"text":"\nIntroduction + to agents:\nCrewAI agents are designed to assist humans with a variety of + tasks. They can be integrated with other tools and systems to provide automation, + task completion, and data processing capabilities. Agents have access to information + and can collaborate with each other to achieve objectives.\n\nTool integration + guide:\nThe CrewAI tool integration guide provides instructions for connecting + agents with other applications and services. This allows agents to access + data, trigger actions, and exchange information across different platforms. + The guide covers API integration, event handling, and customization options + to tailor agent behavior.\n"}],"role":"assistant"}},"stopReason":"end_turn","usage":{"inputTokens":680,"outputTokens":130,"serverToolUsage":{},"totalTokens":810}}' + headers: + Connection: + - keep-alive + Content-Length: + - '890' + Content-Type: + - application/json + Date: + - Wed, 25 Feb 2026 23:26:10 GMT + x-amzn-RequestId: + - X-AMZN-REQUESTID-XXX + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_gemini_mcp_tool_with_optional_fields_no_schema_error.yaml b/lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_gemini_mcp_tool_with_optional_fields_no_schema_error.yaml new file mode 100644 index 000000000..9ef5f324d --- /dev/null +++ b/lib/crewai/tests/cassettes/agents/TestNativeToolCallingBehavior.test_gemini_mcp_tool_with_optional_fields_no_schema_error.yaml @@ -0,0 +1,161 @@ +interactions: +- request: + body: '{"contents": [{"parts": [{"text": "\nCurrent Task: Search the knowledge + base for information about CrewAI agents. No filters needed \u2014 just a general + search.\n\nThis is the expected criteria for your final answer: A brief summary + of relevant search results.\nyou MUST return the actual complete content as + the final answer, not a summary."}], "role": "user"}], "systemInstruction": + {"parts": [{"text": "You are Knowledge Researcher. You are a helpful researcher + who uses the filtered_search tool.\nYour personal goal is: Search a knowledge + base and return relevant results"}], "role": "user"}, "tools": [{"functionDeclarations": + [{"description": "Search a knowledge base. Pass null for any filter you don''t + need.", "name": "python_fake_mcp_server_py_filtered_search", "parameters_json_schema": + {"properties": {"query": {"description": "Search query string", "title": "Query", + "type": "string"}, "filter_type": {"anyOf": [{"type": "string"}, {"type": "object"}, + {"type": "null"}], "default": null, "description": "Optional content-type filter", + "title": "Filter Type"}, "page_id": {"anyOf": [{"type": "string"}, {"type": + "object"}, {"type": "null"}], "default": null, "description": "Optional page + UUID", "title": "Page Id"}}, "required": ["query", "filter_type", "page_id"], + "type": "object"}}]}], "generationConfig": {"stopSequences": ["\nObservation:"]}}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + content-length: + - '1360' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/1.49.0 gl-python/3.13.3 + x-goog-api-key: + - X-GOOG-API-KEY-XXX + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\": + [\n {\n \"functionCall\": {\n \"name\": \"python_fake_mcp_server_py_filtered_search\",\n + \ \"args\": {\n \"query\": \"CrewAI agents\"\n + \ }\n },\n \"thoughtSignature\": \"CsMEAb4+9vuUI8bbZhI2K5IFCJz+FZPQ+EP/o+hvgBgIAImbCgAE3lyB3kNCn3FcFFSBSPP0KKDiEqq/J0tZjsSmNolNSZr2qAc2kLpzDkI26PhKOk4X3WQ9JEUuaCopvsFYd4mBgZqLaK8PAASQQX8zFD5X54TCk6RFtood4o82kouQbXPCUZAKiC9V/tkz3Qqe5M+K7W69MKzsKGiQakO5I5UfIKc/KD69rROuf/ZUw5oU+8+0qQVhvp8HuPWyQW815eyVZidwIUfFRaswRzZp48PefjNWKhf/1NcVM5fy+RLq5M88FQZ9zhcY9wjV3H66G439VHPCH1SekLJ7A7se/60Sj7/oXfzl93FZ9kb4RMA2PsEJSFqb+EDVDhEmkRxsqCERSoEm7vSVut7AEJiO4UG3+5986MjV52IOpgdTZxWwUoJbQ9xKH7fov6rEDLa1ExSYLmqvnPJbZEhfR39vVmTM105qIlzFSVOBekOsqc6VCSX5P0iwmlxGib7YTRYFaqyY2ZuEWp4Xw+m82MjBIFRZPHW80tSG285VSTFUx6dxkMsfC1XT0TzqjwmeV+xSQ9fvuu8hM6EO5XN+vHIJSC2WPrN+Hc0WIaZb8Ol1q4BITA7jxAp8RLg7PD4FI7VzzMzyDMdf7kffSVMRGOdaNC8zGfM2KbSP3J2QA/InmM9bUawKsOz+nMLsjTs2fTp2nvMmNRePWlNkKOxV0DUfxCQRPtQOZk/OKd9U6GpCd/omHtGkk8hWcgYwhxQxXsNOqnIb\"\n + \ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\": + \"STOP\",\n \"index\": 0,\n \"finishMessage\": \"Model generated + function call(s).\"\n }\n ],\n \"usageMetadata\": {\n \"promptTokenCount\": + 203,\n \"candidatesTokenCount\": 28,\n \"totalTokenCount\": 361,\n \"promptTokensDetails\": + [\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 203\n + \ }\n ],\n \"thoughtsTokenCount\": 130\n },\n \"modelVersion\": + \"gemini-2.5-flash\",\n \"responseId\": \"bICfafeTH_T5-8YP8rnE6Qo\"\n}\n" + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Type: + - application/json; charset=UTF-8 + Date: + - Wed, 25 Feb 2026 23:06:21 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=1210 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + X-Frame-Options: + - X-FRAME-OPTIONS-XXX + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "\nCurrent Task: Search the knowledge + base for information about CrewAI agents. No filters needed \u2014 just a general + search.\n\nThis is the expected criteria for your final answer: A brief summary + of relevant search results.\nyou MUST return the actual complete content as + the final answer, not a summary."}], "role": "user"}, {"parts": [{"functionCall": + {"args": {"query": "CrewAI agents"}, "name": "python_fake_mcp_server_py_filtered_search"}}], + "role": "model"}, {"parts": [{"functionResponse": {"name": "python_fake_mcp_server_py_filtered_search", + "response": {"result": "Search results: Introduction to agents, Tool integration + guide"}}}], "role": "user"}, {"parts": [{"text": "Analyze the tool result. If + requirements are met, provide the Final Answer. Otherwise, call the next tool. + Deliver only the answer without meta-commentary."}], "role": "user"}], "systemInstruction": + {"parts": [{"text": "You are Knowledge Researcher. You are a helpful researcher + who uses the filtered_search tool.\nYour personal goal is: Search a knowledge + base and return relevant results"}], "role": "user"}, "tools": [{"functionDeclarations": + [{"description": "Search a knowledge base. Pass null for any filter you don''t + need.", "name": "python_fake_mcp_server_py_filtered_search", "parameters_json_schema": + {"properties": {"query": {"description": "Search query string", "title": "Query", + "type": "string"}, "filter_type": {"anyOf": [{"type": "string"}, {"type": "object"}, + {"type": "null"}], "default": null, "description": "Optional content-type filter", + "title": "Filter Type"}, "page_id": {"anyOf": [{"type": "string"}, {"type": + "object"}, {"type": "null"}], "default": null, "description": "Optional page + UUID", "title": "Page Id"}}, "required": ["query", "filter_type", "page_id"], + "type": "object"}}]}], "generationConfig": {"stopSequences": ["\nObservation:"]}}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - '*/*' + accept-encoding: + - ACCEPT-ENCODING-XXX + connection: + - keep-alive + content-length: + - '1893' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/1.49.0 gl-python/3.13.3 + x-goog-api-key: + - X-GOOG-API-KEY-XXX + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\": + [\n {\n \"text\": \"Search results: Introduction to agents, + Tool integration guide\"\n }\n ],\n \"role\": \"model\"\n + \ },\n \"finishReason\": \"STOP\",\n \"index\": 0\n }\n ],\n + \ \"usageMetadata\": {\n \"promptTokenCount\": 299,\n \"candidatesTokenCount\": + 10,\n \"totalTokenCount\": 309,\n \"promptTokensDetails\": [\n {\n + \ \"modality\": \"TEXT\",\n \"tokenCount\": 299\n }\n ]\n + \ },\n \"modelVersion\": \"gemini-2.5-flash\",\n \"responseId\": \"bYCfacPnLsD4-8YP6p64sQs\"\n}\n" + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Type: + - application/json; charset=UTF-8 + Date: + - Wed, 25 Feb 2026 23:06:22 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=380 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + X-Frame-Options: + - X-FRAME-OPTIONS-XXX + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py index 43477c25e..e022697b8 100644 --- a/lib/crewai/tests/utilities/test_agent_utils.py +++ b/lib/crewai/tests/utilities/test_agent_utils.py @@ -309,6 +309,105 @@ class TestOptionalFieldsPreserveNull: assert "page_id" in params["required"] +class TestStripOpenAISpecificSchemaFields: + """Tests for strip_openai_specific_schema_fields. + + All tests call the real schema pipeline with real tool instances — + no mocking of internal functions. + """ + + def test_removes_additional_properties_at_root(self) -> None: + """additionalProperties must be absent after stripping.""" + from crewai.utilities.pydantic_schema_utils import ( + strip_openai_specific_schema_fields, + ) + + tools = [MCPStyleTool()] + schemas, _ = convert_tools_to_openai_schema(tools) + params = schemas[0]["function"]["parameters"] + + # Confirm it's present in the OpenAI-format schema + assert params.get("additionalProperties") is False + + clean = strip_openai_specific_schema_fields(params) + assert "additionalProperties" not in clean + + def test_removes_additional_properties_recursively(self) -> None: + """Nested objects must also have additionalProperties removed.""" + from crewai.utilities.pydantic_schema_utils import ( + strip_openai_specific_schema_fields, + ) + + class NestedInput(BaseModel): + config: dict[str, Any] = Field(default_factory=dict) + query: str = Field(description="query") + + class NestedTool(BaseTool): + name: str = "nested_tool" + description: str = "tool with nested object" + args_schema: type[BaseModel] = NestedInput + + def _run(self, **kwargs: Any) -> str: + return "ok" + + schemas, _ = convert_tools_to_openai_schema([NestedTool()]) + params = schemas[0]["function"]["parameters"] + clean = strip_openai_specific_schema_fields(params) + + def has_additional_properties(obj: Any) -> bool: + if isinstance(obj, dict): + if "additionalProperties" in obj: + return True + return any(has_additional_properties(v) for v in obj.values()) + if isinstance(obj, list): + return any(has_additional_properties(i) for i in obj) + return False + + assert not has_additional_properties(clean) + + def test_preserves_required_and_properties(self) -> None: + """required and properties must survive the strip.""" + from crewai.utilities.pydantic_schema_utils import ( + strip_openai_specific_schema_fields, + ) + + tools = [MCPStyleTool()] + schemas, _ = convert_tools_to_openai_schema(tools) + params = schemas[0]["function"]["parameters"] + clean = strip_openai_specific_schema_fields(params) + + assert "properties" in clean + assert "required" in clean + assert "query" in clean["properties"] + + def test_gemini_bedrock_path_extracts_clean_parameters(self) -> None: + """extract_tool_info returns the same parameters dict that + strip_openai_specific_schema_fields will clean — verifying the + full Gemini/Bedrock conversion pipeline produces a valid schema.""" + from crewai.llms.providers.utils.common import extract_tool_info + from crewai.utilities.pydantic_schema_utils import ( + strip_openai_specific_schema_fields, + ) + + tools = [MCPStyleTool()] + schemas, _ = convert_tools_to_openai_schema(tools) + + # extract_tool_info is what Gemini/Bedrock call internally + _name, _desc, parameters = extract_tool_info(schemas[0]) + + assert parameters.get("additionalProperties") is False, ( + "Raw OpenAI schema should have additionalProperties=false" + ) + + clean = strip_openai_specific_schema_fields(parameters) + assert "additionalProperties" not in clean + # null types preserved for optional fields + assert any( + opt.get("type") == "null" + for opt in clean["properties"]["page_id"].get("anyOf", []) + ) + + class TestSummarizeMessages: """Tests for summarize_messages function."""