From 1d0a72123361178d990d9238bb38353778dd61f5 Mon Sep 17 00:00:00 2001 From: Sid Gupta Date: Wed, 8 Apr 2026 15:12:01 -0700 Subject: [PATCH 1/3] feat(eval): add evaluate_full_response option to rubric-based evaluation When an agent emits text before a tool call (e.g. presenting a plan), then calls a tool, then emits more text (e.g. an explanation), the rubric_based_final_response_quality_v1 metric only sends the post-tool-call text to the judge. The pre-tool-call text is stored in intermediate_data.invocation_events but is never included in the judge prompt. This means rubrics that check for content in the pre-tool-call text always fail, even though the agent correctly produced that content. This commit adds an `evaluate_full_response` boolean field to `RubricsBasedCriterion` (following the pattern of `evaluate_intermediate_nl_responses` on `HallucinationsCriterion`). When set to true, the evaluator concatenates all text from invocation_events with the final_response before sending to the judge, giving it the complete picture of the agent's output. Usage: ```json { "rubric_based_final_response_quality_v1": { "threshold": 0.8, "evaluate_full_response": true, "rubrics": [...] } } ``` Co-Authored-By: Claude Opus 4.6 (1M context) --- src/google/adk/evaluation/eval_metrics.py | 13 +++++++ .../rubric_based_final_response_quality_v1.py | 38 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/google/adk/evaluation/eval_metrics.py b/src/google/adk/evaluation/eval_metrics.py index 50c3473c3a..5895ad807a 100644 --- a/src/google/adk/evaluation/eval_metrics.py +++ b/src/google/adk/evaluation/eval_metrics.py @@ -144,6 +144,19 @@ class RubricsBasedCriterion(BaseCriterion): ), ) + evaluate_full_response: bool = Field( + default=False, + description=( + "Whether to evaluate the full agent response including intermediate" + " natural language text (e.g. text emitted before tool calls) in" + " addition to the final response. By default, only the final" + " response text is sent to the judge. When True, text from all" + " intermediate invocation events is concatenated with the final" + " response before evaluation. This is useful for agents that emit" + " text both before and after tool calls within a single invocation." + ), + ) + class HallucinationsCriterion(BaseCriterion): """Criterion to use when evaluating agents response for hallucinations.""" diff --git a/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py b/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py index df01aba4ff..9207749984 100644 --- a/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py +++ b/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py @@ -274,7 +274,21 @@ def format_auto_rater_prompt( """Returns the autorater prompt.""" self.create_effective_rubrics_list(actual_invocation.rubrics) user_input = get_text_from_content(actual_invocation.user_content) - final_response = get_text_from_content(actual_invocation.final_response) + + # When evaluate_full_response is enabled, include text from intermediate + # invocation events (e.g. text emitted before tool calls) in addition to + # the final response. This is useful for agents that stream text, call + # tools, then stream more text within a single invocation. + criterion = self._eval_metric.criterion + evaluate_full = ( + isinstance(criterion, RubricsBasedCriterion) + and criterion.evaluate_full_response + ) + + if evaluate_full: + final_response = self._get_full_response_text(actual_invocation) + else: + final_response = get_text_from_content(actual_invocation.final_response) rubrics_text = "\n".join([ f"* {r.rubric_content.text_property}" @@ -310,3 +324,25 @@ def format_auto_rater_prompt( ) return auto_rater_prompt + + @staticmethod + def _get_full_response_text(invocation: Invocation) -> str: + """Concatenates all NL text from invocation events and the final response. + + When an agent emits text before a tool call (e.g. presenting a plan), + that text is stored in intermediate_data.invocation_events but not in + final_response. This method collects text from both sources to give the + judge a complete picture of the agent's output. + """ + parts = [] + if invocation.intermediate_data and isinstance( + invocation.intermediate_data, InvocationEvents + ): + for evt in invocation.intermediate_data.invocation_events: + text = get_text_from_content(evt.content) + if text: + parts.append(text) + final_text = get_text_from_content(invocation.final_response) + if final_text: + parts.append(final_text) + return "\n\n".join(parts) From e8b16089dda73f8714c0e7f29eab26e45afc7a5e Mon Sep 17 00:00:00 2001 From: Sid Gupta Date: Wed, 8 Apr 2026 16:05:38 -0700 Subject: [PATCH 2/3] fix: use getattr instead of isinstance for criterion check The criterion may be deserialized as BaseCriterion (which accepts extra fields via extra="allow") rather than RubricsBasedCriterion, so isinstance check fails even when evaluate_full_response is present. Using getattr with a default handles both cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../adk/evaluation/rubric_based_final_response_quality_v1.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py b/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py index 9207749984..5dc6320da3 100644 --- a/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py +++ b/src/google/adk/evaluation/rubric_based_final_response_quality_v1.py @@ -280,10 +280,7 @@ def format_auto_rater_prompt( # the final response. This is useful for agents that stream text, call # tools, then stream more text within a single invocation. criterion = self._eval_metric.criterion - evaluate_full = ( - isinstance(criterion, RubricsBasedCriterion) - and criterion.evaluate_full_response - ) + evaluate_full = getattr(criterion, "evaluate_full_response", False) if evaluate_full: final_response = self._get_full_response_text(actual_invocation) From d41c8feb7ab56432d75ab08dfde12f4b41781fcb Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Mon, 13 Apr 2026 14:48:30 -0700 Subject: [PATCH 3/3] feat: Live avatar support in ADK Testing plan: Added new unit tests - `test_avatar_config_initialization` - `test_avatar_config_with_name` - `test_receive_video_content` - `test_streaming_with_avatar_config` PiperOrigin-RevId: 899193911 --- .github/.release-please-manifest.json | 2 +- .github/release-please-config.json | 2 +- CHANGELOG.md | 27 ++++++ pyproject.toml | 2 +- src/google/adk/agents/run_config.py | 3 + src/google/adk/flows/llm_flows/basic.py | 3 + .../adk/models/gemini_llm_connection.py | 17 +--- .../adk/sessions/vertex_ai_session_service.py | 54 ++++++------ .../tools/_automatic_function_calling_util.py | 10 +++ src/google/adk/utils/model_name_utils.py | 6 +- src/google/adk/version.py | 2 +- tests/unittests/agents/test_run_config.py | 33 +++++++ .../models/test_gemini_llm_connection.py | 40 +++++++++ .../test_vertex_ai_session_service.py | 53 +++++++++++ .../streaming/test_live_streaming_configs.py | 87 +++++++++++++++++++ .../tools/test_from_function_with_options.py | 42 +++++++++ 16 files changed, 333 insertions(+), 50 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 6103afc2b2..bacdd304e4 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.29.0" + ".": "1.30.0" } diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 14f5b01af1..b8b693afc9 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "last-release-sha": "6b1600fbf53bcf634c5fe4793f02921bc0b75125", + "last-release-sha": "80a7ecf4b31e4c6de4a1425b03422f384c1a032d", "packages": { ".": { "release-type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b9211bbc..d955ad7ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [1.30.0](https://github.com/google/adk-python/compare/v1.29.0...v1.30.0) (2026-04-13) + + +### Features + +* Add Auth Provider support to agent registry ([f2c68eb](https://github.com/google/adk-python/commit/f2c68eb1536f1c0018c2cf7ee3f4417ca442080c)) +* Add Parameter Manager integration to ADK ([b0715d7](https://github.com/google/adk-python/commit/b0715d77a2a433bb2ed07a2475cc4d1f2d662b6c)) +* Add support for Gemma 4 models in ADK ([9d4ecbe](https://github.com/google/adk-python/commit/9d4ecbe9fd1141693e4682cbfe4d542cc62b76ac)), closes [#5156](https://github.com/google/adk-python/issues/5156) +* allow users to include artifacts from artifact_service in A2A events using provided interceptor ([e63d991](https://github.com/google/adk-python/commit/e63d991be84e373fd31be29d4b6b0e32fdbde557)) +* emit a `TaskStatusUpdateEvent` for ADK events with no output parts but with event.actions ([dcc485b](https://github.com/google/adk-python/commit/dcc485b23e3509e2e386636d841033b91c9a401c)) +* Live avatar support in ADK ([a64a8e4](https://github.com/google/adk-python/commit/a64a8e46480753439b91b9cfd41fd190b4dad493)) +* **live:** expose live_session_resumption_update as Event in BaseLlmFlow ([2626ad7](https://github.com/google/adk-python/commit/2626ad7c69fb64a88372225d5583085fc08b1fcd)), closes [#4357](https://github.com/google/adk-python/issues/4357) +* Promote BigQuery tools to Stable ([abcf14c](https://github.com/google/adk-python/commit/abcf14c166baf4f8cc6e919b1eb4c063bf3a92af)) +* **samples:** add sample for skill activation via environment tools ([2cbb523](https://github.com/google/adk-python/commit/2cbb52306910fac994fe1d29bdfcfacb258703b4)) + + +### Bug Fixes + +* Add "gcloud config unset project" command to express mode flow ([e7d8160](https://github.com/google/adk-python/commit/e7d81604126cbdb4d9ee4624e1d1410b06585750)) +* avoid load all agents in adk web server ([cb4dd42](https://github.com/google/adk-python/commit/cb4dd42eff2df6d20c5e53211718ecb023f127fc)) +* Change express mode user flow so it's more clear that an express mode project is being created ([0fedb3b](https://github.com/google/adk-python/commit/0fedb3b5eb2074999d8ccdb839e054ea80da486f)) +* Custom pickling in McpToolset to exclude unpicklable objects like errlog ([d62558c](https://github.com/google/adk-python/commit/d62558cc2d7d6c0372e43c9f009c8c7a6863ff0a)) +* Fix credential leakage vulnerability in Agent Registry ([e3567a6](https://github.com/google/adk-python/commit/e3567a65196bb453cdac4a5ae42f7f079476d748)) +* Include a link to the deployed agent ([547766a](https://github.com/google/adk-python/commit/547766a47779915a8a47745237a46882a02dae9a)) +* preserve interaction ids for interactions SSE tool calls ([9a19304](https://github.com/google/adk-python/commit/9a1930407a4eff67093ea9f14292f1931631a661)), closes [#5169](https://github.com/google/adk-python/issues/5169) +* validate user_id and session_id against path traversal ([cbcb5e6](https://github.com/google/adk-python/commit/cbcb5e6002b5bae89de5309caf7b9bb02d563cfc)), closes [#5110](https://github.com/google/adk-python/issues/5110) + ## [1.29.0](https://github.com/google/adk-python/compare/v1.28.0...v1.29.0) (2026-04-09) diff --git a/pyproject.toml b/pyproject.toml index 15e6054c9f..4cbdb4c246 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "google-cloud-spanner>=3.56.0, <4.0.0", # For Spanner database "google-cloud-speech>=2.30.0, <3.0.0", # For Audio Transcription "google-cloud-storage>=2.18.0, <4.0.0", # For GCS Artifact service - "google-genai>=1.64.0, <2.0.0", # Google GenAI SDK + "google-genai>=1.72.0, <2.0.0", # Google GenAI SDK "graphviz>=0.20.2, <1.0.0", # Graphviz for graph rendering "httpx>=0.27.0, <1.0.0", # HTTP client library "jsonschema>=4.23.0, <5.0.0", # Agent Builder config validation diff --git a/src/google/adk/agents/run_config.py b/src/google/adk/agents/run_config.py index 4790172966..e059cd957d 100644 --- a/src/google/adk/agents/run_config.py +++ b/src/google/adk/agents/run_config.py @@ -198,6 +198,9 @@ class RunConfig(BaseModel): response_modalities: Optional[list[str]] = None """The output modalities. If not set, it's default to AUDIO.""" + avatar_config: Optional[types.AvatarConfig] = None + """Avatar configuration for the live agent.""" + save_input_blobs_as_artifacts: bool = Field( default=False, deprecated=True, diff --git a/src/google/adk/flows/llm_flows/basic.py b/src/google/adk/flows/llm_flows/basic.py index 8751f9b3a2..8e9bfa514c 100644 --- a/src/google/adk/flows/llm_flows/basic.py +++ b/src/google/adk/flows/llm_flows/basic.py @@ -90,6 +90,9 @@ def _build_basic_request( llm_request.live_connect_config.context_window_compression = ( invocation_context.run_config.context_window_compression ) + llm_request.live_connect_config.avatar_config = ( + invocation_context.run_config.avatar_config + ) class _BasicLlmRequestProcessor(BaseLlmRequestProcessor): diff --git a/src/google/adk/models/gemini_llm_connection.py b/src/google/adk/models/gemini_llm_connection.py index 8f491dc88b..5cd1976f41 100644 --- a/src/google/adk/models/gemini_llm_connection.py +++ b/src/google/adk/models/gemini_llm_connection.py @@ -115,16 +115,7 @@ async def send_content(self, content: types.Content): is_gemini_31 = model_name_utils.is_gemini_3_1_flash_live( self._model_version ) - is_gemini_api = self._api_backend == GoogleLLMVariant.GEMINI_API - - # As of now, Gemini 3.1 Flash Live is only available in Gemini API, not - # Vertex AI. - if ( - is_gemini_31 - and is_gemini_api - and len(content.parts) == 1 - and content.parts[0].text - ): + if is_gemini_31 and len(content.parts) == 1 and content.parts[0].text: logger.debug('Using send_realtime_input for Gemini 3.1 text input') await self._gemini_session.send_realtime_input( text=content.parts[0].text @@ -149,11 +140,7 @@ async def send_realtime(self, input: RealtimeInput): is_gemini_31 = model_name_utils.is_gemini_3_1_flash_live( self._model_version ) - is_gemini_api = self._api_backend == GoogleLLMVariant.GEMINI_API - - # As of now, Gemini 3.1 Flash Live is only available in Gemini API, not - # Vertex AI. - if is_gemini_31 and is_gemini_api: + if is_gemini_31: if input.mime_type and input.mime_type.startswith('audio/'): await self._gemini_session.send_realtime_input(audio=input) elif input.mime_type and input.mime_type.startswith('image/'): diff --git a/src/google/adk/sessions/vertex_ai_session_service.py b/src/google/adk/sessions/vertex_ai_session_service.py index c653456a25..d06816f132 100644 --- a/src/google/adk/sessions/vertex_ai_session_service.py +++ b/src/google/adk/sessions/vertex_ai_session_service.py @@ -270,6 +270,7 @@ async def append_event(self, session: Session, event: Event) -> Event: reasoning_engine_id = self._get_reasoning_engine_id(session.app_name) + # Build config (Monolithic approach) config = {} if event.content: config['content'] = event.content.model_dump( @@ -286,9 +287,6 @@ async def append_event(self, session: Session, event: Event) -> Event: k: json.loads(v.model_dump_json(exclude_none=True, by_alias=True)) for k, v in event.actions.requested_auth_configs.items() }, - # TODO: add requested_tool_confirmations, agent_state once - # they are available in the API. - # Note: compaction is stored via event_metadata.custom_metadata. } if event.error_code: config['error_code'] = event.error_code @@ -311,10 +309,8 @@ async def append_event(self, session: Session, event: Event) -> Event: metadata_dict['grounding_metadata'] = event.grounding_metadata.model_dump( exclude_none=True, mode='json' ) - # Store compaction data in custom_metadata since the Vertex AI service - # does not yet support the compaction field. - # TODO: Stop writing to custom_metadata once the Vertex AI service - # supports the compaction field natively in EventActions. + + # ALWAYS write to custom_metadata if event.actions and event.actions.compaction: compaction_dict = event.actions.compaction.model_dump( exclude_none=True, mode='json' @@ -324,8 +320,6 @@ async def append_event(self, session: Session, event: Event) -> Event: key=_COMPACTION_CUSTOM_METADATA_KEY, value=compaction_dict, ) - # Store usage_metadata in custom_metadata since the Vertex AI service - # does not persist it in EventMetadata. if event.usage_metadata: usage_dict = event.usage_metadata.model_dump( exclude_none=True, mode='json' @@ -335,7 +329,12 @@ async def append_event(self, session: Session, event: Event) -> Event: key=_USAGE_METADATA_CUSTOM_METADATA_KEY, value=usage_dict, ) + config['event_metadata'] = metadata_dict + + # Persist the full event state using raw_event. If the client-side SDK + # does not support this field, it will raise a ValidationError, and we + # will fall back to legacy field-based storage. config['raw_event'] = event.model_dump( exclude_none=True, mode='json', @@ -345,7 +344,8 @@ async def append_event(self, session: Session, event: Event) -> Event: # Retry without raw_event if client side validation fails for older SDK # versions. async with self._get_api_client() as api_client: - try: + + async def _do_append(cfg: dict[str, Any]): await api_client.agent_engines.sessions.events.append( name=( f'reasoningEngines/{reasoning_engine_id}/sessions/{session.id}' @@ -355,22 +355,16 @@ async def append_event(self, session: Session, event: Event) -> Event: timestamp=datetime.datetime.fromtimestamp( event.timestamp, tz=datetime.timezone.utc ), - config=config, + config=cfg, ) + + try: + await _do_append(config) except pydantic.ValidationError: + logger.warning('Vertex SDK does not support raw_event, falling back.') if 'raw_event' in config: del config['raw_event'] - await api_client.agent_engines.sessions.events.append( - name=( - f'reasoningEngines/{reasoning_engine_id}/sessions/{session.id}' - ), - author=event.author, - invocation_id=event.invocation_id, - timestamp=datetime.datetime.fromtimestamp( - event.timestamp, tz=datetime.timezone.utc - ), - config=config, - ) + await _do_append(config) return event def _get_reasoning_engine_id(self, app_name: str): @@ -429,8 +423,8 @@ def _get_raw_event(api_event_obj: Any) -> dict[str, Any] | None: def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: """Converts an API event object to an Event object.""" - # Read event data from raw_event first before falling back to top level - # fields. + # Prioritize reading from raw_event to restore full state. Fall back to + # top-level fields for older data that lacks raw_event. raw_event_dict = _get_raw_event(api_event_obj) if raw_event_dict: event_dict = copy.deepcopy(raw_event_dict) @@ -439,8 +433,9 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: 'id': api_event_obj.name.split('/')[-1], 'invocation_id': getattr(api_event_obj, 'invocation_id', None), 'author': getattr(api_event_obj, 'author', None), - 'timestamp': timestamp_obj.timestamp() if timestamp_obj else None, }) + if timestamp_obj: + event_dict['timestamp'] = timestamp_obj.timestamp() return Event.model_validate(event_dict) actions = getattr(api_event_obj, 'actions', None) @@ -514,6 +509,13 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: usage_metadata_data ) + timestamp_obj = getattr(api_event_obj, 'timestamp', None) + timestamp = ( + timestamp_obj.timestamp() + if timestamp_obj + else datetime.datetime.now(datetime.timezone.utc).timestamp() + ) + return Event( id=api_event_obj.name.split('/')[-1], invocation_id=api_event_obj.invocation_id, @@ -522,7 +524,7 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: content=_session_util.decode_model( getattr(api_event_obj, 'content', None), types.Content ), - timestamp=api_event_obj.timestamp.timestamp(), + timestamp=timestamp, error_code=getattr(api_event_obj, 'error_code', None), error_message=getattr(api_event_obj, 'error_message', None), partial=partial, diff --git a/src/google/adk/tools/_automatic_function_calling_util.py b/src/google/adk/tools/_automatic_function_calling_util.py index 392e256b33..aef4424a49 100644 --- a/src/google/adk/tools/_automatic_function_calling_util.py +++ b/src/google/adk/tools/_automatic_function_calling_util.py @@ -368,6 +368,11 @@ def from_function_with_options( parameters_json_schema[name] = types.Schema.model_validate( json_schema_dict ) + if param.default is not inspect.Parameter.empty: + if param.default is not None: + parameters_json_schema[name].default = param.default + else: + parameters_json_schema[name].nullable = True except Exception as e: _function_parameter_parse_util._raise_for_unsupported_param( param, func.__name__, e @@ -392,6 +397,11 @@ def from_function_with_options( type='OBJECT', properties=parameters_json_schema, ) + declaration.parameters.required = ( + _function_parameter_parse_util._get_required_fields( + declaration.parameters + ) + ) if variant == GoogleLLMVariant.GEMINI_API: return declaration diff --git a/src/google/adk/utils/model_name_utils.py b/src/google/adk/utils/model_name_utils.py index 86fd79ab64..1090493a4d 100644 --- a/src/google/adk/utils/model_name_utils.py +++ b/src/google/adk/utils/model_name_utils.py @@ -130,9 +130,6 @@ def is_gemini_2_or_above(model_string: Optional[str]) -> bool: def is_gemini_3_1_flash_live(model_string: Optional[str]) -> bool: """Check if the model is a Gemini 3.1 Flash Live model. - Note: This is a very specific model name for live bidi streaming, so we check - for exact match. - Args: model_string: The model name @@ -141,5 +138,4 @@ def is_gemini_3_1_flash_live(model_string: Optional[str]) -> bool: """ if not model_string: return False - - return model_string == 'gemini-3.1-flash-live-preview' + return model_string.startswith('gemini-3.1-flash-live') diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 1782a41abc..d392c99b2c 100644 --- a/src/google/adk/version.py +++ b/src/google/adk/version.py @@ -13,4 +13,4 @@ # limitations under the License. # version: major.minor.patch -__version__ = "1.29.0" +__version__ = "1.30.0" diff --git a/tests/unittests/agents/test_run_config.py b/tests/unittests/agents/test_run_config.py index c34e9b66b6..cbb82af019 100644 --- a/tests/unittests/agents/test_run_config.py +++ b/tests/unittests/agents/test_run_config.py @@ -17,6 +17,7 @@ from unittest.mock import patch from google.adk.agents.run_config import RunConfig +from google.genai import types import pytest @@ -64,3 +65,35 @@ def test_audio_transcription_configs_are_not_shared_between_instances(): assert ( config1.input_audio_transcription is not config2.input_audio_transcription ) + + +def test_avatar_config_initialization(): + custom_avatar = types.CustomizedAvatar( + image_mime_type="image/jpeg", image_data=b"image_bytes" + ) + avatar_config = types.AvatarConfig( + audio_bitrate_bps=128000, + video_bitrate_bps=1000000, + customized_avatar=custom_avatar, + ) + run_config = RunConfig(avatar_config=avatar_config) + + assert run_config.avatar_config == avatar_config + assert run_config.avatar_config.customized_avatar == custom_avatar + assert ( + run_config.avatar_config.customized_avatar.image_mime_type == "image/jpeg" + ) + assert run_config.avatar_config.customized_avatar.image_data == b"image_bytes" + + +def test_avatar_config_with_name(): + avatar_config = types.AvatarConfig( + audio_bitrate_bps=128000, + video_bitrate_bps=1000000, + avatar_name="test_avatar", + ) + run_config = RunConfig(avatar_config=avatar_config) + + assert run_config.avatar_config == avatar_config + assert run_config.avatar_config.avatar_name == "test_avatar" + assert run_config.avatar_config.customized_avatar is None diff --git a/tests/unittests/models/test_gemini_llm_connection.py b/tests/unittests/models/test_gemini_llm_connection.py index 0bed24831e..2d2870a066 100644 --- a/tests/unittests/models/test_gemini_llm_connection.py +++ b/tests/unittests/models/test_gemini_llm_connection.py @@ -1120,3 +1120,43 @@ async def mock_receive_generator(): assert len(responses) == 1 assert responses[0].go_away == mock_go_away + + +@pytest.mark.asyncio +async def test_receive_video_content(gemini_connection, mock_gemini_session): + """Test receive with video content.""" + mock_content = types.Content( + role='model', + parts=[ + types.Part( + inline_data=types.Blob(data=b'video_data', mime_type='video/mp4') + ) + ], + ) + mock_server_content = mock.Mock() + mock_server_content.model_turn = mock_content + mock_server_content.interrupted = False + mock_server_content.input_transcription = None + mock_server_content.output_transcription = None + mock_server_content.turn_complete = False + mock_server_content.grounding_metadata = None + + mock_message = mock.AsyncMock() + mock_message.usage_metadata = None + mock_message.server_content = mock_server_content + mock_message.tool_call = None + mock_message.session_resumption_update = None + mock_message.go_away = None + + async def mock_receive_generator(): + yield mock_message + + receive_mock = mock.Mock(return_value=mock_receive_generator()) + mock_gemini_session.receive = receive_mock + + responses = [resp async for resp in gemini_connection.receive()] + + assert responses + content_response = next((r for r in responses if r.content), None) + assert content_response is not None + assert content_response.content == mock_content diff --git a/tests/unittests/sessions/test_vertex_ai_session_service.py b/tests/unittests/sessions/test_vertex_ai_session_service.py index 80a7c9c537..d5e12f6038 100644 --- a/tests/unittests/sessions/test_vertex_ai_session_service.py +++ b/tests/unittests/sessions/test_vertex_ai_session_service.py @@ -35,6 +35,7 @@ from google.api_core import exceptions as api_core_exceptions from google.genai import types as genai_types from google.genai.errors import ClientError +import pydantic import pytest MOCK_SESSION_JSON_1 = { @@ -1223,3 +1224,55 @@ async def test_append_event_with_usage_metadata_and_compaction(): assert appended_event.custom_metadata == {'extra': 'info'} assert '_compaction' not in (appended_event.custom_metadata or {}) assert '_usage_metadata' not in (appended_event.custom_metadata or {}) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures('mock_get_api_client') +async def test_append_event_fallback_for_older_sdk(mock_api_client_instance): + """Tests that append_event falls back to custom_metadata when SDK fails on raw_event.""" + session_service = mock_vertex_ai_session_service() + session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert session is not None + + compaction = EventCompaction( + start_timestamp=1000.0, + end_timestamp=2000.0, + compacted_content=genai_types.Content( + parts=[genai_types.Part(text='compacted summary')] + ), + ) + event_to_append = Event( + invocation_id='fallback_invocation', + author='model', + timestamp=1734005534.0, + actions=EventActions(compaction=compaction), + ) + + mock_client = mock_api_client_instance + + async def side_effect(name, author, invocation_id, timestamp, config): + if 'raw_event' in config: + # Trigger a real ValidationError since Pydantic V2 doesn't allow easy + # instantiation + class DummyModel(pydantic.BaseModel): + a: int + + DummyModel(a='not an int') + return await mock_client._append_event( + name, author, invocation_id, timestamp, config + ) + + mock_client.agent_engines.sessions.events.append.side_effect = side_effect + + await session_service.append_event(session, event_to_append) + + # Verify that it was written and restored correctly via custom_metadata + retrieved_session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + appended_event = retrieved_session.events[-1] + + assert appended_event.actions.compaction is not None + assert appended_event.actions.compaction.start_timestamp == 1000.0 diff --git a/tests/unittests/streaming/test_live_streaming_configs.py b/tests/unittests/streaming/test_live_streaming_configs.py index c9cff4ea97..679d3964b9 100644 --- a/tests/unittests/streaming/test_live_streaming_configs.py +++ b/tests/unittests/streaming/test_live_streaming_configs.py @@ -642,3 +642,90 @@ def test_streaming_with_context_window_compression_config(): llm_request_sent_to_mock.live_connect_config.context_window_compression.sliding_window.target_tokens == 500 ) + + +def test_streaming_with_avatar_config(): + """Test avatar_config propagation and video content through run_live. + + Verifies: + 1. avatar_config from RunConfig is propagated to live_connect_config. + 2. Video inline_data from the model flows through events correctly. + """ + # Mock model returns video content followed by turn_complete. + video_response = LlmResponse( + content=types.Content( + role='model', + parts=[ + types.Part( + inline_data=types.Blob( + data=b'video_data', mime_type='video/mp4' + ) + ) + ], + ), + ) + turn_complete_response = LlmResponse( + turn_complete=True, + ) + + mock_model = testing_utils.MockModel.create( + [video_response, turn_complete_response] + ) + + root_agent = Agent( + name='root_agent', + model=mock_model, + tools=[], + ) + + runner = testing_utils.InMemoryRunner( + root_agent=root_agent, response_modalities=['VIDEO'] + ) + + run_config = RunConfig( + response_modalities=['VIDEO'], + avatar_config=types.AvatarConfig(avatar_name='Kai'), + ) + + live_request_queue = LiveRequestQueue() + live_request_queue.send_realtime( + blob=types.Blob(data=b'\x00\xFF', mime_type='audio/pcm') + ) + res_events = runner.run_live(live_request_queue, run_config) + + assert res_events is not None, 'Expected a list of events, got None.' + assert ( + len(res_events) > 0 + ), 'Expected at least one response, but got an empty list.' + assert len(mock_model.requests) == 1 + + # 1. Verify avatar_config was propagated to the live_connect_config. + llm_request_sent_to_mock = mock_model.requests[0] + assert llm_request_sent_to_mock.live_connect_config is not None + assert llm_request_sent_to_mock.live_connect_config.avatar_config is not None + assert ( + llm_request_sent_to_mock.live_connect_config.avatar_config.avatar_name + == 'Kai' + ) + + # 2. Verify video content flows through events. + video_events = [ + e + for e in res_events + if e.content + and e.content.parts + and any( + p.inline_data + and p.inline_data.mime_type + and p.inline_data.mime_type.startswith('video/') + for p in e.content.parts + ) + ] + assert video_events, 'Expected at least one event with video inline_data.' + + video_event = video_events[0] + assert video_event.content.role == 'model' + video_part = video_event.content.parts[0] + assert video_part.inline_data is not None + assert video_part.inline_data.data == b'video_data' + assert video_part.inline_data.mime_type == 'video/mp4' diff --git a/tests/unittests/tools/test_from_function_with_options.py b/tests/unittests/tools/test_from_function_with_options.py index a3f68ee11c..537094da39 100644 --- a/tests/unittests/tools/test_from_function_with_options.py +++ b/tests/unittests/tools/test_from_function_with_options.py @@ -319,3 +319,45 @@ async def test_function(param: str) -> AsyncGenerator[Dict[str, str], None]: # VERTEX_AI should extract yield type (Dict[str, str]) from AsyncGenerator assert declaration.response is not None assert declaration.response.type == types.Type.OBJECT + + +def test_required_fields_set_in_json_schema_fallback(): + """Test that required fields are populated when the json_schema fallback path is used. + + When a parameter has a complex union type (e.g. list[str] | None) that + _parse_schema_from_parameter can't handle, from_function_with_options falls + back to the parameters_json_schema branch. This test verifies that the + required fields are correctly populated in that fallback branch. + """ + + def complex_tool( + query: str, + mode: str = 'default', + tags: list[str] | None = None, + ) -> str: + """A tool where one param has a complex union type.""" + return query + + declaration = _automatic_function_calling_util.from_function_with_options( + complex_tool, GoogleLLMVariant.GEMINI_API + ) + + assert declaration.name == 'complex_tool' + assert declaration.parameters == types.Schema( + type=types.Type.OBJECT, + required=['query'], + properties={ + 'query': types.Schema(type=types.Type.STRING), + 'mode': types.Schema(type=types.Type.STRING, default='default'), + 'tags': types.Schema( + any_of=[ + types.Schema( + items=types.Schema(type=types.Type.STRING), + type=types.Type.ARRAY, + ), + types.Schema(type=types.Type.NULL), + ], + nullable=True, + ), + }, + )