From 3788505f55c990a40f8b081d381aa6258cf6b4d3 Mon Sep 17 00:00:00 2001 From: Pratik Dhanave Date: Wed, 4 Nov 2026 23:29:23 +0530 Subject: [PATCH] fix(tools): take the first non-null union member in _remove_any_of `_remove_any_of` collapses a parameter's `anyOf` to a single schema for the Gemini (non-Vertex) function declaration path used by LangChain and CrewAI tools. Its comment says "Take the first non-null type", but the loop had no `break` and merged every non-null branch with `.update()`. For a heterogeneous union this had two wrong effects: the last non-null branch won instead of the first, and `.update()` left keywords from the earlier branch behind. For example `Union[List[str], int]` produced a schema typed `integer` that still carried the array-only `items` keyword (an invalid schema), and picked `int` rather than the first member. Optional[T] is unaffected because it has a single non-null branch. Add `break` so only the first non-null member is taken, matching the documented contract and producing a valid single-type schema. --- .../tools/_automatic_function_calling_util.py | 1 + .../tools/test_build_function_declaration.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/google/adk/tools/_automatic_function_calling_util.py b/src/google/adk/tools/_automatic_function_calling_util.py index dde0c8ced80..dd52b23f584 100644 --- a/src/google/adk/tools/_automatic_function_calling_util.py +++ b/src/google/adk/tools/_automatic_function_calling_util.py @@ -141,6 +141,7 @@ def _remove_any_of(schema: _JsonSchema) -> None: for type_ in union_types: if type_.get('type') != 'null': property_schema.update(type_) + break def _remove_default(schema: _JsonSchema) -> None: diff --git a/tests/unittests/tools/test_build_function_declaration.py b/tests/unittests/tools/test_build_function_declaration.py index aa9cea21d38..058e2c089df 100644 --- a/tests/unittests/tools/test_build_function_declaration.py +++ b/tests/unittests/tools/test_build_function_declaration.py @@ -17,6 +17,8 @@ from enum import IntEnum from typing import Annotated from typing import Any +from typing import List +from typing import Union from google.adk.features import FeatureName from google.adk.features._feature_registry import temporary_feature_override @@ -1275,3 +1277,51 @@ def tool_func(name: str) -> str: ) assert decl.parameters.required == ['name'] + + def test_for_langchain_collapses_a_union_to_its_first_non_null_member(self): + # A heterogeneous (non-Optional) union must collapse to its first non-null + # member, per _remove_any_of's "Take the first non-null type" contract, + # without leaking keywords from the other members (which would otherwise + # produce an invalid schema such as an INTEGER carrying array `items`). + class TagArgs(BaseModel): + tags: Union[List[str], int] + + def tool_func(tags) -> str: + return str(tags) + + decl = _automatic_function_calling_util.build_function_declaration_for_langchain( + False, + 'tag', + 'Tag something.', + tool_func, + TagArgs.model_json_schema()['properties'], + ) + + schema = decl.parameters.properties['tags'] + # The first non-null member (list[str]) wins. + assert schema.type == 'ARRAY' + assert schema.items is not None + assert schema.items.type == 'STRING' + # No keywords from the other (integer) member leak through, and the union + # keyword itself is gone. + assert schema.any_of is None + + def test_for_crewai_collapses_a_union_to_its_first_non_null_member(self): + class TagArgs(BaseModel): + count: Union[int, str] + + def tool_func(count) -> str: + return str(count) + + decl = _automatic_function_calling_util.build_function_declaration_for_params_for_crewai( + False, + 'tag', + 'Tag something.', + tool_func, + TagArgs.model_json_schema(), + ) + + schema = decl.parameters.properties['count'] + # The first non-null member (int) wins, not the last. + assert schema.type == 'INTEGER' + assert schema.any_of is None