Skip to content

Commit 2fffcd9

Browse files
GWealecopybara-github
authored andcommitted
fix: surface MALFORMED_FUNCTION_CALL so on_model_error can recover
A Gemini turn that finishes with MALFORMED_FUNCTION_CALL yields a response with an error code but no actionable content. The flow builds a content-free event with no function call and the agent loop ends, silently terminating the invocation mid-run instead of surfacing an error. Raise on a content-free MALFORMED_FUNCTION_CALL response so it flows through the existing on_model_error callbacks, mirroring the LiteLlm malformed-argument path (google#5008). Callbacks can now recover; without one the run fails loudly instead of stalling. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 932585954
1 parent 991431f commit 2fffcd9

3 files changed

Lines changed: 116 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
18+
class MalformedFunctionCallError(ValueError):
19+
"""The model returned a malformed function call with no usable content.
20+
21+
Raised by the model layer when a turn finishes with
22+
``FinishReason.MALFORMED_FUNCTION_CALL`` and carries no content the agent can
23+
act on. Subclasses ``ValueError`` so existing handlers keep working, while
24+
``on_model_error`` callbacks can match this specific case to recover (for
25+
example, by retrying the turn).
26+
"""

src/google/adk/models/google_llm.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from google.genai.errors import ClientError
3434
from typing_extensions import override
3535

36+
from ..errors.malformed_function_call_error import MalformedFunctionCallError
3637
from ..utils._google_client_headers import get_tracking_headers
3738
from ..utils._google_client_headers import merge_tracking_headers
3839
from ..utils.context_utils import Aclosing
@@ -62,6 +63,27 @@
6263
"""
6364

6465

66+
def _raise_for_malformed_function_call(llm_response: LlmResponse) -> None:
67+
"""Raises when the model returned a malformed function call with no content.
68+
69+
A ``MALFORMED_FUNCTION_CALL`` finish reason yields a response that carries an
70+
error code but nothing the agent can act on. Left alone it builds a
71+
content-free event with no function call, which silently ends the invocation.
72+
Raising routes it through the on_model_error callbacks so they can recover,
73+
mirroring the LiteLlm malformed-arguments path. The error subclasses
74+
``ValueError`` so callbacks can match this case specifically without breaking
75+
existing handlers.
76+
"""
77+
if (
78+
llm_response.finish_reason == types.FinishReason.MALFORMED_FUNCTION_CALL
79+
and not (llm_response.content and llm_response.content.parts)
80+
):
81+
raise MalformedFunctionCallError(
82+
llm_response.error_message
83+
or 'Model returned a malformed function call.'
84+
)
85+
86+
6587
class _ResourceExhaustedError(ClientError):
6688
"""Represents a resources exhausted error received from the Model."""
6789

@@ -258,6 +280,7 @@ async def generate_content_async(
258280
aggregator.process_response(response)
259281
) as aggregator_gen:
260282
async for llm_response in aggregator_gen:
283+
_raise_for_malformed_function_call(llm_response)
261284
yield llm_response
262285
if (close_result := aggregator.close()) is not None:
263286
# Populate cache metadata in the final aggregated response for
@@ -266,6 +289,7 @@ async def generate_content_async(
266289
cache_manager.populate_cache_metadata_in_response(
267290
close_result, cache_metadata
268291
)
292+
_raise_for_malformed_function_call(close_result)
269293
yield close_result
270294

271295
else:
@@ -283,6 +307,7 @@ async def generate_content_async(
283307
cache_manager.populate_cache_metadata_in_response(
284308
llm_response, cache_metadata
285309
)
310+
_raise_for_malformed_function_call(llm_response)
286311
yield llm_response
287312
except ClientError as ce:
288313
if ce.code == 429:

tests/unittests/models/test_google_llm.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from google.adk import version as adk_version
2323
from google.adk.agents.context_cache_config import ContextCacheConfig
24+
from google.adk.errors.malformed_function_call_error import MalformedFunctionCallError
2425
from google.adk.models.cache_metadata import CacheMetadata
2526
from google.adk.models.gemini_llm_connection import GeminiLlmConnection
2627
from google.adk.models.google_llm import _build_function_declaration_log
@@ -322,6 +323,70 @@ async def mock_coro():
322323
mock_client.aio.models.generate_content.assert_called_once()
323324

324325

326+
@pytest.mark.asyncio
327+
async def test_generate_content_async_malformed_function_call_raises(
328+
gemini_llm, llm_request
329+
):
330+
malformed_response = types.GenerateContentResponse(
331+
candidates=[
332+
types.Candidate(
333+
content=None,
334+
finish_reason=types.FinishReason.MALFORMED_FUNCTION_CALL,
335+
finish_message="Malformed function call: print(default_api.f(x=",
336+
)
337+
]
338+
)
339+
with mock.patch.object(gemini_llm, "api_client") as mock_client:
340+
341+
async def mock_coro():
342+
return malformed_response
343+
344+
mock_client.aio.models.generate_content.return_value = mock_coro()
345+
346+
with pytest.raises(
347+
MalformedFunctionCallError, match="alformed function call"
348+
):
349+
_ = [
350+
resp
351+
async for resp in gemini_llm.generate_content_async(
352+
llm_request, stream=False
353+
)
354+
]
355+
356+
357+
@pytest.mark.asyncio
358+
async def test_generate_content_async_stream_malformed_function_call_raises(
359+
gemini_llm, llm_request
360+
):
361+
mock_responses = [
362+
types.GenerateContentResponse(
363+
candidates=[
364+
types.Candidate(
365+
content=None,
366+
finish_reason=types.FinishReason.MALFORMED_FUNCTION_CALL,
367+
finish_message="Malformed function call",
368+
)
369+
]
370+
),
371+
]
372+
with mock.patch.object(gemini_llm, "api_client") as mock_client:
373+
374+
async def mock_coro():
375+
return MockAsyncIterator(mock_responses)
376+
377+
mock_client.aio.models.generate_content_stream.return_value = mock_coro()
378+
379+
with pytest.raises(
380+
MalformedFunctionCallError, match="alformed function call"
381+
):
382+
_ = [
383+
resp
384+
async for resp in gemini_llm.generate_content_async(
385+
llm_request, stream=True
386+
)
387+
]
388+
389+
325390
@pytest.mark.asyncio
326391
async def test_generate_content_async_stream(gemini_llm, llm_request):
327392
with mock.patch.object(gemini_llm, "api_client") as mock_client:

0 commit comments

Comments
 (0)