Skip to content

Commit fcdb2be

Browse files
speedstorm1copybara-github
authored andcommitted
fix: Echo function_call.id into AFC function responses
fixes #3192 PiperOrigin-RevId: 1005076557
1 parent a216d95 commit fcdb2be

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

‎google/genai/_extra_utils.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,12 @@ def get_function_response_parts(
388388
}
389389
except Exception as e: # pylint: disable=broad-except
390390
func_response = {'error': str(e)}
391-
func_response_part = types.Part.from_function_response(
392-
name=func_name, response=func_response
391+
func_response_part = types.Part(
392+
function_response=types.FunctionResponse(
393+
id=part.function_call.id,
394+
name=func_name,
395+
response=func_response,
396+
)
393397
)
394398
func_response_parts.append(func_response_part)
395399
return func_response_parts
@@ -445,8 +449,12 @@ async def get_function_response_parts_async(
445449
}
446450
except Exception as e: # pylint: disable=broad-except
447451
func_response = {'error': str(e)} # type: ignore[dict-item]
448-
func_response_part = types.Part.from_function_response(
449-
name=func_name, response=func_response
452+
func_response_part = types.Part(
453+
function_response=types.FunctionResponse(
454+
id=part.function_call.id,
455+
name=func_name,
456+
response=func_response,
457+
)
450458
)
451459
func_response_parts.append(func_response_part)
452460
return func_response_parts

‎google/genai/tests/afc/test_get_function_response_parts.py‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,6 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
215215
assert is_error == False
216216

217217

218-
219218
@pytest.mark.asyncio
220219
async def test_mcp_tool_error():
221220
if not _is_mcp_imported:
@@ -270,3 +269,96 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
270269
),
271270
)
272271
assert is_error == True
272+
273+
274+
def _get_weather(city: str) -> str:
275+
return {'Paris': 'rain', 'Rome': 'sun'}[city]
276+
277+
278+
def _fail(city: str) -> str:
279+
del city # Unused.
280+
raise ValueError('boom')
281+
282+
283+
def _response_with_calls(
284+
*function_calls: FunctionCall,
285+
) -> GenerateContentResponse:
286+
return GenerateContentResponse(
287+
candidates=[
288+
Candidate(
289+
content=Content(
290+
parts=[Part(function_call=fc) for fc in function_calls]
291+
)
292+
)
293+
]
294+
)
295+
296+
297+
_PARALLEL_WEATHER_CALLS = _response_with_calls(
298+
FunctionCall(id='call_1', name='get_weather', args={'city': 'Paris'}),
299+
FunctionCall(id='call_2', name='get_weather', args={'city': 'Rome'}),
300+
)
301+
302+
303+
def test_function_call_id_propagated():
304+
"""Each response carries the id of the call it answers."""
305+
parts = get_function_response_parts(
306+
_PARALLEL_WEATHER_CALLS, {'get_weather': _get_weather}
307+
)
308+
309+
assert [p.function_response.id for p in parts] == ['call_1', 'call_2']
310+
assert [p.function_response.response for p in parts] == [
311+
{'result': 'rain'},
312+
{'result': 'sun'},
313+
]
314+
315+
316+
@pytest.mark.asyncio
317+
async def test_function_call_id_propagated_async():
318+
"""Each async response carries the id of the call it answers."""
319+
parts = await get_function_response_parts_async(
320+
_PARALLEL_WEATHER_CALLS, {'get_weather': _get_weather}
321+
)
322+
323+
assert [p.function_response.id for p in parts] == ['call_1', 'call_2']
324+
assert [p.function_response.response for p in parts] == [
325+
{'result': 'rain'},
326+
{'result': 'sun'},
327+
]
328+
329+
330+
def test_function_call_id_propagated_on_error():
331+
"""An error response from a failing tool still carries the call id."""
332+
response = _response_with_calls(
333+
FunctionCall(id='call_1', name='fail', args={'city': 'Paris'})
334+
)
335+
336+
parts = get_function_response_parts(response, {'fail': _fail})
337+
338+
assert parts[0].function_response.id == 'call_1'
339+
assert 'boom' in parts[0].function_response.response['error']
340+
341+
342+
@pytest.mark.asyncio
343+
async def test_function_call_id_propagated_on_error_async():
344+
"""An async error response from a failing tool still carries the call id."""
345+
response = _response_with_calls(
346+
FunctionCall(id='call_1', name='fail', args={'city': 'Paris'})
347+
)
348+
349+
parts = await get_function_response_parts_async(response, {'fail': _fail})
350+
351+
assert parts[0].function_response.id == 'call_1'
352+
assert 'boom' in parts[0].function_response.response['error']
353+
354+
355+
def test_function_call_id_absent():
356+
"""A call without an id yields a response with no id on the wire."""
357+
response = _response_with_calls(
358+
FunctionCall(name='get_weather', args={'city': 'Paris'})
359+
)
360+
361+
parts = get_function_response_parts(response, {'get_weather': _get_weather})
362+
363+
assert parts[0].function_response.id is None
364+
assert 'id' not in parts[0].model_dump(exclude_none=True)['function_response']

0 commit comments

Comments
 (0)