From 634d88f2f832144027f0954d2c30aabed05deafe Mon Sep 17 00:00:00 2001 From: Ayush Agrawal Date: Thu, 22 Oct 2026 14:57:31 -0700 Subject: [PATCH] fix: Echo function_call.id into AFC function responses fixes #3192 PiperOrigin-RevId: 1005076557 --- google/genai/_extra_utils.py | 16 +++- .../afc/test_get_function_response_parts.py | 93 ++++++++++++++++++- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/google/genai/_extra_utils.py b/google/genai/_extra_utils.py index fe118d98c1..c212df8f28 100644 --- a/google/genai/_extra_utils.py +++ b/google/genai/_extra_utils.py @@ -380,8 +380,12 @@ def get_function_response_parts( } except Exception as e: # pylint: disable=broad-except func_response = {'error': str(e)} - func_response_part = types.Part.from_function_response( - name=func_name, response=func_response + func_response_part = types.Part( + function_response=types.FunctionResponse( + id=part.function_call.id, + name=func_name, + response=func_response, + ) ) func_response_parts.append(func_response_part) return func_response_parts @@ -437,8 +441,12 @@ async def get_function_response_parts_async( } except Exception as e: # pylint: disable=broad-except func_response = {'error': str(e)} # type: ignore[dict-item] - func_response_part = types.Part.from_function_response( - name=func_name, response=func_response + func_response_part = types.Part( + function_response=types.FunctionResponse( + id=part.function_call.id, + name=func_name, + response=func_response, + ) ) func_response_parts.append(func_response_part) return func_response_parts diff --git a/google/genai/tests/afc/test_get_function_response_parts.py b/google/genai/tests/afc/test_get_function_response_parts.py index 60448ef43f..26abbb67d0 100644 --- a/google/genai/tests/afc/test_get_function_response_parts.py +++ b/google/genai/tests/afc/test_get_function_response_parts.py @@ -215,7 +215,6 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: assert is_error == False - @pytest.mark.asyncio async def test_mcp_tool_error(): if not _is_mcp_imported: @@ -270,3 +269,95 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: ), ) assert is_error == True + + +def _get_weather(city: str) -> str: + return {'Paris': 'rain', 'Rome': 'sun'}[city] + + +def _fail(city: str) -> str: + raise ValueError('boom') + + +def _response_with_calls( + *function_calls: FunctionCall, +) -> GenerateContentResponse: + return GenerateContentResponse( + candidates=[ + Candidate( + content=Content( + parts=[Part(function_call=fc) for fc in function_calls] + ) + ) + ] + ) + + +_PARALLEL_WEATHER_CALLS = _response_with_calls( + FunctionCall(id='call_1', name='get_weather', args={'city': 'Paris'}), + FunctionCall(id='call_2', name='get_weather', args={'city': 'Rome'}), +) + + +def test_function_call_id_propagated(): + """Each response carries the id of the call it answers.""" + parts = get_function_response_parts( + _PARALLEL_WEATHER_CALLS, {'get_weather': _get_weather} + ) + + assert [p.function_response.id for p in parts] == ['call_1', 'call_2'] + assert [p.function_response.response for p in parts] == [ + {'result': 'rain'}, + {'result': 'sun'}, + ] + + +@pytest.mark.asyncio +async def test_function_call_id_propagated_async(): + """Each async response carries the id of the call it answers.""" + parts = await get_function_response_parts_async( + _PARALLEL_WEATHER_CALLS, {'get_weather': _get_weather} + ) + + assert [p.function_response.id for p in parts] == ['call_1', 'call_2'] + assert [p.function_response.response for p in parts] == [ + {'result': 'rain'}, + {'result': 'sun'}, + ] + + +def test_function_call_id_propagated_on_error(): + """An error response from a failing tool still carries the call id.""" + response = _response_with_calls( + FunctionCall(id='call_1', name='fail', args={'city': 'Paris'}) + ) + + parts = get_function_response_parts(response, {'fail': _fail}) + + assert parts[0].function_response.id == 'call_1' + assert 'boom' in parts[0].function_response.response['error'] + + +@pytest.mark.asyncio +async def test_function_call_id_propagated_on_error_async(): + """An async error response from a failing tool still carries the call id.""" + response = _response_with_calls( + FunctionCall(id='call_1', name='fail', args={'city': 'Paris'}) + ) + + parts = await get_function_response_parts_async(response, {'fail': _fail}) + + assert parts[0].function_response.id == 'call_1' + assert 'boom' in parts[0].function_response.response['error'] + + +def test_function_call_id_absent(): + """A call without an id yields a response with no id on the wire.""" + response = _response_with_calls( + FunctionCall(name='get_weather', args={'city': 'Paris'}) + ) + + parts = get_function_response_parts(response, {'get_weather': _get_weather}) + + assert parts[0].function_response.id is None + assert 'id' not in parts[0].model_dump(exclude_none=True)['function_response']