From 17e0c3a8138ec888fecd1497cad51a7f5377f7c4 Mon Sep 17 00:00:00 2001 From: Andrea Mestriner Date: Tue, 28 Apr 2026 18:55:25 +0100 Subject: [PATCH 1/4] fix(cli_eval): add get_app_or_root_agent resolver Eval flows currently access `agent_module.agent.root_agent` directly, which drops the wrapping `App` (and therefore its plugins, context-cache config, and resumability config). Add `get_app_or_root_agent` that returns the `(app, root_agent)` pair, mirroring the resolution order `AgentLoader._load_from_module_or_package` already uses on the web / run paths. Keep `get_root_agent` as a back-compat wrapper. This commit is the resolver and unit tests only; subsequent commits plumb the App through `EvaluationGenerator` and `LocalEvalService` so plugins fire during eval runs. --- src/google/adk/cli/cli_eval.py | 32 +++++++++-- tests/unittests/cli/utils/test_cli_eval.py | 62 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/google/adk/cli/cli_eval.py b/src/google/adk/cli/cli_eval.py index 33c1693208..5aa0ecb848 100644 --- a/src/google/adk/cli/cli_eval.py +++ b/src/google/adk/cli/cli_eval.py @@ -24,7 +24,9 @@ import click from google.genai import types as genai_types +from ..agents.base_agent import BaseAgent from ..agents.llm_agent import Agent +from ..apps.app import App from ..evaluation.base_eval_service import BaseEvalService from ..evaluation.base_eval_service import EvaluateConfig from ..evaluation.base_eval_service import EvaluateRequest @@ -86,11 +88,33 @@ def get_default_metric_info( ) -def get_root_agent(agent_module_file_path: str) -> Agent: - """Returns root agent given the agent module.""" +def get_app_or_root_agent( + agent_module_file_path: str, +) -> tuple[Optional[App], BaseAgent]: + """Returns the (app, root_agent) pair for the given agent module. + + Resolution order mirrors `AgentLoader._load_from_module_or_package`: + if the module exposes an `App` instance via `agent.app`, that App and its + `root_agent` are returned. Otherwise `app` is None and the bare + `agent.root_agent` is returned. This lets eval flows participate in the + App's plugin / cache / resumability lifecycle when one is defined, while + preserving the bare-`root_agent` path for projects that don't use App. + """ agent_module = _get_agent_module(agent_module_file_path) - root_agent = agent_module.agent.root_agent - return root_agent + app = getattr(agent_module.agent, "app", None) + if isinstance(app, App): + return app, app.root_agent + return None, agent_module.agent.root_agent + + +def get_root_agent(agent_module_file_path: str) -> Agent: + """Returns root agent given the agent module. + + Kept for backward compatibility. New callers should prefer + `get_app_or_root_agent`, which also surfaces the wrapping `App` (if any) + so plugins, context-cache, and resumability configs are honored. + """ + return get_app_or_root_agent(agent_module_file_path)[1] def try_get_reset_func(agent_module_file_path: str) -> Any: diff --git a/tests/unittests/cli/utils/test_cli_eval.py b/tests/unittests/cli/utils/test_cli_eval.py index c6d21fa707..a4aaec34c6 100644 --- a/tests/unittests/cli/utils/test_cli_eval.py +++ b/tests/unittests/cli/utils/test_cli_eval.py @@ -19,6 +19,9 @@ from types import SimpleNamespace from unittest import mock +from google.adk.agents.base_agent import BaseAgent +from google.adk.apps.app import App + def test_get_eval_sets_manager_local(monkeypatch): mock_local_manager = mock.MagicMock() @@ -49,3 +52,62 @@ def test_get_eval_sets_manager_gcs(monkeypatch): ) assert manager == mock_gcs_manager mock_create_gcs.assert_called_once_with("gs://bucket") + + +def _patch_agent_module(monkeypatch, agent_namespace): + """Patches `_get_agent_module` to return a stub whose `.agent` matches.""" + monkeypatch.setattr( + "google.adk.cli.cli_eval._get_agent_module", + lambda _path: SimpleNamespace(agent=agent_namespace), + ) + + +def test_get_app_or_root_agent_with_app(monkeypatch): + """When the module exposes an App, both app and its root_agent are returned.""" + root_agent = BaseAgent(name="root_agent") + app = App(name="my_app", root_agent=root_agent) + _patch_agent_module(monkeypatch, SimpleNamespace(root_agent=root_agent, app=app)) + + from google.adk.cli.cli_eval import get_app_or_root_agent + + resolved_app, resolved_root = get_app_or_root_agent("some/path") + assert resolved_app is app + assert resolved_root is root_agent + + +def test_get_app_or_root_agent_without_app(monkeypatch): + """When only `root_agent` is exposed, app is None.""" + root_agent = BaseAgent(name="root_agent") + _patch_agent_module(monkeypatch, SimpleNamespace(root_agent=root_agent)) + + from google.adk.cli.cli_eval import get_app_or_root_agent + + resolved_app, resolved_root = get_app_or_root_agent("some/path") + assert resolved_app is None + assert resolved_root is root_agent + + +def test_get_app_or_root_agent_app_attribute_not_an_app_instance(monkeypatch): + """If `app` exists but is not an App, it is ignored and we fall back.""" + root_agent = BaseAgent(name="root_agent") + _patch_agent_module( + monkeypatch, + SimpleNamespace(root_agent=root_agent, app="not-an-app"), + ) + + from google.adk.cli.cli_eval import get_app_or_root_agent + + resolved_app, resolved_root = get_app_or_root_agent("some/path") + assert resolved_app is None + assert resolved_root is root_agent + + +def test_get_root_agent_back_compat(monkeypatch): + """Existing `get_root_agent` callers keep getting the bare agent back.""" + root_agent = BaseAgent(name="root_agent") + app = App(name="my_app", root_agent=root_agent) + _patch_agent_module(monkeypatch, SimpleNamespace(root_agent=root_agent, app=app)) + + from google.adk.cli.cli_eval import get_root_agent + + assert get_root_agent("some/path") is root_agent From f02830579bfbd80aea2d5d8003e283f272f13519 Mon Sep 17 00:00:00 2001 From: Andrea Mestriner Date: Tue, 28 Apr 2026 20:18:45 +0100 Subject: [PATCH 2/4] fix(evaluation): forward App through to the eval Runner `_generate_inferences_from_root_agent` now accepts an optional `app` parameter. When provided, the eval Runner is built from a copy of the App with internal eval plugins (`_RequestIntercepterPlugin`, `EnsureRetryOptionsPlugin`) merged into `app.plugins`. The user's App is never mutated, and the App's `context_cache_config` / `resumability_config` ride along automatically. When `app` is None, the legacy bare-agent path is preserved. `_process_query` (used by the public `generate_responses` entry point) now resolves `agent.app` first and forwards it to the helper, so projects that wrap their root agent in an `App` get plugin coverage during eval without further changes. The CLI plumbing that hands the App down from `cli_eval` / `LocalEvalService` is in the next commit. --- .../adk/evaluation/evaluation_generator.py | 55 +++++++- .../evaluation/test_evaluation_generator.py | 133 ++++++++++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/src/google/adk/evaluation/evaluation_generator.py b/src/google/adk/evaluation/evaluation_generator.py index f8fb6795aa..326947812d 100644 --- a/src/google/adk/evaluation/evaluation_generator.py +++ b/src/google/adk/evaluation/evaluation_generator.py @@ -26,6 +26,7 @@ from pydantic import BaseModel from ..agents.llm_agent import Agent +from ..apps.app import App from ..artifacts.base_artifact_service import BaseArtifactService from ..artifacts.in_memory_artifact_service import InMemoryArtifactService from ..events.event import Event @@ -143,7 +144,15 @@ async def _process_query( """Process a query using the agent and evaluation dataset.""" module_path = f"{module_name}" agent_module = importlib.import_module(module_path) - root_agent = agent_module.agent.root_agent + # Prefer the wrapping `App` when the module exposes one, so that + # `app.plugins`, context-cache, and resumability configs participate + # in eval runs the same way they do for `adk web` / `adk run`. + app_obj = getattr(agent_module.agent, "app", None) + if isinstance(app_obj, App): + root_agent = app_obj.root_agent + else: + app_obj = None + root_agent = agent_module.agent.root_agent reset_func = getattr(agent_module.agent, "reset_data", None) @@ -157,6 +166,7 @@ async def _process_query( user_simulator=user_simulator, reset_func=reset_func, initial_session=initial_session, + app=app_obj, ) @staticmethod @@ -197,8 +207,17 @@ async def _generate_inferences_from_root_agent( session_service: Optional[BaseSessionService] = None, artifact_service: Optional[BaseArtifactService] = None, memory_service: Optional[BaseMemoryService] = None, + app: Optional[App] = None, ) -> list[Invocation]: - """Scrapes the root agent in coordination with the user simulator.""" + """Scrapes the root agent in coordination with the user simulator. + + If `app` is provided, the eval Runner is built from a copy of the App + with internal eval plugins merged into `app.plugins`, preserving the + App's `context_cache_config`, `resumability_config`, and any other + application-wide configuration. Otherwise the Runner is built from + the bare `root_agent` with only the internal eval plugins, matching + the legacy behavior. + """ if not session_service: session_service = InMemorySessionService() @@ -235,13 +254,39 @@ async def _generate_inferences_from_root_agent( ensure_retry_options_plugin = EnsureRetryOptionsPlugin( name="ensure_retry_options" ) + internal_eval_plugins = [ + request_intercepter_plugin, + ensure_retry_options_plugin, + ] + + if app is not None: + # Copy the App so we don't mutate the user's instance, and merge our + # internal eval plugins with the user's. Override `root_agent` so the + # Runner targets the agent the caller actually asked us to evaluate + # (e.g., a sub-agent), while still carrying the App's plugins, + # context_cache_config, and resumability_config. + runner_app = app.model_copy( + update={ + "plugins": list(app.plugins) + internal_eval_plugins, + "root_agent": root_agent, + } + ) + runner_kwargs: dict[str, Any] = { + "app": runner_app, + "app_name": app_name, + } + else: + runner_kwargs = { + "app_name": app_name, + "agent": root_agent, + "plugins": internal_eval_plugins, + } + async with Runner( - app_name=app_name, - agent=root_agent, + **runner_kwargs, artifact_service=artifact_service, session_service=session_service, memory_service=memory_service, - plugins=[request_intercepter_plugin, ensure_retry_options_plugin], ) as runner: events = [] while True: diff --git a/tests/unittests/evaluation/test_evaluation_generator.py b/tests/unittests/evaluation/test_evaluation_generator.py index a4aa8691fd..abb596eb79 100644 --- a/tests/unittests/evaluation/test_evaluation_generator.py +++ b/tests/unittests/evaluation/test_evaluation_generator.py @@ -14,10 +14,13 @@ from __future__ import annotations +from google.adk.agents.base_agent import BaseAgent +from google.adk.apps.app import App from google.adk.evaluation.app_details import AgentDetails from google.adk.evaluation.app_details import AppDetails from google.adk.evaluation.evaluation_generator import EvaluationGenerator from google.adk.evaluation.request_intercepter_plugin import _RequestIntercepterPlugin +from google.adk.plugins.base_plugin import BasePlugin from google.adk.evaluation.simulation.user_simulator import NextUserMessage from google.adk.evaluation.simulation.user_simulator import Status as UserSimulatorStatus from google.adk.evaluation.simulation.user_simulator import UserSimulator @@ -479,3 +482,133 @@ async def mock_generate_inferences_side_effect( mock_generate_inferences.assert_called_once() called_with_content = mock_generate_inferences.call_args.args[3] assert called_with_content.parts[0].text == "message 1" + + +class _SpyPlugin(BasePlugin): + """A user-defined plugin used to assert merge behavior.""" + + pass + + +class TestGenerateInferencesFromRootAgentWithApp: + """Tests that App.plugins / configs are honored when an App is provided.""" + + @pytest.fixture + def runner_cls(self, mocker): + """Patches Runner and returns the patched class for kwargs inspection.""" + mock_runner_cls = mocker.patch( + "google.adk.evaluation.evaluation_generator.Runner" + ) + mock_runner_instance = mocker.AsyncMock() + mock_runner_instance.__aenter__.return_value = mock_runner_instance + mock_runner_cls.return_value = mock_runner_instance + yield mock_runner_cls + + @pytest.fixture + def stop_immediately_simulator(self, mocker): + """Returns a UserSimulator that stops on first call (no inference work).""" + sim = mocker.MagicMock(spec=UserSimulator) + sim.get_next_user_message = mocker.AsyncMock( + return_value=NextUserMessage( + status=UserSimulatorStatus.STOP_SIGNAL_DETECTED + ) + ) + return sim + + @pytest.mark.asyncio + async def test_runner_built_from_app_when_provided( + self, runner_cls, mock_session_service, stop_immediately_simulator + ): + """When `app` is passed, Runner is built with `app=` (merged) instead of `agent=`.""" + root_agent = BaseAgent(name="root_agent") + user_plugin = _SpyPlugin(name="user_plugin") + app = App(name="my_app", root_agent=root_agent, plugins=[user_plugin]) + + await EvaluationGenerator._generate_inferences_from_root_agent( + root_agent=root_agent, + user_simulator=stop_immediately_simulator, + app=app, + ) + + runner_cls.assert_called_once() + kwargs = runner_cls.call_args.kwargs + assert "agent" not in kwargs, ( + "Runner must not receive `agent=` when `app=` is provided " + "(would raise ValueError)." + ) + assert "plugins" not in kwargs, ( + "Runner must not receive `plugins=` when `app=` is provided " + "(would raise ValueError)." + ) + runner_app = kwargs["app"] + assert isinstance(runner_app, App) + plugin_names = [p.name for p in runner_app.plugins] + assert "user_plugin" in plugin_names, ( + "User plugin must be preserved in the merged App passed to Runner." + ) + assert "request_intercepter_plugin" in plugin_names + assert "ensure_retry_options" in plugin_names + + @pytest.mark.asyncio + async def test_user_app_is_not_mutated( + self, runner_cls, mock_session_service, stop_immediately_simulator + ): + """The user's App instance must not be mutated across eval runs.""" + root_agent = BaseAgent(name="root_agent") + user_plugin = _SpyPlugin(name="user_plugin") + app = App(name="my_app", root_agent=root_agent, plugins=[user_plugin]) + original_plugins_id = id(app.plugins) + + for _ in range(3): + await EvaluationGenerator._generate_inferences_from_root_agent( + root_agent=root_agent, + user_simulator=stop_immediately_simulator, + app=app, + ) + + # The user's App instance must still hold exactly its original plugin set, + # regardless of how many eval runs reused it. + assert app.plugins == [user_plugin] + assert id(app.plugins) == original_plugins_id + + @pytest.mark.asyncio + async def test_runner_falls_back_to_bare_agent_when_no_app( + self, runner_cls, mock_session_service, stop_immediately_simulator + ): + """When `app` is None, Runner is built with the legacy `agent=`/`plugins=` shape.""" + root_agent = BaseAgent(name="root_agent") + + await EvaluationGenerator._generate_inferences_from_root_agent( + root_agent=root_agent, + user_simulator=stop_immediately_simulator, + ) + + runner_cls.assert_called_once() + kwargs = runner_cls.call_args.kwargs + assert "app" not in kwargs + assert kwargs["agent"] is root_agent + plugin_names = [p.name for p in kwargs["plugins"]] + assert plugin_names == [ + "request_intercepter_plugin", + "ensure_retry_options", + ] + + @pytest.mark.asyncio + async def test_root_agent_override_propagates_to_merged_app( + self, runner_cls, mock_session_service, stop_immediately_simulator + ): + """If a sub-agent is passed as root_agent, the merged App reflects that.""" + full_root = BaseAgent(name="full_root") + sub_agent = BaseAgent(name="sub_agent") + app = App(name="my_app", root_agent=full_root) + + await EvaluationGenerator._generate_inferences_from_root_agent( + root_agent=sub_agent, + user_simulator=stop_immediately_simulator, + app=app, + ) + + runner_app = runner_cls.call_args.kwargs["app"] + assert runner_app.root_agent is sub_agent + # User's App must be untouched. + assert app.root_agent is full_root From ff110b505dcc991c2e8371474e9acce24567ae95 Mon Sep 17 00:00:00 2001 From: Andrea Mestriner Date: Tue, 28 Apr 2026 21:40:19 +0100 Subject: [PATCH 3/4] fix(eval): plumb App through LocalEvalService to fix App.plugins bypass Closes the loop on https://github.com/google/adk-python/issues/: when a project wraps its root agent in `App(root_agent=..., plugins=[...])` and runs `adk eval`, the registered plugins (e.g., `BigQueryAgentAnalyticsPlugin`) now fire on every invocation just like they do for `adk web` / `adk run`. Same applies to `App.context_cache_config` and `App.resumability_config`, which now ride along automatically. Changes: * `LocalEvalService.__init__` accepts an optional `app` keyword argument and forwards it to `_generate_inferences_from_root_agent` for each eval case. * `cli_tools_click.cli_eval` resolves the `App` via `get_app_or_root_agent` and passes it to `LocalEvalService`. * `cli_optimize` (GEPA prompt optimization) also routes through `LocalEvalService` but currently constructs it inside `LocalEvalSampler` with no `app` argument; bringing the optimize path under App-plugin coverage is a separate, narrower follow-up and is intentionally not included here. --- src/google/adk/cli/cli_tools_click.py | 5 +- .../adk/evaluation/local_eval_service.py | 14 ++++ .../evaluation/test_local_eval_service.py | 72 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/google/adk/cli/cli_tools_click.py b/src/google/adk/cli/cli_tools_click.py index 07ccc15892..1d20a082ad 100644 --- a/src/google/adk/cli/cli_tools_click.py +++ b/src/google/adk/cli/cli_tools_click.py @@ -823,8 +823,8 @@ def cli_eval( from ..evaluation.simulation.user_simulator_provider import UserSimulatorProvider from .cli_eval import _collect_eval_results from .cli_eval import _collect_inferences + from .cli_eval import get_app_or_root_agent from .cli_eval import get_default_metric_info - from .cli_eval import get_root_agent from .cli_eval import parse_and_get_evals_to_run from .cli_eval import pretty_print_eval_result except ModuleNotFoundError as mnf: @@ -834,7 +834,7 @@ def cli_eval( print(f"Using evaluation criteria: {eval_config}") eval_metrics = get_eval_metrics_from_config(eval_config) - root_agent = get_root_agent(agent_module_file_path) + app, root_agent = get_app_or_root_agent(agent_module_file_path) app_name = os.path.basename(agent_module_file_path) agents_dir = os.path.dirname(agent_module_file_path) eval_sets_manager = None @@ -940,6 +940,7 @@ def cli_eval( eval_set_results_manager=eval_set_results_manager, user_simulator_provider=user_simulator_provider, metric_evaluator_registry=metric_evaluator_registry, + app=app, ) inference_results = asyncio.run( diff --git a/src/google/adk/evaluation/local_eval_service.py b/src/google/adk/evaluation/local_eval_service.py index 2426204ca0..1a724441f4 100644 --- a/src/google/adk/evaluation/local_eval_service.py +++ b/src/google/adk/evaluation/local_eval_service.py @@ -25,6 +25,7 @@ from typing_extensions import override from ..agents.base_agent import BaseAgent +from ..apps.app import App from ..artifacts.base_artifact_service import BaseArtifactService from ..artifacts.in_memory_artifact_service import InMemoryArtifactService from ..errors.not_found_error import NotFoundError @@ -123,8 +124,20 @@ def __init__( session_id_supplier: Callable[[], str] = _get_session_id, user_simulator_provider: UserSimulatorProvider = UserSimulatorProvider(), memory_service: Optional[BaseMemoryService] = None, + *, + app: Optional[App] = None, ): + """Initializes a LocalEvalService. + + Args: + app: Optional `App` that wraps `root_agent`. When provided, eval runs + are executed through a Runner built from the App, so `app.plugins`, + `app.context_cache_config`, and `app.resumability_config` are + honored during inference. When None, the legacy bare-agent path is + used. + """ self._root_agent = root_agent + self._app = app self._eval_sets_manager = eval_sets_manager metric_evaluator_registry = ( metric_evaluator_registry or DEFAULT_METRIC_EVALUATOR_REGISTRY @@ -491,6 +504,7 @@ async def _perform_inference_single_eval_item( session_service=self._session_service, artifact_service=self._artifact_service, memory_service=self._memory_service, + app=self._app, ) ) diff --git a/tests/unittests/evaluation/test_local_eval_service.py b/tests/unittests/evaluation/test_local_eval_service.py index 386c1fd07a..4d08506227 100644 --- a/tests/unittests/evaluation/test_local_eval_service.py +++ b/tests/unittests/evaluation/test_local_eval_service.py @@ -19,6 +19,7 @@ from typing import Optional from google.adk.agents.llm_agent import LlmAgent +from google.adk.apps.app import App from google.adk.errors.not_found_error import NotFoundError from google.adk.evaluation.base_eval_service import EvaluateConfig from google.adk.evaluation.base_eval_service import EvaluateRequest @@ -791,3 +792,74 @@ def test_copy_invocation_rubrics_to_actual_invocations(): _copy_invocation_rubrics_to_actual_invocations(expected, actual) assert actual[0].rubrics == [rubric1] assert actual[1].rubrics == [rubric2] + + +@pytest.mark.asyncio +async def test_perform_inference_forwards_app_to_evaluation_generator( + dummy_agent, mock_eval_sets_manager, mocker +): + """LocalEvalService passes its `app` through to _generate_inferences_from_root_agent.""" + app = App(name="test_app", root_agent=dummy_agent) + + eval_case = EvalCase(eval_id="case-1", conversation=[]) + mock_eval_sets_manager.get_eval_set.return_value = EvalSet( + eval_set_id="set-1", + eval_cases=[eval_case], + ) + + mock_generate = mocker.patch( + "google.adk.evaluation.local_eval_service.EvaluationGenerator._generate_inferences_from_root_agent", + new=mocker.AsyncMock(return_value=[]), + ) + + service = LocalEvalService( + root_agent=dummy_agent, + eval_sets_manager=mock_eval_sets_manager, + app=app, + ) + + request = InferenceRequest( + app_name="test_app", + eval_set_id="set-1", + eval_case_ids=["case-1"], + inference_config=InferenceConfig(), + ) + async for _ in service.perform_inference(inference_request=request): + pass + + mock_generate.assert_awaited_once() + assert mock_generate.await_args.kwargs["app"] is app + + +@pytest.mark.asyncio +async def test_perform_inference_passes_none_when_no_app( + dummy_agent, mock_eval_sets_manager, mocker +): + """When LocalEvalService has no `app`, it forwards None (legacy behavior).""" + eval_case = EvalCase(eval_id="case-1", conversation=[]) + mock_eval_sets_manager.get_eval_set.return_value = EvalSet( + eval_set_id="set-1", + eval_cases=[eval_case], + ) + + mock_generate = mocker.patch( + "google.adk.evaluation.local_eval_service.EvaluationGenerator._generate_inferences_from_root_agent", + new=mocker.AsyncMock(return_value=[]), + ) + + service = LocalEvalService( + root_agent=dummy_agent, + eval_sets_manager=mock_eval_sets_manager, + ) + + request = InferenceRequest( + app_name="test_app", + eval_set_id="set-1", + eval_case_ids=["case-1"], + inference_config=InferenceConfig(), + ) + async for _ in service.perform_inference(inference_request=request): + pass + + mock_generate.assert_awaited_once() + assert mock_generate.await_args.kwargs["app"] is None From 456cd9818f9ff6eb49405f2398836282a6f92993 Mon Sep 17 00:00:00 2001 From: Andrea Mestriner Date: Tue, 28 Apr 2026 21:51:03 +0100 Subject: [PATCH 4/4] test(cli_tools_click): mock get_app_or_root_agent in eval CLI tests The eval CLI now resolves agents via `get_app_or_root_agent`. Update the shared `mock_get_root_agent` fixture in test_cli_tools_click.py to patch the new resolver and yield `(None, root_agent)`, matching the non-App path the eval-set-id tests exercise. --- tests/unittests/cli/utils/test_cli_tools_click.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unittests/cli/utils/test_cli_tools_click.py b/tests/unittests/cli/utils/test_cli_tools_click.py index 7c642dbbe9..d583ec6414 100644 --- a/tests/unittests/cli/utils/test_cli_tools_click.py +++ b/tests/unittests/cli/utils/test_cli_tools_click.py @@ -59,8 +59,16 @@ def mock_load_eval_set_from_file(): @pytest.fixture def mock_get_root_agent(): - with mock.patch("google.adk.cli.cli_eval.get_root_agent") as mock_func: - mock_func.return_value = root_agent + """Patches the agent resolver used by the eval CLI. + + `cli_eval` resolves agents via `get_app_or_root_agent` (which returns + `(app, root_agent)`); the eval-set tests don't exercise the App path, + so we yield `(None, root_agent)`. + """ + with mock.patch( + "google.adk.cli.cli_eval.get_app_or_root_agent" + ) as mock_func: + mock_func.return_value = (None, root_agent) yield mock_func