From c57d000699d9d4c0a0cb5d1c9e40e86f29f55b05 Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Thu, 26 Mar 2026 15:44:57 -0700 Subject: [PATCH 1/6] chore(release/candidate): release 1.28.0 (#5014) --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 76 +++++++++++++++++++++++++++ src/google/adk/version.py | 2 +- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 215f02e088..bac4ebcfdc 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.27.4" + ".": "1.28.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b0423f8073..8a1fabb0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [1.28.0](https://github.com/google/adk-python/compare/v1.27.5...v1.28.0) (2026-03-26) + + +### Features + +* **a2a:** add lifespan parameter to to_a2a() ([0f4c807](https://github.com/google/adk-python/commit/0f4c8073e5a180a220f88928d67ee8d521486f03)), closes [#4701](https://github.com/google/adk-python/issues/4701) +* Add a new extension for the new version of ADK-A2A integration ([6f0dcb3](https://github.com/google/adk-python/commit/6f0dcb3e26dd82fed1a8564c17a47eec03b04617)) +* Add ability to run individual unit tests to unittests.sh ([b3fcd8a](https://github.com/google/adk-python/commit/b3fcd8a21fe64063cdd8d07121ee4da3adb44c30)) +* Add database_role property to SpannerToolSettings and use it in execute_sql to support fine grained access controls ([360e0f7](https://github.com/google/adk-python/commit/360e0f7ebaba7a682f7230c259b474ace7ff6d13)) +* Add index to events table and update dependencies ([3153e6d](https://github.com/google/adk-python/commit/3153e6d74f401f39e363a36f6fa0664f245013db)), closes [#4827](https://github.com/google/adk-python/issues/4827) +* Add MultiTurn Task success metric ([9a75c06](https://github.com/google/adk-python/commit/9a75c06873b79fbd206b3712231c0280fb2f87ca)) +* Add MultiTurn Task trajectory and tool trajectory metrics ([38bfb44](https://github.com/google/adk-python/commit/38bfb4475406d63af3111775950d9c25acf17ed2)) +* Add slack integration to ADK ([6909a16](https://github.com/google/adk-python/commit/6909a167c8d030111bf7118b9d5e78255a299684)) +* Add Spanner Admin Toolset ([28618a8](https://github.com/google/adk-python/commit/28618a8dcbee9c4faeec6653a5d978d0330f39bb)) +* Add SSE streaming support to conformance tests ([c910961](https://github.com/google/adk-python/commit/c910961501ef559814f54c22aca1609fd3227b80)) +* Add support for Anthropic's thinking_blocks format in LiteLLM integration ([fc45fa6](https://github.com/google/adk-python/commit/fc45fa68d75fbf5276bf5951929026285a8bb4af)), closes [#4801](https://github.com/google/adk-python/issues/4801) +* Add support for timeout to UnsafeLocalCodeExecutor ([71d26ef](https://github.com/google/adk-python/commit/71d26ef7b90fe25a5093e4ccdf74b103e64fac67)) +* **auth:** Integrate GCP IAM Connectors (Noop implementation) ([78e5a90](https://github.com/google/adk-python/commit/78e5a908dcb4b1a93e156c6f1b282f59ec6b69d4)) +* **bigquery:** Migrate 1P BQ Toolset ([08be442](https://github.com/google/adk-python/commit/08be44295de614f30e686113897af7fe9c228751)) ([7aa1f52](https://github.com/google/adk-python/commit/7aa1f5252c15caaf40fde73ac4283fa0a48d8a96)) ([d112131](https://github.com/google/adk-python/commit/d1121317ef4e1ac559f4ae13855ac1af28eef8f6)) ([166ff99](https://github.com/google/adk-python/commit/166ff99b9266cd3bb0e86070c58a67d937216297)) +* enable suppressing A2A experimental warnings ([fdc2b43](https://github.com/google/adk-python/commit/fdc2b4355b5a73b8f32d3fa32a092339d963ce67)) +* Enhance AgentEngineSandboxCodeExecutor sample to automatically provision an Agent Engine if neither agent_engine_resource_name nor sandbox_resource_name is provided ([6c34694](https://github.com/google/adk-python/commit/6c34694da64968bc766a7e5e860c0ed9acbc69c2)) +* Extract and merge EventActions from A2A metadata ([4b677e7](https://github.com/google/adk-python/commit/4b677e73b939f5a13269abd9ba9fe65e4b78d7f6)), closes [#3968](https://github.com/google/adk-python/issues/3968) +* **mcp:** add sampling callback support for MCP sessions ([8f82697](https://github.com/google/adk-python/commit/8f826972cc06ef250c1f020e34b9d1cdbd0788c4)) +* Optional GCP project and credential for GCS access ([2f90c1a](https://github.com/google/adk-python/commit/2f90c1ac09638517b08cd96a17d595f0968f0bf6)) +* Support new embedding model in files retrieval ([faafac9](https://github.com/google/adk-python/commit/faafac9bb33b45174f04746055fc655b12d3e7f7)) + + +### Bug Fixes + +* add agent name validation to prevent arbitrary module imports ([116f75d](https://github.com/google/adk-python/commit/116f75d)) +* add protection for arbitrary module imports ([995cd1c](https://github.com/google/adk-python/commit/995cd1c)), closes [#4947](https://github.com/google/adk-python/issues/4947) +* Add read-only session support in DatabaseSessionService ([f6ea58b](https://github.com/google/adk-python/commit/f6ea58b5939b33afad5a2d2f8fb395150120ae07)), closes [#4771](https://github.com/google/adk-python/issues/4771) +* Allow snake case for skill name ([b157276](https://github.com/google/adk-python/commit/b157276cbb3c4f7f7b97e338e9d9df63d9c949cd)) +* **bigquery:** use valid dataplex OAuth scope ([4010716](https://github.com/google/adk-python/commit/4010716470fc83918dc367c5971342ff551401c8)) +* Default to ClusterIP so GKE deployment isn't publicly exposed by default ([f7359e3](https://github.com/google/adk-python/commit/f7359e3fd40eae3b8ef50c7bc88f1075ffb9b7de)) +* **deps:** bump google-genai minimum to >=1.64.0 for gemini-embedding-2-preview ([f8270c8](https://github.com/google/adk-python/commit/f8270c826bc807da99b4126e98ee1c505f4ed7c3)) +* enforce allowed file extensions for GET requests in the builder API ([96e845e](https://github.com/google/adk-python/commit/96e845ef8cf66b288d937e293a88cdb28b09417c)) +* error when event does not contain long_running_tool_ids ([1f9f0fe](https://github.com/google/adk-python/commit/1f9f0fe9d349c06f48063b856242c67654786dbc)) +* Exclude compromised LiteLLM versions from dependencies pin to 1.82.6 ([77f1c41](https://github.com/google/adk-python/commit/77f1c41be61eed017b008d7ab311923e30b46643)) +* Fix IDE hangs by moving test venv and cache to /tmp ([6f6fd95](https://github.com/google/adk-python/commit/6f6fd955f6dab50c98859294328a98f32181dc27)) +* Fix imports for environment simulation files ([dcccfca](https://github.com/google/adk-python/commit/dcccfca1d1dd1b3b9c273278e9f9c883f0148eba)) +* gate builder endpoints behind web flag ([6c24ccc](https://github.com/google/adk-python/commit/6c24ccc9ec7d0f942e1dd436a823f48ae1a0c695)) +* Handle concurrent creation of app/user state rows in DatabaseSessionService ([d78422a](https://github.com/google/adk-python/commit/d78422a4051bba383202f3f13325e65b8be3ccd3)), closes [#4954](https://github.com/google/adk-python/issues/4954) +* **live:** convert response_modalities to Modality enum before assigning to LiveConnectConfig ([47aaf66](https://github.com/google/adk-python/commit/47aaf66efb3e3825f06fd44578d592924fb7542b)), closes [#4869](https://github.com/google/adk-python/issues/4869) +* **models:** handle arbitrary dict responses in part_to_message_block ([c26d359](https://github.com/google/adk-python/commit/c26d35916e1b6cd12a412516381c6fcbf867bcee)) +* **models:** update 429 docs link for Gemini ([a231c72](https://github.com/google/adk-python/commit/a231c729e6526a2035b4630796f3dc8a658bb203)) +* populate `required` for Pydantic `BaseModel` parameters in `FunctionTool` ([c5d809e](https://github.com/google/adk-python/commit/c5d809e10eeaadfcbd12874d0976b5259f327adf)), closes [#4777](https://github.com/google/adk-python/issues/4777) +* Prevent compaction of events with pending function calls ([991c411](https://github.com/google/adk-python/commit/991c4111e31ffe97acc73c2ddf5cacea0955d39f)), closes [#4740](https://github.com/google/adk-python/issues/4740) +* Prevent uv.lock modifications in unittests.sh ([e6476c9](https://github.com/google/adk-python/commit/e6476c9790eaa8f3a6ae8165351f8fe38bf9bd1e)) +* Refactor blocking subprocess call to use asyncio in bash_tool ([58c4536](https://github.com/google/adk-python/commit/58c453688fea921707a07c21d0669174ea1a3b5f)) +* Refactor LiteLlm check to avoid ImportError ([7b94a76](https://github.com/google/adk-python/commit/7b94a767337e0d642e808734608f07a70e077c62)) +* Reject appends to stale sessions in DatabaseSessionService ([b8e7647](https://github.com/google/adk-python/commit/b8e764715cb1cc7c8bc1de9aa94ca5f5271bb627)), closes [#4751](https://github.com/google/adk-python/issues/4751) +* Remove redundant client_id from fetch_token call ([50d6f35](https://github.com/google/adk-python/commit/50d6f35139b56aa5b9fb06ee53b911269c222ffe)), closes [#4782](https://github.com/google/adk-python/issues/4782) +* returns '<No stdout/stderr captured>' instead of empty strings for clearer agent feedback and correct typing ([3e00e95](https://github.com/google/adk-python/commit/3e00e955519730503e73155723f27b2bc8d5779b)) +* Store and retrieve usage_metadata in Vertex AI custom_metadata ([b318eee](https://github.com/google/adk-python/commit/b318eee979b1625d3d23ad98825c88f54016a12f)) +* Support resolving string annotations for `find_context_parameter` ([22fc332](https://github.com/google/adk-python/commit/22fc332c95b7deca95240b33406513bcc95c6e03)) +* **telemetry:** Rolling back change to fix issue affecting LlmAgent creation due to missing version field ([0e18f81](https://github.com/google/adk-python/commit/0e18f81a5cd0d0392ded653b1a63a236449a2685)) +* **tools:** disable default httpx 5s timeout in OpenAPI tool _request ([4c9c01f](https://github.com/google/adk-python/commit/4c9c01fd4b1c716950700fd56a1a8789795cb7b1)), closes [#4431](https://github.com/google/adk-python/issues/4431) +* **tools:** support regional Discovery Engine endpoints ([30b904e](https://github.com/google/adk-python/commit/30b904e596b0bcea8498a9b47d669585a6c481d3)) +* **tools:** support structured datastores in DiscoveryEngineSearchTool ([f35c3a6](https://github.com/google/adk-python/commit/f35c3a66da7c66967d06d0f5f058f9417abf1f8d)), closes [#3406](https://github.com/google/adk-python/issues/3406) +* Update Agent Registry to use the full agent card if available ([031f581](https://github.com/google/adk-python/commit/031f581ac6e0fb06cc1175217a26bdd0c7382da8)) +* Update eval extras to Vertex SDK package version with constrained LiteLLM upperbound ([27cc98d](https://github.com/google/adk-python/commit/27cc98db5fbc15de27713a5814d5c68e9c835d0f)) +* Update import and version for k8s-agent-sandbox ([1ee0623](https://github.com/google/adk-python/commit/1ee062312813e9564fdff693f883f57987e18c6a)), closes [#4883](https://github.com/google/adk-python/issues/4883) +* Update list_agents to only list directories, not validate agent definitions ([5020954](https://github.com/google/adk-python/commit/50209549206256abe5d1c5d84ab2b14dfdf80d66)) + + +### Code Refactoring +* rename agent simulator to environment simulation. Also add tracing into environment simulation ([99a31bf](https://github.com/google/adk-python/commit/99a31bf77ea6fb2c53c313094734611dcb87b1e2)) + + +### Documentation + +* Feat/Issue Monitoring Agent ([780093f](https://github.com/google/adk-python/commit/780093f389bfbffce965c89ca888d49f992219c1)) +* Use a dedicated API key for docs agents ([51c19cb](https://github.com/google/adk-python/commit/51c19cbc13c422dffd764ed0d7c664deed9e58b3)) + + ## [1.27.4](https://github.com/google/adk-python/compare/v1.27.3...v1.27.4) (2026-03-24) ### Bug Fixes diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 552556e23b..a020c76372 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.27.4" +__version__ = "1.28.0" From 4e05efb76ca96351f6fd868e09f26fe6d8cf8f9f Mon Sep 17 00:00:00 2001 From: Jacksunwei <1281348+Jacksunwei@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:45:10 +0000 Subject: [PATCH 2/6] chore: update last-release-sha for next release --- .github/release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 8c58807069..d9b9605e10 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": "066fcec3e8e669d1c5360e1556afce3f7e068072", + "last-release-sha": "50209549206256abe5d1c5d84ab2b14dfdf80d66", "packages": { ".": { "release-type": "python", From ee69661a616056fa89e0ec2188aaa59bd714d8c9 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Wed, 1 Apr 2026 16:39:25 -0700 Subject: [PATCH 3/6] feat(live): support live for `gemini-3.1-flash-live-preview` model This change updates the method used for sending text, audio and video data to the model. Closes issue #5018 Co-authored-by: Liang Wu PiperOrigin-RevId: 893174037 --- .../adk/models/gemini_llm_connection.py | 51 ++++++++++++++++--- src/google/adk/utils/model_name_utils.py | 18 +++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/google/adk/models/gemini_llm_connection.py b/src/google/adk/models/gemini_llm_connection.py index da508891d4..4cf28acdfc 100644 --- a/src/google/adk/models/gemini_llm_connection.py +++ b/src/google/adk/models/gemini_llm_connection.py @@ -20,6 +20,7 @@ from google.genai import types +from ..utils import model_name_utils from ..utils.content_utils import filter_audio_parts from ..utils.context_utils import Aclosing from ..utils.variant_utils import GoogleLLMVariant @@ -99,7 +100,6 @@ async def send_content(self, content: types.Content): Args: content: The content to send to the model. """ - assert content.parts if content.parts[0].function_response: # All parts have to be function responses. @@ -112,12 +112,30 @@ async def send_content(self, content: types.Content): ) else: logger.debug('Sending LLM new content %s', content) - await self._gemini_session.send( - input=types.LiveClientContent( - turns=[content], - turn_complete=True, - ) + 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 + ): + logger.debug('Using send_realtime_input for Gemini 3.1 text input') + await self._gemini_session.send_realtime_input( + text=content.parts[0].text + ) + else: + await self._gemini_session.send( + input=types.LiveClientContent( + turns=[content], + turn_complete=True, + ) + ) async def send_realtime(self, input: RealtimeInput): """Sends a chunk of audio or a frame of video to the model in realtime. @@ -128,7 +146,26 @@ async def send_realtime(self, input: RealtimeInput): if isinstance(input, types.Blob): # The blob is binary and is very large. So let's not log it. logger.debug('Sending LLM Blob.') - await self._gemini_session.send_realtime_input(media=input) + 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 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/'): + await self._gemini_session.send_realtime_input(video=input) + else: + logger.warning( + 'Blob not sent. Unknown or empty mime type for' + ' send_realtime_input: %s', + input.mime_type, + ) + else: + await self._gemini_session.send_realtime_input(media=input) elif isinstance(input, types.ActivityStart): logger.debug('Sending LLM activity start signal.') diff --git a/src/google/adk/utils/model_name_utils.py b/src/google/adk/utils/model_name_utils.py index 57103fb2c7..86fd79ab64 100644 --- a/src/google/adk/utils/model_name_utils.py +++ b/src/google/adk/utils/model_name_utils.py @@ -125,3 +125,21 @@ def is_gemini_2_or_above(model_string: Optional[str]) -> bool: return False return parsed_version.major >= 2 + + +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 + + Returns: + True if it's a Gemini 3.1 Flash Live model, False otherwise + """ + if not model_string: + return False + + return model_string == 'gemini-3.1-flash-live-preview' From 081adbdfa41490e4868b028a1cdabceb811a7505 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Wed, 1 Apr 2026 17:45:19 -0700 Subject: [PATCH 4/6] fix(live): Buffer tool calls and emit them together upon turn completion The `receive` method now accumulates function calls from multiple `LiveServerMessage` instances. These accumulated tool calls are yielded as a single `LlmResponse` containing all function call parts only when a turn_complete message is received. Without the change, the tool_1's response is sent to the model as soon as it's generated, triggering a second call for tool_2. Upon receiving two consecutive tool_2's responses, the model utters the same message twice. Fixes issue #4902 Co-authored-by: Liang Wu PiperOrigin-RevId: 893197482 --- .../README.md | 38 +++++ .../__init__.py | 15 ++ .../agent.py | 36 +++++ .../adk/models/gemini_llm_connection.py | 23 ++- .../models/test_gemini_llm_connection.py | 133 ++++++++++++++++-- 5 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 contributing/samples/live_bidi_streaming_parallel_tools_agent/README.md create mode 100644 contributing/samples/live_bidi_streaming_parallel_tools_agent/__init__.py create mode 100644 contributing/samples/live_bidi_streaming_parallel_tools_agent/agent.py diff --git a/contributing/samples/live_bidi_streaming_parallel_tools_agent/README.md b/contributing/samples/live_bidi_streaming_parallel_tools_agent/README.md new file mode 100644 index 0000000000..cc96819c38 --- /dev/null +++ b/contributing/samples/live_bidi_streaming_parallel_tools_agent/README.md @@ -0,0 +1,38 @@ +# Simple Live (Bidi-Streaming) Agent with Parallel Tools +This project provides a basic example of a live, [bidirectional streaming](https://google.github.io/adk-docs/streaming/) agent that demonstrates parallel tool execution. + +## Getting Started + +Follow these steps to get the agent up and running: + +1. **Start the ADK Web Server** + Open your terminal, navigate to the root directory that contains the + `live_bidi_streaming_parallel_tools_agent` folder, and execute the following + command: + ```bash + adk web + ``` + +2. **Access the ADK Web UI** + Once the server is running, open your web browser and navigate to the URL + provided in the terminal (it will typically be `http://localhost:8000`). + +3. **Select the Agent** + In the top-left corner of the ADK Web UI, use the dropdown menu to select + this agent (`live_bidi_streaming_parallel_tools_agent`). + +4. **Start Streaming** + Click on the **Audio** icon located near the chat input + box to begin the streaming session. + +5. **Interact with the Agent** + You can now begin talking to the agent, and it will respond in real-time. + Try asking it to perform multiple actions at once, for example: "Turn on the + lights and the TV at the same time." The agent will be able to invoke both + `turn_on_lights` and `turn_on_tv` tools in parallel. + +## Usage Notes + +* You only need to click the **Audio** button once to initiate the + stream. The current version does not support stopping and restarting the stream + by clicking the button again during a session. diff --git a/contributing/samples/live_bidi_streaming_parallel_tools_agent/__init__.py b/contributing/samples/live_bidi_streaming_parallel_tools_agent/__init__.py new file mode 100644 index 0000000000..4015e47d6e --- /dev/null +++ b/contributing/samples/live_bidi_streaming_parallel_tools_agent/__init__.py @@ -0,0 +1,15 @@ +# 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. + +from . import agent diff --git a/contributing/samples/live_bidi_streaming_parallel_tools_agent/agent.py b/contributing/samples/live_bidi_streaming_parallel_tools_agent/agent.py new file mode 100644 index 0000000000..519c31a61b --- /dev/null +++ b/contributing/samples/live_bidi_streaming_parallel_tools_agent/agent.py @@ -0,0 +1,36 @@ +# 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. + + +from google.adk.agents.llm_agent import Agent + + +def turn_on_lights(): + """Turn on the lights.""" + print("turn_on_lights") + return {"status": "OK"} + + +def turn_on_tv(): + """Turn on the tv.""" + print("turn_on_tv") + return {"status": "OK"} + + +root_agent = Agent( + model="gemini-live-2.5-flash-native-audio", + name="Home_helper", + instruction="Be polite and answer all user's questions.", + tools=[turn_on_lights, turn_on_tv], +) diff --git a/src/google/adk/models/gemini_llm_connection.py b/src/google/adk/models/gemini_llm_connection.py index 4cf28acdfc..7fc39748ec 100644 --- a/src/google/adk/models/gemini_llm_connection.py +++ b/src/google/adk/models/gemini_llm_connection.py @@ -203,6 +203,7 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]: """ text = '' + tool_call_parts = [] async with Aclosing(self._gemini_session.receive()) as agen: # TODO(b/440101573): Reuse StreamingResponseAggregator to accumulate # partial content and emit responses as needed. @@ -332,6 +333,13 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]: if text: yield self.__build_full_text_response(text) text = '' + if tool_call_parts: + logger.debug('Returning aggregated tool_call_parts') + yield LlmResponse( + content=types.Content(role='model', parts=tool_call_parts), + model_version=self._model_version, + ) + tool_call_parts = [] yield LlmResponse( turn_complete=True, interrupted=message.server_content.interrupted, @@ -353,17 +361,14 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]: model_version=self._model_version, ) if message.tool_call: + logger.debug('Received tool call: %s', message.tool_call) if text: yield self.__build_full_text_response(text) text = '' - parts = [ + tool_call_parts.extend([ types.Part(function_call=function_call) for function_call in message.tool_call.function_calls - ] - yield LlmResponse( - content=types.Content(role='model', parts=parts), - model_version=self._model_version, - ) + ]) if message.session_resumption_update: logger.debug('Received session resumption message: %s', message) yield ( @@ -372,6 +377,12 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]: model_version=self._model_version, ) ) + if tool_call_parts: + logger.debug('Exited loop with pending tool_call_parts') + yield LlmResponse( + content=types.Content(role='model', parts=tool_call_parts), + model_version=self._model_version, + ) async def close(self): """Closes the llm server connection.""" diff --git a/tests/unittests/models/test_gemini_llm_connection.py b/tests/unittests/models/test_gemini_llm_connection.py index 7b580c6fc0..09bd537d8e 100644 --- a/tests/unittests/models/test_gemini_llm_connection.py +++ b/tests/unittests/models/test_gemini_llm_connection.py @@ -933,33 +933,142 @@ async def test_receive_tool_call_and_grounding_metadata_with_native_audio( mock_metadata_msg.tool_call = None mock_metadata_msg.session_resumption_update = None + # 3. Message with turn_complete + mock_turn_complete_content = mock.create_autospec( + types.LiveServerContent, instance=True + ) + mock_turn_complete_content.model_turn = None + mock_turn_complete_content.grounding_metadata = None + mock_turn_complete_content.turn_complete = True + mock_turn_complete_content.interrupted = False + mock_turn_complete_content.input_transcription = None + mock_turn_complete_content.output_transcription = None + + mock_turn_complete_msg = mock.create_autospec( + types.LiveServerMessage, instance=True + ) + mock_turn_complete_msg.usage_metadata = None + mock_turn_complete_msg.server_content = mock_turn_complete_content + mock_turn_complete_msg.tool_call = None + mock_turn_complete_msg.session_resumption_update = None + async def mock_receive_generator(): yield mock_tool_call_msg yield mock_metadata_msg + yield mock_turn_complete_msg receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in connection.receive()] - assert len(responses) == 2 + assert len(responses) == 3 - # First response: the tool call + # First response: the audio content and grounding metadata + assert responses[0].grounding_metadata == grounding_metadata + assert responses[0].content == mock_content assert responses[0].content is not None assert responses[0].content.parts is not None - assert responses[0].content.parts[0].function_call is not None + assert responses[0].content.parts[0].inline_data == audio_blob + + # Second response: the tool call, buffered until turn_complete + assert responses[1].content is not None + assert responses[1].content.parts is not None + assert responses[1].content.parts[0].function_call is not None assert ( - responses[0].content.parts[0].function_call.name + responses[1].content.parts[0].function_call.name == 'enterprise_web_search' ) - assert responses[0].content.parts[0].function_call.args == { + assert responses[1].content.parts[0].function_call.args == { 'query': 'Google stock price today' } - assert responses[0].grounding_metadata is None + assert responses[1].grounding_metadata is None - # Second response: the audio content and grounding metadata - assert responses[1].grounding_metadata == grounding_metadata - assert responses[1].content == mock_content - assert responses[1].content is not None - assert responses[1].content.parts is not None - assert responses[1].content.parts[0].inline_data == audio_blob + # Third response: the turn_complete + assert responses[2].turn_complete is True + + +@pytest.mark.asyncio +async def test_receive_multiple_tool_calls_buffered_until_turn_complete( + gemini_connection, mock_gemini_session +): + """Test receive buffers multiple tool call messages until turn complete.""" + # First tool call message + mock_tool_call_msg1 = mock.create_autospec( + types.LiveServerMessage, instance=True + ) + mock_tool_call_msg1.usage_metadata = None + mock_tool_call_msg1.server_content = None + mock_tool_call_msg1.session_resumption_update = None + + function_call1 = types.FunctionCall( + name='tool_1', + args={'arg': 'value1'}, + ) + mock_tool_call1 = mock.create_autospec( + types.LiveServerToolCall, instance=True + ) + mock_tool_call1.function_calls = [function_call1] + mock_tool_call_msg1.tool_call = mock_tool_call1 + + # Second tool call message + mock_tool_call_msg2 = mock.create_autospec( + types.LiveServerMessage, instance=True + ) + mock_tool_call_msg2.usage_metadata = None + mock_tool_call_msg2.server_content = None + mock_tool_call_msg2.session_resumption_update = None + + function_call2 = types.FunctionCall( + name='tool_2', + args={'arg': 'value2'}, + ) + mock_tool_call2 = mock.create_autospec( + types.LiveServerToolCall, instance=True + ) + mock_tool_call2.function_calls = [function_call2] + mock_tool_call_msg2.tool_call = mock_tool_call2 + + # Turn complete message + mock_turn_complete_content = mock.create_autospec( + types.LiveServerContent, instance=True + ) + mock_turn_complete_content.model_turn = None + mock_turn_complete_content.grounding_metadata = None + mock_turn_complete_content.turn_complete = True + mock_turn_complete_content.interrupted = False + mock_turn_complete_content.input_transcription = None + mock_turn_complete_content.output_transcription = None + + mock_turn_complete_msg = mock.create_autospec( + types.LiveServerMessage, instance=True + ) + mock_turn_complete_msg.usage_metadata = None + mock_turn_complete_msg.server_content = mock_turn_complete_content + mock_turn_complete_msg.tool_call = None + mock_turn_complete_msg.session_resumption_update = None + + async def mock_receive_generator(): + yield mock_tool_call_msg1 + yield mock_tool_call_msg2 + yield mock_turn_complete_msg + + receive_mock = mock.Mock(return_value=mock_receive_generator()) + mock_gemini_session.receive = receive_mock + + responses = [resp async for resp in gemini_connection.receive()] + + # Expected: One LlmResponse with both tool calls, then one with turn_complete + assert len(responses) == 2 + + # First response: single LlmResponse carrying both function calls + assert responses[0].content is not None + parts = responses[0].content.parts + assert len(parts) == 2 + assert parts[0].function_call.name == 'tool_1' + assert parts[0].function_call.args == {'arg': 'value1'} + assert parts[1].function_call.name == 'tool_2' + assert parts[1].function_call.args == {'arg': 'value2'} + + # Second response: turn_complete True + assert responses[1].turn_complete is True From f037f68d67ae1bd16b00df0c7523fb67cbd1e911 Mon Sep 17 00:00:00 2001 From: Sasha Sobran Date: Thu, 2 Apr 2026 08:22:25 -0700 Subject: [PATCH 5/6] fix: Disallow args on /builder and Add warning about Web UI usage to CLI help Co-authored-by: Sasha Sobran PiperOrigin-RevId: 893521804 --- src/google/adk/cli/cli_tools_click.py | 6 +- src/google/adk/cli/fast_api.py | 95 +++++++++++++++++---------- tests/unittests/cli/test_fast_api.py | 47 +++++++++++-- 3 files changed, 105 insertions(+), 43 deletions(-) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index b9925724bd..171a63b425 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -1713,7 +1713,8 @@ def cli_api_server( default=False, help=( "Optional. Deploy ADK Web UI if set. (default: deploy ADK API server" - " only)" + " only). WARNING: The web UI is for development and testing only — do" + " not use in production." ), ) @click.option( @@ -2229,7 +2230,8 @@ def cli_deploy_agent_engine( default=False, help=( "Optional. Deploy ADK Web UI if set. (default: deploy ADK API server" - " only)" + " only). WARNING: The web UI is for development and testing only — do" + " not use in production." ), ) @click.option( diff --git a/src/google/adk/cli/fast_api.py b/src/google/adk/cli/fast_api.py index 0b6f3fb6fe..4b207b4b7d 100644 --- a/src/google/adk/cli/fast_api.py +++ b/src/google/adk/cli/fast_api.py @@ -27,6 +27,7 @@ import click from fastapi import FastAPI +from fastapi import HTTPException from fastapi import UploadFile from fastapi.responses import FileResponse from fastapi.responses import PlainTextResponse @@ -293,6 +294,39 @@ def _has_parent_reference(path: str) -> bool: _ALLOWED_EXTENSIONS = frozenset({".yaml", ".yml"}) + # --- YAML content security --- + # The `args` key in agent YAML configs (CodeConfig.args, ToolConfig.args) + # allows callers to pass arbitrary arguments to Python constructors and + # functions, which is an RCE vector when exposed through the builder UI. + # Block any upload that contains an `args` key anywhere in the document. + _BLOCKED_YAML_KEYS = frozenset({"args"}) + + def _check_yaml_for_blocked_keys(content: bytes, filename: str) -> None: + """Raise if the YAML document contains any blocked keys.""" + import yaml + + try: + docs = list(yaml.safe_load_all(content)) + except yaml.YAMLError as exc: + raise ValueError(f"Invalid YAML in {filename!r}: {exc}") from exc + + def _walk(node: Any) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key in _BLOCKED_YAML_KEYS: + raise ValueError( + f"Blocked key {key!r} found in {filename!r}. " + f"The '{key}' field is not allowed in builder uploads " + "because it can execute arbitrary code." + ) + _walk(value) + elif isinstance(node, list): + for item in node: + _walk(item) + + for doc in docs: + _walk(doc) + def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]: if not filename: raise ValueError("Upload filename is missing.") @@ -430,40 +464,14 @@ async def builder_build( files: list[UploadFile], tmp: Optional[bool] = False ) -> bool: try: - if tmp: - app_names = set() - uploads = [] - for file in files: - app_name, rel_path = _parse_upload_filename(file.filename) - app_names.add(app_name) - uploads.append((rel_path, file)) - - if len(app_names) != 1: - logger.error( - "Exactly one app name is required, found: %s", - sorted(app_names), - ) - return False - - app_name = next(iter(app_names)) - app_root = _get_app_root(app_name) - tmp_agent_root = _get_tmp_agent_root(app_root, app_name) - tmp_agent_root.mkdir(parents=True, exist_ok=True) - - for rel_path, file in uploads: - destination_path = _resolve_under_dir(tmp_agent_root, rel_path) - destination_path.parent.mkdir(parents=True, exist_ok=True) - with destination_path.open("wb") as buffer: - shutil.copyfileobj(file.file, buffer) - - return True - - app_names = set() - uploads = [] + # Phase 1: parse filenames and read content into memory. + app_names: set[str] = set() + uploads: list[tuple[str, bytes]] = [] for file in files: app_name, rel_path = _parse_upload_filename(file.filename) app_names.add(app_name) - uploads.append((rel_path, file)) + content = await file.read() + uploads.append((rel_path, content)) if len(app_names) != 1: logger.error( @@ -473,6 +481,24 @@ async def builder_build( return False app_name = next(iter(app_names)) + + # Phase 2: validate every file *before* writing anything to disk. + for rel_path, content in uploads: + _check_yaml_for_blocked_keys(content, f"{app_name}/{rel_path}") + + # Phase 3: write validated files to disk. + if tmp: + app_root = _get_app_root(app_name) + tmp_agent_root = _get_tmp_agent_root(app_root, app_name) + tmp_agent_root.mkdir(parents=True, exist_ok=True) + + for rel_path, content in uploads: + destination_path = _resolve_under_dir(tmp_agent_root, rel_path) + destination_path.parent.mkdir(parents=True, exist_ok=True) + destination_path.write_bytes(content) + + return True + app_root = _get_app_root(app_name) app_root.mkdir(parents=True, exist_ok=True) @@ -480,16 +506,15 @@ async def builder_build( if tmp_agent_root.is_dir(): copy_dir_contents(tmp_agent_root, app_root) - for rel_path, file in uploads: + for rel_path, content in uploads: destination_path = _resolve_under_dir(app_root, rel_path) destination_path.parent.mkdir(parents=True, exist_ok=True) - with destination_path.open("wb") as buffer: - shutil.copyfileobj(file.file, buffer) + destination_path.write_bytes(content) return cleanup_tmp(app_name) except ValueError as exc: logger.exception("Error in builder_build: %s", exc) - return False + raise HTTPException(status_code=400, detail=str(exc)) except OSError as exc: logger.exception("Error in builder_build: %s", exc) return False diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 15bc908ddb..95affeeb3e 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1694,8 +1694,7 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): ("app/../escape.yaml", b"nope\n", "application/x-yaml"), )], ) - assert response.status_code == 200 - assert response.json() is False + assert response.status_code == 400 assert not (tmp_path / "escape.yaml").exists() assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() @@ -1709,8 +1708,7 @@ def test_builder_save_rejects_py_files(builder_test_client, tmp_path): ("app/agent.py", b"import os\nos.system('id')\n", "text/plain"), )], ) - assert response.status_code == 200 - assert response.json() is False + assert response.status_code == 400 assert not (tmp_path / "app" / "tmp" / "app" / "agent.py").exists() @@ -1732,8 +1730,7 @@ def test_builder_save_rejects_non_yaml_extensions( (f"app/file{ext}", content, "application/octet-stream"), )], ) - assert response.status_code == 200, f"Expected 200 for {ext}" - assert response.json() is False, f"Expected False for {ext}" + assert response.status_code == 400, f"Expected 400 for {ext}" def test_builder_save_allows_yaml_files(builder_test_client, tmp_path): @@ -1759,6 +1756,44 @@ def test_builder_save_allows_yaml_files(builder_test_client, tmp_path): assert response.json() is True +def test_builder_save_rejects_args_key(builder_test_client, tmp_path): + """Uploading YAML with an `args` key is rejected (RCE prevention).""" + yaml_with_args = b"""\ +name: my_tool +args: + key: value +""" + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[( + "files", + ("app/root_agent.yaml", yaml_with_args, "application/x-yaml"), + )], + ) + assert response.status_code == 400 + assert "args" in response.json()["detail"] + assert not (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").exists() + + +def test_builder_save_rejects_nested_args_key(builder_test_client, tmp_path): + """Uploading YAML with a nested `args` key is rejected.""" + yaml_with_nested_args = b"""\ +tools: + - name: some_tool + args: + param: value +""" + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[( + "files", + ("app/root_agent.yaml", yaml_with_nested_args, "application/x-yaml"), + )], + ) + assert response.status_code == 400 + assert "args" in response.json()["detail"] + + def test_builder_get_rejects_non_yaml_file_paths(builder_test_client, tmp_path): """GET /builder/app/{app_name}?file_path=... rejects non-YAML extensions.""" app_root = tmp_path / "app" From 0d48362f2e5410f0f39fab8b38c2164d4293abc1 Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Thu, 2 Apr 2026 14:32:49 -0700 Subject: [PATCH 6/6] chore(release/candidate): release 1.28.1 (#5121) Co-authored-by: Ankur Sharma --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 13 +++++++++++++ src/google/adk/version.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index bac4ebcfdc..b0daa7d753 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.28.0" + ".": "1.28.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1fabb0da..34048e1e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.28.1](https://github.com/google/adk-python/compare/v1.28.0...v1.28.1) (2026-04-02) + + +### Features + +* **live:** support live for `gemini-3.1-flash-live-preview` model ([ee69661](https://github.com/google/adk-python/commit/ee69661a616056fa89e0ec2188aaa59bd714d8c9)) + + +### Bug Fixes + +* Disallow args on /builder and Add warning about Web UI usage to CLI help ([f037f68](https://github.com/google/adk-python/commit/f037f68d67ae1bd16b00df0c7523fb67cbd1e911)) +* **live:** Buffer tool calls and emit them together upon turn completion ([081adbd](https://github.com/google/adk-python/commit/081adbdfa41490e4868b028a1cdabceb811a7505)) + ## [1.28.0](https://github.com/google/adk-python/compare/v1.27.5...v1.28.0) (2026-03-26) diff --git a/src/google/adk/version.py b/src/google/adk/version.py index a020c76372..ed58e782e7 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.28.0" +__version__ = "1.28.1"