diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 4d4f93c88f..ad65ec8f78 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -1725,9 +1725,21 @@ def _message_to_generate_content_response( for tool_call in tool_calls: if tool_call.type == "function": thought_signature = _extract_thought_signature_from_tool_call(tool_call) + try: + parsed_args = json.loads(tool_call.function.arguments or "{}") + except json.JSONDecodeError: + logger.warning( + "Malformed JSON in tool call arguments for %s (id=%s);" + " passing empty args so the tool dispatch can surface a" + " recoverable error to the model. Raw arguments: %r", + tool_call.function.name, + tool_call.id, + tool_call.function.arguments, + ) + parsed_args = {} part = types.Part.from_function_call( name=tool_call.function.name, - args=json.loads(tool_call.function.arguments or "{}"), + args=parsed_args, ) part.function_call.id = tool_call.id if thought_signature: diff --git a/tests/unittests/models/test_lite_llm_malformed_tool_args.py b/tests/unittests/models/test_lite_llm_malformed_tool_args.py new file mode 100644 index 0000000000..0de16c85d5 --- /dev/null +++ b/tests/unittests/models/test_lite_llm_malformed_tool_args.py @@ -0,0 +1,94 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for tolerant tool-argument JSON parsing in lite_llm. + +When a LiteLLM-wrapped model emits malformed JSON in +``tool_call.function.arguments`` (truncated strings, unclosed objects, …), +``_message_to_generate_content_response`` previously raised +``JSONDecodeError`` and aborted the invocation before tool callbacks +could observe or recover from the failure. The current behaviour is to +log a warning, pass an empty ``args`` dict to the function-call Part, +and let downstream tool dispatch surface a recoverable error to the +model. +""" + +import logging + +from google.adk.models.lite_llm import _message_to_generate_content_response +from litellm import ChatCompletionAssistantMessage +from litellm import ChatCompletionMessageToolCall +from litellm.types.utils import Function + +_LOGGER_NAME = "google_adk.google.adk.models.lite_llm" + + +def _assistant_message_with_tool_args( + arguments: str, +) -> ChatCompletionAssistantMessage: + return ChatCompletionAssistantMessage( + role="assistant", + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + type="function", + id="call_1", + function=Function(name="demo_tool", arguments=arguments), + ) + ], + ) + + +def test_unterminated_string_does_not_raise(caplog): + message = _assistant_message_with_tool_args('{"city":"unterminated') + + with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME): + response = _message_to_generate_content_response(message) + + part = response.content.parts[0] + assert part.function_call.name == "demo_tool" + assert part.function_call.id == "call_1" + assert part.function_call.args == {} + assert any("Malformed JSON" in record.message for record in caplog.records) + + +def test_unclosed_object_does_not_raise(caplog): + message = _assistant_message_with_tool_args('{"a": 1, "b":') + + with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME): + response = _message_to_generate_content_response(message) + + assert response.content.parts[0].function_call.args == {} + assert any("Malformed JSON" in record.message for record in caplog.records) + + +def test_well_formed_arguments_still_parse(): + message = _assistant_message_with_tool_args( + '{"city": "Paris", "units": "metric"}' + ) + + response = _message_to_generate_content_response(message) + + assert response.content.parts[0].function_call.args == { + "city": "Paris", + "units": "metric", + } + + +def test_empty_arguments_become_empty_dict(): + message = _assistant_message_with_tool_args("") + + response = _message_to_generate_content_response(message) + + assert response.content.parts[0].function_call.args == {}