From 4e81af6dd0923b64485298957bfbd8af370ae783 Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Thu, 30 Apr 2026 18:00:26 -0700 Subject: [PATCH 01/28] chore: merge release v1.32.0 to main Merge https://github.com/google/adk-python/pull/5563 Syncs version bump and CHANGELOG from release v1.32.0 to main. COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/5563 from google:release/v1.32.0 1551268ca43df93e275646d6f8d5f9c86b46e813 PiperOrigin-RevId: 908475510 --- .github/.release-please-manifest.json | 2 +- .github/release-please-config.json | 2 +- CHANGELOG.md | 85 +++++++++++++++++++++++++++ src/google/adk/version.py | 2 +- 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 306dc078c6..f16e9b1aea 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.31.0" + ".": "1.32.0" } diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 42b40215c4..8122ea8f75 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": "d69477f6ff348311e1d53e3f2c389dcf037fb049", + "last-release-sha": "5e49cfa6567a09e06409b0f380434f12f85a17c9", "packages": { ".": { "release-type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d4d33d44..a57085d100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,90 @@ # Changelog +## [1.32.0](https://github.com/google/adk-python/compare/v1.31.0...v1.32.0) (2026-04-30) + + +### Features + +* Add an option to prevent the SaveFilesAsArtifactsPlugin from attaching reference file parts to the message ([987c809](https://github.com/google/adk-python/commit/987c809bfc816a9804c905bd5c02397e396e72d3)) +* add credentials parameter to BigQueryAgentAnalyticsPlugin ([34713fb](https://github.com/google/adk-python/commit/34713fb4cccae9fe066587459862f8d4c4aa166f)) +* Add express mode onboarding support to adk deploy cli ([2b04996](https://github.com/google/adk-python/commit/2b04996f7ac342c9e7600e4bb596a666108f8b65)) +* add native OpenTelemetry agentic metrics ([6942aac](https://github.com/google/adk-python/commit/6942aac5d7b1f465c20febe2a48bac90da32c4eb)) +* Add OpenTelemetry tracing for event compaction ([c65dd55](https://github.com/google/adk-python/commit/c65dd5580f42ed330bf4c57cd040f22748ba1444)) +* Add sample agent demonstrating 2LO, 3LO, and API Key auth via GcpAuthProvider ([909a8c2](https://github.com/google/adk-python/commit/909a8c2ad4d06ad485173f40f278c503cd66a063)) +* Add support for Anthropic's thinking blocks ([16952bd](https://github.com/google/adk-python/commit/16952bd397f871df3e5a1b035261ded3b7c226a5)) +* Add support for excluding predefined functions in ComputerUseToolset ([d760037](https://github.com/google/adk-python/commit/d760037f9500a9187bf835ce62eebf21e818f322)) +* Add support for refusal messages in ApigeeLlm ([d6594a1](https://github.com/google/adk-python/commit/d6594a1a2c11fe3f5ac94fd37a9ae4b327fa1a0c)) +* Added indication of user message in history event list ([662354a](https://github.com/google/adk-python/commit/662354ae55c244d79955935f2243ba3deba272e9)) +* Allow user to define credential_key for McpToolset ([282db87](https://github.com/google/adk-python/commit/282db876fdf8e2f0133cfc386b4b4dd1dc9bdd09)), closes [#5103](https://github.com/google/adk-python/issues/5103) +* **analytics:** add support for logging LLM cache metadata to BigQuery ([02deeb9](https://github.com/google/adk-python/commit/02deeb98a08611733949fa2912f433f2ed55681a)) +* **eval:** add evaluate_full_response option to rubric-based evaluation ([#5316](https://github.com/google/adk-python/issues/5316)) ([7623ff1](https://github.com/google/adk-python/commit/7623ff1a27c412ff9b758bb76701e2daff570741)) +* **live:** Add save_live_blob query parameter to /run_live endpoint ([36ab8f1](https://github.com/google/adk-python/commit/36ab8f128c0281e44c2120a17de91e081f2232b1)) +* **mcp:** gracefully handle tool execution errors and transport crashes ([7744cfe](https://github.com/google/adk-python/commit/7744cfe0f36a74f50abb53ec0d42566c439257b3)) + + +### Bug Fixes + +* accumulate list values when merging parallel tool call state_delta ([b0b8b31](https://github.com/google/adk-python/commit/b0b8b310af5cb1184ef3ef57f1bb551e2a9add9a)), closes [#5190](https://github.com/google/adk-python/issues/5190) +* Add support for overriding the API version in GoogleLLM ([1cdd1e7](https://github.com/google/adk-python/commit/1cdd1e74ba3e59e5ef5ebb654184630c1462454e)) +* **auth:** isolate resolved credentials in context to prevent race conditions and data leakage ([5578772](https://github.com/google/adk-python/commit/55787721541fe0a9b5df15b980be87623e57eba8)) +* avoid double-execution of sync FunctionTools returning None ([78a8851](https://github.com/google/adk-python/commit/78a8851809f2be7b9e20158beee8c39cdd3fe2f8)), closes [#5284](https://github.com/google/adk-python/issues/5284) +* block RCE vulnerability via nested YAML configurations in ADK ([74f235b](https://github.com/google/adk-python/commit/74f235b1195805f2316f90533d4d297038448f0a)) +* bump Vertex SDK version ([6380f6a](https://github.com/google/adk-python/commit/6380f6ac767a6e13faf15b7e8ac3bc48acbd5f1b)) +* cancel siblings in parallel function calling on failure ([49985c9](https://github.com/google/adk-python/commit/49985c91ca08e36801e72164cb6314aa9190d144)) +* Capture and include LLM usage metadata in summarized events ([5ce33b9](https://github.com/google/adk-python/commit/5ce33b9c1e7606cb9b84ab925f8ff47ee0347943)), closes [#4014](https://github.com/google/adk-python/issues/4014) +* catch ValueError in safe-JSON serializers for circular refs ([70a7add](https://github.com/google/adk-python/commit/70a7add2bd8ddca12b5fdd63e2052f291817d5be)), closes [#5412](https://github.com/google/adk-python/issues/5412) +* **deps:** bump litellm cap to >=1.83.7 to admit CVE patches ([6d2ada8](https://github.com/google/adk-python/commit/6d2ada8bbc5a08bee3ca76d3e44628b194562212)) +* Disable bound token for mcp_tool ([4c0c6db](https://github.com/google/adk-python/commit/4c0c6db87cd531d932c135a27e69682ed08c6f75)) +* fix dataset location handling in BigQueryAgentAnalyticsPlugin ([c263426](https://github.com/google/adk-python/commit/c263426fe1a8620ebebef4b7efaed1eb5b99c03f)) +* Fix exception handling and argument order in ReflectRetryToolPlugin ([1deab6d](https://github.com/google/adk-python/commit/1deab6d0bf32a0344ab033a1ae61cc7cddf706fd)) +* Fix GcpAuthProvider to return capitalized Bearer scheme ([ad937fe](https://github.com/google/adk-python/commit/ad937fe1b827309787a177a99c42df2679f9286e)) +* fix lifecycle issues with credentials in BigQuery Agent Analytics Plugin ([a69f861](https://github.com/google/adk-python/commit/a69f8612fa4b69273b1bb7c90c4efa53b04440e6)) +* Fix malformated skill.md ([9a0d2f7](https://github.com/google/adk-python/commit/9a0d2f70ba957b8fc2cae8ed3f4aa1f4885a689c)) +* Fix misplaced pytest decorator on helper dataclass in 2LO integration tests ([2343973](https://github.com/google/adk-python/commit/234397353189005b5641df832f8ed45018021ef7)) +* Fix RecursionError in ADK framework by adding circular reference detection to schema resolution ([7de5bc5](https://github.com/google/adk-python/commit/7de5bc54e11986f70a48a9dd83ea39be58ebce40)) +* fix rewind to preserve initial session state ([af1b00a](https://github.com/google/adk-python/commit/af1b00a12b8dd6eee844cc28df7bcd4838e22c1a)), closes [#4933](https://github.com/google/adk-python/issues/4933) +* Fix SSRF and local-file access in load_web_page ([0447e93](https://github.com/google/adk-python/commit/0447e939483c1c6bc8d6df7f96b372d5f8bee7bb)) +* handle None state values in skill_toolset after session rewind ([a977aa3](https://github.com/google/adk-python/commit/a977aa307d56ed1efa89a3ffe4b3d96650a984d6)) +* **litellm:** emit input_audio for audio inline_data parts ([4073238](https://github.com/google/adk-python/commit/4073238151ee35488b50a321482db500b705234b)), closes [#5406](https://github.com/google/adk-python/issues/5406) +* **live:** mark all agents' Event as from other agents ([48b7a64](https://github.com/google/adk-python/commit/48b7a64bcf5ff402d10187d269f41e6bd4b8d74a)) +* **live:** treat input transcription as user message ([ae1f2e6](https://github.com/google/adk-python/commit/ae1f2e6094935c972af3f6682e5c2f79a5ac70d5)) +* **optimization:** handle None metric scores in LocalEvalSampler ([#5415](https://github.com/google/adk-python/issues/5415)) ([684a6e7](https://github.com/google/adk-python/commit/684a6e781adf7e769c8f7f572382ceb61f26a038)) +* **otel:** change `gen_ai.tool_definitions` to `gen_ai.tool.definitions` ([029b87d](https://github.com/google/adk-python/commit/029b87d582bdde98be3532f26adb6e1d851c44d6)) +* preserve cache fingerprint stability on creation failure ([4d5438c](https://github.com/google/adk-python/commit/4d5438cfc89d69d88ba4c324c292fc23e3047f3d)) +* preserve empty-string text parts in A2A converter ([2d61cb6](https://github.com/google/adk-python/commit/2d61cb69704f063f66b5d83b716674f6f94b5903)) +* preserve function call IDs for Anthropic models ([f0c787f](https://github.com/google/adk-python/commit/f0c787fbc9c4a66b0d0eccddc6d6d03b844cfd0b)) +* Prevent LoopAgent from resetting sub-agent state on pause ([8846be5](https://github.com/google/adk-python/commit/8846be585dd3ac585a75aed03d9c6623a5eaa41b)) +* Quote user_id literals in VertexAiSessionService list filters ([bdece00](https://github.com/google/adk-python/commit/bdece003b82959d7d7649cc5c94e26306019299f)) +* read_file/write_file path type mismatch in BaseEnvironment and LocalEnvironment ([782796f](https://github.com/google/adk-python/commit/782796f97eb10d09d8dccb51b93868e1c07b475e)) +* relax EventActions.state_delta value type to Any ([dbec8e9](https://github.com/google/adk-python/commit/dbec8e937adeb608b01f43409033c1de70a86b92)) +* remove exclude_unset=True to correctly serialize pydantic types ([f95ac48](https://github.com/google/adk-python/commit/f95ac48e07daa0934a784c1701a124add0513297)) +* **samples:** Upgrade google-adk to 1.28.1 to fix vulnerability ([b848390](https://github.com/google/adk-python/commit/b8483909049d4a9bad94f6cb44cf6a26fd9faa9d)) +* Sanitize user_id derived from PubSub subscription and Eventarc source ([0c4f157](https://github.com/google/adk-python/commit/0c4f1570388c8361ce6ab072f5ef19a3d92dbdc2)), closes [#5324](https://github.com/google/adk-python/issues/5324) +* Scope Vertex RAG memory display names ([784350d](https://github.com/google/adk-python/commit/784350dba60245ce02ad96994e7ced2b567a4dec)) +* Use correct camelCase functionCallId ([c87ee1e](https://github.com/google/adk-python/commit/c87ee1ee9697b4f5f2b88e45a08eeeabf9a0ad13)) +* web oauth flow and trace view ([87cd310](https://github.com/google/adk-python/commit/87cd310bccb33d7faae7d505a4629a2e1d77fadb)) +* yield tool_call_parts immediately in live mode to unblock Gemini 3.1 tool calls ([f57b05d](https://github.com/google/adk-python/commit/f57b05dac147eb72f54c9053a4a0ba7023ee55dc)) + + +### Performance Improvements + +* lazy-load optional providers and auth chain to cut cold start ~25% ([66bfedc](https://github.com/google/adk-python/commit/66bfedcf8ddc7c5c518c8c7d7a967e1c488e9852)) + + +### Code Refactoring + +* move exception handling from metric emission into instrumentation handlers ([62d7ee0](https://github.com/google/adk-python/commit/62d7ee024aa1e50197722d2c5914192a7f322d60)) +* **tests:** Refactor tests to explicitly handle JSON_SCHEMA_FOR_FUNC_DECL feature flag ([b580891](https://github.com/google/adk-python/commit/b580891adcc2c7afe110dbef394ffd7b3de67629)) +* Use artifact_service.load_artifact during rewind ([c3d50db](https://github.com/google/adk-python/commit/c3d50db9387f7cd76a955299e40a23925dedbc22)), closes [#4932](https://github.com/google/adk-python/issues/4932) + + +### Documentation + +* **gemini:** show subclass pattern for custom Client config ([34c7505](https://github.com/google/adk-python/commit/34c7505cc437578567008c0c8b160de083eae0d1)), closes [#3628](https://github.com/google/adk-python/issues/3628) +* update `output_schema` docstring to reflect support for `tools` and `output_schema` together ([e1e652d](https://github.com/google/adk-python/commit/e1e652d73a3d41f42c517189164f740cb907896d)) +* Update README with instructions for installing ADK extensions ([f2a1179](https://github.com/google/adk-python/commit/f2a117972e40dd3d4299f3ff437b4382600d224e)) +* use sphinx-click to generate docs for google.adk.cli ([f455974](https://github.com/google/adk-python/commit/f4559743febfa91fdde6e5e2e41acf54e20396fe)) + ## [1.31.0](https://github.com/google/adk-python/compare/v1.30.0...v1.31.0) (2026-04-16) diff --git a/src/google/adk/version.py b/src/google/adk/version.py index bc862a31dd..3a7e8f81b4 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.31.0" +__version__ = "1.32.0" From 740557c8965305abc75752082bc3ee63d924742f Mon Sep 17 00:00:00 2001 From: Yifan Wang Date: Thu, 30 Apr 2026 19:04:35 -0700 Subject: [PATCH 02/28] fix: hot reload agents for adk web Co-authored-by: Yifan Wang PiperOrigin-RevId: 908496612 --- src/google/adk/cli/adk_web_server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index ef83dcd45a..aea99751d7 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -1889,6 +1889,7 @@ def _set_telemetry_context_if_needed(runner: Runner): @app.post("/run", response_model_exclude_none=True) async def run_agent(req: RunAgentRequest) -> list[Event]: + self.current_app_name_ref.value = req.app_name runner = await self.get_runner_async(req.app_name) _set_telemetry_context_if_needed(runner) try: @@ -1910,6 +1911,7 @@ async def run_agent(req: RunAgentRequest) -> list[Event]: @app.post("/run_sse") async def run_agent_sse(req: RunAgentRequest) -> StreamingResponse: + self.current_app_name_ref.value = req.app_name stream_mode = StreamingMode.SSE if req.streaming else StreamingMode.NONE runner = await self.get_runner_async(req.app_name) _set_telemetry_context_if_needed(runner) @@ -2089,6 +2091,7 @@ async def run_agent_live( return await websocket.accept() + self.current_app_name_ref.value = req.app_name runner_for_context = await self.get_runner_async(app_name) _set_telemetry_context_if_needed(runner_for_context) From 8286066e71e5c07b5b28979b8327d4b330187ddd Mon Sep 17 00:00:00 2001 From: Yifan Wang Date: Fri, 1 May 2026 13:50:38 -0700 Subject: [PATCH 03/28] fix: should use app_name instead of req.app_name Co-authored-by: Yifan Wang PiperOrigin-RevId: 908883116 --- src/google/adk/cli/adk_web_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index aea99751d7..917aadf7d0 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -2091,7 +2091,7 @@ async def run_agent_live( return await websocket.accept() - self.current_app_name_ref.value = req.app_name + self.current_app_name_ref.value = app_name runner_for_context = await self.get_runner_async(app_name) _set_telemetry_context_if_needed(runner_for_context) From 22fae7e9a09c581f433f3c51ea9a0ab26e689b92 Mon Sep 17 00:00:00 2001 From: George Weale Date: Fri, 1 May 2026 14:31:01 -0700 Subject: [PATCH 04/28] feat(models): add get_function_calls and get_function_responses to LlmResponse Co-authored-by: George Weale PiperOrigin-RevId: 908900779 --- src/google/adk/models/llm_response.py | 18 ++++++++ tests/unittests/models/test_llm_response.py | 48 +++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/google/adk/models/llm_response.py b/src/google/adk/models/llm_response.py index 69c299ce7e..10ab946455 100644 --- a/src/google/adk/models/llm_response.py +++ b/src/google/adk/models/llm_response.py @@ -148,6 +148,24 @@ class LlmResponse(BaseModel): It can be used to identify and chain interactions for stateful conversations. """ + def get_function_calls(self) -> list[types.FunctionCall]: + """Returns the function calls in the response.""" + func_calls = [] + if self.content and self.content.parts: + for part in self.content.parts: + if part.function_call: + func_calls.append(part.function_call) + return func_calls + + def get_function_responses(self) -> list[types.FunctionResponse]: + """Returns the function responses in the response.""" + func_responses = [] + if self.content and self.content.parts: + for part in self.content.parts: + if part.function_response: + func_responses.append(part.function_response) + return func_responses + @staticmethod def create( generate_content_response: types.GenerateContentResponse, diff --git a/tests/unittests/models/test_llm_response.py b/tests/unittests/models/test_llm_response.py index 63e5afda09..e2cbe4c286 100644 --- a/tests/unittests/models/test_llm_response.py +++ b/tests/unittests/models/test_llm_response.py @@ -349,3 +349,51 @@ def test_llm_response_create_includes_model_version(): ) response = LlmResponse.create(generate_content_response) assert response.model_version == 'gemini-2.5-flash' + + +def test_get_function_calls_returns_calls_in_order(): + fc1 = types.FunctionCall(name='a', args={}) + fc2 = types.FunctionCall(name='b', args={'x': 1}) + response = LlmResponse( + content=types.Content( + parts=[ + types.Part(function_call=fc1), + types.Part(text='ignored'), + types.Part(function_call=fc2), + ] + ) + ) + assert response.get_function_calls() == [fc1, fc2] + + +def test_get_function_calls_empty_when_no_content(): + assert LlmResponse().get_function_calls() == [] + + +def test_get_function_calls_empty_when_no_parts(): + response = LlmResponse(content=types.Content(parts=None)) + assert response.get_function_calls() == [] + + +def test_get_function_responses_returns_responses_in_order(): + fr1 = types.FunctionResponse(name='a', response={'r': 1}) + fr2 = types.FunctionResponse(name='b', response={'r': 2}) + response = LlmResponse( + content=types.Content( + parts=[ + types.Part(function_response=fr1), + types.Part(text='ignored'), + types.Part(function_response=fr2), + ] + ) + ) + assert response.get_function_responses() == [fr1, fr2] + + +def test_get_function_responses_empty_when_no_content(): + assert LlmResponse().get_function_responses() == [] + + +def test_get_function_responses_empty_when_no_parts(): + response = LlmResponse(content=types.Content(parts=None)) + assert response.get_function_responses() == [] From 69fa777881b3cb161e5b3dcb005def9a2ad86904 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Fri, 1 May 2026 16:49:32 -0700 Subject: [PATCH 05/28] fix: catch genai.ClientError when sandbox is missing This fixes an issue where AgentEngineSandboxCodeExecutor catches the wrong exception class when attempting to recover from externally-deleted sandboxes. Fixes #5480 Co-authored-by: Amaad Martin PiperOrigin-RevId: 908965545 --- .../agent_engine_sandbox_code_executor.py | 6 ++ ...test_agent_engine_sandbox_code_executor.py | 71 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/google/adk/code_executors/agent_engine_sandbox_code_executor.py b/src/google/adk/code_executors/agent_engine_sandbox_code_executor.py index 6634847271..c9215d3c86 100644 --- a/src/google/adk/code_executors/agent_engine_sandbox_code_executor.py +++ b/src/google/adk/code_executors/agent_engine_sandbox_code_executor.py @@ -131,6 +131,7 @@ def execute_code( sandbox_name = self.sandbox_resource_name if self.sandbox_resource_name is None: from google.api_core import exceptions + from google.genai import errors as genai_errors from vertexai import types # use sandbox name stored in session if available. @@ -148,6 +149,11 @@ def execute_code( create_new_sandbox = True except exceptions.NotFound: create_new_sandbox = True + except genai_errors.ClientError as exc: + if exc.code == 404: + create_new_sandbox = True + else: + raise if create_new_sandbox: # Create a new sandbox and assign it to sandbox_name. diff --git a/tests/unittests/code_executors/test_agent_engine_sandbox_code_executor.py b/tests/unittests/code_executors/test_agent_engine_sandbox_code_executor.py index 78c7c5cd33..32897941dd 100644 --- a/tests/unittests/code_executors/test_agent_engine_sandbox_code_executor.py +++ b/tests/unittests/code_executors/test_agent_engine_sandbox_code_executor.py @@ -155,7 +155,76 @@ def test_execute_code_recreates_sandbox_when_get_returns_none( mock_json_output = MagicMock() mock_json_output.mime_type = "application/json" mock_json_output.data = json.dumps( - {"stdout": "recreated sandbox run", "stderr": ""} + {"msg_out": "recreated sandbox run", "msg_err": ""} + ).encode("utf-8") + mock_json_output.metadata = None + mock_response.outputs = [mock_json_output] + mock_api_client.agent_engines.sandboxes.execute_code.return_value = ( + mock_response + ) + + # Execute using agent_engine_resource_name so a sandbox can be created + executor = AgentEngineSandboxCodeExecutor( + agent_engine_resource_name=( + "projects/123/locations/us-central1/reasoningEngines/456" + ) + ) + code_input = CodeExecutionInput(code='print("hello world")') + result = executor.execute_code(mock_invocation_context, code_input) + + # Assert get was called for the existing sandbox + mock_api_client.agent_engines.sandboxes.get.assert_called_once_with( + name=existing_sandbox_name + ) + + # Assert create was called and session updated with new sandbox + mock_api_client.agent_engines.sandboxes.create.assert_called_once() + assert ( + mock_invocation_context.session.state["sandbox_name"] + == created_sandbox_name + ) + + # Assert execute_code used the created sandbox name + mock_api_client.agent_engines.sandboxes.execute_code.assert_called_once_with( + name=created_sandbox_name, + input_data={"code": 'print("hello world")'}, + ) + + @patch("vertexai.Client") + def test_execute_code_recreates_sandbox_when_get_raises_client_error( + self, + mock_vertexai_client, + mock_invocation_context, + ): + # Setup Mocks + mock_api_client = MagicMock() + mock_vertexai_client.return_value = mock_api_client + + # Existing sandbox name stored in session + existing_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/old" + mock_invocation_context.session.state = { + "sandbox_name": existing_sandbox_name + } + + # Mock get to raise ClientError with code 404 + from google.genai.errors import ClientError + + mock_api_client.agent_engines.sandboxes.get.side_effect = ClientError( + code=404, response_json={"message": "Not Found"} + ) + + # Mock create operation to return a new sandbox resource name + operation_mock = MagicMock() + created_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789" + operation_mock.response.name = created_sandbox_name + mock_api_client.agent_engines.sandboxes.create.return_value = operation_mock + + # Mock execute_code response + mock_response = MagicMock() + mock_json_output = MagicMock() + mock_json_output.mime_type = "application/json" + mock_json_output.data = json.dumps( + {"msg_out": "recreated sandbox run", "msg_err": ""} ).encode("utf-8") mock_json_output.metadata = None mock_response.outputs = [mock_json_output] From ce578fffa0dc02b0033f7f5e705b9422cbd6c252 Mon Sep 17 00:00:00 2001 From: polar3130 Date: Mon, 4 May 2026 11:32:53 -0700 Subject: [PATCH 06/28] feat(apigee): allow injecting credentials into ApigeeLlm Merge https://github.com/google/adk-python/pull/4722 Close https://github.com/google/adk-python/issues/4721 Co-authored-by: Xuan Yang COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/4722 from polar3130:feat/apigee-llm-userinfo-email-scope f7461173d24ad5a4d6cfec886dbcffdf77fe497d PiperOrigin-RevId: 910144731 --- src/google/adk/models/apigee_llm.py | 10 ++++ tests/unittests/models/test_apigee_llm.py | 65 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index 65c4156744..a1575bdce6 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -40,6 +40,7 @@ from .llm_response import LlmResponse if TYPE_CHECKING: + from google.auth.credentials import Credentials from google.genai import Client from .llm_request import LlmRequest @@ -92,6 +93,7 @@ def __init__( custom_headers: dict[str, str] | None = None, retry_options: Optional[types.HttpRetryOptions] = None, api_type: ApiType | str = ApiType.UNKNOWN, + credentials: Credentials | None = None, ): """Initializes the Apigee LLM backend. @@ -123,6 +125,11 @@ def __init__( authorization headers in Vertex AI and Gemini API calls. retry_options: Allow google-genai to retry failed responses. api_type: The type of API to use. One of `ApiType` or string. + credentials: Optional google-auth credentials passed through to the + underlying `genai.Client`. Use this when the Apigee proxy requires + additional OAuth scopes (e.g., `userinfo.email` for tokeninfo-based + caller identification). When omitted, the default `genai.Client` + authentication flow is used. """ # fmt: skip super().__init__(model=model, retry_options=retry_options) @@ -165,6 +172,7 @@ def __init__( ) self._custom_headers = custom_headers or {} self._user_agent = f'google-adk/{adk_version.__version__}' + self._credentials = credentials @classmethod @override @@ -239,6 +247,8 @@ def api_client(self) -> Client: if self._isvertexai: kwargs_for_client['project'] = self._project kwargs_for_client['location'] = self._location + if self._credentials is not None: + kwargs_for_client['credentials'] = self._credentials return Client( http_options=http_options, diff --git a/tests/unittests/models/test_apigee_llm.py b/tests/unittests/models/test_apigee_llm.py index ecbb61d18f..1e371e8aa1 100644 --- a/tests/unittests/models/test_apigee_llm.py +++ b/tests/unittests/models/test_apigee_llm.py @@ -651,6 +651,71 @@ def test_parse_response_usage_metadata(): assert llm_response.usage_metadata.thoughts_token_count == 4 +@pytest.mark.asyncio +@mock.patch('google.genai.Client') +async def test_api_client_passes_credentials_when_provided( + mock_client_constructor, llm_request +): + """Tests that credentials passed to __init__ are forwarded to genai.Client.""" + mock_credentials = mock.Mock() + + mock_client_instance = mock.Mock() + mock_client_instance.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + parts=[Part.from_text(text='Test response')], + role='model', + ) + ) + ] + ) + ) + mock_client_constructor.return_value = mock_client_instance + + apigee_llm = ApigeeLlm( + model=APIGEE_GEMINI_MODEL_ID, + proxy_url=PROXY_URL, + credentials=mock_credentials, + ) + _ = [resp async for resp in apigee_llm.generate_content_async(llm_request)] + + _, kwargs = mock_client_constructor.call_args + assert kwargs['credentials'] is mock_credentials + + +@pytest.mark.asyncio +@mock.patch('google.genai.Client') +async def test_api_client_omits_credentials_when_not_provided( + mock_client_constructor, llm_request +): + """Tests that credentials kwarg is not forwarded when not supplied.""" + mock_client_instance = mock.Mock() + mock_client_instance.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=Content( + parts=[Part.from_text(text='Test response')], + role='model', + ) + ) + ] + ) + ) + mock_client_constructor.return_value = mock_client_instance + + apigee_llm = ApigeeLlm( + model=APIGEE_GEMINI_MODEL_ID, + proxy_url=PROXY_URL, + ) + _ = [resp async for resp in apigee_llm.generate_content_async(llm_request)] + + _, kwargs = mock_client_constructor.call_args + assert 'credentials' not in kwargs + + def test_parse_response_with_refusal(): """Tests that CompletionsHTTPClient parses refusal correctly.""" client = CompletionsHTTPClient(base_url='http://test') From f8b4c59350fea3319c9e53e29968c56c93c57c99 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Mon, 4 May 2026 14:55:29 -0700 Subject: [PATCH 07/28] fix: double append bug PiperOrigin-RevId: 910258324 --- .../adk/sessions/in_memory_session_service.py | 5 ++-- .../sessions/test_session_service.py | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/google/adk/sessions/in_memory_session_service.py b/src/google/adk/sessions/in_memory_session_service.py index 1bef516086..b8f6cfab46 100644 --- a/src/google/adk/sessions/in_memory_session_service.py +++ b/src/google/adk/sessions/in_memory_session_service.py @@ -342,8 +342,9 @@ def _warning(message: str) -> None: # Update the storage session storage_session = self.sessions[app_name][user_id].get(session_id) - storage_session.events.append(event) - storage_session.last_update_time = event.timestamp + if storage_session is not session: + storage_session.events.append(event) + storage_session.last_update_time = event.timestamp if event.actions and event.actions.state_delta: state_deltas = _session_util.extract_state_delta( diff --git a/tests/unittests/sessions/test_session_service.py b/tests/unittests/sessions/test_session_service.py index 2d7d89f15f..02f5159a45 100644 --- a/tests/unittests/sessions/test_session_service.py +++ b/tests/unittests/sessions/test_session_service.py @@ -1013,6 +1013,30 @@ async def test_append_event_allows_markerless_current_session(): await service.close() +@pytest.mark.asyncio +async def test_append_event_when_session_is_same_ref_as_storage_session(): + """Tests that appending an event to a session only appends it once if the user-passed session and the underlying storage session are the same object.""" + service = InMemorySessionService() + app_name = 'my_app' + user_id = 'test_user' + + # Create a session + session = await service.create_session(app_name=app_name, user_id=user_id) + + # Get the actual storage event object from the underlying storage + storage_session = service.sessions[app_name][user_id][session.id] + + # Append the event to the storage session directly + event = Event(invocation_id='inv1', author='user') + await service.append_event(session=storage_session, event=event) + + # Verify that the storage session has only one event + final_session = await service.get_session( + app_name=app_name, user_id=user_id, session_id=session.id + ) + assert len(final_session.events) == 1 + + @pytest.mark.asyncio async def test_get_session_with_config(session_service): app_name = 'my_app' From 95599683230dd13e5792133f30ade3fe19358d52 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 5 May 2026 04:22:31 -0700 Subject: [PATCH 08/28] refactor: remove input.type and output.type attributes from adk metrics They were added prematurely, and currently output.type diverges from what's in semconv (https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/), so this change removes them from our metrics, at least for the time being. PiperOrigin-RevId: 910587563 --- src/google/adk/flows/llm_flows/functions.py | 4 +- src/google/adk/telemetry/_instrumentation.py | 18 +--- src/google/adk/telemetry/_metrics.py | 80 ++------------ tests/unittests/telemetry/test_functional.py | 9 -- tests/unittests/telemetry/test_metrics.py | 106 +------------------ 5 files changed, 18 insertions(+), 199 deletions(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index ea3b3e76c3..3c61c15ff3 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -593,7 +593,7 @@ async def _run_with_trace(): return function_response_event async with _instrumentation.record_tool_execution( - tool, agent, invocation_context, function_args + tool, agent, function_args ) as tel_ctx: tel_ctx.function_response_event = await _run_with_trace() return tel_ctx.function_response_event @@ -828,7 +828,7 @@ async def _run_with_trace(): return function_response_event async with _instrumentation.record_tool_execution( - tool, agent, invocation_context, function_args + tool, agent, function_args ) as tel_ctx: tel_ctx.function_response_event = await _run_with_trace() return tel_ctx.function_response_event diff --git a/src/google/adk/telemetry/_instrumentation.py b/src/google/adk/telemetry/_instrumentation.py index b79a02f4ce..d0f2bf9c31 100644 --- a/src/google/adk/telemetry/_instrumentation.py +++ b/src/google/adk/telemetry/_instrumentation.py @@ -17,7 +17,6 @@ import contextlib import dataclasses import logging -import sys import time from typing import Any from typing import AsyncIterator @@ -94,8 +93,6 @@ async def record_agent_invocation( _metrics.record_agent_invocation_duration( agent.name, elapsed_ms, - ctx.user_content, - ctx.session.events, caught_error, ) _metrics.record_agent_request_size(agent.name, ctx.user_content) @@ -111,14 +108,12 @@ async def record_agent_invocation( async def record_tool_execution( tool: BaseTool, agent: BaseAgent, - invocation_context: InvocationContext, function_args: dict[str, Any], ) -> AsyncIterator[TelemetryContext]: """Unified context manager for consolidated tool execution telemetry.""" start_time = time.monotonic() caught_error: Exception | None = None span: trace.Span | None = None - tel_ctx: TelemetryContext | None = None span_name = f"execute_tool {tool.name}" try: with tracing.tracer.start_as_current_span(span_name) as s: @@ -140,22 +135,11 @@ async def record_tool_execution( error=caught_error, ) finally: - elapsed_ms = _get_elapsed_ms(span, start_time) - result_event = ( - tel_ctx.function_response_event if tel_ctx is not None else None - ) - output_content = ( - result_event.content - if isinstance(result_event, event_lib.Event) - else None - ) try: _metrics.record_tool_execution_duration( tool_name=tool.name, agent_name=agent.name, - elapsed_ms=elapsed_ms, - input_content=invocation_context.user_content, - output_content=output_content, + elapsed_ms=_get_elapsed_ms(span, start_time), error=caught_error, ) except Exception: # pylint: disable=broad-exception-caught diff --git a/src/google/adk/telemetry/_metrics.py b/src/google/adk/telemetry/_metrics.py index 1172bfc1da..70d3d047a4 100644 --- a/src/google/adk/telemetry/_metrics.py +++ b/src/google/adk/telemetry/_metrics.py @@ -27,7 +27,6 @@ # TODO(b/477553411): add these attributes to Otel semconv. GEN_AI_AGENT_VERSION = "gen_ai.agent.version" -GEN_AI_INPUT_TYPE = "gen_ai.input.type" GEN_AI_TOOL_VERSION = "gen_ai.tool.version" # Initialize meter @@ -65,60 +64,37 @@ ) -def record_agent_request_size( - agent_name: str, user_content: types.Content | None -): - """Records the size of the agent request.""" - size = _get_content_size(user_content) - attrs = { - gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name, - GEN_AI_INPUT_TYPE: _get_modality_from_content(user_content), - } - _agent_request_size.record(size, attributes=attrs) - - def record_agent_invocation_duration( agent_name: str, elapsed_ms: float, - user_content: types.Content | None, - events: list[Event], error: Exception | None = None, ): """Records the duration of the agent invocation.""" - response_content: types.Content | None = None - for event in reversed(events): - if event.author == agent_name and event.content: - response_content = event.content - break - - attrs = { - gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name, - GEN_AI_INPUT_TYPE: _get_modality_from_content(user_content), - gen_ai_attributes.GEN_AI_OUTPUT_TYPE: _get_modality_from_content( - response_content - ), - } + attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name} if error is not None: attrs[error_attributes.ERROR_TYPE] = type(error).__name__ _agent_invocation_duration.record(elapsed_ms, attributes=attrs) +def record_agent_request_size( + agent_name: str, user_content: types.Content | None +): + """Records the size of the agent request.""" + size = _get_content_size(user_content) + attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name} + _agent_request_size.record(size, attributes=attrs) + + def record_agent_response_size(agent_name: str, events: list[Event]): """Records the size of the agent response by extracting content from events.""" response_content: types.Content | None = None for event in reversed(events): - # Need to look for author matching agent_name and having content if event.author == agent_name and event.content: response_content = event.content break size = _get_content_size(response_content) - attrs = { - gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name, - gen_ai_attributes.GEN_AI_OUTPUT_TYPE: _get_modality_from_content( - response_content - ), - } + attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name} _agent_response_size.record(size, attributes=attrs) @@ -134,52 +110,18 @@ def record_tool_execution_duration( tool_name: str, agent_name: str, elapsed_ms: float, - input_content: types.Content | None, - output_content: types.Content | None, error: Exception | None = None, ): """Records the duration of the tool execution.""" attrs = { gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name, gen_ai_attributes.GEN_AI_TOOL_NAME: tool_name, - GEN_AI_INPUT_TYPE: _get_modality_from_content(input_content), } if error is not None: attrs[error_attributes.ERROR_TYPE] = type(error).__name__ - else: - attrs[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = _get_modality_from_content( - output_content - ) _tool_execution_duration.record(elapsed_ms, attributes=attrs) -# Helper functions copied from metrics_plugin.py - - -def _get_modality_from_content( - content: types.Content | None, -) -> str: - if content is None or not content.parts: - return "unknown" - modalities = set() - for part in content.parts: - if part.text is not None: - modalities.add("text") - inline_data = part.inline_data - if inline_data and inline_data.mime_type: - mime = inline_data.mime_type - if "/" in mime: - modalities.add(mime.split("/")[0]) - file_data = part.file_data - if file_data and file_data.mime_type: - mime = file_data.mime_type - if "/" in mime: - modalities.add(mime.split("/")[0]) - if not modalities: - return "text" - return ",".join(sorted(modalities)) - - def _get_content_size( content: types.Content | None, ) -> int: diff --git a/tests/unittests/telemetry/test_functional.py b/tests/unittests/telemetry/test_functional.py index 879bfa0198..1a490e47fc 100644 --- a/tests/unittests/telemetry/test_functional.py +++ b/tests/unittests/telemetry/test_functional.py @@ -316,8 +316,6 @@ async def generate_random_number(): MetricPoint( attributes={ "gen_ai.agent.name": "complex_agent", - "gen_ai.input.type": "text", - "gen_ai.output.type": "text", }, value=None, ) @@ -334,8 +332,6 @@ async def generate_random_number(): attributes={ "gen_ai.agent.name": "complex_agent", "gen_ai.tool.name": "generate_random_number", - "gen_ai.input.type": "text", - "gen_ai.output.type": "text", }, value=None, ), @@ -343,8 +339,6 @@ async def generate_random_number(): attributes={ "gen_ai.agent.name": "complex_agent", "gen_ai.tool.name": "get_current_time", - "gen_ai.input.type": "text", - "gen_ai.output.type": "text", }, value=None, ), @@ -401,7 +395,6 @@ async def failing_tool(): attributes={ "gen_ai.agent.name": "error_agent", "gen_ai.tool.name": "failing_tool", - "gen_ai.input.type": "text", "error.type": "ValueError", }, value=None, @@ -410,8 +403,6 @@ async def failing_tool(): attributes={ "gen_ai.agent.name": "error_agent", "gen_ai.tool.name": "get_current_time", - "gen_ai.input.type": "text", - "gen_ai.output.type": "text", }, value=None, ), diff --git a/tests/unittests/telemetry/test_metrics.py b/tests/unittests/telemetry/test_metrics.py index 90357271a1..5a4ecbe8d3 100644 --- a/tests/unittests/telemetry/test_metrics.py +++ b/tests/unittests/telemetry/test_metrics.py @@ -84,58 +84,41 @@ def test_record_agent_request_size(mock_meter_setup): assert args[0] == 5 # len('hello') want_attributes = { "gen_ai.agent.name": "test_agent", - "gen_ai.input.type": "text", } assert kwargs["attributes"] == want_attributes def test_record_agent_invocation_duration(mock_meter_setup): """Tests record_agent_invocation_duration records correctly.""" - event = mock.MagicMock( - author="test_agent", - content=types.Content(parts=[types.Part(text="hello response")]), - ) _metrics.record_agent_invocation_duration( "test_agent", 1000.0, - types.Content(parts=[types.Part(text="hello")]), - [event], ) agent_duration_hist = mock_meter_setup["agent_duration"] agent_duration_hist.record.assert_called_once() args, kwargs = agent_duration_hist.record.call_args assert args[0] == 1000.0 - want_attributes = { - "gen_ai.agent.name": "test_agent", - "gen_ai.input.type": "text", - "gen_ai.output.type": "text", - } + want_attributes = {"gen_ai.agent.name": "test_agent"} assert kwargs["attributes"] == want_attributes def test_record_agent_invocation_duration_with_error(mock_meter_setup): """Tests record_agent_invocation_duration records error correctly.""" test_error = ValueError("agent failed") - event = mock.MagicMock( - author="test_agent", - content=types.Content(parts=[types.Part(text="hello response")]), - ) _metrics.record_agent_invocation_duration( "test_agent", 1000.0, - types.Content(parts=[types.Part(text="hello")]), - [event], error=test_error, ) agent_duration_hist = mock_meter_setup["agent_duration"] agent_duration_hist.record.assert_called_once() _, kwargs = agent_duration_hist.record.call_args assert kwargs["attributes"]["error.type"] == "ValueError" - assert kwargs["attributes"]["gen_ai.output.type"] == "text" def test_record_agent_response_size(mock_meter_setup): """Tests record_agent_response_size records correctly.""" + response_text = "response" event = mock.MagicMock( author="test_agent", content=types.Content(parts=[types.Part(text="response")]), @@ -144,11 +127,8 @@ def test_record_agent_response_size(mock_meter_setup): response_size_hist = mock_meter_setup["response_size"] response_size_hist.record.assert_called_once() args, kwargs = response_size_hist.record.call_args - assert args[0] == 8 # len('response') - want_attributes = { - "gen_ai.agent.name": "test_agent", - "gen_ai.output.type": "text", - } + assert args[0] == len(response_text) + want_attributes = {"gen_ai.agent.name": "test_agent"} assert kwargs["attributes"] == want_attributes @@ -171,8 +151,6 @@ def test_record_tool_execution_duration(mock_meter_setup): "test_tool", "test_agent", 500.0, - types.Content(parts=[types.Part(text="input")]), - types.Content(parts=[types.Part(text="result")]), ) tool_duration_hist = mock_meter_setup["tool_duration"] tool_duration_hist.record.assert_called_once() @@ -181,8 +159,6 @@ def test_record_tool_execution_duration(mock_meter_setup): want_attributes = { "gen_ai.agent.name": "test_agent", "gen_ai.tool.name": "test_tool", - "gen_ai.input.type": "text", - "gen_ai.output.type": "text", } assert kwargs["attributes"] == want_attributes @@ -194,8 +170,6 @@ def test_record_tool_execution_duration_with_error(mock_meter_setup): "test_tool", "test_agent", 500.0, - types.Content(parts=[types.Part(text="input")]), - None, error=test_error, ) tool_duration_hist = mock_meter_setup["tool_duration"] @@ -204,78 +178,6 @@ def test_record_tool_execution_duration_with_error(mock_meter_setup): assert kwargs["attributes"]["error.type"] == "ValueError" -@pytest.mark.parametrize( - "content,expected", - [ - (None, "unknown"), - (types.Content(parts=[types.Part(text="hello")]), "text"), - ( - types.Content( - parts=[ - types.Part( - inline_data=types.Blob(mime_type="image/jpeg", data=b"") - ) - ] - ), - "image", - ), - ( - types.Content( - parts=[ - types.Part( - file_data=types.FileData( - mime_type="video/mp4", file_uri="" - ) - ) - ] - ), - "video", - ), - ( - types.Content( - parts=[ - types.Part(text="hello"), - types.Part( - inline_data=types.Blob(mime_type="image/png", data=b"") - ), - ] - ), - "image,text", - ), - ( - types.Content( - parts=[types.Part(text="hello"), types.Part(text="world")] - ), - "text", - ), - ( - types.Content( - parts=[ - types.Part( - inline_data=types.Blob(mime_type="invalid", data=b"") - ) - ] - ), - "text", - ), - (types.Content(parts=[]), "unknown"), - ], - ids=[ - "none_content", - "simple_text", - "inline_image", - "file_video", - "combo", - "deduplication", - "invalid_mime", - "empty_parts", - ], -) -def test_get_modality_from_content_parameterized(content, expected): - """Tests _get_modality_from_content with various inputs.""" - assert _metrics._get_modality_from_content(content) == expected - - @pytest.mark.parametrize( "content,expected_size", [ From 03d6208aacac8c19adec45ce0dd837f9e3a7f66f Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 5 May 2026 04:54:24 -0700 Subject: [PATCH 09/28] refactor: adjust computation of workflow.steps metric and add new unit tests PiperOrigin-RevId: 910601469 --- src/google/adk/telemetry/_instrumentation.py | 3 +- src/google/adk/telemetry/_metrics.py | 11 ++- tests/unittests/telemetry/test_functional.py | 3 +- .../telemetry/test_instrumentation.py | 82 +++++++++++++++++++ tests/unittests/telemetry/test_metrics.py | 22 +++-- 5 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 tests/unittests/telemetry/test_instrumentation.py diff --git a/src/google/adk/telemetry/_instrumentation.py b/src/google/adk/telemetry/_instrumentation.py index d0f2bf9c31..0e4ad3c6ea 100644 --- a/src/google/adk/telemetry/_instrumentation.py +++ b/src/google/adk/telemetry/_instrumentation.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from ..agents.base_agent import BaseAgent from ..agents.invocation_context import InvocationContext + from ..tools.base_tool import BaseTool def _get_elapsed_ms(span: trace.Span | None, fallback_start: float) -> float: @@ -97,7 +98,7 @@ async def record_agent_invocation( ) _metrics.record_agent_request_size(agent.name, ctx.user_content) _metrics.record_agent_response_size(agent.name, ctx.session.events) - _metrics.record_agent_workflow_steps(agent.name, len(ctx.session.events)) + _metrics.record_agent_workflow_steps(agent.name, ctx.session.events) except Exception: # pylint: disable=broad-exception-caught logger.exception( "Failed to record agent metrics for agent %s", agent.name diff --git a/src/google/adk/telemetry/_metrics.py b/src/google/adk/telemetry/_metrics.py index 70d3d047a4..0f32ac1920 100644 --- a/src/google/adk/telemetry/_metrics.py +++ b/src/google/adk/telemetry/_metrics.py @@ -98,12 +98,11 @@ def record_agent_response_size(agent_name: str, events: list[Event]): _agent_response_size.record(size, attributes=attrs) -def record_agent_workflow_steps(agent_name: str, steps_count: int): - """Records the number of steps in the agent workflow.""" - attrs = { - gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name, - } - _agent_workflow_steps.record(steps_count, attributes=attrs) +def record_agent_workflow_steps(agent_name: str, events: list[Event]): + """Records the number of steps in the agent workflow by counting the number of events.""" + attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name} + count = sum(1 for event in events if event.author == agent_name) + _agent_workflow_steps.record(count, attributes=attrs) def record_tool_execution_duration( diff --git a/tests/unittests/telemetry/test_functional.py b/tests/unittests/telemetry/test_functional.py index 1a490e47fc..645fd4050c 100644 --- a/tests/unittests/telemetry/test_functional.py +++ b/tests/unittests/telemetry/test_functional.py @@ -349,7 +349,8 @@ async def generate_random_number(): got_steps = _extract_metrics(metrics_list, "gen_ai.agent.workflow.steps") assert len(got_steps) == 1 want_steps = [ - MetricPoint(attributes={"gen_ai.agent.name": "complex_agent"}, value=6) + # (tool call + result) x 2 + text response = 5 steps + MetricPoint(attributes={"gen_ai.agent.name": "complex_agent"}, value=5) ] assert got_steps == want_steps diff --git a/tests/unittests/telemetry/test_instrumentation.py b/tests/unittests/telemetry/test_instrumentation.py new file mode 100644 index 0000000000..cdbb8f3af1 --- /dev/null +++ b/tests/unittests/telemetry/test_instrumentation.py @@ -0,0 +1,82 @@ +# 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. + +# pylint: disable=protected-access + +import time +from unittest import mock + +from google.adk.telemetry import _instrumentation +from opentelemetry import trace + + +def test_get_elapsed_ms_span_none(): + """Tests fallback when span is None.""" + start_time = 10.0 + with mock.patch("time.monotonic", return_value=12.0): + elapsed = _instrumentation._get_elapsed_ms(None, start_time) + assert elapsed == 2000.0 # (12 - 10) * 1000 + + +def test_get_elapsed_ms_span_valid(): + """Tests duration calculation with valid span times.""" + mock_span = mock.MagicMock(spec=trace.Span) + mock_span.start_time = 1000000000 # 1s in ns + mock_span.end_time = 2000000000 # 2s in ns + elapsed = _instrumentation._get_elapsed_ms(mock_span, time.monotonic()) + assert elapsed == 1000.0 # (2 - 1) * 1000 ms + + +def test_get_elapsed_ms_span_missing_start(): + """Tests fallback when start_time is missing.""" + mock_span = mock.MagicMock(spec=trace.Span) + del mock_span.start_time + mock_span.end_time = 2000000000 + start_time = 10.0 + with mock.patch("time.monotonic", return_value=12.0): + elapsed = _instrumentation._get_elapsed_ms(mock_span, start_time) + assert elapsed == 2000.0 + + +def test_get_elapsed_ms_span_missing_end(): + """Tests fallback when end_time is missing.""" + mock_span = mock.MagicMock(spec=trace.Span) + mock_span.start_time = 1000000000 + del mock_span.end_time + start_time = 10.0 + with mock.patch("time.monotonic", return_value=12.0): + elapsed = _instrumentation._get_elapsed_ms(mock_span, start_time) + assert elapsed == 2000.0 + + +def test_get_elapsed_ms_span_non_int_start(): + """Tests fallback when start_time is not an integer.""" + mock_span = mock.MagicMock(spec=trace.Span) + mock_span.start_time = 1000000000.0 + mock_span.end_time = 2000000000 + start_time = 10.0 + with mock.patch("time.monotonic", return_value=12.0): + elapsed = _instrumentation._get_elapsed_ms(mock_span, start_time) + assert elapsed == 2000.0 + + +def test_get_elapsed_ms_span_non_int_end(): + """Tests fallback when end_time is not an integer.""" + mock_span = mock.MagicMock(spec=trace.Span) + mock_span.start_time = 1000000000 + mock_span.end_time = 2000000000.0 + start_time = 10.0 + with mock.patch("time.monotonic", return_value=12.0): + elapsed = _instrumentation._get_elapsed_ms(mock_span, start_time) + assert elapsed == 2000.0 diff --git a/tests/unittests/telemetry/test_metrics.py b/tests/unittests/telemetry/test_metrics.py index 5a4ecbe8d3..cab10a5c6e 100644 --- a/tests/unittests/telemetry/test_metrics.py +++ b/tests/unittests/telemetry/test_metrics.py @@ -75,13 +75,14 @@ def create_histogram_side_effect(name, **_kwargs): def test_record_agent_request_size(mock_meter_setup): """Tests record_agent_request_size records correctly.""" + user_content = "hello" _metrics.record_agent_request_size( - "test_agent", types.Content(parts=[types.Part(text="hello")]) + "test_agent", types.Content(parts=[types.Part(text=user_content)]) ) request_size_hist = mock_meter_setup["request_size"] request_size_hist.record.assert_called_once() args, kwargs = request_size_hist.record.call_args - assert args[0] == 5 # len('hello') + assert args[0] == len(user_content) want_attributes = { "gen_ai.agent.name": "test_agent", } @@ -121,7 +122,7 @@ def test_record_agent_response_size(mock_meter_setup): response_text = "response" event = mock.MagicMock( author="test_agent", - content=types.Content(parts=[types.Part(text="response")]), + content=types.Content(parts=[types.Part(text=response_text)]), ) _metrics.record_agent_response_size("test_agent", [event]) response_size_hist = mock_meter_setup["response_size"] @@ -134,14 +135,19 @@ def test_record_agent_response_size(mock_meter_setup): def test_record_agent_workflow_steps(mock_meter_setup): """Tests record_agent_workflow_steps records correctly.""" - _metrics.record_agent_workflow_steps("test_agent", 5) + _metrics.record_agent_workflow_steps( + "test_agent", + [ + mock.MagicMock(author="test_agent"), + mock.MagicMock(author="test_agent"), + mock.MagicMock(author="other_agent"), + ], + ) steps_hist = mock_meter_setup["steps"] steps_hist.record.assert_called_once() args, kwargs = steps_hist.record.call_args - assert args[0] == 5 - want_attributes = { - "gen_ai.agent.name": "test_agent", - } + assert args[0] == 2 + want_attributes = {"gen_ai.agent.name": "test_agent"} assert kwargs["attributes"] == want_attributes From fc2720378e8997269d30f5439051f5e43d5fa028 Mon Sep 17 00:00:00 2001 From: Yuvraj Angad Singh <36276913+yuvrajangadsingh@users.noreply.github.com> Date: Tue, 5 May 2026 10:24:14 -0700 Subject: [PATCH 10/28] fix: prevent state_delta overwrite on function_response-only events Merge https://github.com/google/adk-python/pull/4642 Fixes #3178 Co-authored-by: Xuan Yang COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/4642 from yuvrajangadsingh:fix/state-delta-overwrite-3178 f4cc2f2ed9cb1f17e047064f0756796c3228b1e0 PiperOrigin-RevId: 910767582 --- src/google/adk/agents/llm_agent.py | 6 ++++ .../agents/test_llm_agent_output_save.py | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index c61ac4abc0..ed83d00413 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -857,6 +857,12 @@ def __maybe_save_output_to_state(self, event: Event): if not result.strip(): return result = validate_schema(self.output_schema, result) + elif not result: + # No text parts found and no output_schema. Skip to avoid + # overwriting state_delta values already set by callbacks + # (e.g. after_tool_callback with skip_summarization on + # function_response-only events). + return event.actions.state_delta[self.output_key] = result @model_validator(mode='after') diff --git a/tests/unittests/agents/test_llm_agent_output_save.py b/tests/unittests/agents/test_llm_agent_output_save.py index e904130105..ad7b48e3e2 100644 --- a/tests/unittests/agents/test_llm_agent_output_save.py +++ b/tests/unittests/agents/test_llm_agent_output_save.py @@ -276,3 +276,34 @@ def test_maybe_save_output_to_state_handles_empty_final_chunk_with_schema( # ASSERT: Because the method should return early, the state_delta # should remain empty. assert len(event.actions.state_delta) == 0 + + def test_maybe_save_output_to_state_skips_function_response_only_event(self): + """Test that state_delta set by callback is not overwritten when event + only has function_response parts and no text.""" + agent = LlmAgent(name="test_agent", output_key="result") + + # Simulate a function_response-only event (no text parts) + parts = [ + types.Part( + function_response=types.FunctionResponse( + name="my_tool", + response={"status": "success", "data": [1, 2, 3]}, + ) + ) + ] + content = types.Content(role="user", parts=parts) + + event = Event( + invocation_id="test_invocation", + author="test_agent", + content=content, + actions=EventActions( + skip_summarization=True, + state_delta={"result": [1, 2, 3]}, + ), + ) + + agent._LlmAgent__maybe_save_output_to_state(event) + + # The callback-set value should be preserved, not overwritten with "" + assert event.actions.state_delta["result"] == [1, 2, 3] From 9d1bb4b4870233e574f5c06ddd2b62a48272398f Mon Sep 17 00:00:00 2001 From: Haiyuan Cao Date: Tue, 5 May 2026 10:28:05 -0700 Subject: [PATCH 11/28] fix: fix fork detection, correct offload limits, and add response logging in BigQuery plugin This PR addresses three distinct issues in the BigQuery Agent Analytics Plugin: 1. Fix false-positive fork detection: When the plugin is deployed via Vertex AI Agent Engine, it undergoes a pickle/unpickle lifecycle which resets `_init_pid` to 0. Previously, `_ensure_started()` would incorrectly detect this as a fork since `os.getpid()` is never 0, causing unnecessary cold-start latency and log noise. The PID check now distinguishes `_init_pid == 0` (unpickled) from a real fork. 2. Correct GCS offload unit mismatch: Separates the evaluation limits for offloading text content to GCS. It evaluates the byte-based storage guard (`inline_text_limit`) and the character-based truncation limit (`max_length`) independently, preventing mismatched unit comparisons. 3. Add AGENT_RESPONSE logging: Logs final response events emitted by agents to BigQuery. This explicitly filters out intermediate steps such as function calls/responses, streaming partials, and invisible internal reasoning ("thoughts") so that only the final visible response text is captured. Co-authored-by: Haiyuan Cao PiperOrigin-RevId: 910770002 --- .../bigquery_agent_analytics_plugin.py | 94 +++- .../test_bigquery_agent_analytics_plugin.py | 423 ++++++++++++++++++ 2 files changed, 507 insertions(+), 10 deletions(-) diff --git a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py index 50eb72ffdb..3a09fc942f 100644 --- a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py +++ b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py @@ -1430,14 +1430,18 @@ async def _parse_content_object( # CASE C: Text elif hasattr(part, "text") and part.text: - text_len = len(part.text.encode("utf-8")) - # If max_length is set and smaller than inline limit, use it as threshold - # to prefer offloading over truncation. - offload_threshold = self.inline_text_limit - if self.max_length != -1 and self.max_length < offload_threshold: - offload_threshold = self.max_length - - if self.offloader and text_len > offload_threshold: + char_len = len(part.text) + byte_len = len(part.text.encode("utf-8")) + + # Decide whether to offload using each limit in its own + # unit. inline_text_limit is a byte-based storage guard; + # max_length is a character-based truncation limit. + exceeds_inline_byte_limit = byte_len > self.inline_text_limit + exceeds_char_limit = ( + self.max_length != -1 and char_len > self.max_length + ) + + if self.offloader and (exceeds_inline_byte_limit or exceeds_char_limit): # Text is too big, treat as file path = f"{datetime.now().date()}/{self.trace_id}/{self.span_id}_p{idx}.txt" try: @@ -1906,6 +1910,18 @@ def _get_events_schema() -> list[bigquery.SchemaField]: " '$.a2a_metadata.\"a2a:response\"') AS a2a_response" ), ], + "AGENT_RESPONSE": [ + "JSON_VALUE(content, '$.response') AS response_text", + "JSON_VALUE(attributes, '$.source_event_id') AS source_event_id", + ( + "JSON_VALUE(attributes," + " '$.source_event_author') AS source_event_author" + ), + ( + "JSON_VALUE(attributes," + " '$.source_event_branch') AS source_event_branch" + ), + ], } _VIEW_SQL_TEMPLATE = """\ @@ -2653,7 +2669,14 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: async def _ensure_started(self, **kwargs) -> None: """Ensures that the plugin is started and initialized.""" - if os.getpid() != self._init_pid: + # _init_pid == 0 means the plugin was unpickled and has never been + # initialized in this process (the pickle sentinel set by + # __getstate__). Skip the fork reset in that case — no fork + # happened, and _started is already False so _lazy_setup will run. + # Real forks are caught by os.register_at_fork (line 108) and by + # this check when _init_pid is a real (non-zero) PID from a + # different process. + if self._init_pid != 0 and os.getpid() != self._init_pid: self._reset_runtime_state() if not self._started: # Kept original lock name as it was not explicitly changed. @@ -2665,6 +2688,10 @@ async def _ensure_started(self, **kwargs) -> None: await self._lazy_setup(**kwargs) self._started = True self._startup_error = None + # Record the current PID so fork detection works for + # the rest of this instance's lifetime. + if self._init_pid == 0: + self._init_pid = os.getpid() except Exception as e: self._startup_error = e logger.error("Failed to initialize BigQuery Plugin: %s", e) @@ -2966,7 +2993,7 @@ async def on_event_callback( invocation_context: InvocationContext, event: "Event", ) -> None: - """Logs state changes, HITL events, and A2A interactions. + """Logs state changes, HITL events, A2A interactions, and agent responses. - Checks each event for a non-empty state_delta and logs it as a STATE_DELTA event. @@ -2978,6 +3005,9 @@ async def on_event_callback( and logs them as ``A2A_INTERACTION`` events so the remote agent's response and cross-reference IDs (``a2a:task_id``, ``a2a:context_id``) are visible in BigQuery. + - Detects final response events emitted by agents and logs + them as ``AGENT_RESPONSE`` so the visible response text + (after all callback modifications) is captured in BigQuery. The HITL detection must happen here (not in tool callbacks) because ``adk_request_credential``, ``adk_request_confirmation``, and @@ -3080,6 +3110,50 @@ async def on_event_callback( ), ) + # --- Final agent response logging --- + # Captures final response events emitted by agents (after all + # after_model_callback modifications). Uses a strict guard to + # avoid false positives from skip_summarization function + # responses, long-running tool pause events, and thought-only + # events (which ADK treats as invisible internal reasoning). + is_agent_response = ( + event.content + and event.content.parts + and event.is_final_response() + and event.partial is not True + and not event.get_function_calls() + and not event.get_function_responses() + and not event.long_running_tool_ids + ) + if is_agent_response: + # Filter to visible text parts only. Exclude thoughts + # (internal reasoning, A2A working/submitted updates), + # empty parts, and non-text parts (executable_code, etc.) + # that would render as "other" in _format_content. + visible_parts = [ + p + for p in event.content.parts + if p.text and not getattr(p, "thought", None) + ] + if visible_parts: + visible_content = types.Content( + role=event.content.role, parts=visible_parts + ) + formatted, truncated = self._format_content_safely(visible_content) + await self._log_event( + "AGENT_RESPONSE", + callback_ctx, + raw_content={"response": formatted}, + is_truncated=truncated, + event_data=EventData( + extra_attributes={ + "source_event_id": event.id, + "source_event_author": event.author, + "source_event_branch": event.branch, + }, + ), + ) + return None async def on_state_change_callback( diff --git a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py index 8a05392bec..5719adf2b4 100644 --- a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py +++ b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py @@ -7408,3 +7408,426 @@ async def test_view_error_still_logged( ) as plugin: await plugin._ensure_started() assert plugin._started + + +# ================================================================ +# TEST CLASS: Fork detection after pickle (Issue #86 / PR #5528) +# ================================================================ +class TestForkDetectionAfterPickle: + """Tests that unpickled plugins do not false-positive fork detection.""" + + @pytest.mark.asyncio + async def test_no_reset_after_unpickle( + self, + mock_auth_default, + mock_bq_client, + mock_write_client, + mock_to_arrow_schema, + mock_asyncio_to_thread, + ): + """Unpickled plugin does not trigger _reset_runtime_state and + + records os.getpid() after startup. + """ + import pickle + + config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig( + create_views=False, + ) + plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin( + PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config + ) + pickled = pickle.dumps(plugin) + unpickled = pickle.loads(pickled) + + assert unpickled._init_pid == 0 + + with mock.patch.object(unpickled, "_reset_runtime_state") as mock_reset: + await unpickled._ensure_started() + mock_reset.assert_not_called() + + assert unpickled._started + assert unpickled._init_pid == os.getpid() + await unpickled.shutdown() + + @pytest.mark.asyncio + async def test_reset_on_real_fork( + self, + mock_auth_default, + mock_bq_client, + mock_write_client, + mock_to_arrow_schema, + mock_asyncio_to_thread, + ): + """Plugin detects real fork when _init_pid is a real non-zero PID.""" + config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig( + create_views=False, + ) + async with managed_plugin( + project_id=PROJECT_ID, + dataset_id=DATASET_ID, + table_id=TABLE_ID, + config=config, + ) as plugin: + await plugin._ensure_started() + plugin._init_pid = max(os.getpid() - 1, 1) + plugin._started = True + + with mock.patch.object( + plugin, "_reset_runtime_state", wraps=plugin._reset_runtime_state + ) as mock_reset: + await plugin._ensure_started() + mock_reset.assert_called_once() + + +# ================================================================ +# TEST CLASS: GCS offload unit mismatch fix (Issue #5561) +# ================================================================ +class TestOffloadUnitSeparation: + """Tests that byte-based inline limit and character-based truncation + + limit are evaluated independently for the GCS offload decision. + """ + + @pytest.mark.asyncio + async def test_multibyte_text_offloaded_by_byte_limit(self): + """Multi-byte text exceeding inline_text_limit bytes is offloaded.""" + mock_offloader = mock.AsyncMock() + mock_offloader.upload_content.return_value = "gs://bucket/offloaded.txt" + + parser = bigquery_agent_analytics_plugin.HybridContentParser( + offloader=mock_offloader, + trace_id="t", + span_id="s", + max_length=-1, + ) + text = "\U0001f600" * 10000 + assert len(text) == 10000 + assert len(text.encode("utf-8")) > 32 * 1024 + + content = types.Content(parts=[types.Part(text=text)]) + _, parts, _ = await parser._parse_content_object(content) + + mock_offloader.upload_content.assert_called_once() + assert parts[0]["storage_mode"] == "GCS_REFERENCE" + + @pytest.mark.asyncio + async def test_ascii_under_both_limits_stays_inline(self): + """ASCII text under both byte and character limits stays inline.""" + mock_offloader = mock.AsyncMock() + + parser = bigquery_agent_analytics_plugin.HybridContentParser( + offloader=mock_offloader, + trace_id="t", + span_id="s", + max_length=50000, + ) + text = "A" * 1000 + content = types.Content(parts=[types.Part(text=text)]) + _, parts, _ = await parser._parse_content_object(content) + + mock_offloader.upload_content.assert_not_called() + assert parts[0]["storage_mode"] == "INLINE" + assert parts[0]["text"] == text + + @pytest.mark.asyncio + async def test_text_exceeding_char_limit_offloaded(self): + """ASCII text exceeding max_length characters is offloaded.""" + mock_offloader = mock.AsyncMock() + mock_offloader.upload_content.return_value = "gs://bucket/big.txt" + + parser = bigquery_agent_analytics_plugin.HybridContentParser( + offloader=mock_offloader, + trace_id="t", + span_id="s", + max_length=100, + ) + text = "X" * 200 + assert len(text.encode("utf-8")) < 32 * 1024 + assert len(text) > 100 + + content = types.Content(parts=[types.Part(text=text)]) + _, parts, _ = await parser._parse_content_object(content) + + mock_offloader.upload_content.assert_called_once() + assert parts[0]["storage_mode"] == "GCS_REFERENCE" + + @pytest.mark.asyncio + async def test_multibyte_under_char_and_byte_limits_stays_inline(self): + """Regression test: 3K emoji (12K bytes) with max_length=10000 + + should stay inline — under both real limits. + """ + mock_offloader = mock.AsyncMock() + parser = bigquery_agent_analytics_plugin.HybridContentParser( + offloader=mock_offloader, + trace_id="t", + span_id="s", + max_length=10000, + ) + + text = "\U0001f600" * 3000 + assert len(text) < 10000 + assert len(text.encode("utf-8")) > 10000 + assert len(text.encode("utf-8")) < 32 * 1024 + + content = types.Content(parts=[types.Part(text=text)]) + _, parts, _ = await parser._parse_content_object(content) + + mock_offloader.upload_content.assert_not_called() + assert parts[0]["storage_mode"] == "INLINE" + + @pytest.mark.asyncio + async def test_no_offloader_falls_back_to_truncate(self): + """Without offloader, text exceeding char limit is truncated inline.""" + parser = bigquery_agent_analytics_plugin.HybridContentParser( + offloader=None, + trace_id="t", + span_id="s", + max_length=50, + ) + text = "Z" * 200 + content = types.Content(parts=[types.Part(text=text)]) + _, parts, is_truncated = await parser._parse_content_object(content) + + assert is_truncated + assert parts[0]["storage_mode"] == "INLINE" + assert "TRUNCATED" in parts[0]["text"] + + +# ================================================================ +# TEST CLASS: AGENT_RESPONSE logging (Issue #87) +# ================================================================ +class TestAgentResponseLogging: + """Tests that final agent response events are captured correctly.""" + + @pytest.mark.asyncio + async def test_logs_final_text_response( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + dummy_arrow_schema, + ): + """Final text response is logged as AGENT_RESPONSE with + + source_event_author from event.author. + """ + event = event_lib.Event( + author="sub_agent", + content=types.Content(parts=[types.Part(text="Here is your answer.")]), + ) + + bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context) + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema) + agent_resp_rows = [r for r in rows if r["event_type"] == "AGENT_RESPONSE"] + assert len(agent_resp_rows) == 1 + row = agent_resp_rows[0] + content = json.loads(row["content"]) + assert "Here is your answer" in content["response"] + attributes = json.loads(row["attributes"]) + # source_event_author must come from event.author + assert attributes["source_event_author"] == "sub_agent" + + @pytest.mark.asyncio + async def test_skips_function_call_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Events with function calls are not logged as AGENT_RESPONSE.""" + fc = types.FunctionCall(name="my_tool", args={"x": 1}) + event = event_lib.Event( + author="agent", + content=types.Content(parts=[types.Part(function_call=fc)]), + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_skips_function_response_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Events with function responses are not logged as AGENT_RESPONSE.""" + fr = types.FunctionResponse(name="my_tool", response={"result": "ok"}) + event = event_lib.Event( + author="agent", + content=types.Content(parts=[types.Part(function_response=fr)]), + actions=event_actions_lib.EventActions(skip_summarization=True), + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_skips_partial_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Partial streaming events are not logged as AGENT_RESPONSE.""" + event = event_lib.Event( + author="agent", + content=types.Content(parts=[types.Part(text="partial chunk")]), + partial=True, + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_skips_long_running_tool_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Long-running tool events are not logged as AGENT_RESPONSE.""" + fc = types.FunctionCall(name="long_tool", args={}) + event = event_lib.Event( + author="agent", + content=types.Content(parts=[types.Part(function_call=fc)]), + long_running_tool_ids={"call-1"}, + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_skips_thought_only_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Thought-only final events are not logged as AGENT_RESPONSE.""" + event = event_lib.Event( + author="agent", + content=types.Content( + parts=[types.Part(text="internal reasoning...", thought=True)] + ), + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_mixed_thought_and_visible_logs_only_visible( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + dummy_arrow_schema, + ): + """Mixed thought + visible text logs only the visible portion.""" + event = event_lib.Event( + author="agent", + content=types.Content( + parts=[ + types.Part(text="thinking step 1...", thought=True), + types.Part(text="Here is the answer."), + ] + ), + ) + + bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context) + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema) + agent_resp_rows = [r for r in rows if r["event_type"] == "AGENT_RESPONSE"] + assert len(agent_resp_rows) == 1 + content = json.loads(agent_resp_rows[0]["content"]) + assert "Here is the answer" in content["response"] + assert "thinking step" not in content["response"] + + @pytest.mark.asyncio + async def test_skips_empty_part_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Events with only empty Part() do not log AGENT_RESPONSE.""" + event = event_lib.Event( + author="agent", + content=types.Content(parts=[types.Part()]), + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_skips_empty_text_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Events with Part(text='') do not log AGENT_RESPONSE.""" + event = event_lib.Event( + author="agent", + content=types.Content(parts=[types.Part(text="")]), + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 + + @pytest.mark.asyncio + async def test_skips_executable_code_only_events( + self, + bq_plugin_inst, + mock_write_client, + invocation_context, + ): + """Events with only executable_code parts do not log AGENT_RESPONSE.""" + event = event_lib.Event( + author="agent", + content=types.Content( + parts=[ + types.Part( + executable_code=types.ExecutableCode( + code="print('hi')", language="PYTHON" + ) + ) + ] + ), + ) + + await bq_plugin_inst.on_event_callback( + invocation_context=invocation_context, event=event + ) + await asyncio.sleep(0.05) + assert mock_write_client.append_rows.call_count == 0 From 92c06fe55df10d43c6a0d1f79d66211b33390deb Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Tue, 5 May 2026 11:09:58 -0700 Subject: [PATCH 12/28] fix: Mock subprocess.run in test_cli_deploy to avoid gcloud calls Co-authored-by: Xuan Yang PiperOrigin-RevId: 910796426 --- tests/unittests/cli/utils/test_cli_deploy.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index 0ccc224a19..27cc33f9dd 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -714,6 +714,13 @@ def mock_handle_login(): cli_deploy._onboarding, "handle_login_with_google", mock_handle_login ) + # Mock subprocess.run to avoid calling gcloud + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **k: types.SimpleNamespace(stdout="fake-project\n"), + ) + fake_vertexai = types.ModuleType("vertexai") class _FakeAgentEngines: From 398f28feb47d87ec9c4c03dd3e0e7b87a1699e6e Mon Sep 17 00:00:00 2001 From: Kathy Wu Date: Tue, 5 May 2026 11:48:43 -0700 Subject: [PATCH 13/28] fix: Use project and location instead of API key when deploying to agent engine Deploying using API key has been leading to "Deploy failed: 'NoneType' object has no attribute 'before_request'" errors because creating cloud resources requires ADC credentials (project id and region) Co-authored-by: Kathy Wu PiperOrigin-RevId: 910821145 --- src/google/adk/cli/cli_deploy.py | 40 +++++------------ tests/unittests/cli/utils/test_cli_deploy.py | 45 ++++++++++---------- 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/src/google/adk/cli/cli_deploy.py b/src/google/adk/cli/cli_deploy.py index 5e7cf97bb3..19a7dde9ae 100644 --- a/src/google/adk/cli/cli_deploy.py +++ b/src/google/adk/cli/cli_deploy.py @@ -1096,36 +1096,18 @@ def to_agent_engine( from ..utils._google_client_headers import get_tracking_headers - if project and region: - click.echo('Initializing Vertex AI...') - client = vertexai.Client( - project=project, - location=region, - http_options={'headers': get_tracking_headers()}, - ) - elif api_key: - click.echo('Initializing Vertex AI in Express Mode with API key...') - client = vertexai.Client( - api_key=api_key, http_options={'headers': get_tracking_headers()} - ) - else: - click.echo( - 'No project/region or api_key provided. Starting onboarding flow...' - ) + if not project or not region: + click.echo('No project/region provided. Starting onboarding flow...') auth_info = _onboarding.handle_login_with_google() - if isinstance(auth_info, _onboarding.VertexAIAuth): - click.echo('Initializing Vertex AI...') - client = vertexai.Client( - project=auth_info.project_id, - location=auth_info.region, - http_options={'headers': get_tracking_headers()}, - ) - elif isinstance(auth_info, _onboarding.ExpressModeAuth): - click.echo('Initializing Vertex AI in Express Mode with API key...') - client = vertexai.Client( - api_key=auth_info.api_key, - http_options={'headers': get_tracking_headers()}, - ) + project = auth_info.project_id + region = auth_info.region + + click.echo('Initializing Vertex AI...') + client = vertexai.Client( + project=project, + location=region, + http_options={'headers': get_tracking_headers()}, + ) click.echo('Vertex AI initialized.') is_config_agent = False diff --git a/tests/unittests/cli/utils/test_cli_deploy.py b/tests/unittests/cli/utils/test_cli_deploy.py index 27cc33f9dd..a1e66139f8 100644 --- a/tests/unittests/cli/utils/test_cli_deploy.py +++ b/tests/unittests/cli/utils/test_cli_deploy.py @@ -702,14 +702,13 @@ def test_to_agent_engine_triggers_onboarding( agent_dir: Callable[[bool, bool], Path], ) -> None: """It should trigger onboarding when credentials are missing.""" - onboarding_recorder = _Recorder() - - def mock_handle_login(): - onboarding_recorder() - return cli_deploy._onboarding.ExpressModeAuth( - api_key="fake_api_key", project_id="fake_project", region="fake_region" - ) - + mock_handle_login = mock.Mock( + return_value=cli_deploy._onboarding.ExpressModeAuth( + api_key="fake_api_key", + project_id="fake_project", + region="fake_region", + ) + ) monkeypatch.setattr( cli_deploy._onboarding, "handle_login_with_google", mock_handle_login ) @@ -722,23 +721,18 @@ def mock_handle_login(): ) fake_vertexai = types.ModuleType("vertexai") + mock_client = mock.Mock() + fake_vertexai.Client = mock.Mock(return_value=mock_client) - class _FakeAgentEngines: + mock_agent_engines = mock.Mock() + mock_client.agent_engines = mock_agent_engines - def create(self, *, config: Dict[str, Any]) -> Any: - _ = config - return types.SimpleNamespace( - api_resource=types.SimpleNamespace( - name="projects/p/locations/l/reasoningEngines/e" - ) + mock_agent_engines.create.return_value = types.SimpleNamespace( + api_resource=types.SimpleNamespace( + name="projects/p/locations/l/reasoningEngines/e" ) + ) - class _FakeVertexClient: - - def __init__(self, *_args: Any, **_kwargs: Any) -> None: - self.agent_engines = _FakeAgentEngines() - - fake_vertexai.Client = _FakeVertexClient monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai) src_dir = agent_dir(False, False) @@ -749,4 +743,11 @@ def __init__(self, *_args: Any, **_kwargs: Any) -> None: trace_to_cloud=True, ) - assert len(onboarding_recorder.calls) == 1 + mock_handle_login.assert_called_once() + + # Verify vertexai.Client was initialized with correct args + fake_vertexai.Client.assert_called_once() + kwargs = fake_vertexai.Client.call_args.kwargs + assert kwargs.get("project") == "fake_project" + assert kwargs.get("location") == "fake_region" + assert "api_key" not in kwargs or kwargs.get("api_key") is None From 3a1eadce66804db08f6520cc11f9c60e81bb9e30 Mon Sep 17 00:00:00 2001 From: George Weale Date: Tue, 5 May 2026 12:18:24 -0700 Subject: [PATCH 14/28] fix: use asyncio.sleep to avoid blocking event loop Co-authored-by: George Weale PiperOrigin-RevId: 910839657 --- .../environment_simulation_engine.py | 3 +- .../test_environment_simulation_engine.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/environment_simulation/environment_simulation_engine.py b/src/google/adk/tools/environment_simulation/environment_simulation_engine.py index 3d260c6954..98371a839d 100644 --- a/src/google/adk/tools/environment_simulation/environment_simulation_engine.py +++ b/src/google/adk/tools/environment_simulation/environment_simulation_engine.py @@ -18,7 +18,6 @@ import concurrent.futures import logging import random -import time from typing import Any from typing import Dict from typing import Optional @@ -107,7 +106,7 @@ async def simulate( self._random_generator.random() < injection_config.injection_probability ): - time.sleep(injection_config.injected_latency_seconds) + await asyncio.sleep(injection_config.injected_latency_seconds) if injection_config.injected_error: return { "error_code": ( diff --git a/tests/unittests/tools/environment_simulation/test_environment_simulation_engine.py b/tests/unittests/tools/environment_simulation/test_environment_simulation_engine.py index efa9538807..e8346f400f 100644 --- a/tests/unittests/tools/environment_simulation/test_environment_simulation_engine.py +++ b/tests/unittests/tools/environment_simulation/test_environment_simulation_engine.py @@ -221,3 +221,37 @@ async def test_injection_with_random_seed_is_deterministic( result2 = await engine_injected.simulate(mock_tool, {}, MagicMock()) assert result2 == {"injected": True} mock_create_strategy.assert_not_called() + + async def test_injected_latency_awaits_asyncio_sleep( + self, mock_create_strategy, mock_analyzer + ): + """Regression guard against blocking time.sleep in the async path.""" + del mock_create_strategy, mock_analyzer + latency = 0.2 + config = EnvironmentSimulationConfig( + tool_simulation_configs=[ + ToolSimulationConfig( + tool_name="test_tool", + injection_configs=[ + InjectionConfig( + injected_latency_seconds=latency, + injected_response={"injected": True}, + ) + ], + ) + ], + simulation_model="test-model", + simulation_model_configuration=genai_types.GenerateContentConfig(), + ) + engine = EnvironmentSimulationEngine(config) + mock_tool = MagicMock() + mock_tool.name = "test_tool" + + with patch( + "google.adk.tools.environment_simulation.environment_simulation_engine.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + result = await engine.simulate(mock_tool, {}, MagicMock()) + + mock_sleep.assert_awaited_once_with(latency) + assert result == {"injected": True} From 01f1fc9c912a97ff27bb1332a28324f991eae77d Mon Sep 17 00:00:00 2001 From: Kathy Wu Date: Tue, 5 May 2026 14:10:14 -0700 Subject: [PATCH 15/28] fix: Only append skills to system instruction if ListSkillsTool isn't available This reduces the token usage of having skills appended to the instruction on every call Co-authored-by: Kathy Wu PiperOrigin-RevId: 910904229 --- src/google/adk/tools/skill_toolset.py | 14 +++++++----- tests/unittests/tools/test_skill_toolset.py | 25 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/google/adk/tools/skill_toolset.py b/src/google/adk/tools/skill_toolset.py index 08dc937d7b..5ffa4bbbae 100644 --- a/src/google/adk/tools/skill_toolset.py +++ b/src/google/adk/tools/skill_toolset.py @@ -900,11 +900,15 @@ async def process_llm_request( self, *, tool_context: ToolContext, llm_request: LlmRequest ) -> None: """Processes the outgoing LLM request to include available skills.""" - skills = self._list_skills() - skills_xml = prompt.format_skills_as_xml(skills) - instructions = [] - instructions.append(_DEFAULT_SKILL_SYSTEM_INSTRUCTION) - instructions.append(skills_xml) + instructions = [_DEFAULT_SKILL_SYSTEM_INSTRUCTION] + + has_list_skills = any(isinstance(t, ListSkillsTool) for t in self._tools) + + if not has_list_skills: + skills = self._list_skills() + skills_xml = prompt.format_skills_as_xml(skills) + instructions.append(skills_xml) + llm_request.append_instructions(instructions) diff --git a/tests/unittests/tools/test_skill_toolset.py b/tests/unittests/tools/test_skill_toolset.py index 7d60110177..8f2ccbe040 100644 --- a/tests/unittests/tools/test_skill_toolset.py +++ b/tests/unittests/tools/test_skill_toolset.py @@ -427,7 +427,7 @@ async def test_load_resource_process_llm_request_binary( @pytest.mark.asyncio -async def test_process_llm_request( +async def test_process_llm_request_with_list_skills_tool( mock_skill1, mock_skill2, tool_context_instance ): toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) @@ -437,6 +437,29 @@ async def test_process_llm_request( tool_context=tool_context_instance, llm_request=llm_req ) + llm_req.append_instructions.assert_called_once_with( + [skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION] + ) + + +@pytest.mark.asyncio +async def test_process_llm_request_without_list_skills_tool( + mock_skill1, mock_skill2, tool_context_instance +): + toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2]) + # Manually remove ListSkillsTool from self._tools to simulate it not being available + toolset._tools = [ + t + for t in toolset._tools + if not isinstance(t, skill_toolset.ListSkillsTool) + ] + + llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True) + + await toolset.process_llm_request( + tool_context=tool_context_instance, llm_request=llm_req + ) + llm_req.append_instructions.assert_called_once() args, _ = llm_req.append_instructions.call_args instructions = args[0] From e5fa990e580a59184b7c7c641833be7e83774fd5 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 5 May 2026 15:17:03 -0700 Subject: [PATCH 16/28] chore: Checks gemini EAP models for gemini-builtin tools PiperOrigin-RevId: 910941961 --- src/google/adk/tools/url_context_tool.py | 7 ++++++- src/google/adk/utils/model_name_utils.py | 25 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/google/adk/tools/url_context_tool.py b/src/google/adk/tools/url_context_tool.py index 5e923e7447..86c5f48914 100644 --- a/src/google/adk/tools/url_context_tool.py +++ b/src/google/adk/tools/url_context_tool.py @@ -21,6 +21,7 @@ from ..utils.model_name_utils import is_gemini_1_model from ..utils.model_name_utils import is_gemini_2_or_above +from ..utils.model_name_utils import is_gemini_eap_model from ..utils.model_name_utils import is_gemini_model_id_check_disabled from .base_tool import BaseTool from .tool_context import ToolContext @@ -52,7 +53,11 @@ async def process_llm_request( llm_request.config.tools = llm_request.config.tools or [] if is_gemini_1_model(llm_request.model): raise ValueError('Url context tool cannot be used in Gemini 1.x.') - elif is_gemini_2_or_above(llm_request.model) or model_check_disabled: + elif ( + is_gemini_2_or_above(llm_request.model) + or is_gemini_eap_model(llm_request.model) + or model_check_disabled + ): llm_request.config.tools.append( types.Tool(url_context=types.UrlContext()) ) diff --git a/src/google/adk/utils/model_name_utils.py b/src/google/adk/utils/model_name_utils.py index 457e72e184..8c5cd8b422 100644 --- a/src/google/adk/utils/model_name_utils.py +++ b/src/google/adk/utils/model_name_utils.py @@ -127,6 +127,31 @@ def is_gemini_2_or_above(model_string: Optional[str]) -> bool: return parsed_version.major >= 2 +def is_gemini_eap_model(model_string: Optional[str]) -> bool: + """Check if the model is an Early Access Program (EAP) Gemini model. + + Matches names of the form ``gemini--early-exp`` optionally + followed by a numeric suffix, e.g. ``gemini-flash-early-exp`` or + ``gemini-flash-early-exp3``. ```` is one or more + alphanumeric/underscore segments separated by ``-`` (e.g. ``flash``, + ``pro``, ``flash-lite``). + + Args: + model_string: Either a simple model name or path-based model name. + + Returns: + True if it matches the EAP naming convention, False otherwise. + """ + if not model_string: + return False + + model_name = extract_model_name(model_string) + return ( + re.match(r'^gemini-[a-z0-9_]+(?:-[a-z0-9_]+)*-early-exp\d*$', model_name) + is not None + ) + + def is_gemini_3_1_flash_live(model_string: Optional[str]) -> bool: """Check if the model is a Gemini 3.1 Flash Live model. From 83f981761b963ca51a286cbd004c043567517a3c Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 5 May 2026 22:44:39 -0700 Subject: [PATCH 17/28] fix: Raise a clear actionable error when CustomAuthScheme lacks a registered AuthProvider PiperOrigin-RevId: 911115744 --- src/google/adk/auth/credential_manager.py | 46 +++++++++++-------- .../unittests/auth/test_credential_manager.py | 28 ++++++++++- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/google/adk/auth/credential_manager.py b/src/google/adk/auth/credential_manager.py index 46d8057cd9..92d0fe4aa9 100644 --- a/src/google/adk/auth/credential_manager.py +++ b/src/google/adk/auth/credential_manager.py @@ -183,26 +183,34 @@ async def get_auth_credential( ) -> Optional[AuthCredential]: """Load and prepare authentication credential through a structured workflow.""" - # Pydantic may have deserialized an unknown scheme into a generic - # CustomAuthScheme. If so, rehydrate it first into a specific subclass. - # Note: Custom authentication scheme classes must have been imported into - # the Python runtime before get_auth_credential is called for their - # subclasses to be registered. This is fine as developer will anyway import - # them while registering the auth providers. - # Note: `__subclasses__()` only returns immediate subclasses, if there is a - # subclass of a subclass of CustomAuthScheme then it will not be returned. - # pylint: disable=unidiomatic-typecheck Needs exact class matching. - if type(self._auth_config.auth_scheme) is CustomAuthScheme: - self._auth_config.auth_scheme = _rehydrate_custom_scheme( - self._auth_config.auth_scheme, - CustomAuthScheme.__subclasses__(), + # Step 0: Handle CustomAuthScheme if present + if isinstance(self._auth_config.auth_scheme, CustomAuthScheme): + # Pydantic may have deserialized an unknown scheme into a generic + # CustomAuthScheme. If so, rehydrate it first into a specific subclass. + # Note: Custom authentication scheme classes must have been imported into + # the Python runtime before get_auth_credential is called for their + # subclasses to be registered. This is fine as developer will anyway + # import them while registering the auth providers. + # Note: `__subclasses__()` only returns immediate subclasses, if there is + # a subclass of a subclass of CustomAuthScheme then it will not be + # returned. + # pylint: disable=unidiomatic-typecheck Needs exact class matching. + if type(self._auth_config.auth_scheme) is CustomAuthScheme: + self._auth_config.auth_scheme = _rehydrate_custom_scheme( + self._auth_config.auth_scheme, + CustomAuthScheme.__subclasses__(), + ) + + provider = self._auth_provider_registry.get_provider( + self._auth_config.auth_scheme ) - # First, check if a registered auth provider is available before attempting - # to retrieve tokens natively. - provider = self._auth_provider_registry.get_provider( - self._auth_config.auth_scheme - ) - if provider: + if provider is None: + raise ValueError( + "No auth provider registered for custom auth scheme " + f"{self._auth_config.auth_scheme.type_!r}. " + "Register it using `CredentialManager.register_auth_provider(" + ")`." + ) provided_credential = await provider.get_auth_credential( self._auth_config, context ) diff --git a/tests/unittests/auth/test_credential_manager.py b/tests/unittests/auth/test_credential_manager.py index 559c76138b..0ba9490897 100644 --- a/tests/unittests/auth/test_credential_manager.py +++ b/tests/unittests/auth/test_credential_manager.py @@ -48,7 +48,7 @@ from .. import testing_utils -class DummyAuthScheme(SecurityBase): +class DummyAuthScheme(CustomAuthScheme): """A custom auth scheme for testing pluggable auth providers.""" type_: str = "dummy_auth_scheme" @@ -1122,3 +1122,29 @@ def test_rehydrate_custom_scheme_failure(self): _rehydrate_custom_scheme( scheme=custom_scheme, supported_schemes=[DummyAuthScheme] ) + + @pytest.mark.asyncio + async def test_get_auth_credential_raises_error_when_no_provider_registered( + self, mocker + ): + """Test that a ValueError is raised when no provider is registered for a CustomAuthScheme.""" + + class DummyCustomScheme(CustomAuthScheme): + type_: str = "dummy_custom_auth_scheme" + + auth_config = mocker.Mock(spec=AuthConfig, instance=True) + auth_config.auth_scheme = DummyCustomScheme() + + manager = CredentialManager(auth_config) + + with pytest.raises( + ValueError, + match=( + r"No auth provider registered for custom auth scheme " + r"'dummy_custom_auth_scheme'\. " + r"Register it using `CredentialManager\.register_auth_provider\(" + ), + ): + await manager.get_auth_credential( + mocker.Mock(spec=CallbackContext, instance=True) + ) From 211e2ceb70ac6b61400559761d1d6548d906a79b Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Wed, 6 May 2026 10:22:11 -0700 Subject: [PATCH 18/28] fix: skipping state_delta overwrite on function_response-only events Co-authored-by: Xuan Yang PiperOrigin-RevId: 911411431 --- src/google/adk/agents/llm_agent.py | 17 +++++++----- .../agents/test_llm_agent_output_save.py | 26 ++++++++++++++++++- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index ed83d00413..b41b7f4eff 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -845,6 +845,17 @@ def __maybe_save_output_to_state(self, event: Event): # Handle text responses if event.is_final_response() and event.content and event.content.parts: + # Skip if no text parts at all to avoid overwriting state_delta values + # already set (e.g. after_tool_callback with skip_summarization + # on function_response-only events). + has_text_part = any( + part.text is not None and not part.thought + for part in event.content.parts + ) + + if not has_text_part: + return + result = ''.join( part.text for part in event.content.parts @@ -857,12 +868,6 @@ def __maybe_save_output_to_state(self, event: Event): if not result.strip(): return result = validate_schema(self.output_schema, result) - elif not result: - # No text parts found and no output_schema. Skip to avoid - # overwriting state_delta values already set by callbacks - # (e.g. after_tool_callback with skip_summarization on - # function_response-only events). - return event.actions.state_delta[self.output_key] = result @model_validator(mode='after') diff --git a/tests/unittests/agents/test_llm_agent_output_save.py b/tests/unittests/agents/test_llm_agent_output_save.py index ad7b48e3e2..cb3206b210 100644 --- a/tests/unittests/agents/test_llm_agent_output_save.py +++ b/tests/unittests/agents/test_llm_agent_output_save.py @@ -279,7 +279,9 @@ def test_maybe_save_output_to_state_handles_empty_final_chunk_with_schema( def test_maybe_save_output_to_state_skips_function_response_only_event(self): """Test that state_delta set by callback is not overwritten when event - only has function_response parts and no text.""" + + only has function_response parts and no text. + """ agent = LlmAgent(name="test_agent", output_key="result") # Simulate a function_response-only event (no text parts) @@ -307,3 +309,25 @@ def test_maybe_save_output_to_state_skips_function_response_only_event(self): # The callback-set value should be preserved, not overwritten with "" assert event.actions.state_delta["result"] == [1, 2, 3] + + def test_maybe_save_output_to_state_saves_empty_string_when_text_is_empty( + self, + ): + """Test that output is saved as empty string when part.text is explicitly empty.""" + agent = LlmAgent(name="test_agent", output_key="result") + + # Explicitly construct a part with empty string text + parts = [types.Part(text="")] + content = types.Content(role="model", parts=parts) + event = Event( + invocation_id="test_invocation", + author="test_agent", + content=content, + actions=EventActions(), + ) + + agent._LlmAgent__maybe_save_output_to_state(event) + + # Assert key exists and value is empty string + assert "result" in event.actions.state_delta + assert not event.actions.state_delta["result"] From b58ce57b676e242a799c44cc5ac368002bfb550b Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 6 May 2026 14:35:19 -0700 Subject: [PATCH 19/28] chore: Enable gemini EAP models for gemini-builtin tools PiperOrigin-RevId: 911554133 --- .../code_executors/built_in_code_executor.py | 4 +- .../retrieval/vertex_ai_rag_retrieval.py | 4 +- src/google/adk/tools/url_context_tool.py | 9 +-- src/google/adk/utils/model_name_utils.py | 17 +++-- src/google/adk/utils/output_schema_utils.py | 4 +- .../unittests/utils/test_model_name_utils.py | 72 ++++++++++--------- 6 files changed, 61 insertions(+), 49 deletions(-) diff --git a/src/google/adk/code_executors/built_in_code_executor.py b/src/google/adk/code_executors/built_in_code_executor.py index a4e3203461..d330a04f8c 100644 --- a/src/google/adk/code_executors/built_in_code_executor.py +++ b/src/google/adk/code_executors/built_in_code_executor.py @@ -19,7 +19,7 @@ from ..agents.invocation_context import InvocationContext from ..models import LlmRequest -from ..utils.model_name_utils import is_gemini_2_or_above +from ..utils.model_name_utils import is_gemini_eap_or_2_or_above from ..utils.model_name_utils import is_gemini_model_id_check_disabled from .base_code_executor import BaseCodeExecutor from .code_execution_utils import CodeExecutionInput @@ -44,7 +44,7 @@ def execute_code( def process_llm_request(self, llm_request: LlmRequest) -> None: """Pre-process the LLM request for Gemini 2.0+ models to use the code execution tool.""" model_check_disabled = is_gemini_model_id_check_disabled() - if is_gemini_2_or_above(llm_request.model) or model_check_disabled: + if is_gemini_eap_or_2_or_above(llm_request.model) or model_check_disabled: llm_request.config = llm_request.config or types.GenerateContentConfig() llm_request.config.tools = llm_request.config.tools or [] llm_request.config.tools.append( diff --git a/src/google/adk/tools/retrieval/vertex_ai_rag_retrieval.py b/src/google/adk/tools/retrieval/vertex_ai_rag_retrieval.py index 4d564ca164..61c3ed95c0 100644 --- a/src/google/adk/tools/retrieval/vertex_ai_rag_retrieval.py +++ b/src/google/adk/tools/retrieval/vertex_ai_rag_retrieval.py @@ -23,7 +23,7 @@ from google.genai import types from typing_extensions import override -from ...utils.model_name_utils import is_gemini_2_or_above +from ...utils.model_name_utils import is_gemini_eap_or_2_or_above from ...utils.model_name_utils import is_gemini_model_id_check_disabled from ..tool_context import ToolContext from .base_retrieval_tool import BaseRetrievalTool @@ -65,7 +65,7 @@ async def process_llm_request( ) -> None: # Use Gemini built-in Vertex AI RAG tool for Gemini 2 models. model_check_disabled = is_gemini_model_id_check_disabled() - if is_gemini_2_or_above(llm_request.model) or model_check_disabled: + if is_gemini_eap_or_2_or_above(llm_request.model) or model_check_disabled: llm_request.config = ( types.GenerateContentConfig() if not llm_request.config diff --git a/src/google/adk/tools/url_context_tool.py b/src/google/adk/tools/url_context_tool.py index 86c5f48914..c93231e1be 100644 --- a/src/google/adk/tools/url_context_tool.py +++ b/src/google/adk/tools/url_context_tool.py @@ -20,8 +20,7 @@ from typing_extensions import override from ..utils.model_name_utils import is_gemini_1_model -from ..utils.model_name_utils import is_gemini_2_or_above -from ..utils.model_name_utils import is_gemini_eap_model +from ..utils.model_name_utils import is_gemini_eap_or_2_or_above from ..utils.model_name_utils import is_gemini_model_id_check_disabled from .base_tool import BaseTool from .tool_context import ToolContext @@ -53,11 +52,7 @@ async def process_llm_request( llm_request.config.tools = llm_request.config.tools or [] if is_gemini_1_model(llm_request.model): raise ValueError('Url context tool cannot be used in Gemini 1.x.') - elif ( - is_gemini_2_or_above(llm_request.model) - or is_gemini_eap_model(llm_request.model) - or model_check_disabled - ): + elif is_gemini_eap_or_2_or_above(llm_request.model) or model_check_disabled: llm_request.config.tools.append( types.Tool(url_context=types.UrlContext()) ) diff --git a/src/google/adk/utils/model_name_utils.py b/src/google/adk/utils/model_name_utils.py index 8c5cd8b422..b2e032e0d1 100644 --- a/src/google/adk/utils/model_name_utils.py +++ b/src/google/adk/utils/model_name_utils.py @@ -99,18 +99,27 @@ def is_gemini_1_model(model_string: Optional[str]) -> bool: return re.match(r'^gemini-1\.\d+', model_name) is not None -def is_gemini_2_or_above(model_string: Optional[str]) -> bool: - """Check if the model is a Gemini 2.0 or newer model using semantic versions. +def is_gemini_eap_or_2_or_above(model_string: Optional[str]) -> bool: + """Check if the model is a Gemini EAP or a Gemini 2.0+ model. + + EAP (Early Access Program) Gemini models follow a different naming + convention (see ``_is_gemini_eap_model``) and do not encode a numeric + version, so they are checked first. Otherwise the model name is parsed + as a semantic version and is considered a match when the major version + is ``>= 2``. Args: model_string: Either a simple model name or path-based model name Returns: - True if it's a Gemini 2.0+ model, False otherwise + True if it's a Gemini EAP model or a Gemini 2.0+ model, False otherwise """ if not model_string: return False + if _is_gemini_eap_model(model_string): + return True + model_name = extract_model_name(model_string) if not model_name.startswith('gemini-'): return False @@ -127,7 +136,7 @@ def is_gemini_2_or_above(model_string: Optional[str]) -> bool: return parsed_version.major >= 2 -def is_gemini_eap_model(model_string: Optional[str]) -> bool: +def _is_gemini_eap_model(model_string: Optional[str]) -> bool: """Check if the model is an Early Access Program (EAP) Gemini model. Matches names of the form ``gemini--early-exp`` optionally diff --git a/src/google/adk/utils/output_schema_utils.py b/src/google/adk/utils/output_schema_utils.py index 228e95b66d..ab9bee1ae8 100644 --- a/src/google/adk/utils/output_schema_utils.py +++ b/src/google/adk/utils/output_schema_utils.py @@ -23,7 +23,7 @@ from typing import Union from ..models.base_llm import BaseLlm -from .model_name_utils import is_gemini_2_or_above +from .model_name_utils import is_gemini_eap_or_2_or_above from .variant_utils import get_google_llm_variant from .variant_utils import GoogleLLMVariant @@ -49,5 +49,5 @@ def can_use_output_schema_with_tools(model: Union[str, BaseLlm]) -> bool: return ( get_google_llm_variant() == GoogleLLMVariant.VERTEX_AI - and is_gemini_2_or_above(model_string) + and is_gemini_eap_or_2_or_above(model_string) ) diff --git a/tests/unittests/utils/test_model_name_utils.py b/tests/unittests/utils/test_model_name_utils.py index 26aca5f215..bb2654c3db 100644 --- a/tests/unittests/utils/test_model_name_utils.py +++ b/tests/unittests/utils/test_model_name_utils.py @@ -16,7 +16,7 @@ from google.adk.utils.model_name_utils import extract_model_name from google.adk.utils.model_name_utils import is_gemini_1_model -from google.adk.utils.model_name_utils import is_gemini_2_or_above +from google.adk.utils.model_name_utils import is_gemini_eap_or_2_or_above from google.adk.utils.model_name_utils import is_gemini_model from google.adk.utils.model_name_utils import is_gemini_model_id_check_disabled @@ -189,50 +189,56 @@ def test_is_gemini_1_model_edge_cases(self): class TestIsGemini2Model: - """Test the is_gemini_2_or_above function.""" + """Test the is_gemini_eap_or_2_or_above function.""" - def test_is_gemini_2_or_above_simple_names(self): + def test_is_gemini_eap_or_2_or_above_simple_names(self): """Test Gemini 2.0+ model detection with simple model names.""" - assert is_gemini_2_or_above('gemini-2.5-flash') is True - assert is_gemini_2_or_above('gemini-2.5-pro') is True - assert is_gemini_2_or_above('gemini-2.9-experimental') is True - assert is_gemini_2_or_above('gemini-2-pro') is True - assert is_gemini_2_or_above('gemini-2') is True - assert is_gemini_2_or_above('gemini-3.0-pro') is True - assert is_gemini_2_or_above('gemini-1.5-flash') is False - assert is_gemini_2_or_above('gemini-1.0-pro') is False - assert is_gemini_2_or_above('claude-3-sonnet') is False - - def test_is_gemini_2_or_above_path_based_names(self): + assert is_gemini_eap_or_2_or_above('gemini-2.5-flash') is True + assert is_gemini_eap_or_2_or_above('gemini-2.5-pro') is True + assert is_gemini_eap_or_2_or_above('gemini-2.9-experimental') is True + assert is_gemini_eap_or_2_or_above('gemini-2-pro') is True + assert is_gemini_eap_or_2_or_above('gemini-2') is True + assert is_gemini_eap_or_2_or_above('gemini-3.0-pro') is True + assert is_gemini_eap_or_2_or_above('gemini-flash-early-exp') is True + assert is_gemini_eap_or_2_or_above('gemini-flash-early-exp3') is True + assert is_gemini_eap_or_2_or_above('gemini-flash-lite-early-exp') is True + assert is_gemini_eap_or_2_or_above('gemini-pro-early-exp') is True + assert is_gemini_eap_or_2_or_above('gemini-1.5-flash') is False + assert is_gemini_eap_or_2_or_above('gemini-1.0-pro') is False + assert is_gemini_eap_or_2_or_above('claude-3-sonnet') is False + + def test_is_gemini_eap_or_2_or_above_path_based_names(self): """Test Gemini 2.0+ model detection with path-based model names.""" gemini_2_path = 'projects/265104255505/locations/us-central1/publishers/google/models/gemini-2.5-flash' - assert is_gemini_2_or_above(gemini_2_path) is True + assert is_gemini_eap_or_2_or_above(gemini_2_path) is True gemini_2_path_2 = 'projects/12345/locations/us-east1/publishers/google/models/gemini-2.5-pro-preview' - assert is_gemini_2_or_above(gemini_2_path_2) is True + assert is_gemini_eap_or_2_or_above(gemini_2_path_2) is True gemini_1_path = 'projects/265104255505/locations/us-central1/publishers/google/models/gemini-1.5-flash' - assert is_gemini_2_or_above(gemini_1_path) is False + assert is_gemini_eap_or_2_or_above(gemini_1_path) is False gemini_3_path = 'projects/12345/locations/us-east1/publishers/google/models/gemini-3.0-pro' - assert is_gemini_2_or_above(gemini_3_path) is True + assert is_gemini_eap_or_2_or_above(gemini_3_path) is True - def test_is_gemini_2_or_above_edge_cases(self): + def test_is_gemini_eap_or_2_or_above_edge_cases(self): """Test edge cases for Gemini 2.0+ model detection.""" # Test with None - assert is_gemini_2_or_above(None) is False + assert is_gemini_eap_or_2_or_above(None) is False # Test with empty string - assert is_gemini_2_or_above('') is False + assert is_gemini_eap_or_2_or_above('') is False # Test with model names containing gemini-2 but not starting with it - assert is_gemini_2_or_above('my-gemini-2.5-model') is False - assert is_gemini_2_or_above('custom-gemini-2.5-flash') is False + assert is_gemini_eap_or_2_or_above('my-gemini-2.5-model') is False + assert is_gemini_eap_or_2_or_above('custom-gemini-2.5-flash') is False # Test with invalid versions - assert is_gemini_2_or_above('gemini-2.') is False # Missing version number - assert is_gemini_2_or_above('gemini-0.9-test') is False - assert is_gemini_2_or_above('gemini-one') is False + assert ( + is_gemini_eap_or_2_or_above('gemini-2.') is False + ) # Missing version number + assert is_gemini_eap_or_2_or_above('gemini-0.9-test') is False + assert is_gemini_eap_or_2_or_above('gemini-one') is False class TestModelNameUtilsIntegration: @@ -255,14 +261,14 @@ def test_model_classification_consistency(self): for model in test_models: # A model can only be either Gemini 1.x or Gemini 2.0+, not both if is_gemini_1_model(model): - assert not is_gemini_2_or_above( + assert not is_gemini_eap_or_2_or_above( model ), f'Model {model} classified as both Gemini 1.x and 2.0+' assert is_gemini_model( model ), f'Model {model} is Gemini 1.x but not classified as Gemini' - if is_gemini_2_or_above(model): + if is_gemini_eap_or_2_or_above(model): assert not is_gemini_1_model( model ), f'Model {model} classified as both Gemini 1.x and 2.0+' @@ -271,7 +277,9 @@ def test_model_classification_consistency(self): ), f'Model {model} is Gemini 2.0+ but not classified as Gemini' # If it's neither Gemini 1.x nor 2.0+, it should not be classified as Gemini - if not is_gemini_1_model(model) and not is_gemini_2_or_above(model): + if not is_gemini_1_model(model) and not is_gemini_eap_or_2_or_above( + model + ): if model and 'gemini-' not in extract_model_name(model): assert not is_gemini_model( model @@ -312,9 +320,9 @@ def test_path_vs_simple_model_consistency(self): f'Inconsistent Gemini 1.x classification for {simple_model} vs' f' {path_model}' ) - assert is_gemini_2_or_above(simple_model) == is_gemini_2_or_above( - path_model - ), ( + assert is_gemini_eap_or_2_or_above( + simple_model + ) == is_gemini_eap_or_2_or_above(path_model), ( f'Inconsistent Gemini 2.0+ classification for {simple_model} vs' f' {path_model}' ) From 153c7f70931018ed1687716d4bee7ffe2fd917ac Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Wed, 6 May 2026 15:32:12 -0700 Subject: [PATCH 20/28] ci: ignore browser assets in license lint Merge https://github.com/google/adk-python/pull/5612 ## Summary - Added `.github/header-checker-lint.yml` with full configuration to ignore `src/google/adk/cli/browser/**`. - Ensured Python files are still checked by adding `py` to `sourceFileExtensions` ## Test Plan - Verify that the `License Header Lint GCF` / `header-check` status check passes on this PR Co-authored-by: Wei Sun (Jack) COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/5612 from google:ci/ignore-browser-assets d08d1618a2de883a300f817c9fdc777daba5185e PiperOrigin-RevId: 911583387 --- .github/header-checker-lint.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/header-checker-lint.yml diff --git a/.github/header-checker-lint.yml b/.github/header-checker-lint.yml new file mode 100644 index 0000000000..81e48a25f0 --- /dev/null +++ b/.github/header-checker-lint.yml @@ -0,0 +1,13 @@ +allowedCopyrightHolders: + - 'Google LLC' +allowedLicenses: + - 'Apache-2.0' + - 'MIT' + - 'BSD-3' +sourceFileExtensions: + - 'ts' + - 'js' + - 'java' + - 'py' +ignoreFiles: + - 'src/google/adk/cli/browser/**' From 3117e0913689960dc951035bb6ca7b8551cec4d3 Mon Sep 17 00:00:00 2001 From: "Wei Sun (Jack)" Date: Wed, 6 May 2026 22:16:38 -0700 Subject: [PATCH 21/28] chore: further fix header-check via 2025 --> 2026 Co-authored-by: Wei Sun (Jack) PiperOrigin-RevId: 911737937 --- contributing/samples/bigquery_mcp/__init__.py | 2 +- contributing/samples/bigquery_mcp/agent.py | 2 +- contributing/samples/mcp_toolset_auth/__init__.py | 2 +- contributing/samples/mcp_toolset_auth/agent.py | 2 +- contributing/samples/mcp_toolset_auth/main.py | 2 +- contributing/samples/mcp_toolset_auth/oauth_mcp_server.py | 2 +- contributing/samples/plugin_debug_logging/__init__.py | 2 +- contributing/samples/plugin_debug_logging/agent.py | 2 +- contributing/samples/sandbox_computer_use/__init__.py | 2 +- contributing/samples/sandbox_computer_use/agent.py | 2 +- contributing/samples/sandbox_computer_use/main.py | 2 +- src/google/adk/integrations/vmaas/__init__.py | 2 +- src/google/adk/integrations/vmaas/sandbox_client.py | 2 +- src/google/adk/integrations/vmaas/sandbox_computer.py | 2 +- tests/unittests/cli/test_cli_feature_options.py | 2 +- tests/unittests/integrations/vmaas/__init__.py | 2 +- tests/unittests/integrations/vmaas/test_sandbox_client.py | 2 +- tests/unittests/integrations/vmaas/test_sandbox_computer.py | 2 +- tests/unittests/models/test_litellm_import.py | 2 +- tests/unittests/plugins/test_debug_logging_plugin.py | 2 +- tests/unittests/tools/mcp_tool/test_session_context.py | 2 +- tests/unittests/tools/retrieval/test_base_retrieval_tool.py | 2 +- tests/unittests/tools/test_load_memory_tool.py | 2 +- tests/unittests/utils/test_google_client_headers.py | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/contributing/samples/bigquery_mcp/__init__.py b/contributing/samples/bigquery_mcp/__init__.py index c48963cdc7..4015e47d6e 100644 --- a/contributing/samples/bigquery_mcp/__init__.py +++ b/contributing/samples/bigquery_mcp/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/bigquery_mcp/agent.py b/contributing/samples/bigquery_mcp/agent.py index 4116bc6cf4..fb3a16f22d 100644 --- a/contributing/samples/bigquery_mcp/agent.py +++ b/contributing/samples/bigquery_mcp/agent.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/mcp_toolset_auth/__init__.py b/contributing/samples/mcp_toolset_auth/__init__.py index c48963cdc7..4015e47d6e 100644 --- a/contributing/samples/mcp_toolset_auth/__init__.py +++ b/contributing/samples/mcp_toolset_auth/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/mcp_toolset_auth/agent.py b/contributing/samples/mcp_toolset_auth/agent.py index 791fe761eb..bc0e8e54dd 100644 --- a/contributing/samples/mcp_toolset_auth/agent.py +++ b/contributing/samples/mcp_toolset_auth/agent.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/mcp_toolset_auth/main.py b/contributing/samples/mcp_toolset_auth/main.py index e9b8950a4c..f02c553301 100644 --- a/contributing/samples/mcp_toolset_auth/main.py +++ b/contributing/samples/mcp_toolset_auth/main.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/mcp_toolset_auth/oauth_mcp_server.py b/contributing/samples/mcp_toolset_auth/oauth_mcp_server.py index 0eeab51c6a..d9d76dd08a 100644 --- a/contributing/samples/mcp_toolset_auth/oauth_mcp_server.py +++ b/contributing/samples/mcp_toolset_auth/oauth_mcp_server.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/plugin_debug_logging/__init__.py b/contributing/samples/plugin_debug_logging/__init__.py index c48963cdc7..4015e47d6e 100644 --- a/contributing/samples/plugin_debug_logging/__init__.py +++ b/contributing/samples/plugin_debug_logging/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/plugin_debug_logging/agent.py b/contributing/samples/plugin_debug_logging/agent.py index 0a93ad0ef5..7b6f0bee8b 100644 --- a/contributing/samples/plugin_debug_logging/agent.py +++ b/contributing/samples/plugin_debug_logging/agent.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/sandbox_computer_use/__init__.py b/contributing/samples/sandbox_computer_use/__init__.py index c48963cdc7..4015e47d6e 100644 --- a/contributing/samples/sandbox_computer_use/__init__.py +++ b/contributing/samples/sandbox_computer_use/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/sandbox_computer_use/agent.py b/contributing/samples/sandbox_computer_use/agent.py index 8cdb1f9e7e..b95dbad89a 100644 --- a/contributing/samples/sandbox_computer_use/agent.py +++ b/contributing/samples/sandbox_computer_use/agent.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/contributing/samples/sandbox_computer_use/main.py b/contributing/samples/sandbox_computer_use/main.py index 1813adff46..e6c60bc298 100644 --- a/contributing/samples/sandbox_computer_use/main.py +++ b/contributing/samples/sandbox_computer_use/main.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/src/google/adk/integrations/vmaas/__init__.py b/src/google/adk/integrations/vmaas/__init__.py index 846800a9d8..911b532fbc 100644 --- a/src/google/adk/integrations/vmaas/__init__.py +++ b/src/google/adk/integrations/vmaas/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/src/google/adk/integrations/vmaas/sandbox_client.py b/src/google/adk/integrations/vmaas/sandbox_client.py index 5fff119f37..1a264a1146 100644 --- a/src/google/adk/integrations/vmaas/sandbox_client.py +++ b/src/google/adk/integrations/vmaas/sandbox_client.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/src/google/adk/integrations/vmaas/sandbox_computer.py b/src/google/adk/integrations/vmaas/sandbox_computer.py index fdcf6fc21d..1dd38e0ac7 100644 --- a/src/google/adk/integrations/vmaas/sandbox_computer.py +++ b/src/google/adk/integrations/vmaas/sandbox_computer.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/cli/test_cli_feature_options.py b/tests/unittests/cli/test_cli_feature_options.py index c19ea75fa2..f7fc6ad983 100644 --- a/tests/unittests/cli/test_cli_feature_options.py +++ b/tests/unittests/cli/test_cli_feature_options.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/integrations/vmaas/__init__.py b/tests/unittests/integrations/vmaas/__init__.py index 0a2669d7a2..58d482ea38 100644 --- a/tests/unittests/integrations/vmaas/__init__.py +++ b/tests/unittests/integrations/vmaas/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/integrations/vmaas/test_sandbox_client.py b/tests/unittests/integrations/vmaas/test_sandbox_client.py index c9b70a81ef..3449c17c72 100644 --- a/tests/unittests/integrations/vmaas/test_sandbox_client.py +++ b/tests/unittests/integrations/vmaas/test_sandbox_client.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/integrations/vmaas/test_sandbox_computer.py b/tests/unittests/integrations/vmaas/test_sandbox_computer.py index c0802ae44f..9020663279 100644 --- a/tests/unittests/integrations/vmaas/test_sandbox_computer.py +++ b/tests/unittests/integrations/vmaas/test_sandbox_computer.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/models/test_litellm_import.py b/tests/unittests/models/test_litellm_import.py index 179dd4703e..d515829e41 100644 --- a/tests/unittests/models/test_litellm_import.py +++ b/tests/unittests/models/test_litellm_import.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/plugins/test_debug_logging_plugin.py b/tests/unittests/plugins/test_debug_logging_plugin.py index 202e259d32..ee0f1a7b01 100644 --- a/tests/unittests/plugins/test_debug_logging_plugin.py +++ b/tests/unittests/plugins/test_debug_logging_plugin.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/tools/mcp_tool/test_session_context.py b/tests/unittests/tools/mcp_tool/test_session_context.py index 7e77429e0c..50025dc245 100644 --- a/tests/unittests/tools/mcp_tool/test_session_context.py +++ b/tests/unittests/tools/mcp_tool/test_session_context.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/tools/retrieval/test_base_retrieval_tool.py b/tests/unittests/tools/retrieval/test_base_retrieval_tool.py index 8c40f49265..2df240e510 100644 --- a/tests/unittests/tools/retrieval/test_base_retrieval_tool.py +++ b/tests/unittests/tools/retrieval/test_base_retrieval_tool.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/tools/test_load_memory_tool.py b/tests/unittests/tools/test_load_memory_tool.py index a3affd3ed3..af065bab2e 100644 --- a/tests/unittests/tools/test_load_memory_tool.py +++ b/tests/unittests/tools/test_load_memory_tool.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/tests/unittests/utils/test_google_client_headers.py b/tests/unittests/utils/test_google_client_headers.py index e7cc02968b..4b50945f7d 100644 --- a/tests/unittests/utils/test_google_client_headers.py +++ b/tests/unittests/utils/test_google_client_headers.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. From fb92aad9c53bb9f6706fb27751d71fcda2419500 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 7 May 2026 09:21:22 -0700 Subject: [PATCH 22/28] fix(simulation): Add error message when LlmBackedUserSimulator returns empty response PiperOrigin-RevId: 911999966 --- .../simulation/llm_backed_user_simulator.py | 48 +++++++--- .../test_llm_backed_user_simulator.py | 96 +++++++++++++++++-- 2 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/google/adk/evaluation/simulation/llm_backed_user_simulator.py b/src/google/adk/evaluation/simulation/llm_backed_user_simulator.py index 2f11301730..fcfc4fd0ad 100644 --- a/src/google/adk/evaluation/simulation/llm_backed_user_simulator.py +++ b/src/google/adk/evaluation/simulation/llm_backed_user_simulator.py @@ -16,7 +16,6 @@ import logging from typing import ClassVar -from typing import Optional from google.genai import types as genai_types from pydantic import Field @@ -72,7 +71,7 @@ class LlmBackedUserSimulatorConfig(BaseUserSimulatorConfig): (Not recommended) If you don't want a limit, you can set the value to -1.""", ) - custom_instructions: Optional[str] = Field( + custom_instructions: str | None = Field( default=None, description="""Custom instructions for the LlmBackedUserSimulator. The instructions must contain the following formatting placeholders following Jinja syntax: @@ -88,7 +87,7 @@ class LlmBackedUserSimulatorConfig(BaseUserSimulatorConfig): @field_validator("custom_instructions") @classmethod - def validate_custom_instructions(cls, value: Optional[str]) -> Optional[str]: + def validate_custom_instructions(cls, value: str | None) -> str | None: if value is None: return value if not is_valid_user_simulator_template( @@ -158,11 +157,11 @@ def _summarize_conversation( async def _get_llm_response( self, rewritten_dialogue: str, - ) -> str: - """Sends a user message generation request to the LLM and returns the full response.""" + ) -> tuple[str, str | None]: + """Sends a user message generation request to the LLM and returns the full response and potential error reason.""" if self._invocation_count == 0: # first invocation - send the static starting prompt - return self._conversation_scenario.starting_prompt + return self._conversation_scenario.starting_prompt, None user_agent_instructions = get_llm_backed_user_simulator_prompt( conversation_plan=self._conversation_scenario.conversation_plan, @@ -187,8 +186,21 @@ async def _get_llm_response( add_default_retry_options_if_not_present(llm_request) response = "" + error_reason = None + has_thought_tokens = False async with Aclosing(self._llm.generate_content_async(llm_request)) as agen: async for llm_response in agen: + error_code = llm_response.error_code + if error_code: + logger.warning( + "User simulator LLM returned error: code=%s, message=%s", + error_code, + getattr(llm_response, "error_message", ""), + ) + error_reason = f"safety filters or other error (code={error_code})" + response = "" + break + generated_content: genai_types.Content = llm_response.content if ( not generated_content @@ -196,10 +208,22 @@ async def _get_llm_response( or not generated_content.parts ): continue + for part in generated_content.parts: - if part.text and not part.thought: + if part.thought: + has_thought_tokens = True + elif part.text: response += part.text - return response + + if not response: + if error_reason: + pass # Keep the error reason from error_code + elif has_thought_tokens: + error_reason = "LLM returned only thinking tokens" + else: + error_reason = "LLM returned empty response" + + return response, error_reason @override async def get_next_user_message( @@ -234,11 +258,11 @@ async def get_next_user_message( rewritten_dialogue = self._summarize_conversation(events) # query the LLM for the next user message - response = await self._get_llm_response(rewritten_dialogue) + response, error_reason = await self._get_llm_response(rewritten_dialogue) self._invocation_count += 1 # is the conversation over? (Has the user simulator output the stop signal?) - if _STOP_SIGNAL.lower() in response.lower(): + if response and _STOP_SIGNAL.lower() in response.lower(): logger.info( "Stopping user message generation as the stop signal was detected." ) @@ -256,11 +280,11 @@ async def get_next_user_message( # if we are here, the user agent failed to generate a message, which is not # a valid result for the LLM backed user simulator. - raise RuntimeError("Failed to generate a user message") + raise RuntimeError(f"Failed to generate a user message: {error_reason}") @override def get_simulation_evaluator( self, - ) -> Optional[Evaluator]: + ) -> Evaluator | None: """Returns an Evaluator that evaluates if the simulation was successful or not.""" raise NotImplementedError() diff --git a/tests/unittests/evaluation/simulation/test_llm_backed_user_simulator.py b/tests/unittests/evaluation/simulation/test_llm_backed_user_simulator.py index 87abeef9c4..2ff957509d 100644 --- a/tests/unittests/evaluation/simulation/test_llm_backed_user_simulator.py +++ b/tests/unittests/evaluation/simulation/test_llm_backed_user_simulator.py @@ -129,7 +129,8 @@ async def to_async_iter(items): def mock_llm_agent(mocker): """Provides a mock LLM agent.""" mock_llm_registry_cls = mocker.patch( - "google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry" + "google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry", + autospec=True, ) mock_llm_registry = mocker.MagicMock() mock_llm_registry_cls.return_value = mock_llm_registry @@ -207,18 +208,25 @@ async def test_get_llm_response_return_value( self, simulator, mock_llm_agent, mocker ): """Tests that _get_llm_response returns the full response correctly.""" - mock_llm_response = mocker.MagicMock() + mock_llm_response = mocker.create_autospec( + types.GenerateContentResponse, instance=True + ) + mock_llm_response.error_code = None mock_llm_response.content = types.Content( parts=[ types.Part(text="some thought", thought=True), types.Part(text="Hello world!"), ] ) + mock_llm_response.parts = mock_llm_response.content.parts mock_llm_agent.generate_content_async.return_value = to_async_iter( [mock_llm_response] ) - response = await simulator._get_llm_response(rewritten_dialogue="") + response, error_reason = await simulator._get_llm_response( + rewritten_dialogue="" + ) assert response == "Hello world!" + assert error_reason is None @pytest.mark.asyncio async def test_get_next_user_message_first_invocation( @@ -257,10 +265,14 @@ async def test_turn_limit_reached(self, conversation_scenario): @pytest.mark.asyncio async def test_stop_signal_detected(self, simulator, mock_llm_agent, mocker): """Tests get_next_user_message when the stop signal is detected.""" - mock_llm_response = mocker.MagicMock() + mock_llm_response = mocker.create_autospec( + types.GenerateContentResponse, instance=True + ) + mock_llm_response.error_code = None mock_llm_response.content = types.Content( parts=[types.Part(text="Thanks! Bye!")] ) + mock_llm_response.parts = mock_llm_response.content.parts mock_llm_agent.generate_content_async.return_value = to_async_iter( [mock_llm_response] ) @@ -273,11 +285,69 @@ async def test_stop_signal_detected(self, simulator, mock_llm_agent, mocker): assert next_user_message.user_message is None @pytest.mark.asyncio - async def test_no_message_generated(self, simulator, mock_llm_agent): - """Tests get_next_user_message when no message is generated.""" + async def test_no_message_generated_empty_response( + self, simulator, mock_llm_agent + ): + """Tests get_next_user_message when no message is generated (empty stream).""" mock_llm_agent.generate_content_async.return_value = to_async_iter([]) - with pytest.raises(RuntimeError, match="Failed to generate a user message"): + with pytest.raises( + RuntimeError, + match="Failed to generate a user message: LLM returned empty response", + ): + await simulator.get_next_user_message(events=_INPUT_EVENTS) + + @pytest.mark.asyncio + async def test_get_next_user_message_safety_blocked( + self, simulator, mock_llm_agent, mocker + ): + """Tests get_next_user_message when response is safety blocked.""" + mock_llm_response = mocker.create_autospec( + types.GenerateContentResponse, instance=True + ) + mock_llm_response.content = None + mock_llm_response.error_code = "SAFETY" + mock_llm_response.error_message = "Blocked by safety" + mock_llm_response.parts = [] + mock_llm_agent.generate_content_async.return_value = to_async_iter( + [mock_llm_response] + ) + + with pytest.raises( + RuntimeError, + match=( + "Failed to generate a user message: safety filters or other error" + " \\(code=SAFETY\\)" + ), + ): + await simulator.get_next_user_message(events=_INPUT_EVENTS) + + @pytest.mark.asyncio + async def test_get_next_user_message_thinking_only( + self, simulator, mock_llm_agent, mocker + ): + """Tests get_next_user_message when response contains only thinking tokens.""" + mock_llm_response = mocker.create_autospec( + types.GenerateContentResponse, instance=True + ) + mock_llm_response.content = types.Content( + parts=[ + types.Part(text="thinking...", thought=True), + ] + ) + mock_llm_response.error_code = None + mock_llm_response.parts = mock_llm_response.content.parts + mock_llm_agent.generate_content_async.return_value = to_async_iter( + [mock_llm_response] + ) + + with pytest.raises( + RuntimeError, + match=( + "Failed to generate a user message: LLM returned only thinking" + " tokens" + ), + ): await simulator.get_next_user_message(events=_INPUT_EVENTS) @pytest.mark.asyncio @@ -285,10 +355,14 @@ async def test_get_next_user_message_success( self, simulator, mock_llm_agent, mocker ): """Tests get_next_user_message when the user message is generated successfully.""" - mock_llm_response = mocker.MagicMock() + mock_llm_response = mocker.create_autospec( + types.GenerateContentResponse, instance=True + ) + mock_llm_response.error_code = None mock_llm_response.content = types.Content( parts=[types.Part(text="I need to book a flight.")] ) + mock_llm_response.parts = mock_llm_response.content.parts mock_llm_agent.generate_content_async.return_value = to_async_iter( [mock_llm_response] ) @@ -309,10 +383,14 @@ async def test_get_next_user_message_with_persona_success( self, simulator_with_persona, mock_llm_agent, mocker ): """Tests get_next_user_message when the user message is generated successfully.""" - mock_llm_response = mocker.MagicMock() + mock_llm_response = mocker.create_autospec( + types.GenerateContentResponse, instance=True + ) + mock_llm_response.error_code = None mock_llm_response.content = types.Content( parts=[types.Part(text="I need to book a flight.")] ) + mock_llm_response.parts = mock_llm_response.content.parts mock_llm_agent.generate_content_async.return_value = to_async_iter( [mock_llm_response] ) From 83ae40525aa734f4a3b365614cce43831612a1ec Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 7 May 2026 10:35:39 -0700 Subject: [PATCH 23/28] feat: Make ADK environment tools truncation limit configurable PiperOrigin-RevId: 912036870 --- .../tools/environment/_environment_toolset.py | 14 +- src/google/adk/tools/environment/_tools.py | 46 +++++- tests/unittests/tools/BUILD | 13 ++ .../tools/test_environment_toolset.py | 142 ++++++++++++++++++ 4 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 tests/unittests/tools/test_environment_toolset.py diff --git a/src/google/adk/tools/environment/_environment_toolset.py b/src/google/adk/tools/environment/_environment_toolset.py index 42e5b02fb0..2d894c3325 100644 --- a/src/google/adk/tools/environment/_environment_toolset.py +++ b/src/google/adk/tools/environment/_environment_toolset.py @@ -60,17 +60,21 @@ def __init__( self, *, environment: BaseEnvironment, + max_output_chars: Optional[int] = None, **kwargs: Any, ): """Create an environment toolset. Args: - environment: The environment used to execute commands and - perform file I/O. + environment: The environment used to execute commands and perform file + I/O. + max_output_chars: Maximum character limit for stdout/stderr/file + truncation. **kwargs: Forwarded to ``BaseToolset.__init__``. """ super().__init__(**kwargs) self._environment = environment + self._max_output_chars = max_output_chars self._environment_initialized = False @override @@ -82,8 +86,10 @@ async def get_tools( await self._environment.initialize() self._environment_initialized = True return [ - ExecuteTool(self._environment), - ReadFileTool(self._environment), + ExecuteTool(self._environment, max_output_chars=self._max_output_chars), + ReadFileTool( + self._environment, max_output_chars=self._max_output_chars + ), EditFileTool(self._environment), WriteFileTool(self._environment), ] diff --git a/src/google/adk/tools/environment/_tools.py b/src/google/adk/tools/environment/_tools.py index 61612f688b..67baa8f40d 100644 --- a/src/google/adk/tools/environment/_tools.py +++ b/src/google/adk/tools/environment/_tools.py @@ -58,12 +58,20 @@ def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: class ExecuteTool(BaseTool): """Run a shell command in the environment's working directory.""" - def __init__(self, environment: BaseEnvironment): + def __init__( + self, + environment: BaseEnvironment, + *, + max_output_chars: Optional[int] = None, + ): super().__init__( name='Execute', description=_EXECUTE_TOOL_DESCRIPTION, ) self._environment = environment + self._max_output_chars = ( + max_output_chars if max_output_chars is not None else MAX_OUTPUT_CHARS + ) @override def _get_declaration(self) -> Optional[types.FunctionDeclaration]: @@ -111,9 +119,15 @@ async def run_async( result: dict[str, Any] = {'status': 'ok'} if execution_result.stdout: - result['stdout'] = _truncate(execution_result.stdout) + result['stdout'] = _truncate( + execution_result.stdout, + limit=self._max_output_chars, + ) if execution_result.stderr: - result['stderr'] = _truncate(execution_result.stderr) + result['stderr'] = _truncate( + execution_result.stderr, + limit=self._max_output_chars, + ) if execution_result.exit_code != 0: result['status'] = 'error' result['exit_code'] = execution_result.exit_code @@ -127,7 +141,12 @@ async def run_async( class ReadFileTool(BaseTool): """Read a file from the environment.""" - def __init__(self, environment: BaseEnvironment): + def __init__( + self, + environment: BaseEnvironment, + *, + max_output_chars: Optional[int] = None, + ): super().__init__( name='ReadFile', description=( @@ -136,6 +155,9 @@ def __init__(self, environment: BaseEnvironment): ), ) self._environment = environment + self._max_output_chars = ( + max_output_chars if max_output_chars is not None else MAX_OUTPUT_CHARS + ) @override def _get_declaration(self) -> Optional[types.FunctionDeclaration]: @@ -190,7 +212,13 @@ async def run_async( cmd = f"cat -n '{path}' | sed -n '{sed_range}p'" res = await self._environment.execute(cmd) if res.exit_code == 0: - return {'status': 'ok', 'content': _truncate(res.stdout)} + return { + 'status': 'ok', + 'content': _truncate( + res.stdout, + limit=self._max_output_chars, + ), + } try: data_bytes = await self._environment.read_file(path) @@ -217,7 +245,13 @@ async def run_async( numbered = ''.join( f'{start + i:6d}\t{line}' for i, line in enumerate(selected) ) - result = {'status': 'ok', 'content': _truncate(numbered)} + result = { + 'status': 'ok', + 'content': _truncate( + numbered, + limit=self._max_output_chars, + ), + } if start > 1 or end < total: result['total_lines'] = total return result diff --git a/tests/unittests/tools/BUILD b/tests/unittests/tools/BUILD index cf2f69c45a..48c28cb449 100644 --- a/tests/unittests/tools/BUILD +++ b/tests/unittests/tools/BUILD @@ -31,3 +31,16 @@ pytest_test( "//third_party/py/pytest_asyncio", ], ) + +pytest_test( + name = "test_environment_toolset", + srcs = ["test_environment_toolset.py"], + args = [ + "-p", + "pytest_asyncio.plugin", + ], + deps = [ + "//third_party/py/google/adk", + "//third_party/py/pytest_asyncio", + ], +) diff --git a/tests/unittests/tools/test_environment_toolset.py b/tests/unittests/tools/test_environment_toolset.py new file mode 100644 index 0000000000..26f2059e4f --- /dev/null +++ b/tests/unittests/tools/test_environment_toolset.py @@ -0,0 +1,142 @@ +# 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 EnvironmentToolset and configurable output limits.""" + +from pathlib import Path +from typing import Any +from typing import Optional +from unittest import mock + +from google.adk.environment._base_environment import BaseEnvironment +from google.adk.environment._base_environment import ExecutionResult +from google.adk.tools.environment._environment_toolset import EnvironmentToolset +from google.adk.tools.tool_context import ToolContext +import pytest +import pytest_asyncio + + +class _FakeEnvironment(BaseEnvironment): + """Fake environment to return customized execution and read results.""" + + def __init__(self, *, stdout: str, file_content: bytes): + self._stdout = stdout + self._file_content = file_content + + @property + def working_dir(self) -> Path: + return Path("/workspace") + + async def initialize(self) -> None: + pass + + async def close(self) -> None: + pass + + async def execute( + self, command: str, *, timeout: Optional[float] = None + ) -> ExecutionResult: + return ExecutionResult( + exit_code=0, + stdout=self._stdout, + stderr="", + timed_out=False, + ) + + async def read_file(self, path: Path) -> bytes: + return self._file_content + + async def write_file(self, path: Path, content: str | bytes) -> None: + pass + + +@pytest.mark.asyncio +async def test_default_truncation_limit(): + """Verify tools default to the standard 30k limit.""" + long_text = "a" * 40_000 + env = _FakeEnvironment( + stdout=long_text, file_content=long_text.encode("utf-8") + ) + toolset = EnvironmentToolset(environment=env) + tools = await toolset.get_tools() + + # 1. Check ExecuteTool + execute_tool = next(t for t in tools if t.name == "Execute") + res = await execute_tool.run_async( + args={"command": "dummy"}, tool_context=mock.MagicMock(spec=ToolContext) + ) + assert res["status"] == "ok" + assert len(res["stdout"]) == 30_000 + len( + "\n... (truncated, 40000 total chars)" + ) + assert res["stdout"].endswith("\n... (truncated, 40000 total chars)") + + # 2. Check ReadFileTool + read_file_tool = next(t for t in tools if t.name == "ReadFile") + res = await read_file_tool.run_async( + args={"path": "dummy.txt"}, tool_context=mock.MagicMock(spec=ToolContext) + ) + assert res["status"] == "ok" + assert len(res["content"]) == 30_000 + len( + "\n... (truncated, 40000 total chars)" + ) + + +@pytest.mark.asyncio +async def test_custom_truncation_limit(): + """Verify tools honor custom max_output_chars limits.""" + long_text = "a" * 40_000 + env = _FakeEnvironment( + stdout=long_text, file_content=long_text.encode("utf-8") + ) + toolset = EnvironmentToolset(environment=env, max_output_chars=10_000) + tools = await toolset.get_tools() + + # 1. Check ExecuteTool + execute_tool = next(t for t in tools if t.name == "Execute") + res = await execute_tool.run_async( + args={"command": "dummy"}, tool_context=mock.MagicMock(spec=ToolContext) + ) + assert res["status"] == "ok" + assert len(res["stdout"]) == 10_000 + len( + "\n... (truncated, 40000 total chars)" + ) + + # 2. Check ReadFileTool + read_file_tool = next(t for t in tools if t.name == "ReadFile") + res = await read_file_tool.run_async( + args={"path": "dummy.txt"}, tool_context=mock.MagicMock(spec=ToolContext) + ) + assert res["status"] == "ok" + assert len(res["content"]) == 10_000 + len( + "\n... (truncated, 40000 total chars)" + ) + + +@pytest.mark.asyncio +async def test_no_truncation_under_limit(): + """Verify short outputs are not truncated.""" + short_text = "a" * 100 + env = _FakeEnvironment( + stdout=short_text, file_content=short_text.encode("utf-8") + ) + toolset = EnvironmentToolset(environment=env, max_output_chars=10_000) + tools = await toolset.get_tools() + + execute_tool = next(t for t in tools if t.name == "Execute") + res = await execute_tool.run_async( + args={"command": "dummy"}, tool_context=mock.MagicMock(spec=ToolContext) + ) + assert res["status"] == "ok" + assert res["stdout"] == short_text From 0bc767e6892742d6290d3445d028f95925187aed Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 7 May 2026 11:14:24 -0700 Subject: [PATCH 24/28] feat: add BufferableSessionService PiperOrigin-RevId: 912057968 --- src/google/adk/runners.py | 4 ++++ src/google/adk/sessions/base_session_service.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 0f36d6389d..2de5cd69d9 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -1616,6 +1616,10 @@ async def close(self): if self.plugin_manager: await self.plugin_manager.close() + # Close Session Service + if self.session_service: + await self.session_service.flush() + logger.info('Runner closed.') if sys.version_info < (3, 11): diff --git a/src/google/adk/sessions/base_session_service.py b/src/google/adk/sessions/base_session_service.py index af94bb9eeb..324abe230b 100644 --- a/src/google/adk/sessions/base_session_service.py +++ b/src/google/adk/sessions/base_session_service.py @@ -124,6 +124,13 @@ async def append_event(self, session: Session, event: Event) -> Event: session.events.append(event) return event + async def flush(self): + """Flushes any buffered events. + + For non-buffering implementations, this can be a no-op. + """ + pass + def _apply_temp_state(self, session: Session, event: Event) -> None: """Applies temp-scoped state delta to the in-memory session state. From e8339953911a8b580cfc2d88c7008234a43beece Mon Sep 17 00:00:00 2001 From: Kathy Wu Date: Thu, 7 May 2026 14:27:17 -0700 Subject: [PATCH 25/28] fix: Update expressmode api call to include default api key param Express mode API was updated to require "get_default_api_key: True" for returning api key Co-authored-by: Kathy Wu PiperOrigin-RevId: 912153767 --- src/google/adk/cli/utils/gcp_utils.py | 6 +++++- tests/unittests/cli/utils/test_gcp_utils.py | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/google/adk/cli/utils/gcp_utils.py b/src/google/adk/cli/utils/gcp_utils.py index 70f603e4e9..a5a8ebb6de 100644 --- a/src/google/adk/cli/utils/gcp_utils.py +++ b/src/google/adk/cli/utils/gcp_utils.py @@ -139,7 +139,11 @@ def sign_up_express( "POST", ":signUp", location=location, - data={"region": location, "tos_accepted": True}, + data={ + "region": location, + "tos_accepted": True, + "get_default_api_key": True, + }, ) return { "project_id": project.get("projectId"), diff --git a/tests/unittests/cli/utils/test_gcp_utils.py b/tests/unittests/cli/utils/test_gcp_utils.py index 962359eb4b..824bc81b2f 100644 --- a/tests/unittests/cli/utils/test_gcp_utils.py +++ b/tests/unittests/cli/utils/test_gcp_utils.py @@ -126,11 +126,19 @@ def test_sign_up_express(self, mock_auth_default, mock_session_cls): result = gcp_utils.sign_up_express() self.assertEqual(result["project_id"], "new-project") self.assertEqual(result["api_key"], "new-api-key") - args, _ = mock_session.post.call_args + args, kwargs = mock_session.post.call_args self.assertEqual( args[0], "https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:signUp", ) + self.assertEqual( + kwargs["json"], + { + "region": "us-central1", + "tos_accepted": True, + "get_default_api_key": True, + }, + ) @mock.patch( "google.adk.cli.utils.gcp_utils.resourcemanager_v3.ProjectsClient" From 88421f80a0b008e90f18401abca4ceec3548f6cd Mon Sep 17 00:00:00 2001 From: Kathy Wu Date: Thu, 7 May 2026 18:00:37 -0700 Subject: [PATCH 26/28] fix: Filter out video events with inline data from being stored in session Co-authored-by: Kathy Wu PiperOrigin-RevId: 912249311 --- contributing/samples/hello_world/agent.py | 2 +- src/google/adk/flows/llm_flows/contents.py | 18 +++++++------- src/google/adk/runners.py | 8 +++---- tests/unittests/test_runners.py | 28 ++++++++++++++++++++++ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/contributing/samples/hello_world/agent.py b/contributing/samples/hello_world/agent.py index 28d1847aef..01def21ad1 100755 --- a/contributing/samples/hello_world/agent.py +++ b/contributing/samples/hello_world/agent.py @@ -65,7 +65,7 @@ async def check_prime(nums: list[int]) -> str: root_agent = Agent( - model='gemini-2.5-flash', + model='projects/adk-cat/locations/us-central1/publishers/google/models/gemini-2.5-flash', name='hello_world_agent', description=( 'hello world agent that can roll a dice of 8 sides and check prime' diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index feeb8ef972..027a79bdf4 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -795,8 +795,8 @@ def _is_request_input_event(event: Event) -> bool: return _is_function_call_event(event, REQUEST_INPUT_FUNCTION_CALL_NAME) -def _is_live_model_audio_event_with_inline_data(event: Event) -> bool: - """Check if the event is a live/bidi audio event with inline data. +def _is_live_model_media_event_with_inline_data(event: Event) -> bool: + """Check if the event is a live/bidi media event (audio, video, image) with inline data. There are two possible cases and we only care about the second case: content=Content( @@ -826,12 +826,14 @@ def _is_live_model_audio_event_with_inline_data(event: Event) -> bool: if not event.content or not event.content.parts: return False for part in event.content.parts: - if ( - part.inline_data - and part.inline_data.mime_type - and part.inline_data.mime_type.startswith('audio/') - ): - return True + if part.inline_data and part.inline_data.mime_type: + mime = part.inline_data.mime_type.lower() + if ( + mime.startswith('audio/') + or mime.startswith('video/') + or mime.startswith('image/') + ): + return True return False diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index 2de5cd69d9..f52f07abb3 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -786,17 +786,17 @@ async def _compute_artifact_delta_for_rewind( def _should_append_event(self, event: Event, is_live_call: bool) -> bool: """Checks if an event should be appended to the session.""" - # Don't append audio response from model in live mode to session. + # Don't append media (audio/video/image) response from model in live mode to session. # The data is appended to artifacts with a reference in file_data in the - # event. + # event if save_live_blob is True. # We should append non-partial events only.For example, non-finished(partial) # transcription events should not be appended. # Function call and function response events should be appended. # Other control events should be appended. - if is_live_call and contents._is_live_model_audio_event_with_inline_data( + if is_live_call and contents._is_live_model_media_event_with_inline_data( event ): - # We don't append live model audio events with inline data to avoid + # We don't append live model media events with inline data to avoid # storing large blobs in the session. However, events with file_data # (references to artifacts) should be appended. return False diff --git a/tests/unittests/test_runners.py b/tests/unittests/test_runners.py index 08336204f9..aa3fc030f3 100644 --- a/tests/unittests/test_runners.py +++ b/tests/unittests/test_runners.py @@ -1188,6 +1188,34 @@ def test_should_append_event_other_event(self): ) assert self.runner._should_append_event(event, is_live_call=True) is True + def test_should_not_append_event_live_model_video(self): + event = Event( + invocation_id="inv1", + author="model", + content=types.Content( + parts=[ + types.Part( + inline_data=types.Blob(data=b"123", mime_type="video/mp4") + ) + ] + ), + ) + assert self.runner._should_append_event(event, is_live_call=True) is False + + def test_should_append_event_non_live_model_video(self): + event = Event( + invocation_id="inv1", + author="model", + content=types.Content( + parts=[ + types.Part( + inline_data=types.Blob(data=b"123", mime_type="video/mp4") + ) + ] + ), + ) + assert self.runner._should_append_event(event, is_live_call=False) is True + @pytest.fixture def user_agent_module(tmp_path, monkeypatch): From 896b6da98f4504c428af688b0a42d064d102088b Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Fri, 8 May 2026 14:03:51 -0700 Subject: [PATCH 27/28] chore(release/candidate): release 1.33.0 (#5646) Co-authored-by: Xuan Yang --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++ src/google/adk/version.py | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index f16e9b1aea..9a3ece4da7 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.32.0" + ".": "1.33.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a57085d100..baa2a92d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [1.33.0](https://github.com/google/adk-python/compare/v1.32.0...v1.33.0) (2026-05-08) + + +### Features + +* add BufferableSessionService ([0bc767e](https://github.com/google/adk-python/commit/0bc767e6892742d6290d3445d028f95925187aed)) +* **apigee:** allow injecting credentials into ApigeeLlm ([ce578ff](https://github.com/google/adk-python/commit/ce578fffa0dc02b0033f7f5e705b9422cbd6c252)) +* Make ADK environment tools truncation limit configurable ([83ae405](https://github.com/google/adk-python/commit/83ae40525aa734f4a3b365614cce43831612a1ec)) +* **models:** add get_function_calls and get_function_responses to LlmResponse ([22fae7e](https://github.com/google/adk-python/commit/22fae7e9a09c581f433f3c51ea9a0ab26e689b92)) + + +### Bug Fixes + +* catch genai.ClientError when sandbox is missing ([69fa777](https://github.com/google/adk-python/commit/69fa777881b3cb161e5b3dcb005def9a2ad86904)), closes [#5480](https://github.com/google/adk-python/issues/5480) +* double append bug ([f8b4c59](https://github.com/google/adk-python/commit/f8b4c59350fea3319c9e53e29968c56c93c57c99)) +* Filter out video events with inline data from being stored in session ([88421f8](https://github.com/google/adk-python/commit/88421f80a0b008e90f18401abca4ceec3548f6cd)) +* fix fork detection, correct offload limits, and add response logging in BigQuery plugin ([9d1bb4b](https://github.com/google/adk-python/commit/9d1bb4b4870233e574f5c06ddd2b62a48272398f)) +* hot reload agents for adk web ([740557c](https://github.com/google/adk-python/commit/740557c8965305abc75752082bc3ee63d924742f)) +* Only append skills to system instruction if ListSkillsTool isn't available ([01f1fc9](https://github.com/google/adk-python/commit/01f1fc9c912a97ff27bb1332a28324f991eae77d)) +* prevent state_delta overwrite on function_response-only events ([fc27203](https://github.com/google/adk-python/commit/fc2720378e8997269d30f5439051f5e43d5fa028), [211e2ce](https://github.com/google/adk-python/commit/211e2ceb70ac6b61400559761d1d6548d906a79b)), closes [#3178](https://github.com/google/adk-python/issues/3178) +* Raise a clear actionable error when CustomAuthScheme lacks a registered AuthProvider ([83f9817](https://github.com/google/adk-python/commit/83f981761b963ca51a286cbd004c043567517a3c)) +* should use app_name instead of req.app_name ([8286066](https://github.com/google/adk-python/commit/8286066e71e5c07b5b28979b8327d4b330187ddd)) +* **simulation:** Add error message when LlmBackedUserSimulator returns empty response ([fb92aad](https://github.com/google/adk-python/commit/fb92aad9c53bb9f6706fb27751d71fcda2419500)) +* Update expressmode api call to include default api key param ([e833995](https://github.com/google/adk-python/commit/e8339953911a8b580cfc2d88c7008234a43beece)) +* use asyncio.sleep to avoid blocking event loop ([3a1eadc](https://github.com/google/adk-python/commit/3a1eadce66804db08f6520cc11f9c60e81bb9e30)) +* Use project and location instead of API key when deploying to agent engine ([398f28f](https://github.com/google/adk-python/commit/398f28feb47d87ec9c4c03dd3e0e7b87a1699e6e)) + + +### Code Refactoring + +* adjust computation of workflow.steps metric and add new unit tests ([03d6208](https://github.com/google/adk-python/commit/03d6208aacac8c19adec45ce0dd837f9e3a7f66f)) +* remove input.type and output.type attributes from adk metrics ([9559968](https://github.com/google/adk-python/commit/95599683230dd13e5792133f30ade3fe19358d52)) + ## [1.32.0](https://github.com/google/adk-python/compare/v1.31.0...v1.32.0) (2026-04-30) diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 3a7e8f81b4..91b8650e52 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.32.0" +__version__ = "1.33.0" From 643ebd10f79560503f838e19998c99f2dcb9ce0b Mon Sep 17 00:00:00 2001 From: Jacksunwei <1281348+Jacksunwei@users.noreply.github.com> Date: Fri, 8 May 2026 21:04:11 +0000 Subject: [PATCH 28/28] 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 8122ea8f75..b25f273fed 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": "5e49cfa6567a09e06409b0f380434f12f85a17c9", + "last-release-sha": "88421f80a0b008e90f18401abca4ceec3548f6cd", "packages": { ".": { "release-type": "python",