diff --git a/src/google/adk/models/google_llm.py b/src/google/adk/models/google_llm.py index 23c9c27810..5cd87ee922 100644 --- a/src/google/adk/models/google_llm.py +++ b/src/google/adk/models/google_llm.py @@ -553,6 +553,21 @@ def _build_request_log(req: LlmRequest) -> str: """ +def _get_text_from_response(resp: types.GenerateContentResponse) -> str: + """Extract text from a GenerateContentResponse without triggering warnings. + + The resp.text property emits a UserWarning when the response contains + non-text parts (e.g. function_call). This helper iterates over parts + directly to avoid that warning. + """ + if not resp.candidates: + return '' + parts = resp.candidates[0].content.parts if resp.candidates[0].content else [] + if not parts: + return '' + return ''.join(part.text for part in parts if part.text) + + def _build_response_log(resp: types.GenerateContentResponse) -> str: function_calls_text = [] if function_calls := resp.function_calls: @@ -560,11 +575,12 @@ def _build_response_log(resp: types.GenerateContentResponse) -> str: function_calls_text.append( f'name: {func_call.name}, args: {func_call.args}' ) + text = _get_text_from_response(resp) return f""" LLM Response: ----------------------------------------------------------- Text: -{resp.text} +{text} ----------------------------------------------------------- Function calls: {_NEW_LINE.join(function_calls_text)} diff --git a/tests/unittests/models/test_google_llm.py b/tests/unittests/models/test_google_llm.py index 70aa01b69d..fe5fc833d1 100644 --- a/tests/unittests/models/test_google_llm.py +++ b/tests/unittests/models/test_google_llm.py @@ -14,6 +14,7 @@ import os import sys +import warnings from typing import Optional from unittest import mock from unittest.mock import AsyncMock @@ -24,6 +25,8 @@ from google.adk.models.gemini_llm_connection import GeminiLlmConnection from google.adk.models.google_llm import _build_function_declaration_log from google.adk.models.google_llm import _build_request_log +from google.adk.models.google_llm import _build_response_log +from google.adk.models.google_llm import _get_text_from_response from google.adk.models.google_llm import _RESOURCE_EXHAUSTED_POSSIBLE_FIX_MESSAGE from google.adk.models.google_llm import _ResourceExhaustedError from google.adk.models.google_llm import Gemini @@ -2140,3 +2143,108 @@ async def __aexit__(self, *args): # Verify the final speech_config is still None assert config_arg.speech_config is None assert isinstance(connection, GeminiLlmConnection) + + +class TestGetTextFromResponse: + """Tests for _get_text_from_response helper.""" + + def test_text_only_response(self): + """Text-only responses should return the text.""" + resp = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + role="model", + parts=[Part.from_text(text="Hello world")], + ), + ) + ] + ) + assert _get_text_from_response(resp) == "Hello world" + + def test_function_call_response_no_warning(self): + """Function-call responses must not trigger a UserWarning.""" + resp = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + role="model", + parts=[ + Part( + function_call=types.FunctionCall( + name="my_tool", args={"q": "test"} + ) + ) + ], + ), + ) + ] + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + text = _get_text_from_response(resp) + assert text == "" + + def test_mixed_parts_no_warning(self): + """Mixed text + function_call responses should extract text without warning.""" + resp = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + role="model", + parts=[ + Part.from_text(text="Calling tool..."), + Part( + function_call=types.FunctionCall( + name="my_tool", args={} + ) + ), + ], + ), + ) + ] + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + text = _get_text_from_response(resp) + assert text == "Calling tool..." + + def test_empty_candidates(self): + """Empty candidates should return empty string.""" + resp = types.GenerateContentResponse(candidates=[]) + assert _get_text_from_response(resp) == "" + + def test_none_content(self): + """Candidate with None content should return empty string.""" + resp = types.GenerateContentResponse( + candidates=[types.Candidate(content=None)] + ) + assert _get_text_from_response(resp) == "" + + +class TestBuildResponseLogNoWarning: + """Test that _build_response_log does not trigger warnings on function calls.""" + + def test_function_call_log_no_warning(self): + """Regression test for https://github.com/google/adk-python/issues/4685.""" + resp = types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + role="model", + parts=[ + Part( + function_call=types.FunctionCall( + name="my_tool", args={"question": "2+2"} + ) + ) + ], + ), + finish_reason=types.FinishReason.STOP, + ) + ] + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + log = _build_response_log(resp) + assert "my_tool" in log