mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-03-11 14:28:14 +00:00
Compare commits
8 Commits
cursor/har
...
lorenze/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b00b5f837 | ||
|
|
120f2ef6e7 | ||
|
|
168b650226 | ||
|
|
d675a26899 | ||
|
|
c57400bff7 | ||
|
|
a70af77893 | ||
|
|
18acf6dffd | ||
|
|
e6c11a539f |
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -168,7 +168,9 @@ def convert_tools_to_openai_schema(
|
||||
parameters: dict[str, Any] = {}
|
||||
if hasattr(tool, "args_schema") and tool.args_schema is not None:
|
||||
try:
|
||||
schema_output = generate_model_description(tool.args_schema)
|
||||
schema_output = generate_model_description(
|
||||
tool.args_schema, strip_null_types=False
|
||||
)
|
||||
parameters = schema_output.get("json_schema", {}).get("schema", {})
|
||||
# Remove title and description from schema root as they're redundant
|
||||
parameters.pop("title", None)
|
||||
|
||||
@@ -417,7 +417,40 @@ def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
return schema
|
||||
|
||||
|
||||
def generate_model_description(model: type[BaseModel]) -> ModelDescription:
|
||||
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],
|
||||
*,
|
||||
strip_null_types: bool = True,
|
||||
) -> ModelDescription:
|
||||
"""Generate JSON schema description of a Pydantic model.
|
||||
|
||||
This function takes a Pydantic model class and returns its JSON schema,
|
||||
@@ -426,6 +459,9 @@ def generate_model_description(model: type[BaseModel]) -> ModelDescription:
|
||||
|
||||
Args:
|
||||
model: A Pydantic model class.
|
||||
strip_null_types: When ``True`` (default), remove ``null`` from
|
||||
``anyOf`` / ``type`` arrays. Set to ``False`` to allow sending ``null`` for
|
||||
optional fields.
|
||||
|
||||
Returns:
|
||||
A ModelDescription with JSON schema representation of the model.
|
||||
@@ -442,7 +478,9 @@ def generate_model_description(model: type[BaseModel]) -> ModelDescription:
|
||||
json_schema = fix_discriminator_mappings(json_schema)
|
||||
json_schema = convert_oneof_to_anyof(json_schema)
|
||||
json_schema = ensure_all_properties_required(json_schema)
|
||||
json_schema = strip_null_from_types(json_schema)
|
||||
|
||||
if strip_null_types:
|
||||
json_schema = strip_null_from_types(json_schema)
|
||||
|
||||
return {
|
||||
"type": "json_schema",
|
||||
|
||||
@@ -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.<field> 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=<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=<key> AWS_SECRET_ACCESS_KEY=<secret> 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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/converse
|
||||
response:
|
||||
body:
|
||||
string: '{"metrics":{"latencyMs":1866},"output":{"message":{"content":[{"text":"Understood.
|
||||
Here is the search query to find information about CrewAI agents in the knowledge
|
||||
base:"},{"toolUse":{"input":{"query":"CrewAI agents","filter_type":null,"page_id":null},"name":"python_fake_mcp_server_py_filtered_search","toolUseId":"tooluse_DH3PzO6KYfG8Zn9tGJZrvL"}}],"role":"assistant"}},"stopReason":"tool_use","usage":{"inputTokens":572,"outputTokens":129,"serverToolUsage":{},"totalTokens":701}}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '483'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Wed, 25 Feb 2026 23:49:21 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_DH3PzO6KYfG8Zn9tGJZrvL", "name": "python_fake_mcp_server_py_filtered_search",
|
||||
"input": {}}}]}, {"role": "user", "content": [{"toolResult": {"toolUseId": "tooluse_DH3PzO6KYfG8Zn9tGJZrvL",
|
||||
"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-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/converse
|
||||
response:
|
||||
body:
|
||||
string: '{"metrics":{"latencyMs":2324},"output":{"message":{"content":[{"text":"<result>\nIntroduction
|
||||
to agents:\nCrewAI agents are autonomous software entities that can perform
|
||||
a variety of tasks within a larger system or application. They are designed
|
||||
to be modular, scalable, and highly configurable, allowing them to be integrated
|
||||
into a wide range of use cases. Agents can handle tasks such as data processing,
|
||||
decision-making, and event-driven automation.\n\nTool integration guide:\nThe
|
||||
CrewAI tool integration guide provides detailed instructions on how to integrate
|
||||
CrewAI agents into your existing software systems and workflows. It covers
|
||||
topics such as agent deployment, configuration, communication protocols, and
|
||||
monitoring/reporting. The guide includes code samples and best practices to
|
||||
ensure seamless integration.\n</result>"}],"role":"assistant"}},"stopReason":"end_turn","usage":{"inputTokens":680,"outputTokens":154,"serverToolUsage":{},"totalTokens":834}}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '969'
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Wed, 25 Feb 2026 23:49:24 GMT
|
||||
x-amzn-RequestId:
|
||||
- X-AMZN-REQUESTID-XXX
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
version: 1
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from typing import Any, Literal, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -235,6 +235,178 @@ def _make_mock_i18n() -> MagicMock:
|
||||
}.get(key, "")
|
||||
return mock_i18n
|
||||
|
||||
class MCPStyleInput(BaseModel):
|
||||
"""Input schema mimicking an MCP tool with optional fields."""
|
||||
|
||||
query: str = Field(description="Search query")
|
||||
filter_type: Optional[Literal["internal", "user"]] = Field(
|
||||
default=None, description="Filter type"
|
||||
)
|
||||
page_id: Optional[str] = Field(
|
||||
default=None, description="Page UUID"
|
||||
)
|
||||
|
||||
|
||||
class MCPStyleTool(BaseTool):
|
||||
"""A tool mimicking MCP tool schemas with optional fields."""
|
||||
|
||||
name: str = "mcp_search"
|
||||
description: str = "Search with optional filters"
|
||||
args_schema: type[BaseModel] = MCPStyleInput
|
||||
|
||||
def _run(self, **kwargs: Any) -> str:
|
||||
return "result"
|
||||
|
||||
|
||||
class TestOptionalFieldsPreserveNull:
|
||||
"""Tests that optional tool fields preserve null in the schema."""
|
||||
|
||||
def test_optional_string_allows_null(self) -> None:
|
||||
"""Optional[str] fields should include null in the schema so the LLM
|
||||
can send null instead of being forced to guess a value."""
|
||||
tools = [MCPStyleTool()]
|
||||
schemas, _ = convert_tools_to_openai_schema(tools)
|
||||
|
||||
params = schemas[0]["function"]["parameters"]
|
||||
page_id_prop = params["properties"]["page_id"]
|
||||
|
||||
assert "anyOf" in page_id_prop
|
||||
type_options = [opt.get("type") for opt in page_id_prop["anyOf"]]
|
||||
assert "string" in type_options
|
||||
assert "null" in type_options
|
||||
|
||||
def test_optional_literal_allows_null(self) -> None:
|
||||
"""Optional[Literal[...]] fields should include null."""
|
||||
tools = [MCPStyleTool()]
|
||||
schemas, _ = convert_tools_to_openai_schema(tools)
|
||||
|
||||
params = schemas[0]["function"]["parameters"]
|
||||
filter_prop = params["properties"]["filter_type"]
|
||||
|
||||
assert "anyOf" in filter_prop
|
||||
has_null = any(opt.get("type") == "null" for opt in filter_prop["anyOf"])
|
||||
assert has_null
|
||||
|
||||
def test_required_field_stays_non_null(self) -> None:
|
||||
"""Required fields without Optional should NOT have null."""
|
||||
tools = [MCPStyleTool()]
|
||||
schemas, _ = convert_tools_to_openai_schema(tools)
|
||||
|
||||
params = schemas[0]["function"]["parameters"]
|
||||
query_prop = params["properties"]["query"]
|
||||
|
||||
assert query_prop.get("type") == "string"
|
||||
assert "anyOf" not in query_prop
|
||||
|
||||
def test_all_fields_in_required_for_strict_mode(self) -> None:
|
||||
"""All fields (including optional) must be in required for strict mode."""
|
||||
tools = [MCPStyleTool()]
|
||||
schemas, _ = convert_tools_to_openai_schema(tools)
|
||||
|
||||
params = schemas[0]["function"]["parameters"]
|
||||
assert "query" in params["required"]
|
||||
assert "filter_type" in params["required"]
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user