Skip to content

Commit a57c3e4

Browse files
RaghunandanKumarharanrk
authored andcommitted
feat(tools): Support tuple tool parameters
Merge #6076 ## Summary - Support homogeneous tuple annotations such as `tuple[float, float]` and `tuple[str, ...]` in automatic function declarations. - Parse supported tuples as array schemas instead of falling back to Pydantic JSON schema fields that `google.genai.types.Schema` rejects. - Add regression coverage for tuple tool parameters and optional tuple parameters. Fixes #3575 ## Verification - `uv run pytest tests/unittests/tools/test_from_function_with_options.py -q` - `uv run pytest tests/unittests/tools -q` - `uv run pre-commit run --files src/google/adk/tools/_function_parameter_parse_util.py tests/unittests/tools/test_from_function_with_options.py` Co-authored-by: Haran Rajkumar <haranrk@google.com> COPYBARA_INTEGRATE_REVIEW=#6076 from RaghunandanKumar:fix/issue-3575 a53780a PiperOrigin-RevId: 934745222
1 parent e042b8d commit a57c3e4

2 files changed

Lines changed: 158 additions & 19 deletions

File tree

src/google/adk/tools/_function_parameter_parse_util.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def _is_default_value_compatible(
182182
or isinstance(annotation, typing_types.GenericAlias)
183183
or isinstance(annotation, typing_types.UnionType)
184184
):
185-
origin = get_origin(annotation)
185+
origin: Any = get_origin(annotation)
186186
if origin in (Union, typing_types.UnionType):
187187
return any(
188188
_is_default_value_compatible(default_value, arg)
@@ -208,6 +208,22 @@ def _is_default_value_compatible(
208208
for item in default_value
209209
)
210210

211+
if origin is tuple:
212+
if not isinstance(default_value, tuple):
213+
return False
214+
args = get_args(annotation)
215+
if len(args) == 2 and args[-1] is Ellipsis:
216+
return all(
217+
_is_default_value_compatible(item, args[0])
218+
for item in default_value
219+
)
220+
if len(args) != len(default_value):
221+
return False
222+
return all(
223+
_is_default_value_compatible(item, arg)
224+
for item, arg in zip(default_value, args)
225+
)
226+
211227
if origin is Literal:
212228
return default_value in get_args(annotation)
213229

@@ -299,7 +315,7 @@ def _parse_schema_from_parameter(
299315
or isinstance(param.annotation, typing_types.GenericAlias)
300316
or isinstance(param.annotation, typing_types.UnionType)
301317
):
302-
origin = get_origin(param.annotation)
318+
origin: Any = get_origin(param.annotation)
303319
args = get_args(param.annotation)
304320
if origin is dict:
305321
schema.type = types.Type.OBJECT
@@ -339,6 +355,43 @@ def _parse_schema_from_parameter(
339355
schema.default = param.default
340356
_raise_if_schema_unsupported(variant, schema)
341357
return schema
358+
if origin is tuple:
359+
# A genai array schema only carries a single `items` type, so only
360+
# homogeneous tuples can be represented. `tuple[T, ...]` maps to an
361+
# unbounded array, while a fixed-length homogeneous tuple
362+
# (e.g. `tuple[T, T]`) additionally pins min_items/max_items to the
363+
# arity. Heterogeneous tuples (e.g. `tuple[str, int]`) cannot be
364+
# represented and intentionally raise so that from_function_with_options
365+
# routes them through the standard unsupported-parameter handling.
366+
fixed_length = None
367+
if len(args) == 2 and args[-1] is Ellipsis:
368+
item_annotation = args[0]
369+
elif args and all(arg == args[0] for arg in args):
370+
item_annotation = args[0]
371+
fixed_length = len(args)
372+
else:
373+
raise ValueError(
374+
f'Tuple type {param.annotation} must use one repeated item type.'
375+
)
376+
schema.type = types.Type.ARRAY
377+
schema.items = _parse_schema_from_parameter(
378+
variant,
379+
inspect.Parameter(
380+
'item',
381+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
382+
annotation=item_annotation,
383+
),
384+
func_name,
385+
)
386+
if fixed_length is not None:
387+
schema.min_items = fixed_length
388+
schema.max_items = fixed_length
389+
if param.default is not inspect.Parameter.empty:
390+
if not _is_default_value_compatible(param.default, param.annotation):
391+
raise ValueError(default_value_error_msg)
392+
schema.default = param.default
393+
_raise_if_schema_unsupported(variant, schema)
394+
return schema
342395
if origin in (Union, typing_types.UnionType):
343396
schema.any_of = []
344397
schema.type = types.Type.OBJECT

tests/unittests/tools/test_from_function_with_options.py

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from google.adk.utils.variant_utils import GoogleLLMVariant
2323
from google.genai import types
2424
import pydantic
25+
import pytest
2526

2627

2728
def test_from_function_with_options_no_return_annotation_gemini():
@@ -229,6 +230,72 @@ def test_function(
229230
assert declaration.response.type == types.Type.STRING
230231

231232

233+
def test_from_function_with_tuple_type_parameter():
234+
"""Test from_function_with_options with fixed-size homogeneous tuple."""
235+
236+
def test_function(
237+
coordinate: tuple[float, float],
238+
) -> str:
239+
"""Formats a coordinate pair."""
240+
return f'{coordinate[0]}, {coordinate[1]}'
241+
242+
declaration = _automatic_function_calling_util.from_function_with_options(
243+
test_function, GoogleLLMVariant.VERTEX_AI
244+
)
245+
246+
assert declaration.name == 'test_function'
247+
assert declaration.parameters.type == types.Type.OBJECT
248+
coordinate_schema = declaration.parameters.properties['coordinate']
249+
assert coordinate_schema.type == types.Type.ARRAY
250+
assert coordinate_schema.items.type == types.Type.NUMBER
251+
# Fixed-size tuples pin the array length so the model emits exactly the
252+
# expected number of items.
253+
assert coordinate_schema.min_items == 2
254+
assert coordinate_schema.max_items == 2
255+
assert declaration.response.type == types.Type.STRING
256+
257+
258+
def test_from_function_with_variadic_tuple_type_parameter():
259+
"""Test from_function_with_options with variable-length homogeneous tuple."""
260+
261+
def test_function(
262+
tags: tuple[str, ...],
263+
) -> str:
264+
"""Joins tags."""
265+
return ', '.join(tags)
266+
267+
declaration = _automatic_function_calling_util.from_function_with_options(
268+
test_function, GoogleLLMVariant.VERTEX_AI
269+
)
270+
271+
tags_schema = declaration.parameters.properties['tags']
272+
assert tags_schema.type == types.Type.ARRAY
273+
assert tags_schema.items.type == types.Type.STRING
274+
# Variadic tuples are unbounded, so no size constraints are set.
275+
assert tags_schema.min_items is None
276+
assert tags_schema.max_items is None
277+
278+
279+
def test_from_function_with_heterogeneous_tuple_raises():
280+
"""Heterogeneous tuples can't map to a single-`items` array schema.
281+
282+
`google.genai.types.Schema` arrays carry a single `items` type (no
283+
positional `prefixItems`), so a tuple like `tuple[str, int]` cannot be
284+
represented. It must surface a clear error rather than be silently coerced
285+
into an incorrect homogeneous array.
286+
"""
287+
288+
def test_function(
289+
pair: tuple[str, int],
290+
) -> str:
291+
return f'{pair[0]}: {pair[1]}'
292+
293+
with pytest.raises(ValueError):
294+
_automatic_function_calling_util.from_function_with_options(
295+
test_function, GoogleLLMVariant.VERTEX_AI
296+
)
297+
298+
232299
def test_from_function_with_collections_return_type():
233300
"""Test from_function_with_options with collections return type."""
234301

@@ -322,14 +389,8 @@ async def test_function(param: str) -> AsyncGenerator[Dict[str, str], None]:
322389
assert declaration.response.type == types.Type.OBJECT
323390

324391

325-
def test_required_fields_set_in_json_schema_fallback():
326-
"""Test that required fields are populated when the json_schema fallback path is used.
327-
328-
When a parameter has a complex type (e.g. tuple[str, ...] | None) that
329-
_parse_schema_from_parameter can't handle, from_function_with_options falls
330-
back to the parameters_json_schema branch. This test verifies that the
331-
required fields are correctly populated in that fallback branch.
332-
"""
392+
def test_required_fields_set_with_optional_tuple_parameter():
393+
"""Test that required fields are populated with optional tuple parameters."""
333394

334395
def complex_tool(
335396
query: str,
@@ -351,19 +412,43 @@ def complex_tool(
351412
'query': types.Schema(type=types.Type.STRING),
352413
'mode': types.Schema(type=types.Type.STRING, default='default'),
353414
'tags': types.Schema(
354-
any_of=[
355-
types.Schema(
356-
items=types.Schema(type=types.Type.STRING),
357-
type=types.Type.ARRAY,
358-
),
359-
types.Schema(type=types.Type.NULL),
360-
],
415+
items=types.Schema(type=types.Type.STRING),
361416
nullable=True,
417+
type=types.Type.ARRAY,
362418
),
363419
},
364420
)
365421

366422

423+
def test_required_fields_set_in_json_schema_fallback():
424+
"""Required fields are populated when the json_schema fallback path is used.
425+
426+
A parameter whose type `_parse_schema_from_parameter` cannot handle (here
427+
`Sequence[str]`) forces from_function_with_options onto the pydantic
428+
json_schema fallback branch. This verifies that branch still derives required
429+
fields correctly: parameters without defaults are required, parameters with
430+
defaults are not.
431+
"""
432+
433+
def complex_tool(
434+
query: str,
435+
items: Sequence[str],
436+
mode: str = 'default',
437+
) -> str:
438+
return query
439+
440+
declaration = _automatic_function_calling_util.from_function_with_options(
441+
complex_tool, GoogleLLMVariant.VERTEX_AI
442+
)
443+
444+
assert declaration.name == 'complex_tool'
445+
assert declaration.parameters.type == types.Type.OBJECT
446+
# query and items have no defaults -> required; mode has a default -> not.
447+
assert set(declaration.parameters.required) == {'query', 'items'}
448+
assert declaration.parameters.properties['items'].type == types.Type.ARRAY
449+
assert declaration.parameters.properties['mode'].default == 'default'
450+
451+
367452
def test_schema_sanitization_for_complex_union_type():
368453
"""Test schema is sanitized for complex union type."""
369454

@@ -390,8 +475,9 @@ def test_format_preservation_for_vertex_fallback():
390475
class ComplexModel(pydantic.BaseModel):
391476
# Field with format that would be stripped by Gemini sanitization
392477
email: str = pydantic.Field(json_schema_extra={'format': 'email'})
393-
# Complex field to trigger fallback (tuple is not handled by _parse_schema_from_parameter)
394-
complex_field: tuple[str, ...]
478+
# Complex field to trigger fallback (Sequence is not handled by
479+
# _parse_schema_from_parameter)
480+
complex_field: Sequence[str]
395481

396482
def my_tool(param: ComplexModel) -> str:
397483
return f'ok {param}'

0 commit comments

Comments
 (0)