From 8fc25f1eecb0d1c51f02d5cb621638d2641b2a8d Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 6 Jul 2026 15:33:06 -0700 Subject: [PATCH 01/14] fix: emit standard OTel cloud.resource_id for Agent Engine telemetry The agent-engine branch of get_gcp_resource() emitted the resource id under a non-standard "cloud.resource.id" key, so the Agent Engine dashboard's cloud.resource_id filter matched no rows and every panel read 0. Use the OTel CLOUD_RESOURCE_ID constant ("cloud.resource_id"). Close #6247 Co-authored-by: George Weale PiperOrigin-RevId: 943528352 --- src/google/adk/telemetry/google_cloud.py | 9 ++++++++- .../unittests/telemetry/test_google_cloud.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/google/adk/telemetry/google_cloud.py b/src/google/adk/telemetry/google_cloud.py index 0f1e12e90f5..a0c8f7db398 100644 --- a/src/google/adk/telemetry/google_cloud.py +++ b/src/google/adk/telemetry/google_cloud.py @@ -43,6 +43,13 @@ logger = logging.getLogger("google_adk." + __name__) +try: + from opentelemetry.semconv._incubating.attributes.cloud_attributes import CLOUD_RESOURCE_ID +except ImportError: + # cloud.resource_id only lives in the private _incubating package; fall back + # to the literal key the Agent Engine dashboard filters on if that path moves. + CLOUD_RESOURCE_ID = "cloud.resource_id" + _GCP_LOG_NAME_ENV_VARIABLE_NAME = "GOOGLE_CLOUD_DEFAULT_LOG_NAME" _DEFAULT_LOG_NAME = "adk-otel" @@ -246,7 +253,7 @@ def get_gcp_resource(project_id: Optional[str] = None) -> Resource: ), } if cloud_resource_id is not None: - resource_attributes["cloud.resource.id"] = cloud_resource_id + resource_attributes[CLOUD_RESOURCE_ID] = cloud_resource_id if agent_engine_id: resource = Resource.create(attributes=resource_attributes).merge( diff --git a/tests/unittests/telemetry/test_google_cloud.py b/tests/unittests/telemetry/test_google_cloud.py index 7559f9f2011..ac1aba1971b 100644 --- a/tests/unittests/telemetry/test_google_cloud.py +++ b/tests/unittests/telemetry/test_google_cloud.py @@ -113,6 +113,25 @@ def test_get_gcp_resource( ) +def test_get_gcp_resource_sets_standard_cloud_resource_id( + monkeypatch: pytest.MonkeyPatch, +): + # Arrange. + monkeypatch.setenv("GOOGLE_CLOUD_AGENT_ENGINE_ID", "1234567890") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1") + + # Act. + otel_resource = get_gcp_resource("my-project") + + # Assert. + # The Agent Engine dashboard filters on the OTel-standard key. + assert otel_resource.attributes.get("cloud.resource_id") == ( + "//aiplatform.googleapis.com/projects/my-project" + "/locations/us-central1/reasoningEngines/1234567890" + ) + assert "cloud.resource.id" not in otel_resource.attributes + + @mock.patch.object(mtls, "should_use_client_cert", autospec=True) def test_use_client_cert_effective_from_mtls(mock_should_use): mock_should_use.return_value = True From 53a8ab167fb2eb3fdd0507f08a81498b328a8411 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 6 Jul 2026 15:36:04 -0700 Subject: [PATCH 02/14] fix: correct misleading workflow-agent deprecation messages SequentialAgent, LoopAgent, and ParallelAgent were deprecated in favor of Workflow, but Workflow is not a BaseAgent and cannot be used as an LlmAgent sub_agent, so "use Workflow instead" is incomplete for the sub-agent composition pattern. Keep recommending Workflow for orchestration, but state the sub-agent limitation directly in the deprecation message. Relates to #5872. Co-authored-by: George Weale PiperOrigin-RevId: 943529799 --- src/google/adk/agents/loop_agent.py | 8 ++++---- src/google/adk/agents/parallel_agent.py | 8 ++++---- src/google/adk/agents/sequential_agent.py | 9 +++++---- tests/unittests/agents/test_loop_agent.py | 5 +++++ tests/unittests/agents/test_parallel_agent.py | 5 +++++ tests/unittests/agents/test_sequential_agent.py | 5 +++++ 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/google/adk/agents/loop_agent.py b/src/google/adk/agents/loop_agent.py index 6bf52214048..5d289bf49c8 100644 --- a/src/google/adk/agents/loop_agent.py +++ b/src/google/adk/agents/loop_agent.py @@ -51,8 +51,8 @@ class LoopAgentState(BaseAgentState): @deprecated( - 'LoopAgent is deprecated and will be removed in future versions.' - ' Please use Workflow instead.' + 'LoopAgent is deprecated in favor of Workflow and will be removed in a' + ' future version. Workflow cannot yet be used as an LlmAgent sub-agent.' ) class LoopAgent(BaseAgent): """A shell agent that run its sub-agents in a loop. @@ -61,8 +61,8 @@ class LoopAgent(BaseAgent): reached, the loop agent will stop. .. deprecated:: - LoopAgent is deprecated and will be removed in future versions. - Please use Workflow instead. + LoopAgent is deprecated in favor of Workflow and will be removed in a + future version. Workflow cannot yet be used as an LlmAgent sub-agent. """ config_type: ClassVar[type[BaseAgentConfig]] = LoopAgentConfig diff --git a/src/google/adk/agents/parallel_agent.py b/src/google/adk/agents/parallel_agent.py index 8e98a30d867..dfd1e7749cb 100644 --- a/src/google/adk/agents/parallel_agent.py +++ b/src/google/adk/agents/parallel_agent.py @@ -158,8 +158,8 @@ async def process_an_agent(events_for_one_agent): @deprecated( - 'ParallelAgent is deprecated and will be removed in future versions.' - ' Please use Workflow instead.' + 'ParallelAgent is deprecated in favor of Workflow and will be removed in' + ' a future version. Workflow cannot yet be used as an LlmAgent sub-agent.' ) class ParallelAgent(BaseAgent): """A shell agent that runs its sub-agents in parallel in an isolated manner. @@ -171,8 +171,8 @@ class ParallelAgent(BaseAgent): - Generating multiple responses for review by a subsequent evaluation agent. .. deprecated:: - ParallelAgent is deprecated and will be removed in future versions. - Please use Workflow instead. + ParallelAgent is deprecated in favor of Workflow and will be removed in a + future version. Workflow cannot yet be used as an LlmAgent sub-agent. """ config_type: ClassVar[type[BaseAgentConfig]] = ParallelAgentConfig diff --git a/src/google/adk/agents/sequential_agent.py b/src/google/adk/agents/sequential_agent.py index 01510ff4cbb..3b690175363 100644 --- a/src/google/adk/agents/sequential_agent.py +++ b/src/google/adk/agents/sequential_agent.py @@ -47,15 +47,16 @@ class SequentialAgentState(BaseAgentState): @deprecated( - 'SequentialAgent is deprecated and will be removed in future versions.' - ' Please use Workflow instead.' + 'SequentialAgent is deprecated in favor of Workflow and will be removed' + ' in a future version. Workflow cannot yet be used as an LlmAgent' + ' sub-agent.' ) class SequentialAgent(BaseAgent): """A shell agent that runs its sub-agents in sequence. .. deprecated:: - SequentialAgent is deprecated and will be removed in future versions. - Please use Workflow instead. + SequentialAgent is deprecated in favor of Workflow and will be removed in + a future version. Workflow cannot yet be used as an LlmAgent sub-agent. """ config_type: ClassVar[Type[BaseAgentConfig]] = SequentialAgentConfig diff --git a/tests/unittests/agents/test_loop_agent.py b/tests/unittests/agents/test_loop_agent.py index 0e23d9d42cd..e4465883890 100644 --- a/tests/unittests/agents/test_loop_agent.py +++ b/tests/unittests/agents/test_loop_agent.py @@ -285,3 +285,8 @@ def mock_should_pause(event): # Verify that the sub-agent state was NOT reset assert agent.name in parent_ctx.agent_states assert parent_ctx.agent_states[agent.name] == {'some_key': 'some_value'} + + +def test_deprecation_mentions_sub_agent_limitation(): + with pytest.warns(DeprecationWarning, match='sub-agent'): + LoopAgent(name='deprecated_loop', sub_agents=[]) diff --git a/tests/unittests/agents/test_parallel_agent.py b/tests/unittests/agents/test_parallel_agent.py index 305911c100e..76e2405ec11 100644 --- a/tests/unittests/agents/test_parallel_agent.py +++ b/tests/unittests/agents/test_parallel_agent.py @@ -409,3 +409,8 @@ async def test_merge_agent_run_pre_3_11_no_aclose_error_on_failure(): # would raise RuntimeError here. for agen in agent_runs: await agen.aclose() + + +def test_deprecation_mentions_sub_agent_limitation(): + with pytest.warns(DeprecationWarning, match='sub-agent'): + ParallelAgent(name='deprecated_parallel', sub_agents=[]) diff --git a/tests/unittests/agents/test_sequential_agent.py b/tests/unittests/agents/test_sequential_agent.py index 85523d2dca1..aadb5ad5800 100644 --- a/tests/unittests/agents/test_sequential_agent.py +++ b/tests/unittests/agents/test_sequential_agent.py @@ -201,3 +201,8 @@ async def test_run_live(request: pytest.FixtureRequest): assert events[1].author == agent_2.name assert events[0].content.parts[0].text == f'Hello, live {agent_1.name}!' assert events[1].content.parts[0].text == f'Hello, live {agent_2.name}!' + + +def test_deprecation_mentions_sub_agent_limitation(): + with pytest.warns(DeprecationWarning, match='sub-agent'): + SequentialAgent(name='deprecated_sequential', sub_agents=[]) From 9787e97ed27d65458d7d90a9b201b773d9eab5fb Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 6 Jul 2026 15:36:41 -0700 Subject: [PATCH 03/14] test: cover after_run_callback dispatch on Workflow node root The Runner already dispatches run_after_run_callback on the BaseNode (_run_node_async) path; add an end-to-end regression test that a plugin's after_run_callback fires once on a Workflow root. Close #5282 Co-authored-by: George Weale PiperOrigin-RevId: 943530150 --- tests/unittests/runners/test_runner_node.py | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/unittests/runners/test_runner_node.py b/tests/unittests/runners/test_runner_node.py index 9559c0933cb..075b2b6765f 100644 --- a/tests/unittests/runners/test_runner_node.py +++ b/tests/unittests/runners/test_runner_node.py @@ -26,9 +26,12 @@ from google.adk.agents.callback_context import CallbackContext from google.adk.agents.context import Context +from google.adk.agents.invocation_context import InvocationContext from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.run_config import RunConfig +from google.adk.apps.app import App from google.adk.events.event import Event +from google.adk.plugins.base_plugin import BasePlugin from google.adk.runners import Runner from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.workflow import node @@ -1393,3 +1396,43 @@ async def _run_impl( assert call_counts['child'] == 2 outputs2 = [e.output for e in events2 if e.output is not None] assert 'child_out_2' in outputs2 + + +# --------------------------------------------------------------------------- +# Plugin lifecycle on the node path +# --------------------------------------------------------------------------- + + +class _AfterRunCountingPlugin(BasePlugin): + """Counts how many times after_run_callback is dispatched.""" + + def __init__(self) -> None: + super().__init__(name='after_run_counter') + self.after_run_calls = 0 + + async def after_run_callback( + self, *, invocation_context: InvocationContext + ) -> None: + self.after_run_calls += 1 + + +@pytest.mark.asyncio +async def test_after_run_callback_dispatched_on_workflow_root(): + """Runner dispatches plugin after_run_callback on a Workflow(BaseNode) root.""" + + def terminal(node_input: str) -> str: + return node_input.upper() + + plugin = _AfterRunCountingPlugin() + workflow = Workflow(name='wf', edges=[(START, terminal)]) + app = App(name='test', root_agent=workflow, plugins=[plugin]) + ss = InMemorySessionService() + runner = Runner(app=app, session_service=ss) + session = await ss.create_session(app_name='test', user_id='u') + + async for _ in runner.run_async( + user_id='u', session_id=session.id, new_message=_user_message('hi') + ): + pass + + assert plugin.after_run_calls == 1 From 5301ffa2c71833b2cbf93a023e4ceaf9a748f320 Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Mon, 6 Jul 2026 15:42:18 -0700 Subject: [PATCH 04/14] fix: Address mypy failures in google_api_tool/ Co-authored-by: Xuan Yang PiperOrigin-RevId: 943533056 --- pyproject.toml | 3 +++ .../adk/tools/google_api_tool/google_api_toolset.py | 8 ++++++-- .../google_api_tool/googleapi_to_openapi_converter.py | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0972f353d6e..5c617ee8cdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -306,6 +306,9 @@ python_version = "3.11" disable_error_code = [ "import-not-found", "import-untyped", "unused-ignore" ] strict = true plugins = [ "pydantic.mypy" ] +overrides = [ { module = [ + "google.auth.*", +], ignore_missing_imports = true, follow_imports = "skip" } ] [tool.pytest] ini_options.testpaths = [ "tests" ] diff --git a/src/google/adk/tools/google_api_tool/google_api_toolset.py b/src/google/adk/tools/google_api_tool/google_api_toolset.py index 35d130c7ab5..aac8cd6b500 100644 --- a/src/google/adk/tools/google_api_tool/google_api_toolset.py +++ b/src/google/adk/tools/google_api_tool/google_api_toolset.py @@ -90,8 +90,12 @@ def __init__( cert_path, key_path, passphrase = self._mtls_certs.get_certs() if cert_path and key_path and passphrase: - def client_factory(): - return httpx.AsyncClient(cert=(cert_path, key_path, passphrase)) + def client_factory() -> httpx.AsyncClient: + if passphrase: + return httpx.AsyncClient( + cert=(cert_path, key_path, passphrase) # type: ignore[arg-type] + ) + return httpx.AsyncClient(cert=(cert_path, key_path)) self._httpx_client_factory = client_factory diff --git a/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py b/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py index b6eab32be88..6fd704cd703 100644 --- a/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py +++ b/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py @@ -130,6 +130,9 @@ def convert(self) -> Dict[str, Any]: if not self._google_api_spec: self.fetch_google_api_spec() + if self._google_api_spec is None: + raise RuntimeError("Failed to initialize Google API specification.") + # Convert basic API information self._convert_info() @@ -170,6 +173,8 @@ def _convert_info(self) -> None: def _convert_servers(self) -> None: """Convert server information.""" + if self._google_api_spec is None: + raise RuntimeError("API spec must be initialized before conversion.") use_client_cert = getattr(self, "_use_client_cert", False) if use_client_cert and "mtlsRootUrl" in self._google_api_spec: root_url = self._google_api_spec["mtlsRootUrl"] From 9a79fa1e3117c6392ad0aa3a33ac57ac3e127c62 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:45:01 -0700 Subject: [PATCH 05/14] docs: fix typo in per-turn user simulator quality prompt Merge https://github.com/google/adk-python/pull/6305 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **2. Or, if no issue exists, describe the change:** **Problem:** The with-persona evaluator prompt template in `src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py` misspells "existing" as "exsisting" in its "Definition of Conversation History" section. This text is part of the prompt sent to the model at evaluation time, so it is model-facing rather than an internal comment. The sibling non-persona template in the same file already uses the correct spelling in the identical sentence, so the two templates are inconsistent. **Solution:** Correct the single word `exsisting` -> `existing` on that line. This makes the two templates consistent and fixes the model-facing text. No code path changes. ### Testing Plan This is a small typo fix in a prompt string, so no behavioral tests are added. The existing module test suite still passes and linting is clean: - `pre-commit run --files src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py` -> all Passed/Skipped. - `pytest tests/unittests/evaluation/simulation/test_per_turn_user_simulation_quality_prompts.py -q` -> 6 passed. **Unit Tests:** - [ ] I have added or updated unit tests for my change. (N/A: typo-only fix in a prompt string literal; the module's tests mock the templates and do not snapshot the prompt text.) - [x] All unit tests pass locally. ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] New and existing unit tests pass locally with my changes. COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/6305 from anxkhn:docs/simulator-prompt-typo 34dc49f167e3e96f6e2600bcf25aba3b747ce1c2 PiperOrigin-RevId: 943534249 --- .../simulation/per_turn_user_simulator_quality_prompts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py b/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py index 1862272cf3b..985011fe355 100644 --- a/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py +++ b/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py @@ -146,7 +146,7 @@ # Definition of Conversation History The Conversation History is the actual dialogue between the User Simulator and the Agent. -The Conversation History may not be complete, but the exsisting dialogue should adhere to the Conversation Plan. +The Conversation History may not be complete, but the existing dialogue should adhere to the Conversation Plan. The Conversation History may contain instances where the User Simulator troubleshoots an incorrect/inappropriate response from the Agent in order to enforce the Conversation Plan. The Conversation History is finished only when the User Simulator outputs `{{ stop_signal }}` in its response. If this token is missing, the conversation between the User Simulator and the Agent has not finished, and more turns can be generated. From 969909f2ba30c3483feae18f6d585e24e03b2610 Mon Sep 17 00:00:00 2001 From: Haran Rajkumar Date: Mon, 6 Jul 2026 15:48:37 -0700 Subject: [PATCH 06/14] docs(samples): Add ManagedAgent sample using server-side google_search Add a runnable sample under contributing/samples/managed_agent/basic showing how to use ManagedAgent (backed by the Managed Agents API) with the server-side google_search tool, mirroring the live integration test flow. The sample exposes a root_agent in agent.py and ships a README covering the required enterprise/ADC setup and example prompts (including a multi-turn follow-up that reuses the recovered remote sandbox). Co-authored-by: Haran Rajkumar PiperOrigin-RevId: 943536117 --- .../samples/managed_agent/basic/README.md | 47 ++++++++++++++++++ .../samples/managed_agent/basic/__init__.py | 15 ++++++ .../samples/managed_agent/basic/agent.py | 49 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 contributing/samples/managed_agent/basic/README.md create mode 100644 contributing/samples/managed_agent/basic/__init__.py create mode 100644 contributing/samples/managed_agent/basic/agent.py diff --git a/contributing/samples/managed_agent/basic/README.md b/contributing/samples/managed_agent/basic/README.md new file mode 100644 index 00000000000..d50095e2c4e --- /dev/null +++ b/contributing/samples/managed_agent/basic/README.md @@ -0,0 +1,47 @@ +# Managed Agent + +## Overview + +This sample runs a `ManagedAgent` configured with the built-in `google_search` +tool. Given an open-ended request, the server-side harness autonomously issues +many searches and synthesizes the result in a single turn. + +## Sample Inputs + +- `Compare the current flagship smartphones from Apple, Samsung, and Google. For each, find its launch price, display size, and main rear camera resolution, then recommend the best value for someone who mostly takes photos.` + + The showcase input. A single question the harness answers by fanning out into a + dozen-odd searches. It does broad discovery first, then targeted per-model spec + and price lookups, self-correcting when it hits a stale model, before composing + a comparison table and a reasoned recommendation. + +- `Which of those would you pick for shooting video instead?` + + A follow-up turn that reuses the recovered remote sandbox and the previous + interaction, continuing the same research thread. This demonstrates multi-turn + chaining. + +## Graph + +```mermaid +graph LR + User -->|message| ManagedAgent + ManagedAgent -->|interactions.create| ManagedAgentsAPI + ManagedAgentsAPI -->|server-side research loop: google_search ×N| ManagedAgentsAPI + ManagedAgentsAPI -->|streamed events| ManagedAgent + ManagedAgent -->|answer| User +``` + +## How To + +- **Create the agent**: instantiate `ManagedAgent` with an `agent_id`, an + `environment` spec, and a list of server-side `tools`. No `model` is set; the + model is part of the managed agent on the server. +- **Provision a sandbox**: `environment={'type': 'remote'}` requests a fresh + remote sandbox. The resulting environment id is stored on emitted events, so + subsequent turns automatically recover and reuse it. +- **Multi-turn chaining**: the agent recovers the `previous_interaction_id` from + the session events, so follow-up turns continue the same interaction without + any extra wiring. +- **Drive it**: a `ManagedAgent` is a `BaseAgent`, so a standard `Runner` runs + it just like any other agent. diff --git a/contributing/samples/managed_agent/basic/__init__.py b/contributing/samples/managed_agent/basic/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/managed_agent/basic/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/managed_agent/basic/agent.py b/contributing/samples/managed_agent/basic/agent.py new file mode 100644 index 00000000000..c6c7b3bc34a --- /dev/null +++ b/contributing/samples/managed_agent/basic/agent.py @@ -0,0 +1,49 @@ +# 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. + +"""A ManagedAgent backed by the Managed Agents API (interactions.create). + +``ManagedAgent`` calls the Managed Agents API directly from its run loop instead +of running a local model loop. It currently supports server-side tools only +(ADK built-in tools and raw ``google.genai.types.Tool`` configs); here we wire +up ``google_search``, which runs entirely on the server. + +A fresh remote sandbox is provisioned via ``environment={'type': 'remote'}``; +the environment id is recovered from prior events so multi-turn conversations +reuse the same sandbox. + +Run with ``adk web`` / ``adk run contributing/samples/managed_agent/basic``. See +the README for the required environment / auth setup. +""" + +import os + +from google.adk.agents import ManagedAgent +from google.adk.tools import google_search + +# The Managed Agent id served by the Managed Agents API. Override with the +# MANAGED_AGENT_ID environment variable if your project has access to a +# different agent. +_DEFAULT_AGENT_ID = 'antigravity-preview-05-2026' + +root_agent = ManagedAgent( + name='managed_search_agent', + agent_id=os.environ.get('MANAGED_AGENT_ID', _DEFAULT_AGENT_ID), + # Provision a remote sandbox for the agent. The environment id is recovered + # from prior events, so follow-up turns reuse the same sandbox. + environment={'type': 'remote'}, + # Only server-side tools are supported today. google_search is an ADK + # built-in tool that executes on the server. + tools=[google_search], +) From 8b011f9ec93d115580f98e48cca2f0f9b7db6b88 Mon Sep 17 00:00:00 2001 From: Haran Rajkumar Date: Mon, 6 Jul 2026 15:50:33 -0700 Subject: [PATCH 07/14] docs: update GoogleSearchTool docstring to refer to Gemini models Updates the class description of GoogleSearchTool to refer generically to 'Gemini models' rather than 'Gemini 2 models', as it applies to the broader family. Closes #12345 Co-authored-by: Haran Rajkumar PiperOrigin-RevId: 943537157 --- src/google/adk/tools/google_search_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/tools/google_search_tool.py b/src/google/adk/tools/google_search_tool.py index 65b0a37d002..8e4b384c885 100644 --- a/src/google/adk/tools/google_search_tool.py +++ b/src/google/adk/tools/google_search_tool.py @@ -31,7 +31,7 @@ class GoogleSearchTool(BaseTool): - """A built-in tool that is automatically invoked by Gemini 2 models to retrieve search results from Google Search. + """A built-in tool that is automatically invoked by Gemini models to retrieve search results from Google Search. This tool operates internally within the model and does not require or perform local code execution. From 28b77212588a018ffd081b995f92776ba1556509 Mon Sep 17 00:00:00 2001 From: Yifan Wang Date: Mon, 6 Jul 2026 15:59:10 -0700 Subject: [PATCH 08/14] fix: Use RELEASE_PAT for checkout in release-update-adk-web workflow This resolves a token conflict where actions/checkout persisted the default GITHUB_TOKEN, causing subsequent git push operations by create-pull-request (which uses RELEASE_PAT) to fail with a 400 Bad Request. Co-authored-by: Yifan Wang PiperOrigin-RevId: 943541293 --- .github/workflows/release-update-adk-web.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-update-adk-web.yaml b/.github/workflows/release-update-adk-web.yaml index 37d8d45f07f..923c4b6023d 100644 --- a/.github/workflows/release-update-adk-web.yaml +++ b/.github/workflows/release-update-adk-web.yaml @@ -37,6 +37,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + token: ${{ secrets.RELEASE_PAT }} - name: Fetch and unzip frontend assets run: | From 79b8923679524516b15295865636cd469a854dd9 Mon Sep 17 00:00:00 2001 From: Yifan Wang Date: Mon, 6 Jul 2026 16:13:20 -0700 Subject: [PATCH 09/14] fix: Set persist-credentials to false in release-update-adk-web workflow This resolves a "Duplicate header: Authorization" error (HTTP 400) when create-pull-request runs. actions/checkout@v6 persists credentials by default, which conflicts with the token used by create-pull-request. Co-authored-by: Yifan Wang PiperOrigin-RevId: 943548154 --- .github/workflows/release-update-adk-web.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-update-adk-web.yaml b/.github/workflows/release-update-adk-web.yaml index 923c4b6023d..b0796652293 100644 --- a/.github/workflows/release-update-adk-web.yaml +++ b/.github/workflows/release-update-adk-web.yaml @@ -38,7 +38,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 with: - token: ${{ secrets.RELEASE_PAT }} + persist-credentials: false - name: Fetch and unzip frontend assets run: | From 9a4f479d9fdce9d1d7830a9df40ba59fe01088ea Mon Sep 17 00:00:00 2001 From: XananasX7 Date: Mon, 6 Jul 2026 16:13:57 -0700 Subject: [PATCH 10/14] fix: add DNS-rebinding protection to _OriginCheckMiddleware Merge https://github.com/google/adk-python/pull/6227 When the ADK web server is on loopback and no explicit allow-origins list is configured, require that the Origin header also resolves to a loopback host to prevent DNS-rebinding attacks. PiperOrigin-RevId: 943548404 --- src/google/adk/cli/api_server.py | 71 ++++++- .../cli/test_dns_rebinding_protection.py | 196 ++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/cli/test_dns_rebinding_protection.py diff --git a/src/google/adk/cli/api_server.py b/src/google/adk/cli/api_server.py index 1715aa29c01..aa529ddf980 100644 --- a/src/google/adk/cli/api_server.py +++ b/src/google/adk/cli/api_server.py @@ -165,6 +165,48 @@ def _get_scope_header( return None +import ipaddress as _ipaddress + +_LOOPBACK_HOSTNAMES = frozenset({"localhost"}) + + +def _is_loopback_address(host: str) -> bool: + """Return True if *host* (with or without a port) refers to a loopback address. + + Handles all four forms produced by browsers and uvicorn: + - Plain IPv4: "127.0.0.1" + - IPv4 with port: "127.0.0.1:8000" + - Bracketed IPv6: "[::1]" + - Bracketed IPv6+port: "[::1]:8000" + - Plain IPv6 (scope): "::1" (ASGI server tuple value) + - Hostname: "localhost" + - Hostname with port: "localhost:8000" + """ + bare = host + if bare.startswith("["): + # Bracketed IPv6: [addr] or [addr]:port + end = bare.find("]") + if end != -1: + bare = bare[1:end] + elif bare.count(":") == 1: + # IPv4:port or hostname:port (IPv6 without brackets has > 1 colon) + bare = bare.rsplit(":", 1)[0] + if bare in _LOOPBACK_HOSTNAMES: + return True + try: + return _ipaddress.ip_address(bare).is_loopback + except ValueError: + return False + + +def _get_server_host(scope: dict[str, Any]) -> Optional[str]: + """Return the host the server is actually bound to (from ASGI server port).""" + server = scope.get("server") + if server and len(server) == 2: + return str(server[0]) + 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") @@ -201,12 +243,39 @@ def _is_request_origin_allowed( allowed_origin_regex: Optional[re.Pattern[str]], has_configured_allowed_origins: bool, ) -> bool: - """Validate an Origin header against explicit config or same-origin.""" + """Validate an Origin header against explicit config or same-origin. + + DNS-rebinding protection: when the server is bound to a loopback address + (127.0.0.1 / ::1 / localhost) and no explicit allow-origins have been + configured, we additionally require that the request's Origin header also + resolves to a loopback host. This prevents a DNS-rebinding attack where + an external page temporarily resolves to 127.0.0.1 and then POSTs to the + local development server by matching its own (evil.com) origin against the + Host header it controls. + """ if has_configured_allowed_origins and _is_origin_allowed( origin, allowed_literal_origins, allowed_origin_regex ): return True + # DNS-rebinding guard: if the server is on loopback and no explicit + # allow-origins list is configured, only permit origins whose host is also + # loopback. This mirrors the protection used by the MCP go-sdk SSEHandler. + server_host = _get_server_host(scope) + if ( + not has_configured_allowed_origins + and server_host is not None + and _is_loopback_address(server_host) + ): + try: + from urllib.parse import urlparse # noqa: PLC0415 (local import OK here) + + origin_host = urlparse(origin).hostname or "" + except Exception: # pylint: disable=broad-except + return False + if not _is_loopback_address(origin_host): + return False + request_origin = _get_request_origin(scope) if request_origin is None: return False diff --git a/tests/unittests/cli/test_dns_rebinding_protection.py b/tests/unittests/cli/test_dns_rebinding_protection.py new file mode 100644 index 00000000000..bf0a6d347ed --- /dev/null +++ b/tests/unittests/cli/test_dns_rebinding_protection.py @@ -0,0 +1,196 @@ +# 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 DNS-rebinding protection in _OriginCheckMiddleware.""" + +from google.adk.cli.api_server import _is_loopback_address +from google.adk.cli.api_server import _is_request_origin_allowed +import pytest + + +class TestIsLoopbackAddress: + """Unit tests for _is_loopback_address.""" + + @pytest.mark.parametrize( + "host", + [ + "127.0.0.1", + "localhost", + "::1", + "[::1]", + "127.0.0.1:8000", + "localhost:8000", + "[::1]:8000", + "127.1.2.3", # any 127.x.x.x is loopback + ], + ) + def test_loopback_hosts(self, host: str): + assert _is_loopback_address(host), f"{host!r} should be loopback" + + @pytest.mark.parametrize( + "host", + [ + "evil.com", + "127.evil.com", + "0.0.0.0", + "192.168.1.1", + "10.0.0.1", + "128.0.0.1", + "", + ], + ) + def test_non_loopback_hosts(self, host: str): + assert not _is_loopback_address(host), f"{host!r} should NOT be loopback" + + +class TestDnsRebindingProtection: + """Tests that DNS-rebinding attacks are blocked when server is on loopback.""" + + def _make_scope( + self, server_host: str = "127.0.0.1", host_header: str = "127.0.0.1:8000" + ) -> dict: + """Build a minimal ASGI scope for testing.""" + return { + "type": "http", + "method": "POST", + "server": (server_host, 8000), + "headers": [ + (b"host", host_header.encode()), + ], + "scheme": "http", + } + + # --- DNS rebinding scenarios (should be BLOCKED) --- + + def test_dns_rebinding_evil_origin_loopback_server_no_configured_origins( + self, + ): + """Attacker page (evil.com) DNS-rebinds to 127.0.0.1 and sends a POST. + + Browser sends Origin: http://evil.com, Host: evil.com. + Server is bound to 127.0.0.1. + No explicit allow-origins configured. + Expected: BLOCKED. + """ + scope = self._make_scope( + server_host="127.0.0.1", host_header="evil.com:8000" + ) + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert ( + not result + ), "DNS-rebinding from evil.com should be blocked on loopback server" + + def test_dns_rebinding_127_evil_origin(self): + """Origin header host starts with '127.' but is a hostname (127.evil.com).""" + scope = self._make_scope( + server_host="127.0.0.1", host_header="127.evil.com:8000" + ) + result = _is_request_origin_allowed( + origin="http://127.evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert not result + + def test_dns_rebinding_localhost_server(self): + """Same attack, server bound as 'localhost'.""" + scope = self._make_scope(server_host="localhost", host_header="evil.com") + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert not result + + def test_dns_rebinding_ipv6_loopback_server(self): + """Same attack, server bound to ::1.""" + scope = self._make_scope(server_host="::1", host_header="evil.com") + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert not result + + # --- Legitimate same-origin requests (should be ALLOWED) --- + + def test_same_origin_localhost_allowed(self): + """Legitimate browser request from localhost UI to localhost server.""" + scope = self._make_scope( + server_host="127.0.0.1", host_header="127.0.0.1:8000" + ) + result = _is_request_origin_allowed( + origin="http://127.0.0.1:8000", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert result, "Same-origin localhost request should be allowed" + + def test_same_origin_localhost_named(self): + """Browser opens http://localhost:8000 -> requests to localhost:8000.""" + scope = self._make_scope( + server_host="127.0.0.1", host_header="localhost:8000" + ) + result = _is_request_origin_allowed( + origin="http://localhost:8000", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert result + + # --- Explicit allow-origins configured (allow-list bypasses DNS guard) --- + + def test_explicit_allowlist_overrides_dns_rebinding_guard(self): + """If the developer explicitly allows evil.com, it should be permitted.""" + scope = self._make_scope(server_host="127.0.0.1", host_header="evil.com") + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=["http://evil.com"], + allowed_origin_regex=None, + has_configured_allowed_origins=True, + ) + assert result, "Explicitly allowed origin should still pass" + + # --- Non-loopback server (protection does not apply) --- + + def test_non_loopback_server_no_dns_guard(self): + """Server bound to 0.0.0.0 — DNS guard must not interfere with same-origin check.""" + scope = self._make_scope( + server_host="0.0.0.0", host_header="example.com:8000" + ) + result = _is_request_origin_allowed( + origin="http://example.com:8000", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert result, "Same-origin on public server should be allowed" From 527e3c1089ab13ad6f8ef2aee9f05cb823d726e7 Mon Sep 17 00:00:00 2001 From: Haran Rajkumar Date: Mon, 6 Jul 2026 16:15:11 -0700 Subject: [PATCH 11/14] docs(samples): Add ManagedAgent code-execution sample Add a runnable sample under contributing/samples/managed_agent/code_execution showing how to use ManagedAgent with the server-side code execution tool. Since ManagedAgent has no code_executor field, code execution is enabled by passing the raw types.Tool(code_execution=types.ToolCodeExecution()) config in tools. The sample exposes a root_agent in agent.py and ships a README plus a matching single-turn live integration test that verifies a code-executed prime-sum computation. Co-authored-by: Haran Rajkumar PiperOrigin-RevId: 943548907 --- .../managed_agent/code_execution/README.md | 54 ++++++++++++++++++ .../managed_agent/code_execution/__init__.py | 15 +++++ .../managed_agent/code_execution/agent.py | 55 +++++++++++++++++++ tests/integration/test_managed_agent.py | 35 ++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 contributing/samples/managed_agent/code_execution/README.md create mode 100644 contributing/samples/managed_agent/code_execution/__init__.py create mode 100644 contributing/samples/managed_agent/code_execution/agent.py diff --git a/contributing/samples/managed_agent/code_execution/README.md b/contributing/samples/managed_agent/code_execution/README.md new file mode 100644 index 00000000000..1b8b0b5215c --- /dev/null +++ b/contributing/samples/managed_agent/code_execution/README.md @@ -0,0 +1,54 @@ +# Managed Agent - Code Execution + +## Overview + +This sample runs a `ManagedAgent` configured with the built-in **code execution** +tool so it can write and run code server-side to compute answers. + +Unlike a regular `LlmAgent` (which enables code execution via +`code_executor=BuiltInCodeExecutor()`), `ManagedAgent` has no `code_executor` +field. Instead you pass the raw built-in tool config +`types.Tool(code_execution=types.ToolCodeExecution())` in `tools` -- the same +config `BuiltInCodeExecutor` produces under the hood. This makes the sample a +demonstration of the raw `types.Tool` server-side tool path. + +## Sample Inputs + +- `What is the sum of the first 50 prime numbers? Use code to compute it.` + + The model writes and runs code server-side; the answer (5117) comes from the + executed code rather than the model guessing. + +- `Now do the same for the first 100 primes.` + + A follow-up turn that reuses the recovered remote sandbox and the previous + interaction (answer: 24133), demonstrating multi-turn chaining. + +## Graph + +```mermaid +graph LR + User -->|message| ManagedAgent + ManagedAgent -->|interactions.create| ManagedAgentsAPI + ManagedAgentsAPI -->|server-side code execution| ManagedAgentsAPI + ManagedAgentsAPI -->|streamed events| ManagedAgent + ManagedAgent -->|answer| User +``` + +## How To + +- **Create the agent**: instantiate `ManagedAgent` with an `agent_id`, an + `environment` spec, and + `tools=[types.Tool(code_execution=types.ToolCodeExecution())]`. No `model` is + set -- the model is part of the managed agent on the server. +- **Enable code execution**: `ManagedAgent` has no `code_executor` field, so the + raw `types.Tool(code_execution=...)` config is passed in `tools`. The + interactions converter turns it into the server-side `code_execution` tool. +- **Provision a sandbox**: `environment={'type': 'remote'}` requests a fresh + remote sandbox. The resulting environment id is stored on emitted events, so + subsequent turns automatically recover and reuse it. +- **Multi-turn chaining**: the agent recovers the `previous_interaction_id` from + the session events, so follow-up turns continue the same interaction without + any extra wiring. +- **Drive it**: a `ManagedAgent` is a `BaseAgent`, so a standard `Runner` runs + it just like any other agent. diff --git a/contributing/samples/managed_agent/code_execution/__init__.py b/contributing/samples/managed_agent/code_execution/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/managed_agent/code_execution/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/managed_agent/code_execution/agent.py b/contributing/samples/managed_agent/code_execution/agent.py new file mode 100644 index 00000000000..dfc69436a2f --- /dev/null +++ b/contributing/samples/managed_agent/code_execution/agent.py @@ -0,0 +1,55 @@ +# 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. + +"""A ManagedAgent that runs server-side code execution. + +``ManagedAgent`` calls the Managed Agents API directly from its run loop instead +of running a local model loop. It currently supports server-side tools only. + +Unlike ``LlmAgent``, ``ManagedAgent`` has no ``code_executor`` field, so code +execution is enabled by passing the raw built-in tool config +``types.Tool(code_execution=types.ToolCodeExecution())`` in ``tools`` -- the +same config ``BuiltInCodeExecutor`` produces under the hood. The model writes +and runs code on the server to compute answers. + +A fresh remote sandbox is provisioned via ``environment={'type': 'remote'}``; +the environment id is recovered from prior events so multi-turn conversations +reuse the same sandbox. + +Run with ``adk web`` / +``adk run contributing/samples/managed_agent/code_execution``. See the README +for the required environment / auth setup. +""" + +import os + +from google.adk.agents import ManagedAgent +from google.genai import types + +# The Managed Agent id served by the Managed Agents API. Override with the +# MANAGED_AGENT_ID environment variable if your project has access to a +# different agent. +_DEFAULT_AGENT_ID = 'antigravity-preview-05-2026' + +root_agent = ManagedAgent( + name='managed_code_execution_agent', + agent_id=os.environ.get('MANAGED_AGENT_ID', _DEFAULT_AGENT_ID), + # Provision a remote sandbox for the agent. The environment id is recovered + # from prior events, so follow-up turns reuse the same sandbox. + environment={'type': 'remote'}, + # ManagedAgent has no `code_executor` field; enable server-side code + # execution by passing the raw built-in tool config. This is the same config + # BuiltInCodeExecutor appends for a regular LlmAgent. + tools=[types.Tool(code_execution=types.ToolCodeExecution())], +) diff --git a/tests/integration/test_managed_agent.py b/tests/integration/test_managed_agent.py index befc57ab5a9..dde6d56a9a3 100644 --- a/tests/integration/test_managed_agent.py +++ b/tests/integration/test_managed_agent.py @@ -86,3 +86,38 @@ async def test_google_search_project_hail_mary(): assert ( 'james ortiz' in answer.lower() ), f'expected the grounded answer to contain "James Ortiz"; got: {answer!r}' + + +@pytest.mark.asyncio +async def test_code_execution_prime_sum(): + agent = ManagedAgent( + name='managed_code_execution_agent', + agent_id=_AGENT_ID, + environment={'type': 'remote'}, + tools=[types.Tool(code_execution=types.ToolCodeExecution())], + ) + session_service = InMemorySessionService() + runner = Runner( + app_name='managed_agent_it', + agent=agent, + session_service=session_service, + ) + session = await session_service.create_session( + app_name='managed_agent_it', user_id='test_user' + ) + + events = await _run_turn( + runner, + session, + 'What is the sum of the first 50 prime numbers? Use code to compute it.', + ) + + answer = _joined_text(events) + print('\n=== ManagedAgent code execution answer ===\n', answer) + # The model may stream the number with thousands separators and/or stray + # whitespace (e.g. "5,117" or "5, 117"), so remove all whitespace and commas + # before matching the code-executed sum. + normalized = ''.join(answer.split()).replace(',', '') + assert ( + '5117' in normalized + ), f'expected the code-executed sum 5117; got: {answer!r}' From a721c1eb3433bd2764bed68733b4f475ff7b7c67 Mon Sep 17 00:00:00 2001 From: k4w_wak Date: Mon, 6 Jul 2026 16:16:47 -0700 Subject: [PATCH 12/14] fix(security): enable Jinja2 autoescape to prevent XSS in gepa sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge https://github.com/google/adk-python/pull/5526 ## Security Fix: XSS via Jinja2 Template Injection (CWE-79) ### Vulnerability `contributing/samples/gepa/rater_lib.py` instantiates `jinja2.Environment()` **without** `autoescape=True`. The companion template `rubric_validation_template.txt` renders `{{user_input}}` and `{{model_response}}` without escaping. ### Impact Since ADK is Google's official framework for building AI agents, developers copy/adapt this sample code into production web applications. Unescaped user-controlled input in Jinja2 templates enables: - **Cross-Site Scripting (XSS)** — Arbitrary JavaScript execution in browsers - **Session Hijacking** — Steal cookies/tokens if rendered in web context - **Phishing** — Inject fake login forms ### Proof of Concept ```python # user_input: # Renders as: # model_response: # Renders as: ``` ### Changes 1. **rater_lib.py:170** — `jinja2.Environment()` → `jinja2.Environment(autoescape=True)` 2. **rubric_validation_template.txt:158** — `{{user_input}}` → `{{user_input|e}}` 3. **rubric_validation_template.txt:163** — `{{model_response}}` → `{{model_response|e}}` Defense in depth: `autoescape=True` provides baseline protection, explicit `|e` filters ensure escaping even if autoescape is later disabled. ### References - CWE-79: Cross-site Scripting (XSS) - OWASP A7:2017 — Cross-site Scripting - Jinja2 docs: https://jinja.palletsprojects.com/en/3.1.x/api/#autoescaping Co-authored-by: Shangjie Chen COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/5526 from k4w-wak:fix/jinja2-xss-autoescape b5b6d3eee616aa95c6a06ced4053fdc0745d5e0a PiperOrigin-RevId: 943549514 --- .../samples/integrations/gepa/rater_lib.py | 2 +- .../integrations/gepa/rater_lib_test.py | 79 +++++++++++++++++++ .../gepa/rubric_validation_template.txt | 4 +- 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 contributing/samples/integrations/gepa/rater_lib_test.py diff --git a/contributing/samples/integrations/gepa/rater_lib.py b/contributing/samples/integrations/gepa/rater_lib.py index 50bbdc229d2..67c46da71c8 100644 --- a/contributing/samples/integrations/gepa/rater_lib.py +++ b/contributing/samples/integrations/gepa/rater_lib.py @@ -167,7 +167,7 @@ def __call__(self, messages: list[dict[str, Any]]) -> dict[str, Any]: Returns: A dictionary containing rating information including score. """ - env = jinja2.Environment() + env = jinja2.Environment(autoescape=True) env.globals['user_input'] = ( messages[0].get('parts', [{}])[0].get('text', '') if messages else '' ) diff --git a/contributing/samples/integrations/gepa/rater_lib_test.py b/contributing/samples/integrations/gepa/rater_lib_test.py new file mode 100644 index 00000000000..54a7db8a93e --- /dev/null +++ b/contributing/samples/integrations/gepa/rater_lib_test.py @@ -0,0 +1,79 @@ +# 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. +import sys +from unittest import mock + +# Mock the retry module before importing rater_lib +mock_retry_module = mock.MagicMock() + + +def mock_retry_decorator(*args, **kwargs): + def decorator(func): + return func + + return decorator + + +mock_retry_module.retry = mock_retry_decorator +sys.modules["retry"] = mock_retry_module + +from gepa import rater_lib + + +def test_rater_escapes_html_inputs_to_prevent_xss(): + """Rater escapes HTML tags in user and model inputs to prevent XSS. + + Setup: Mock genai.Client to return a dummy rating response. + Act: Call Rater with messages containing HTML tags. + Assert: Verify that the rendered prompt template contains escaped HTML. + """ + # Arrange + with mock.patch("google.genai.Client") as mock_client_cls: + mock_client = mock_client_cls.return_value + mock_generate = mock_client.models.generate_content + mock_generate.return_value.text = ( + "Property: The agent fulfilled the user's primary request.\n" + "Evidence: mock evidence\n" + "Rationale: mock rationale\n" + "Verdict: yes" + ) + + template_path = ( + "contributing/samples/integrations/gepa/rubric_validation_template.txt" + ) + rater = rater_lib.Rater( + tool_declarations="[]", validation_template_path=template_path + ) + + messages = [ + { + "role": "user", + "parts": [{"text": ""}], + }, + { + "role": "model", + "parts": [{"text": "Hello "}], + }, + ] + + # Act + rater(messages) + + # Assert + assert mock_generate.called + call_args = mock_generate.call_args + contents = call_args.kwargs["contents"] + + assert "<script>alert('XSS')</script>" in contents + assert "Hello <img src=x onerror=alert(1)>" in contents diff --git a/contributing/samples/integrations/gepa/rubric_validation_template.txt b/contributing/samples/integrations/gepa/rubric_validation_template.txt index eeab5af849c..74db111ab02 100644 --- a/contributing/samples/integrations/gepa/rubric_validation_template.txt +++ b/contributing/samples/integrations/gepa/rubric_validation_template.txt @@ -155,12 +155,12 @@ Verdict: no - {{user_input}} + {{user_input|e}} -{{model_response}} +{{model_response|e}} From ac99770646a7112c6f53797ddf5d7f14dadf223b Mon Sep 17 00:00:00 2001 From: Naishadam Raghunandan Kumar <54378462+RaghunandanKumar@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:25:38 -0700 Subject: [PATCH 13/14] fix(auth): allow configuring OAuth prompt parameter Merge https://github.com/google/adk-python/pull/5818 Closes #3046 ## Summary `AuthHandler.generate_auth_uri()` currently hardcodes `prompt=consent` when building OAuth authorization URLs. That makes app-level OAuth configuration less flexible than the underlying providers allow. Flows that need `prompt=none` (or another prompt mode) cannot express that through `OAuth2Auth`, even though the rest of the OAuth request metadata is already configurable. This change adds an optional `prompt` field to `OAuth2Auth`, preserves `consent` as the default, and uses the configured value when generating the authorization URL. ## Changes - `src/google/adk/auth/auth_credential.py` - Add optional `prompt` field to `OAuth2Auth`. - `src/google/adk/auth/auth_handler.py` - Use `auth_credential.oauth2.prompt or "consent"` when populating OAuth authorization params. - `tests/unittests/auth/test_auth_handler.py` - Extend the OAuth session test double to surface `prompt` in generated auth URIs. - Assert the default OAuth flow still includes `prompt=consent`. - Add coverage for a custom `prompt="none"` override. ## Test plan - [x] `python3.12 -m py_compile src/google/adk/auth/auth_credential.py src/google/adk/auth/auth_handler.py tests/unittests/auth/test_auth_handler.py` - [ ] `PYTHONPATH=src .venv/bin/python -m pytest tests/unittests/auth/test_auth_handler.py -q` - Blocked locally while bootstrapping a minimal ad hoc venv: importing `google.adk` for this test path pulls additional runtime dependencies (`google.genai`, `opentelemetry.semconv`, and friends). I stopped after confirming the touched files compile cleanly rather than trying to recreate the repo's full `uv sync --all-extras` environment by hand. - [ ] Reviewer / CI confirmation that the existing auth handler unit suite passes in the standard repo environment. Co-authored-by: George Weale COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/5818 from RaghunandanKumar:fix/oauth-prompt-configurable 727e2e59884a01f5b2e9c825300a3e9f8d35456d PiperOrigin-RevId: 943553106 --- src/google/adk/auth/auth_credential.py | 1 + src/google/adk/auth/auth_handler.py | 2 +- tests/unittests/auth/test_auth_handler.py | 23 ++++++++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/google/adk/auth/auth_credential.py b/src/google/adk/auth/auth_credential.py index 4a2add823c6..747c21c987f 100644 --- a/src/google/adk/auth/auth_credential.py +++ b/src/google/adk/auth/auth_credential.py @@ -87,6 +87,7 @@ class OAuth2Auth(BaseModelWithConfig): expires_at: int | None = None expires_in: int | None = None audience: str | None = None + prompt: str | None = None code_verifier: str | None = None code_challenge_method: str | None = None token_endpoint_auth_method: ( diff --git a/src/google/adk/auth/auth_handler.py b/src/google/adk/auth/auth_handler.py index 9dce6b56afb..ea3aec52283 100644 --- a/src/google/adk/auth/auth_handler.py +++ b/src/google/adk/auth/auth_handler.py @@ -211,7 +211,7 @@ def generate_auth_uri( ) params = { "access_type": "offline", - "prompt": "consent", + "prompt": auth_credential.oauth2.prompt or "consent", } if auth_credential.oauth2.audience: params["audience"] = auth_credential.oauth2.audience diff --git a/tests/unittests/auth/test_auth_handler.py b/tests/unittests/auth/test_auth_handler.py index c35777acab5..c2ef39127af 100644 --- a/tests/unittests/auth/test_auth_handler.py +++ b/tests/unittests/auth/test_auth_handler.py @@ -67,6 +67,8 @@ def create_authorization_url(self, url, **kwargs): params = f"client_id={self.client_id}&scope={self.scope}" if kwargs.get("audience"): params += f"&audience={kwargs.get('audience')}" + if kwargs.get("prompt"): + params += f"&prompt={kwargs.get('prompt')}" return f"{url}?{params}", "mock_state" def fetch_token( @@ -251,6 +253,25 @@ def test_generate_auth_uri_with_audience_and_prompt( result = handler.generate_auth_uri() assert "audience=test_audience" in result.oauth2.auth_uri + assert "prompt=consent" in result.oauth2.auth_uri + + @patch("google.adk.auth.auth_handler.OAuth2Session", MockOAuth2Session) + def test_generate_auth_uri_with_custom_prompt( + self, openid_auth_scheme, oauth2_credentials + ): + """Test generating an auth URI with a custom prompt override.""" + oauth2_credentials.oauth2.prompt = "none" + exchanged = oauth2_credentials.model_copy(deep=True) + + config = AuthConfig( + auth_scheme=openid_auth_scheme, + raw_auth_credential=oauth2_credentials, + exchanged_auth_credential=exchanged, + ) + handler = AuthHandler(config) + result = handler.generate_auth_uri() + + assert "prompt=none" in result.oauth2.auth_uri @patch("google.adk.auth.auth_handler.OAuth2Session", MockOAuth2Session) def test_generate_auth_uri_openid( @@ -299,7 +320,7 @@ def test_generate_auth_uri_client_credentials_with_missing_scopes( assert ( result.oauth2.auth_uri - == "https://example.com/oauth2/token?client_id=mock_client_id&scope=" + == "https://example.com/oauth2/token?client_id=mock_client_id&scope=&prompt=consent" ) assert result.oauth2.state == "mock_state" From 34b953efa9191a8419ca5fd71d1a869dc4373aaa Mon Sep 17 00:00:00 2001 From: Shangjie Chen Date: Mon, 6 Jul 2026 20:08:15 -0700 Subject: [PATCH 14/14] refactor: Clean up redundant self-transfer checks in LlmAgentWrapper - Remove redundant self-transfer check in LlmAgentWrapper, relying on the unified check in transfer_utils. Co-authored-by: Shangjie Chen PiperOrigin-RevId: 943630479 --- src/google/adk/workflow/_llm_agent_wrapper.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/google/adk/workflow/_llm_agent_wrapper.py b/src/google/adk/workflow/_llm_agent_wrapper.py index a4f0dc900ed..20fe74ace6b 100644 --- a/src/google/adk/workflow/_llm_agent_wrapper.py +++ b/src/google/adk/workflow/_llm_agent_wrapper.py @@ -394,10 +394,6 @@ async def run_llm_agent_as_node( break # close this run_iter; outer loop re-enters if event.actions.transfer_to_agent: target_name = event.actions.transfer_to_agent - if target_name == agent.name: - raise ValueError( - f"Agent '{target_name}' cannot transfer to itself." - ) from ..agents.llm_agent import LlmAgent