From dc08d86534ba00e74048bb8271dfdca86e845f43 Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 11 Mar 2026 16:24:36 -0700 Subject: [PATCH 01/11] fix: Refactor LiteLlm check to avoid ImportError The `can_use_output_schema_with_tools` function now checks if a model is a LiteLlm instance by inspecting its type's Method Resolution Order, rather than directly importing `LiteLlm` Co-authored-by: George Weale PiperOrigin-RevId: 882253446 --- src/google/adk/utils/output_schema_utils.py | 12 +++-- .../utils/test_output_schema_utils.py | 54 ++++++++++++++++--- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/google/adk/utils/output_schema_utils.py b/src/google/adk/utils/output_schema_utils.py index bb31d098a1..228e95b66d 100644 --- a/src/google/adk/utils/output_schema_utils.py +++ b/src/google/adk/utils/output_schema_utils.py @@ -36,10 +36,14 @@ def can_use_output_schema_with_tools(model: Union[str, BaseLlm]) -> bool: # tool_choice enforcement # This is strictly more reliable than the SetModelResponseTool # prompt-based workaround. - from ..models.lite_llm import LiteLlm - - if isinstance(model, LiteLlm): - return True + if not isinstance(model, str): + try: + from ..models.lite_llm import LiteLlm + except ImportError: + LiteLlm = None + + if LiteLlm is not None and isinstance(model, LiteLlm): + return True model_string = model if isinstance(model, str) else model.model diff --git a/tests/unittests/utils/test_output_schema_utils.py b/tests/unittests/utils/test_output_schema_utils.py index cf759c996b..2f9eb4bb09 100644 --- a/tests/unittests/utils/test_output_schema_utils.py +++ b/tests/unittests/utils/test_output_schema_utils.py @@ -12,10 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + +import builtins +import importlib.util +import sys +from typing import Any from google.adk.models.anthropic_llm import Claude +from google.adk.models.base_llm import BaseLlm from google.adk.models.google_llm import Gemini -from google.adk.models.lite_llm import LiteLlm from google.adk.utils.output_schema_utils import can_use_output_schema_with_tools import pytest @@ -38,19 +44,51 @@ (Claude(model="claude-3.7-sonnet"), "1", False), (Claude(model="claude-3.7-sonnet"), "0", False), (Claude(model="claude-3.7-sonnet"), None, False), - (LiteLlm(model="openai/gpt-4o"), "1", True), - (LiteLlm(model="openai/gpt-4o"), "0", True), - (LiteLlm(model="openai/gpt-4o"), None, True), - (LiteLlm(model="anthropic/claude-3.7-sonnet"), None, True), - (LiteLlm(model="fireworks_ai/llama-v3p1-70b"), None, True), ], ) def test_can_use_output_schema_with_tools( - monkeypatch, model, env_value, expected -): + monkeypatch: pytest.MonkeyPatch, + model: str | BaseLlm, + env_value: str | None, + expected: bool, +) -> None: """Test can_use_output_schema_with_tools.""" if env_value is not None: monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", env_value) else: monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) assert can_use_output_schema_with_tools(model) == expected + + +def test_can_use_output_schema_with_tools_with_litellm_model() -> None: + """Test LiteLlm detection when the optional module is available.""" + if importlib.util.find_spec("litellm") is None: + pytest.skip("litellm is not installed") + + from google.adk.models.lite_llm import LiteLlm + + assert can_use_output_schema_with_tools(LiteLlm(model="openai/gpt-4o")) + + +def test_can_use_output_schema_with_tools_without_litellm_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test optional LiteLlm import failures do not affect other models.""" + original_import = builtins.__import__ + + def _failing_import( + name: str, + globals_dict: dict[str, Any] | None = None, + locals_dict: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name.endswith("lite_llm"): + raise ImportError("litellm not installed") + return original_import(name, globals_dict, locals_dict, fromlist, level) + + monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) + monkeypatch.delitem(sys.modules, "google.adk.models.lite_llm", raising=False) + monkeypatch.setattr(builtins, "__import__", _failing_import) + + assert not can_use_output_schema_with_tools(Claude(model="claude-3.7-sonnet")) From 09060a1d9327376e8a8a7b5b3abfaf1945fb5e7b Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Thu, 12 Mar 2026 15:42:43 -0700 Subject: [PATCH 02/11] chore(release/candidate): release 1.27.0 Co-authored-by: Liang Wu <18244712+wuliang229@users.noreply.github.com> --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 98 +++++++++++++++++++++++++++ src/google/adk/version.py | 2 +- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index f97891a673..c775f946fb 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.26.0" + ".": "1.27.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a8197b7a..49b189b095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # Changelog +## [1.27.0](https://github.com/google/adk-python/compare/v1.26.0...v1.27.0) (2026-03-12) + +### Features +* **[Core]** + * Introduce A2A request interceptors in RemoteA2aAgent ([6f772d2](https://github.com/google/adk-python/commit/6f772d2b0841446bc168ccf405b59eb17c1d671a)) + * Add UiWidget to EventActions for supporting new experimental UI Widgets feature ([530ff06](https://github.com/google/adk-python/commit/530ff06ece61a93855a53235e85af18b46b2a6a0)) + * **auth:** Add pluggable support for auth integrations using AuthProviderRegistry within CredentialManager ([d004074](https://github.com/google/adk-python/commit/d004074c90525442a69cebe226440bb318abad29)) + * Support all `types.SchemaUnion` as output_schema in LLM Agent ([63f450e](https://github.com/google/adk-python/commit/63f450e0231f237ee1af37f17420d37b15426d48)) + * durable runtime support ([07fdd23](https://github.com/google/adk-python/commit/07fdd23c9c3f5046aa668fb480840f67f13bf271)) + * **runners:** pass GetSessionConfig through Runner to session service ([eff724a](https://github.com/google/adk-python/commit/eff724ac9aef2a203607f772c473703f21c09a72)) + +* **[Models]** + * Add support for PDF documents in Anthropic LLM ([4c8ba74](https://github.com/google/adk-python/commit/4c8ba74fcb07014db187ef8db8246ff966379aa9)) + * Add streaming support for Anthropic models ([5770cd3](https://github.com/google/adk-python/commit/5770cd3776c8805086ece34d747e589e36916a34)), closes [#3250](https://github.com/google/adk-python/issues/3250) + * Enable output schema with tools for LiteLlm models ([89df5fc](https://github.com/google/adk-python/commit/89df5fcf883b599cf7bfe40bde35b8d86ab0146b)), closes [#3969](https://github.com/google/adk-python/issues/3969) + * Preserve thought_signature in LiteLLM tool calls ([ae565be](https://github.com/google/adk-python/commit/ae565be30e64249b2913ad647911061a8b170e21)), closes [#4650](https://github.com/google/adk-python/issues/4650) + +* **[Web]** + * Updated human in the loop: developers now can respond to long running functions directly in chat + * Render artifacts when resuming + * Fix some light mode styles + * Fix token level streaming not working properly ([22799c0](https://github.com/google/adk-python/commit/22799c0833569753021078f7bd8dcd11ece562fe)) + +* **[Observability]** + * **telemetry:** add new gen_ai.agent.version span attribute ([ffe97ec](https://github.com/google/adk-python/commit/ffe97ec5ad7229c0b4ba573f33eb0edb8bb2877a)) + * **otel:** add `gen_ai.tool.definitions` to experimental semconv ([4dd4d5e](https://github.com/google/adk-python/commit/4dd4d5ecb6a1dadbc41389dac208616f6d21bc6e)) + * **otel:** add experimental semantic convention and emit `gen_ai.client.inference.operation.details` event ([19718e9](https://github.com/google/adk-python/commit/19718e9c174af7b1287b627e6b23a609db1ee5e2)) + * add missing token usage span attributes during model usage ([77bf325](https://github.com/google/adk-python/commit/77bf325d2bf556621c3276f74ee2816fce2a7085)) + * capture tool execution error code in OpenTelemetry spans ([e0a6c6d](https://github.com/google/adk-python/commit/e0a6c6db6f8e2db161f8b86b9f11030f0cec807a)) + +* **[Tools]** + * Warn when accessing DEFAULT_SKILL_SYSTEM_INSTRUCTION ([35366f4](https://github.com/google/adk-python/commit/35366f4e2a0575090fe12cd85f51e8116a1cd0d3)) + * add preserve_property_names option to OpenAPIToolset ([078b516](https://github.com/google/adk-python/commit/078b5163ff47acec69b1c8e105f62eb7b74f5548)) + * Add gcs filesystem support for Skills. It supports skills in text and pdf format, also has some sample agents ([6edcb97](https://github.com/google/adk-python/commit/6edcb975827dbd543a40ae3a402d2389327df603)) + * Add list_skills_in_dir to skills utils ([327b3af](https://github.com/google/adk-python/commit/327b3affd2d0a192f5a072b90fdb4aae7575be90)) + * Add support for MCP App UI widgets in MCPTool ([86db35c](https://github.com/google/adk-python/commit/86db35c338adaafb41e156311465e71e17edf35e)) + * add Dataplex Catalog search tool to BigQuery ADK ([82c2eef](https://github.com/google/adk-python/commit/82c2eefb27313c5b11b9e9382f626f543c53a29e)) + * Add RunSkillScriptTool to SkillToolset ([636f68f](https://github.com/google/adk-python/commit/636f68fbee700aa47f01e2cfd746859353b3333d)) + * Add support for ADK tools in SkillToolset ([44a5e6b](https://github.com/google/adk-python/commit/44a5e6bdb8e8f02891e72b65ef883f108c506f6a)) + * limit number of user-provided BigQuery job labels and reserve internal prefixes ([8c4ff74](https://github.com/google/adk-python/commit/8c4ff74e7d70cf940f54f6d7735f001495ce75d5)) + * Add param support to Bigtable execute_sql ([5702a4b](https://github.com/google/adk-python/commit/5702a4b1f59b17fd8b290fc125c349240b0953d7)) + * **bigtable:** add Bigtable cluster metadata tools ([34c560e](https://github.com/google/adk-python/commit/34c560e66e7ad379f586bbcd45a9460dc059bee2)) + * execute-type param addition in GkeCodeExecutor ([9c45166](https://github.com/google/adk-python/commit/9c451662819a6c7de71be71d12ea715b2fe74135)) + * **skill:** Add BashTool ([8a31612](https://github.com/google/adk-python/commit/8a3161202e4bac0bb8e8801b100f4403c1c75646)) + * Add support for toolsets to additional_tools field of SkillToolset ([066fcec](https://github.com/google/adk-python/commit/066fcec3e8e669d1c5360e1556afce3f7e068072)) + + +* **[Optimization]** + * Add `adk optimize` command ([b18d7a1](https://github.com/google/adk-python/commit/b18d7a140f8e18e03255b07e6d89948427790095)) + * Add interface between optimization infra and LocalEvalService ([7b7ddda](https://github.com/google/adk-python/commit/7b7ddda46ca701952f002b2807b89dbef5322414)) + * Add GEPA root agent prompt optimizer ([4e3e2cb](https://github.com/google/adk-python/commit/4e3e2cb58858e08a79bc6119ad49b6c049dbc0d0)) + +* **[Integrations]** + * Enhance BigQuery plugin schema upgrades and error reporting ([bcf38fa](https://github.com/google/adk-python/commit/bcf38fa2bac2f0d1ab74e07e01eb5160bad1d6dc)) + * Enhance BQ plugin with fork safety, auto views, and trace continuity ([80c5a24](https://github.com/google/adk-python/commit/80c5a245557cd75870e72bff0ecfaafbd37fdbc7)) + * Handle Conflict Errors in BigQuery Agent Analytics Plugin ([372c76b](https://github.com/google/adk-python/commit/372c76b857daa1102e76d755c0758f1515d6f180)) + * Added tracking headers for ADK CLI command to Agent Engine ([3117446](https://github.com/google/adk-python/commit/3117446293d30039c2f21f3d17a64a456c42c47d)) + +* **[A2A]** + * New implementation of A2aAgentExecutor and A2A-ADK conversion ([87ffc55](https://github.com/google/adk-python/commit/87ffc55640dea1185cf67e6f9b78f70b30867bcc)) + * New implementation of RemoteA2aAgent and A2A-ADK conversion ([6770e41](https://github.com/google/adk-python/commit/6770e419f5e200f4c7ad26587e1f769693ef4da0)) + +### Bug Fixes + +* Allow artifact services to accept dictionary representations of types.Part ([b004da5](https://github.com/google/adk-python/commit/b004da50270475adc9e1d7afe4064ca1d10c560a)), closes [#2886](https://github.com/google/adk-python/issues/2886) +* Decode image data from ComputerUse tool response into image blobs ([d7cfd8f](https://github.com/google/adk-python/commit/d7cfd8fe4def2198c113ff1993ef39cd519908a1)) +* Expand LiteLLM reasoning extraction to include 'reasoning' field ([9468487](https://github.com/google/adk-python/commit/94684874e436c2959cfc90ec346010a6f4fddc49)), closes [#3694](https://github.com/google/adk-python/issues/3694) +* Filter non-agent directories from list_agents() ([3b5937f](https://github.com/google/adk-python/commit/3b5937f022adf9286dc41e01e3618071a23eb992)) +* Fix Type Error by initializing user_content as a Content object ([2addf6b](https://github.com/google/adk-python/commit/2addf6b9dacfe87344aeec0101df98d99c23bdb1)) +* Handle length finish reason in LiteLLM responses ([4c6096b](https://github.com/google/adk-python/commit/4c6096baa1b0bed8533397287a5c11a0c4cb9101)), closes [#4482](https://github.com/google/adk-python/issues/4482) +* In SaveFilesAsArtifactsPlugin, write the artifact delta to state then event actions so that the plugin works with ADK Web UI's artifacts panel ([d6f31be](https://github.com/google/adk-python/commit/d6f31be554d9b7ee15fd9c95ae655b2265fb1f32)) +* Make invocation_context optional in convert_event_to_a2a_message ([8e79a12](https://github.com/google/adk-python/commit/8e79a12d6bcde43cc33247b7ee6cc9e929fa6288)) +* Optimize row-level locking in append_event ([d61846f](https://github.com/google/adk-python/commit/d61846f6c6dd5e357abb0e30eaf61fe27896ae6a)), closes [#4655](https://github.com/google/adk-python/issues/4655) +* Preserve thought_signature in FunctionCall conversions between GenAI and A2A ([f9c104f](https://github.com/google/adk-python/commit/f9c104faf73e2a002bb3092b50fb88f4eed78163)) +* Prevent splitting of SSE events with artifactDelta for function resume requests ([6a929af](https://github.com/google/adk-python/commit/6a929af718fa77199d1eecc62b16c54beb1c8d84)), closes [#4487](https://github.com/google/adk-python/issues/4487) +* Propagate file names during A2A to/from Genai Part conversion ([f324fa2](https://github.com/google/adk-python/commit/f324fa2d62442301ebb2e7974eb97ea870471410)) +* Propagate thought from A2A TextPart metadata to GenAI Part ([e59929e](https://github.com/google/adk-python/commit/e59929e11a56aaee7bb0c45cd4c9d9fef689548c)) +* Re-export DEFAULT_SKILL_SYSTEM_INSTRUCTION to skills and skill/prompt.py to avoid breaking current users ([de4dee8](https://github.com/google/adk-python/commit/de4dee899cd777a01ba15906f8496a72e717ea98)) +* Refactor type string update in Anthropic tool param conversion ([ab4b736](https://github.com/google/adk-python/commit/ab4b736807dabee65659486a68135d9f1530834c)) +* **simulation:** handle NoneType generated_content ([9d15517](https://github.com/google/adk-python/commit/9d155177b956f690d4c99560f582e3e90e111f71)) +* Store and retrieve EventCompaction via custom_metadata in Vertex AISessionService ([2e434ca](https://github.com/google/adk-python/commit/2e434ca7be765d45426fde9d52b131921bd9fa30)), closes [#3465](https://github.com/google/adk-python/issues/3465) +* Support before_tool_callback and after_tool_callback in Live mode ([c36a708](https://github.com/google/adk-python/commit/c36a708058163ade061cd3d2f9957231a505a62d)), closes [#4704](https://github.com/google/adk-python/issues/4704) +* temp-scoped state now visible to subsequent agents in same invocation ([2780ae2](https://github.com/google/adk-python/commit/2780ae2892adfbebc7580c843d2eaad29f86c335)) +* **tools:** Handle JSON Schema boolean schemas in Gemini schema conversion ([3256a67](https://github.com/google/adk-python/commit/3256a679da3e0fb6f18b26057e87f5284680cb58)) +* typo in A2A EXPERIMENTAL warning ([eb55eb7](https://github.com/google/adk-python/commit/eb55eb7e7f0fa647d762205225c333dcd8a08dd0)) +* Update agent_engine_sandbox_code_executor in ADK ([dff4c44](https://github.com/google/adk-python/commit/dff4c4404051b711c8be437ba0ae26ca2763df7d)) +* update Bigtable query tools to async functions ([72f3e7e](https://github.com/google/adk-python/commit/72f3e7e1e00d93c632883027bf6d31a9095cd6c2)) +* Update expected UsageMetadataChunk in LiteLLM tests ([dd0851a](https://github.com/google/adk-python/commit/dd0851ac74d358bc030def5adf242d875ab18265)), closes [#4680](https://github.com/google/adk-python/issues/4680) +* update toolbox server and SDK package versions ([2e370ea](https://github.com/google/adk-python/commit/2e370ea688033f0663501171d0babfb0d74de4b2)) +* Validate session before streaming instead of eagerly advancing the runner generator ([ebbc114](https://github.com/google/adk-python/commit/ebbc1147863956e85931f8d46abb0632e3d1cf67)) + + +### Code Refactoring + +* extract reusable functions from hitl and auth preprocessor ([c59afc2](https://github.com/google/adk-python/commit/c59afc21cbed27d1328872cdc2b0e182ab2ca6c8)) +* Rename base classes and TypeVars in optimization data types ([9154ef5](https://github.com/google/adk-python/commit/9154ef59d29eb37538914e9967c4392cc2a24237)) + + ## [1.26.0](https://github.com/google/adk-python/compare/v1.25.1...v1.26.0) (2026-02-26) diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 2e373f505a..6570514289 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.26.0" +__version__ = "1.27.0" From 501c827f5e185d634c9f84f588827a5b57b32a21 Mon Sep 17 00:00:00 2001 From: Jacksunwei <1281348+Jacksunwei@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:42:56 +0000 Subject: [PATCH 03/11] 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 053aab23c3..8c58807069 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": "8f5428150d18ed732b66379c0acb806a9121c3cb", + "last-release-sha": "066fcec3e8e669d1c5360e1556afce3f7e068072", "packages": { ".": { "release-type": "python", From 9dd5e66191708a38f0371f866800c59041f9662e Mon Sep 17 00:00:00 2001 From: Achuth Narayan Rajagopal Date: Fri, 13 Mar 2026 14:20:43 -0700 Subject: [PATCH 04/11] fix(telemetery): Rolling back change to fix issue affecting LlmAgent creation due to missing version field Co-authored-by: Achuth Narayan Rajagopal PiperOrigin-RevId: 883336463 --- src/google/adk/agents/base_agent.py | 7 ----- src/google/adk/agents/base_agent_config.py | 4 --- src/google/adk/telemetry/tracing.py | 5 ---- tests/unittests/telemetry/test_spans.py | 30 ---------------------- 4 files changed, 46 deletions(-) diff --git a/src/google/adk/agents/base_agent.py b/src/google/adk/agents/base_agent.py index 27971711ca..dec85690b3 100644 --- a/src/google/adk/agents/base_agent.py +++ b/src/google/adk/agents/base_agent.py @@ -136,12 +136,6 @@ class MyAgent(BaseAgent): sub_agents: list[BaseAgent] = Field(default_factory=list) """The sub-agents of this agent.""" - version: str = '' - """The agent's version. - - Version of the agent being invoked. Used to identify the Agent involved in telemetry. - """ - before_agent_callback: Optional[BeforeAgentCallback] = None """Callback or list of callbacks to be invoked before the agent run. @@ -686,7 +680,6 @@ def __create_kwargs( kwargs: Dict[str, Any] = { 'name': config.name, - 'version': config.version, 'description': config.description, } if config.sub_agents: diff --git a/src/google/adk/agents/base_agent_config.py b/src/google/adk/agents/base_agent_config.py index 9dca68c5e6..3859cb3550 100644 --- a/src/google/adk/agents/base_agent_config.py +++ b/src/google/adk/agents/base_agent_config.py @@ -55,10 +55,6 @@ class BaseAgentConfig(BaseModel): name: str = Field(description='Required. The name of the agent.') - version: str = Field( - default='', description='Optional. The version of the agent.' - ) - description: str = Field( default='', description='Optional. The description of the agent.' ) diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 4c79ca60e4..5c05968d31 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -83,8 +83,6 @@ USER_CONTENT_ELIDED = '' -GEN_AI_AGENT_VERSION = 'gen_ai.agent.version' - # Needed to avoid circular imports if TYPE_CHECKING: from ..agents.base_agent import BaseAgent @@ -159,7 +157,6 @@ def trace_agent_invocation( span.set_attribute(GEN_AI_AGENT_DESCRIPTION, agent.description) span.set_attribute(GEN_AI_AGENT_NAME, agent.name) - span.set_attribute(GEN_AI_AGENT_VERSION, agent.version) span.set_attribute(GEN_AI_CONVERSATION_ID, ctx.session.id) @@ -495,7 +492,6 @@ def use_generate_content_span( USER_ID: invocation_context.session.user_id, 'gcp.vertex.agent.event_id': model_response_event.id, 'gcp.vertex.agent.invocation_id': invocation_context.invocation_id, - GEN_AI_AGENT_VERSION: invocation_context.agent.version, } if ( _is_gemini_agent(invocation_context.agent) @@ -530,7 +526,6 @@ async def use_inference_span( USER_ID: invocation_context.session.user_id, 'gcp.vertex.agent.event_id': model_response_event.id, 'gcp.vertex.agent.invocation_id': invocation_context.invocation_id, - GEN_AI_AGENT_VERSION: invocation_context.agent.version, } if ( _is_gemini_agent(invocation_context.agent) diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index e9df610f1a..c4bd485fba 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -26,7 +26,6 @@ from google.adk.models.llm_response import LlmResponse from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.telemetry.tracing import ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS -from google.adk.telemetry.tracing import GEN_AI_AGENT_VERSION from google.adk.telemetry.tracing import trace_agent_invocation from google.adk.telemetry.tracing import trace_call_llm from google.adk.telemetry.tracing import trace_inference_result @@ -122,33 +121,6 @@ async def test_trace_agent_invocation(mock_span_fixture): mock.call('gen_ai.operation.name', 'invoke_agent'), mock.call('gen_ai.agent.description', agent.description), mock.call('gen_ai.agent.name', agent.name), - mock.call(GEN_AI_AGENT_VERSION, ''), - mock.call( - 'gen_ai.conversation.id', - invocation_context.session.id, - ), - ] - mock_span_fixture.set_attribute.assert_has_calls( - expected_calls, any_order=True - ) - assert mock_span_fixture.set_attribute.call_count == len(expected_calls) - - -@pytest.mark.asyncio -async def test_trace_agent_invocation_with_version(mock_span_fixture): - """Test trace_agent_invocation sets span attributes correctly when version is provided.""" - agent = LlmAgent(name='test_llm_agent', model='gemini-pro') - agent.description = 'Test agent description' - agent.version = '1.0.0' - invocation_context = await _create_invocation_context(agent) - - trace_agent_invocation(mock_span_fixture, agent, invocation_context) - - expected_calls = [ - mock.call('gen_ai.operation.name', 'invoke_agent'), - mock.call('gen_ai.agent.description', agent.description), - mock.call('gen_ai.agent.name', agent.name), - mock.call(GEN_AI_AGENT_VERSION, agent.version), mock.call( 'gen_ai.conversation.id', invocation_context.session.id, @@ -815,7 +787,6 @@ async def test_generate_content_span( mock_span.set_attributes.assert_called_once_with({ GEN_AI_AGENT_NAME: invocation_context.agent.name, - GEN_AI_AGENT_VERSION: '', GEN_AI_CONVERSATION_ID: invocation_context.session.id, USER_ID: invocation_context.session.user_id, 'gcp.vertex.agent.event_id': 'event-123', @@ -1136,7 +1107,6 @@ async def test_generate_content_span_with_experimental_semconv( mock_span.set_attributes.assert_called_once_with({ GEN_AI_AGENT_NAME: invocation_context.agent.name, - GEN_AI_AGENT_VERSION: '', GEN_AI_CONVERSATION_ID: invocation_context.session.id, USER_ID: invocation_context.session.user_id, 'gcp.vertex.agent.event_id': 'event-123', From 1111e3a90d13f716cf1bee7ad37299fa715505d5 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Fri, 13 Mar 2026 16:59:08 -0700 Subject: [PATCH 05/11] fix(bigquery): use valid dataplex OAuth scope Closes issue #4805 Co-authored-by: Liang Wu PiperOrigin-RevId: 883403628 --- src/google/adk/tools/bigquery/bigquery_credentials.py | 2 +- tests/unittests/tools/bigquery/test_bigquery_credentials.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/google/adk/tools/bigquery/bigquery_credentials.py b/src/google/adk/tools/bigquery/bigquery_credentials.py index 958ce9d7ec..c491c52ee6 100644 --- a/src/google/adk/tools/bigquery/bigquery_credentials.py +++ b/src/google/adk/tools/bigquery/bigquery_credentials.py @@ -21,7 +21,7 @@ BIGQUERY_TOKEN_CACHE_KEY = "bigquery_token_cache" BIGQUERY_SCOPES = [ "https://www.googleapis.com/auth/bigquery", - "https://www.googleapis.com/auth/dataplex", + "https://www.googleapis.com/auth/dataplex.read-write", ] BIGQUERY_DEFAULT_SCOPE = ["https://www.googleapis.com/auth/bigquery"] diff --git a/tests/unittests/tools/bigquery/test_bigquery_credentials.py b/tests/unittests/tools/bigquery/test_bigquery_credentials.py index e20662924b..f0c188d0a4 100644 --- a/tests/unittests/tools/bigquery/test_bigquery_credentials.py +++ b/tests/unittests/tools/bigquery/test_bigquery_credentials.py @@ -47,7 +47,7 @@ def test_valid_credentials_object_auth_credentials(self): assert config.client_secret is None assert config.scopes == [ "https://www.googleapis.com/auth/bigquery", - "https://www.googleapis.com/auth/dataplex", + "https://www.googleapis.com/auth/dataplex.read-write", ] def test_valid_credentials_object_oauth2_credentials(self): @@ -90,7 +90,7 @@ def test_valid_client_id_secret_pair_default_scope(self): assert config.client_secret == "test_client_secret" assert config.scopes == [ "https://www.googleapis.com/auth/bigquery", - "https://www.googleapis.com/auth/dataplex", + "https://www.googleapis.com/auth/dataplex.read-write", ] def test_valid_client_id_secret_pair_w_scope(self): @@ -135,7 +135,7 @@ def test_valid_client_id_secret_pair_w_empty_scope(self): assert config.client_secret == "test_client_secret" assert config.scopes == [ "https://www.googleapis.com/auth/bigquery", - "https://www.googleapis.com/auth/dataplex", + "https://www.googleapis.com/auth/dataplex.read-write", ] def test_missing_client_secret_raises_error(self): From 52351ae2efd6d8f5ecafc4bda622f5115aef3795 Mon Sep 17 00:00:00 2001 From: George Weale Date: Tue, 17 Mar 2026 11:24:04 -0700 Subject: [PATCH 06/11] fix: Store and retrieve usage_metadata in Vertex AI custom_metadata The Vertex AI session service does not natively support persisting usage_metadata. This change serializes usage_metadata into the custom_metadata field under the key '_usage_metadata' when appending events and deserializes it back when retrieving events. This allows usage information to be round-tripped through the Vertex AI session service. Co-authored-by: George Weale PiperOrigin-RevId: 885121070 --- .../adk/sessions/vertex_ai_session_service.py | 58 ++++++-- .../test_vertex_ai_session_service.py | 128 ++++++++++++++++++ 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/google/adk/sessions/vertex_ai_session_service.py b/src/google/adk/sessions/vertex_ai_session_service.py index 8cb7109ece..9e5c9bb2ec 100644 --- a/src/google/adk/sessions/vertex_ai_session_service.py +++ b/src/google/adk/sessions/vertex_ai_session_service.py @@ -42,6 +42,20 @@ logger = logging.getLogger('google_adk.' + __name__) +_COMPACTION_CUSTOM_METADATA_KEY = '_compaction' +_USAGE_METADATA_CUSTOM_METADATA_KEY = '_usage_metadata' + + +def _set_internal_custom_metadata( + metadata_dict: dict[str, Any], *, key: str, value: dict[str, Any] +) -> None: + """Stores internal metadata alongside user-provided custom metadata.""" + existing_custom_metadata = metadata_dict.get('custom_metadata') or {} + metadata_dict['custom_metadata'] = { + **existing_custom_metadata, + key: value, + } + class VertexAiSessionService(BaseSessionService): """Connects to the Vertex AI Agent Engine Session Service using Agent Engine SDK. @@ -301,11 +315,22 @@ async def append_event(self, session: Session, event: Event) -> Event: compaction_dict = event.actions.compaction.model_dump( exclude_none=True, mode='json' ) - existing_custom = metadata_dict.get('custom_metadata') or {} - metadata_dict['custom_metadata'] = { - **existing_custom, - '_compaction': compaction_dict, - } + _set_internal_custom_metadata( + metadata_dict, + key=_COMPACTION_CUSTOM_METADATA_KEY, + value=compaction_dict, + ) + # Store usage_metadata in custom_metadata since the Vertex AI service + # does not persist it in EventMetadata. + if event.usage_metadata: + usage_dict = event.usage_metadata.model_dump( + exclude_none=True, mode='json' + ) + _set_internal_custom_metadata( + metadata_dict, + key=_USAGE_METADATA_CUSTOM_METADATA_KEY, + value=usage_dict, + ) config['event_metadata'] = metadata_dict async with self._get_api_client() as api_client: @@ -378,11 +403,20 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: # Extract compaction data stored in custom_metadata. # NOTE: This read path must be kept permanently because sessions # written before native compaction support store compaction data - # in custom_metadata under the '_compaction' key. + # in custom_metadata under the compaction metadata key. compaction_data = None - if custom_metadata and '_compaction' in custom_metadata: + usage_metadata_data = None + if custom_metadata and ( + _COMPACTION_CUSTOM_METADATA_KEY in custom_metadata + or _USAGE_METADATA_CUSTOM_METADATA_KEY in custom_metadata + ): custom_metadata = dict(custom_metadata) # avoid mutating the API response - compaction_data = custom_metadata.pop('_compaction') + compaction_data = custom_metadata.pop( + _COMPACTION_CUSTOM_METADATA_KEY, None + ) + usage_metadata_data = custom_metadata.pop( + _USAGE_METADATA_CUSTOM_METADATA_KEY, None + ) if not custom_metadata: custom_metadata = None grounding_metadata = _session_util.decode_model( @@ -397,6 +431,7 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: branch = None custom_metadata = None compaction_data = None + usage_metadata_data = None grounding_metadata = None if actions: @@ -416,6 +451,12 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: else: event_actions = EventActions() + usage_metadata = None + if usage_metadata_data: + usage_metadata = types.GenerateContentResponseUsageMetadata.model_validate( + usage_metadata_data + ) + return Event( id=api_event_obj.name.split('/')[-1], invocation_id=api_event_obj.invocation_id, @@ -434,4 +475,5 @@ def _from_api_event(api_event_obj: vertexai.types.SessionEvent) -> Event: custom_metadata=custom_metadata, grounding_metadata=grounding_metadata, long_running_tool_ids=long_running_tool_ids, + usage_metadata=usage_metadata, ) diff --git a/tests/unittests/sessions/test_vertex_ai_session_service.py b/tests/unittests/sessions/test_vertex_ai_session_service.py index c095ddd9d2..20fdbe3c6d 100644 --- a/tests/unittests/sessions/test_vertex_ai_session_service.py +++ b/tests/unittests/sessions/test_vertex_ai_session_service.py @@ -911,3 +911,131 @@ async def test_append_event_with_compaction_and_custom_metadata(): # User custom_metadata is preserved without the internal _compaction key assert appended_event.custom_metadata == {'user_key': 'user_value'} assert '_compaction' not in (appended_event.custom_metadata or {}) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures('mock_get_api_client') +async def test_append_event_with_usage_metadata(): + """usage_metadata round-trips through append_event and get_session.""" + session_service = mock_vertex_ai_session_service() + session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert session is not None + + event_to_append = Event( + invocation_id='usage_invocation', + author='model', + timestamp=1734005536.0, + usage_metadata=genai_types.GenerateContentResponseUsageMetadata( + prompt_token_count=150, + candidates_token_count=50, + total_token_count=200, + ), + ) + + await session_service.append_event(session, event_to_append) + + retrieved_session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert retrieved_session is not None + + appended_event = retrieved_session.events[-1] + assert appended_event.usage_metadata is not None + assert appended_event.usage_metadata.prompt_token_count == 150 + assert appended_event.usage_metadata.candidates_token_count == 50 + assert appended_event.usage_metadata.total_token_count == 200 + # custom_metadata should remain None when only usage_metadata was stored + assert appended_event.custom_metadata is None + + +@pytest.mark.asyncio +@pytest.mark.usefixtures('mock_get_api_client') +async def test_append_event_with_usage_metadata_and_custom_metadata(): + """Both usage_metadata and user custom_metadata survive the round-trip.""" + session_service = mock_vertex_ai_session_service() + session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert session is not None + + event_to_append = Event( + invocation_id='usage_and_meta_invocation', + author='model', + timestamp=1734005537.0, + usage_metadata=genai_types.GenerateContentResponseUsageMetadata( + prompt_token_count=300, + total_token_count=400, + ), + custom_metadata={'my_key': 'my_value'}, + ) + + await session_service.append_event(session, event_to_append) + + retrieved_session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert retrieved_session is not None + + appended_event = retrieved_session.events[-1] + # usage_metadata is restored + assert appended_event.usage_metadata is not None + assert appended_event.usage_metadata.prompt_token_count == 300 + assert appended_event.usage_metadata.total_token_count == 400 + # User custom_metadata is preserved without internal keys + assert appended_event.custom_metadata == {'my_key': 'my_value'} + assert '_usage_metadata' not in (appended_event.custom_metadata or {}) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures('mock_get_api_client') +async def test_append_event_with_usage_metadata_and_compaction(): + """usage_metadata, compaction, and user custom_metadata all coexist.""" + session_service = mock_vertex_ai_session_service() + session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert session is not None + + compaction = EventCompaction( + start_timestamp=500.0, + end_timestamp=600.0, + compacted_content=genai_types.Content( + parts=[genai_types.Part(text='compacted')] + ), + ) + event_to_append = Event( + invocation_id='all_three_invocation', + author='model', + timestamp=1734005538.0, + actions=EventActions(compaction=compaction), + usage_metadata=genai_types.GenerateContentResponseUsageMetadata( + prompt_token_count=1000, + candidates_token_count=250, + total_token_count=1250, + ), + custom_metadata={'extra': 'info'}, + ) + + await session_service.append_event(session, event_to_append) + + retrieved_session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + assert retrieved_session is not None + + appended_event = retrieved_session.events[-1] + # Compaction is restored + assert appended_event.actions.compaction is not None + assert appended_event.actions.compaction.start_timestamp == 500.0 + assert appended_event.actions.compaction.end_timestamp == 600.0 + # usage_metadata is restored + assert appended_event.usage_metadata is not None + assert appended_event.usage_metadata.prompt_token_count == 1000 + assert appended_event.usage_metadata.candidates_token_count == 250 + assert appended_event.usage_metadata.total_token_count == 1250 + # User custom_metadata is preserved without internal keys + assert appended_event.custom_metadata == {'extra': 'info'} + assert '_compaction' not in (appended_event.custom_metadata or {}) + assert '_usage_metadata' not in (appended_event.custom_metadata or {}) From 276adfb7ad552213c0201a3c95efbc9876bf3b66 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 23 Mar 2026 14:57:57 -0700 Subject: [PATCH 07/11] fix: add protection for arbitrary module imports Close #4947 Co-authored-by: George Weale PiperOrigin-RevId: 888296476 --- src/google/adk/cli/adk_web_server.py | 180 +++++++++++++++++- .../cli/test_adk_web_server_run_live.py | 73 +++++++ tests/unittests/cli/test_fast_api.py | 42 +++- 3 files changed, 292 insertions(+), 3 deletions(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index afedb7387a..927cd7ad03 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -20,6 +20,7 @@ import json import logging import os +import re import sys import time import traceback @@ -138,6 +139,158 @@ def _parse_cors_origins( return literal_origins, combined_regex +def _is_origin_allowed( + origin: str, + allowed_literal_origins: list[str], + allowed_origin_regex: Optional[re.Pattern[str]], +) -> bool: + """Check whether the given origin matches the allowed origins.""" + if "*" in allowed_literal_origins: + return True + if origin in allowed_literal_origins: + return True + if allowed_origin_regex is not None: + return allowed_origin_regex.fullmatch(origin) is not None + return False + + +def _normalize_origin_scheme(scheme: str) -> str: + """Normalize request schemes to the browser Origin scheme space.""" + if scheme == "ws": + return "http" + if scheme == "wss": + return "https" + return scheme + + +def _strip_optional_quotes(value: str) -> str: + """Strip a single pair of wrapping quotes from a header value.""" + if len(value) >= 2 and value[0] == '"' and value[-1] == '"': + return value[1:-1] + return value + + +def _get_scope_header( + scope: dict[str, Any], header_name: bytes +) -> Optional[str]: + """Return the first matching header value from an ASGI scope.""" + for candidate_name, candidate_value in scope.get("headers", []): + if candidate_name == header_name: + return candidate_value.decode("latin-1").split(",", 1)[0].strip() + return None + + +def _get_request_origin(scope: dict[str, Any]) -> Optional[str]: + """Compute the effective origin for the current HTTP/WebSocket request.""" + forwarded = _get_scope_header(scope, b"forwarded") + if forwarded is not None: + proto = None + host = None + for element in forwarded.split(",", 1)[0].split(";"): + if "=" not in element: + continue + name, value = element.split("=", 1) + if name.strip().lower() == "proto": + proto = _strip_optional_quotes(value.strip()) + elif name.strip().lower() == "host": + host = _strip_optional_quotes(value.strip()) + if proto is not None and host is not None: + return f"{_normalize_origin_scheme(proto)}://{host}" + + host = _get_scope_header(scope, b"x-forwarded-host") + if host is None: + host = _get_scope_header(scope, b"host") + if host is None: + return None + + proto = _get_scope_header(scope, b"x-forwarded-proto") + if proto is None: + proto = scope.get("scheme", "http") + return f"{_normalize_origin_scheme(proto)}://{host}" + + +def _is_request_origin_allowed( + origin: str, + scope: dict[str, Any], + allowed_literal_origins: list[str], + allowed_origin_regex: Optional[re.Pattern[str]], + has_configured_allowed_origins: bool, +) -> bool: + """Validate an Origin header against explicit config or same-origin.""" + if has_configured_allowed_origins and _is_origin_allowed( + origin, allowed_literal_origins, allowed_origin_regex + ): + return True + + request_origin = _get_request_origin(scope) + if request_origin is None: + return False + return origin == request_origin + + +_SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + + +class _OriginCheckMiddleware: + """ASGI middleware that blocks cross-origin state-changing requests.""" + + def __init__( + self, + app: Any, + has_configured_allowed_origins: bool, + allowed_origins: list[str], + allowed_origin_regex: Optional[re.Pattern[str]], + ) -> None: + self._app = app + self._has_configured_allowed_origins = has_configured_allowed_origins + self._allowed_origins = allowed_origins + self._allowed_origin_regex = allowed_origin_regex + + async def __call__( + self, + scope: dict[str, Any], + receive: Any, + send: Any, + ) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + method = scope.get("method", "GET") + if method in _SAFE_HTTP_METHODS: + await self._app(scope, receive, send) + return + + origin = _get_scope_header(scope, b"origin") + if origin is None: + await self._app(scope, receive, send) + return + + if _is_request_origin_allowed( + origin, + scope, + self._allowed_origins, + self._allowed_origin_regex, + self._has_configured_allowed_origins, + ): + await self._app(scope, receive, send) + return + + response_body = b"Forbidden: origin not allowed" + await send({ + "type": "http.response.start", + "status": 403, + "headers": [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(response_body)).encode()), + ], + }) + await send({ + "type": "http.response.body", + "body": response_body, + }) + + class ApiServerSpanExporter(export_lib.SpanExporter): def __init__(self, trace_dict): @@ -757,8 +910,12 @@ async def internal_lifespan(app: FastAPI): # Run the FastAPI server. app = FastAPI(lifespan=internal_lifespan) + has_configured_allowed_origins = bool(allow_origins) if allow_origins: literal_origins, combined_regex = _parse_cors_origins(allow_origins) + compiled_origin_regex = ( + re.compile(combined_regex) if combined_regex is not None else None + ) app.add_middleware( CORSMiddleware, allow_origins=literal_origins, @@ -767,6 +924,16 @@ async def internal_lifespan(app: FastAPI): allow_methods=["*"], allow_headers=["*"], ) + else: + literal_origins = [] + compiled_origin_regex = None + + app.add_middleware( + _OriginCheckMiddleware, + has_configured_allowed_origins=has_configured_allowed_origins, + allowed_origins=literal_origins, + allowed_origin_regex=compiled_origin_regex, + ) @app.get("/health") async def health() -> dict[str, str]: @@ -1802,14 +1969,23 @@ async def run_agent_live( enable_affective_dialog: bool | None = Query(default=None), enable_session_resumption: bool | None = Query(default=None), ) -> None: + ws_origin = websocket.headers.get("origin") + if ws_origin is not None and not _is_request_origin_allowed( + ws_origin, + websocket.scope, + literal_origins, + compiled_origin_regex, + has_configured_allowed_origins, + ): + await websocket.close(code=1008, reason="Origin not allowed") + return + await websocket.accept() session = await self.session_service.get_session( app_name=app_name, user_id=user_id, session_id=session_id ) if not session: - # Accept first so that the client is aware of connection establishment, - # then close with a specific code. await websocket.close(code=1002, reason="Session not found") return diff --git a/tests/unittests/cli/test_adk_web_server_run_live.py b/tests/unittests/cli/test_adk_web_server_run_live.py index 1c3c42593c..012af92d56 100644 --- a/tests/unittests/cli/test_adk_web_server_run_live.py +++ b/tests/unittests/cli/test_adk_web_server_run_live.py @@ -22,6 +22,7 @@ from google.adk.events.event import Event from google.adk.sessions.in_memory_session_service import InMemorySessionService import pytest +from starlette.websockets import WebSocketDisconnect class _DummyAgent(BaseAgent): @@ -203,3 +204,75 @@ async def _get_runner_async(_self, _app_name: str): run_config.session_resumption.transparent is expected_session_resumption_transparent ) + + +_WS_BASE_URL = ( + "/run_live" + "?app_name=test_app" + "&user_id=user" + "&session_id=session" + "&modalities=AUDIO" +) + + +def _build_ws_client(): + """Build a TestClient wired to a capturing runner.""" + session_service = InMemorySessionService() + asyncio.run( + session_service.create_session( + app_name="test_app", + user_id="user", + session_id="session", + state={}, + ) + ) + + runner = _CapturingRunner() + adk_web_server = AdkWebServer( + agent_loader=_DummyAgentLoader(), + session_service=session_service, + memory_service=types.SimpleNamespace(), + artifact_service=types.SimpleNamespace(), + credential_service=types.SimpleNamespace(), + eval_sets_manager=types.SimpleNamespace(), + eval_set_results_manager=types.SimpleNamespace(), + agents_dir=".", + ) + + async def _get_runner_async(_self, _app_name: str): + return runner + + adk_web_server.get_runner_async = _get_runner_async.__get__(adk_web_server) # pytype: disable=attribute-error + + fast_api_app = adk_web_server.get_fast_api_app( + setup_observer=lambda _observer, _server: None, + tear_down_observer=lambda _observer, _server: None, + ) + return TestClient(fast_api_app) + + +def test_run_live_rejects_disallowed_origin(): + client = _build_ws_client() + with pytest.raises(WebSocketDisconnect) as exc_info: + with client.websocket_connect( + _WS_BASE_URL, + headers={"origin": "https://evil.com"}, + ) as ws: + ws.receive_text() + assert exc_info.value.code == 1008 + + +def test_run_live_allows_matching_origin(): + client = _build_ws_client() + with client.websocket_connect( + _WS_BASE_URL, + headers={"origin": "http://testserver"}, + ) as ws: + _ = ws.receive_text() + + +def test_run_live_allows_no_origin_header(): + """Non-browser clients (curl, wscat, SDKs) send no Origin header.""" + client = _build_ws_client() + with client.websocket_connect(_WS_BASE_URL) as ws: + _ = ws.receive_text() diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 0ea28e6683..13b5a670e9 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -593,7 +593,7 @@ def builder_test_client( session_service_uri="", artifact_service_uri="", memory_service_uri="", - allow_origins=["*"], + allow_origins=None, a2a=False, host="127.0.0.1", port=8000, @@ -1595,6 +1595,46 @@ def test_builder_final_save_preserves_tools_and_cleans_tmp( assert not tmp_dir.exists() or not any(tmp_dir.iterdir()) +def test_builder_save_rejects_cross_origin_post(builder_test_client, tmp_path): + response = builder_test_client.post( + "/builder/save?tmp=true", + headers={"origin": "https://evil.com"}, + files=[( + "files", + ("app/root_agent.yaml", b"name: app\n", "application/x-yaml"), + )], + ) + + assert response.status_code == 403 + assert response.text == "Forbidden: origin not allowed" + assert not (tmp_path / "app" / "tmp" / "app").exists() + + +def test_builder_save_allows_same_origin_post(builder_test_client, tmp_path): + response = builder_test_client.post( + "/builder/save?tmp=true", + headers={"origin": "http://testserver"}, + files=[( + "files", + ("app/root_agent.yaml", b"name: app\n", "application/x-yaml"), + )], + ) + + assert response.status_code == 200 + assert response.json() is True + assert (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").is_file() + + +def test_builder_get_allows_cross_origin_get(builder_test_client): + response = builder_test_client.get( + "/builder/app/missing?tmp=true", + headers={"origin": "https://evil.com"}, + ) + + assert response.status_code == 200 + assert response.text == "" + + def test_builder_cancel_deletes_tmp_idempotent(builder_test_client, tmp_path): tmp_agent_root = tmp_path / "app" / "tmp" / "app" tmp_agent_root.mkdir(parents=True, exist_ok=True) From 74033e49fb70ad0cea6270d9daada119089f2046 Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Mon, 23 Mar 2026 15:53:17 -0700 Subject: [PATCH 08/11] chore(release/candidate): release 1.27.3 --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ src/google/adk/version.py | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index c775f946fb..7282be844a 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.27.0" + ".": "1.27.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b189b095..dc5a5bd2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.27.3](https://github.com/google/adk-python/compare/v1.27.2...v1.27.3) (2026-03-23) +### Bug Fixes + * add protection for arbitrary module imports ([276adfb](https://github.com/google/adk-python/commit/276adfb7ad552213c0201a3c95efbc9876bf3b66)) + +## [1.27.2](https://github.com/google/adk-python/compare/v1.27.1...v1.27.2) (2026-03-17) +### Bug Fixes + * Use valid dataplex OAuth scope for BigQueryToolset ([4010716](https://github.com/google/adk-python/commit/4010716470fc83918dc367c5971342ff551401c8)) + * Store and retrieve usage_metadata in Vertex AI custom_metadata ([b318eee](https://github.com/google/adk-python/commit/b318eee979b1625d3d23ad98825c88f54016a12f)) + +## [1.27.1](https://github.com/google/adk-python/compare/v1.27.0...v1.27.1) (2026-03-13) +### Bug Fixes + * Rolling back change to fix issue affecting LlmAgent creation due to missing version field ([0e18f81](https://github.com/google/adk-python/commit/0e18f81a5cd0d0392ded653b1a63a236449a2685)) + + ## [1.27.0](https://github.com/google/adk-python/compare/v1.26.0...v1.27.0) (2026-03-12) ### Features diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 6570514289..c732594d19 100644 --- a/src/google/adk/version.py +++ b/src/google/adk/version.py @@ -13,4 +13,4 @@ # limitations under the License. # version: major.minor.patch -__version__ = "1.27.0" +__version__ = "1.27.3" From 44b3f72d8f4ee09461d0acd8816149e801260b84 Mon Sep 17 00:00:00 2001 From: Sasha Sobran Date: Tue, 24 Mar 2026 14:16:10 -0700 Subject: [PATCH 09/11] fix: gate builder endpoints behind web flag Co-authored-by: Sasha Sobran PiperOrigin-RevId: 888850792 --- src/google/adk/cli/fast_api.py | 443 ++++++++++++++------------- tests/unittests/cli/test_fast_api.py | 116 ++++++- 2 files changed, 343 insertions(+), 216 deletions(-) diff --git a/src/google/adk/cli/fast_api.py b/src/google/adk/cli/fast_api.py index 8f78c15f9b..4d666f78d3 100644 --- a/src/google/adk/cli/fast_api.py +++ b/src/google/adk/cli/fast_api.py @@ -266,150 +266,192 @@ def tear_down_observer(observer: Observer, _: AdkWebServer): **extra_fast_api_args, ) - agents_base_path = (Path.cwd() / agents_dir).resolve() - - def _get_app_root(app_name: str) -> Path: - if app_name in ("", ".", ".."): - raise ValueError(f"Invalid app name: {app_name!r}") - if Path(app_name).name != app_name or "\\" in app_name: - raise ValueError(f"Invalid app name: {app_name!r}") - app_root = (agents_base_path / app_name).resolve() - if not app_root.is_relative_to(agents_base_path): - raise ValueError(f"Invalid app name: {app_name!r}") - return app_root - - def _normalize_relative_path(path: str) -> str: - return path.replace("\\", "/").lstrip("/") - - def _has_parent_reference(path: str) -> bool: - return any(part == ".." for part in path.split("/")) - - def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]: - if not filename: - raise ValueError("Upload filename is missing.") - filename = _normalize_relative_path(filename) - if "/" not in filename: - raise ValueError(f"Invalid upload filename: {filename!r}") - app_name, rel_path = filename.split("/", 1) - if not app_name or not rel_path: - raise ValueError(f"Invalid upload filename: {filename!r}") - if rel_path.startswith("/"): - raise ValueError(f"Absolute upload path rejected: {filename!r}") - if _has_parent_reference(rel_path): - raise ValueError(f"Path traversal rejected: {filename!r}") - return app_name, rel_path - - def _parse_file_path(file_path: str) -> str: - file_path = _normalize_relative_path(file_path) - if not file_path: - raise ValueError("file_path is missing.") - if file_path.startswith("/"): - raise ValueError(f"Absolute file_path rejected: {file_path!r}") - if _has_parent_reference(file_path): - raise ValueError(f"Path traversal rejected: {file_path!r}") - return file_path - - def _resolve_under_dir(root_dir: Path, rel_path: str) -> Path: - file_path = root_dir / rel_path - resolved_root_dir = root_dir.resolve() - resolved_file_path = file_path.resolve() - if not resolved_file_path.is_relative_to(resolved_root_dir): - raise ValueError(f"Path escapes root_dir: {rel_path!r}") - return file_path - - def _get_tmp_agent_root(app_root: Path, app_name: str) -> Path: - tmp_agent_root = app_root / "tmp" / app_name - resolved_tmp_agent_root = tmp_agent_root.resolve() - if not resolved_tmp_agent_root.is_relative_to(app_root): - raise ValueError(f"Invalid tmp path for app: {app_name!r}") - return tmp_agent_root - - def copy_dir_contents(source_dir: Path, dest_dir: Path) -> None: - dest_dir.mkdir(parents=True, exist_ok=True) - for source_path in source_dir.iterdir(): - if source_path.name == "tmp": - continue - - dest_path = dest_dir / source_path.name - if source_path.is_dir(): - if dest_path.exists() and dest_path.is_file(): - dest_path.unlink() - shutil.copytree(source_path, dest_path, dirs_exist_ok=True) - elif source_path.is_file(): - if dest_path.exists() and dest_path.is_dir(): - shutil.rmtree(dest_path) - shutil.copy2(source_path, dest_path) - - def cleanup_tmp(app_name: str) -> bool: - try: - app_root = _get_app_root(app_name) - except ValueError as exc: - logger.exception("Error in cleanup_tmp: %s", exc) - return False - - try: - tmp_agent_root = _get_tmp_agent_root(app_root, app_name) - except ValueError as exc: - logger.exception("Error in cleanup_tmp: %s", exc) - return False - - try: - shutil.rmtree(tmp_agent_root) - except FileNotFoundError: - pass - except OSError as exc: - logger.exception("Error deleting tmp agent root: %s", exc) - return False - - tmp_dir = app_root / "tmp" - resolved_tmp_dir = tmp_dir.resolve() - if not resolved_tmp_dir.is_relative_to(app_root): - logger.error( - "Refusing to delete tmp outside app_root: %s", resolved_tmp_dir - ) - return False + # --- Builder endpoints (agent editor UI) --- + # Only register when the web UI is enabled. In headless / production + # deployments (e.g. `adk deploy cloud_run`) these endpoints are unnecessary + # and expose an attack surface that allows arbitrary file writes under the + # agents directory. + # See https://github.com/google/adk-python/issues/4947 + if web: + agents_base_path = (Path.cwd() / agents_dir).resolve() + + def _get_app_root(app_name: str) -> Path: + if app_name in ("", ".", ".."): + raise ValueError(f"Invalid app name: {app_name!r}") + if Path(app_name).name != app_name or "\\" in app_name: + raise ValueError(f"Invalid app name: {app_name!r}") + app_root = (agents_base_path / app_name).resolve() + if not app_root.is_relative_to(agents_base_path): + raise ValueError(f"Invalid app name: {app_name!r}") + return app_root + + def _normalize_relative_path(path: str) -> str: + return path.replace("\\", "/").lstrip("/") + + def _has_parent_reference(path: str) -> bool: + return any(part == ".." for part in path.split("/")) + + _ALLOWED_UPLOAD_EXTENSIONS = frozenset({".yaml", ".yml"}) + + def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]: + if not filename: + raise ValueError("Upload filename is missing.") + filename = _normalize_relative_path(filename) + if "/" not in filename: + raise ValueError(f"Invalid upload filename: {filename!r}") + app_name, rel_path = filename.split("/", 1) + if not app_name or not rel_path: + raise ValueError(f"Invalid upload filename: {filename!r}") + if rel_path.startswith("/"): + raise ValueError(f"Absolute upload path rejected: {filename!r}") + if _has_parent_reference(rel_path): + raise ValueError(f"Path traversal rejected: {filename!r}") + ext = os.path.splitext(rel_path)[1].lower() + if ext not in _ALLOWED_UPLOAD_EXTENSIONS: + raise ValueError( + f"File type not allowed: {rel_path!r}" + f" (allowed: {', '.join(sorted(_ALLOWED_UPLOAD_EXTENSIONS))})" + ) + return app_name, rel_path + + def _parse_file_path(file_path: str) -> str: + file_path = _normalize_relative_path(file_path) + if not file_path: + raise ValueError("file_path is missing.") + if file_path.startswith("/"): + raise ValueError(f"Absolute file_path rejected: {file_path!r}") + if _has_parent_reference(file_path): + raise ValueError(f"Path traversal rejected: {file_path!r}") + return file_path + + def _resolve_under_dir(root_dir: Path, rel_path: str) -> Path: + file_path = root_dir / rel_path + resolved_root_dir = root_dir.resolve() + resolved_file_path = file_path.resolve() + if not resolved_file_path.is_relative_to(resolved_root_dir): + raise ValueError(f"Path escapes root_dir: {rel_path!r}") + return file_path + + def _get_tmp_agent_root(app_root: Path, app_name: str) -> Path: + tmp_agent_root = app_root / "tmp" / app_name + resolved_tmp_agent_root = tmp_agent_root.resolve() + if not resolved_tmp_agent_root.is_relative_to(app_root): + raise ValueError(f"Invalid tmp path for app: {app_name!r}") + return tmp_agent_root + + def copy_dir_contents(source_dir: Path, dest_dir: Path) -> None: + dest_dir.mkdir(parents=True, exist_ok=True) + for source_path in source_dir.iterdir(): + if source_path.name == "tmp": + continue - try: - tmp_dir.rmdir() - except OSError: - pass + dest_path = dest_dir / source_path.name + if source_path.is_dir(): + if dest_path.exists() and dest_path.is_file(): + dest_path.unlink() + shutil.copytree(source_path, dest_path, dirs_exist_ok=True) + elif source_path.is_file(): + if dest_path.exists() and dest_path.is_dir(): + shutil.rmtree(dest_path) + shutil.copy2(source_path, dest_path) + + def cleanup_tmp(app_name: str) -> bool: + try: + app_root = _get_app_root(app_name) + except ValueError as exc: + logger.exception("Error in cleanup_tmp: %s", exc) + return False - return True + try: + tmp_agent_root = _get_tmp_agent_root(app_root, app_name) + except ValueError as exc: + logger.exception("Error in cleanup_tmp: %s", exc) + return False - def ensure_tmp_exists(app_name: str) -> bool: - try: - app_root = _get_app_root(app_name) - except ValueError as exc: - logger.exception("Error in ensure_tmp_exists: %s", exc) - return False + try: + shutil.rmtree(tmp_agent_root) + except FileNotFoundError: + pass + except OSError as exc: + logger.exception("Error deleting tmp agent root: %s", exc) + return False - if not app_root.is_dir(): - return False + tmp_dir = app_root / "tmp" + resolved_tmp_dir = tmp_dir.resolve() + if not resolved_tmp_dir.is_relative_to(app_root): + logger.error( + "Refusing to delete tmp outside app_root: %s", resolved_tmp_dir + ) + return False - try: - tmp_agent_root = _get_tmp_agent_root(app_root, app_name) - except ValueError as exc: - logger.exception("Error in ensure_tmp_exists: %s", exc) - return False + try: + tmp_dir.rmdir() + except OSError: + pass - if tmp_agent_root.exists(): return True - try: - tmp_agent_root.mkdir(parents=True, exist_ok=True) - copy_dir_contents(app_root, tmp_agent_root) - except OSError as exc: - logger.exception("Error in ensure_tmp_exists: %s", exc) - return False + def ensure_tmp_exists(app_name: str) -> bool: + try: + app_root = _get_app_root(app_name) + except ValueError as exc: + logger.exception("Error in ensure_tmp_exists: %s", exc) + return False - return True + if not app_root.is_dir(): + return False + + try: + tmp_agent_root = _get_tmp_agent_root(app_root, app_name) + except ValueError as exc: + logger.exception("Error in ensure_tmp_exists: %s", exc) + return False + + if tmp_agent_root.exists(): + return True + + try: + tmp_agent_root.mkdir(parents=True, exist_ok=True) + copy_dir_contents(app_root, tmp_agent_root) + except OSError as exc: + logger.exception("Error in ensure_tmp_exists: %s", exc) + return False + + return True + + @app.post("/builder/save", response_model_exclude_none=True) + async def builder_build( + files: list[UploadFile], tmp: Optional[bool] = False + ) -> bool: + try: + if tmp: + app_names = set() + uploads = [] + for file in files: + app_name, rel_path = _parse_upload_filename(file.filename) + app_names.add(app_name) + uploads.append((rel_path, file)) + + if len(app_names) != 1: + logger.error( + "Exactly one app name is required, found: %s", + sorted(app_names), + ) + return False + + app_name = next(iter(app_names)) + app_root = _get_app_root(app_name) + tmp_agent_root = _get_tmp_agent_root(app_root, app_name) + tmp_agent_root.mkdir(parents=True, exist_ok=True) + + for rel_path, file in uploads: + destination_path = _resolve_under_dir(tmp_agent_root, rel_path) + destination_path.parent.mkdir(parents=True, exist_ok=True) + with destination_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + return True - @app.post("/builder/save", response_model_exclude_none=True) - async def builder_build( - files: list[UploadFile], tmp: Optional[bool] = False - ) -> bool: - try: - if tmp: app_names = set() uploads = [] for file in files: @@ -419,108 +461,85 @@ async def builder_build( if len(app_names) != 1: logger.error( - "Exactly one app name is required, found: %s", sorted(app_names) + "Exactly one app name is required, found: %s", + sorted(app_names), ) return False app_name = next(iter(app_names)) app_root = _get_app_root(app_name) + app_root.mkdir(parents=True, exist_ok=True) + tmp_agent_root = _get_tmp_agent_root(app_root, app_name) - tmp_agent_root.mkdir(parents=True, exist_ok=True) + if tmp_agent_root.is_dir(): + copy_dir_contents(tmp_agent_root, app_root) for rel_path, file in uploads: - destination_path = _resolve_under_dir(tmp_agent_root, rel_path) + destination_path = _resolve_under_dir(app_root, rel_path) destination_path.parent.mkdir(parents=True, exist_ok=True) with destination_path.open("wb") as buffer: shutil.copyfileobj(file.file, buffer) - return True - - app_names = set() - uploads = [] - for file in files: - app_name, rel_path = _parse_upload_filename(file.filename) - app_names.add(app_name) - uploads.append((rel_path, file)) - - if len(app_names) != 1: - logger.error( - "Exactly one app name is required, found: %s", sorted(app_names) - ) + return cleanup_tmp(app_name) + except ValueError as exc: + logger.exception("Error in builder_build: %s", exc) + return False + except OSError as exc: + logger.exception("Error in builder_build: %s", exc) return False - app_name = next(iter(app_names)) - app_root = _get_app_root(app_name) - app_root.mkdir(parents=True, exist_ok=True) + @app.post( + "/builder/app/{app_name}/cancel", response_model_exclude_none=True + ) + async def builder_cancel(app_name: str) -> bool: + return cleanup_tmp(app_name) - tmp_agent_root = _get_tmp_agent_root(app_root, app_name) - if tmp_agent_root.is_dir(): - copy_dir_contents(tmp_agent_root, app_root) + @app.get( + "/builder/app/{app_name}", + response_model_exclude_none=True, + response_class=PlainTextResponse, + ) + async def get_agent_builder( + app_name: str, + file_path: Optional[str] = None, + tmp: Optional[bool] = False, + ): + try: + app_root = _get_app_root(app_name) + except ValueError as exc: + logger.exception("Error in get_agent_builder: %s", exc) + return "" - for rel_path, file in uploads: - destination_path = _resolve_under_dir(app_root, rel_path) - destination_path.parent.mkdir(parents=True, exist_ok=True) - with destination_path.open("wb") as buffer: - shutil.copyfileobj(file.file, buffer) + agent_dir = app_root + if tmp: + if not ensure_tmp_exists(app_name): + return "" + agent_dir = app_root / "tmp" / app_name - return cleanup_tmp(app_name) - except ValueError as exc: - logger.exception("Error in builder_build: %s", exc) - return False - except OSError as exc: - logger.exception("Error in builder_build: %s", exc) - return False - - @app.post("/builder/app/{app_name}/cancel", response_model_exclude_none=True) - async def builder_cancel(app_name: str) -> bool: - return cleanup_tmp(app_name) - - @app.get( - "/builder/app/{app_name}", - response_model_exclude_none=True, - response_class=PlainTextResponse, - ) - async def get_agent_builder( - app_name: str, - file_path: Optional[str] = None, - tmp: Optional[bool] = False, - ): - try: - app_root = _get_app_root(app_name) - except ValueError as exc: - logger.exception("Error in get_agent_builder: %s", exc) - return "" - - agent_dir = app_root - if tmp: - if not ensure_tmp_exists(app_name): - return "" - agent_dir = app_root / "tmp" / app_name + if not file_path: + rel_path = "root_agent.yaml" + else: + try: + rel_path = _parse_file_path(file_path) + except ValueError as exc: + logger.exception("Error in get_agent_builder: %s", exc) + return "" - if not file_path: - rel_path = "root_agent.yaml" - else: try: - rel_path = _parse_file_path(file_path) + agent_file_path = _resolve_under_dir(agent_dir, rel_path) except ValueError as exc: logger.exception("Error in get_agent_builder: %s", exc) return "" - try: - agent_file_path = _resolve_under_dir(agent_dir, rel_path) - except ValueError as exc: - logger.exception("Error in get_agent_builder: %s", exc) - return "" - - if not agent_file_path.is_file(): - return "" + if not agent_file_path.is_file(): + return "" - return FileResponse( - path=agent_file_path, - media_type="application/x-yaml", - filename=file_path or f"{app_name}.yaml", - headers={"Cache-Control": "no-store"}, - ) + return FileResponse( + path=agent_file_path, + media_type="application/x-yaml", + filename=file_path or f"{app_name}.yaml", + headers={"Cache-Control": "no-store"}, + ) if a2a: from a2a.server.apps import A2AStarletteApplication diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 13b5a670e9..e53d5a918c 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -1560,16 +1560,18 @@ def test_patch_memory(test_app, create_test_session, mock_memory_service): logger.info("Add session to memory test completed successfully") -def test_builder_final_save_preserves_tools_and_cleans_tmp( +def test_builder_final_save_preserves_files_and_cleans_tmp( builder_test_client, tmp_path ): files = [ - ("files", ("app/__init__.py", b"from . import agent\n", "text/plain")), - ("files", ("app/tools.py", b"def tool():\n return 1\n", "text/plain")), ( "files", ("app/root_agent.yaml", b"name: app\n", "application/x-yaml"), ), + ( + "files", + ("app/sub_agent.yaml", b"name: sub\n", "application/x-yaml"), + ), ] response = builder_test_client.post("/builder/save?tmp=true", files=files) assert response.status_code == 200 @@ -1589,7 +1591,7 @@ def test_builder_final_save_preserves_tools_and_cleans_tmp( assert response.status_code == 200 assert response.json() is True - assert (tmp_path / "app" / "tools.py").is_file() + assert (tmp_path / "app" / "sub_agent.yaml").is_file() assert not (tmp_path / "app" / "tmp" / "app").exists() tmp_dir = tmp_path / "app" / "tmp" assert not tmp_dir.exists() or not any(tmp_dir.iterdir()) @@ -1698,6 +1700,112 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path): assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists() +def test_builder_save_rejects_py_files(builder_test_client, tmp_path): + """Uploading .py files via /builder/save is rejected.""" + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[( + "files", + ("app/agent.py", b"import os\nos.system('id')\n", "text/plain"), + )], + ) + assert response.status_code == 200 + assert response.json() is False + assert not (tmp_path / "app" / "tmp" / "app" / "agent.py").exists() + + +def test_builder_save_rejects_non_yaml_extensions( + builder_test_client, tmp_path +): + """Uploading non-YAML files (.json, .txt, .sh, etc.) is rejected.""" + for ext, content in [ + (".py", b"print('hi')"), + (".json", b"{}"), + (".txt", b"hello"), + (".sh", b"#!/bin/bash"), + (".pth", b"import os"), + ]: + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[( + "files", + (f"app/file{ext}", content, "application/octet-stream"), + )], + ) + assert response.status_code == 200, f"Expected 200 for {ext}" + assert response.json() is False, f"Expected False for {ext}" + + +def test_builder_save_allows_yaml_files(builder_test_client, tmp_path): + """Uploading .yaml and .yml files is allowed.""" + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[( + "files", + ("app/root_agent.yaml", b"name: app\n", "application/x-yaml"), + )], + ) + assert response.status_code == 200 + assert response.json() is True + + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[( + "files", + ("app/sub_agent.yml", b"name: sub\n", "application/x-yaml"), + )], + ) + assert response.status_code == 200 + assert response.json() is True + + +def test_builder_endpoints_not_registered_without_web( + mock_session_service, + mock_artifact_service, + mock_memory_service, + mock_agent_loader, + mock_eval_sets_manager, + mock_eval_set_results_manager, +): + """Builder endpoints must not be registered when web=False (e.g. deploy).""" + client = _create_test_client( + mock_session_service, + mock_artifact_service, + mock_memory_service, + mock_agent_loader, + mock_eval_sets_manager, + mock_eval_set_results_manager, + web=False, + ) + # /builder/save should return 404/405, not 200 + response = client.post( + "/builder/save", + files=[ + ("files", ("app/agent.yaml", b"name: test\n", "application/x-yaml")) + ], + ) + assert response.status_code in (404, 405) + + # /builder/app/{name}/cancel should also be absent + response = client.post("/builder/app/app/cancel") + assert response.status_code in (404, 405) + + # /builder/app/{name} GET should also be absent + response = client.get("/builder/app/app") + assert response.status_code in (404, 405) + + +def test_builder_endpoints_registered_with_web(builder_test_client): + """Builder endpoints are available when web=True.""" + response = builder_test_client.post( + "/builder/save?tmp=true", + files=[ + ("files", ("app/agent.yaml", b"name: test\n", "application/x-yaml")) + ], + ) + assert response.status_code == 200 + + def test_agent_run_resume_without_message_success( test_app, create_test_session ): From fa5e707c11ad748e7db2f653b526d9bdc4b7d405 Mon Sep 17 00:00:00 2001 From: George Weale Date: Tue, 24 Mar 2026 13:10:08 -0700 Subject: [PATCH 10/11] fix: Exclude compromised LiteLLM versions from dependencies pin to 1.82.6 Versions 1.82.7 and 1.82.8 of LiteLLM were affected by a supply chain attack and are now explicitly excluded from the dependency constraints for both project and dev dependencies. Co-authored-by: George Weale PiperOrigin-RevId: 888818704 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4bd800131d..0e2544063d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ test = [ "kubernetes>=29.0.0", # For GkeCodeExecutor "langchain-community>=0.3.17", "langgraph>=0.2.60, <0.4.8", # For LangGraphAgent - "litellm>=1.75.5, <2.0.0", # For LiteLLM tests + "litellm>=1.75.5, <=1.82.6", # For LiteLLM tests. Upper bound pinned: versions 1.82.7+ compromised in supply chain attack. "llama-index-readers-file>=0.4.0", # For retrieval tests "openai>=1.100.2", # For LiteLLM "opentelemetry-instrumentation-google-genai>=0.3b0, <1.0.0", @@ -159,7 +159,7 @@ extensions = [ "kubernetes>=29.0.0", # For GkeCodeExecutor "k8s-agent-sandbox>=0.1.1.post2", # For GkeCodeExecutor sandbox mode "langgraph>=0.2.60, <0.4.8", # For LangGraphAgent - "litellm>=1.75.5, <2.0.0", # For LiteLlm class. Currently has OpenAI limitations. TODO: once LiteLlm fix it + "litellm>=1.75.5, <=1.82.6", # For LiteLlm class. Upper bound pinned: versions 1.82.7+ compromised in supply chain attack. "llama-index-readers-file>=0.4.0", # For retrieval using LlamaIndex. "llama-index-embeddings-google-genai>=0.3.0", # For files retrieval using LlamaIndex. "lxml>=5.3.0", # For load_web_page tool. From 07f542e540921a53bd277ea0719f4b1f1f5c3476 Mon Sep 17 00:00:00 2001 From: "Wei (Jack) Sun" Date: Tue, 24 Mar 2026 16:36:42 -0700 Subject: [PATCH 11/11] chore(release/candidate): release 1.27.4 (#4989) --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 6 ++++++ src/google/adk/version.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 7282be844a..215f02e088 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.27.3" + ".": "1.27.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5a5bd2fa..b0423f8073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.27.4](https://github.com/google/adk-python/compare/v1.27.3...v1.27.4) (2026-03-24) +### Bug Fixes + +* Exclude compromised LiteLLM versions from dependencies pin to 1.82.6 ([fa5e707](https://github.com/google/adk-python/commit/fa5e707c11ad748e7db2f653b526d9bdc4b7d405)) +* gate builder endpoints behind web flag ([44b3f72](https://github.com/google/adk-python/commit/44b3f72d8f4ee09461d0acd8816149e801260b84)) + ## [1.27.3](https://github.com/google/adk-python/compare/v1.27.2...v1.27.3) (2026-03-23) ### Bug Fixes * add protection for arbitrary module imports ([276adfb](https://github.com/google/adk-python/commit/276adfb7ad552213c0201a3c95efbc9876bf3b66)) diff --git a/src/google/adk/version.py b/src/google/adk/version.py index c732594d19..552556e23b 100644 --- a/src/google/adk/version.py +++ b/src/google/adk/version.py @@ -13,4 +13,4 @@ # limitations under the License. # version: major.minor.patch -__version__ = "1.27.3" +__version__ = "1.27.4"