Skip to content

Commit fa2dc3b

Browse files
committed
fix(eval): wire App plugins through eval paths
- Thread app= from cli, dev_server, and AgentEvaluator - Build eval Runner from app.model_copy with merged plugins - Share Runner construction across live and non-live paths - Add unit tests covering each eval surface Closes #5503
1 parent 8addc44 commit fa2dc3b

11 files changed

Lines changed: 660 additions & 22 deletions

src/google/adk/cli/cli_eval.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from google.genai import types as genai_types
2828

2929
from ..agents.base_agent import BaseAgent
30+
from ..apps.app import App
3031
from ..evaluation.base_eval_service import BaseEvalService
3132
from ..evaluation.base_eval_service import EvaluateConfig
3233
from ..evaluation.base_eval_service import EvaluateRequest
@@ -75,20 +76,43 @@ def _get_agent_module(agent_module_file_path: str) -> ModuleType:
7576
return _import_from_path(module_name, file_path)
7677

7778

78-
async def get_root_agent(agent_module_file_path: str) -> BaseAgent:
79-
"""Returns root agent given the agent module."""
79+
async def get_app_or_root_agent(
80+
agent_module_file_path: str,
81+
) -> tuple[Optional[App], BaseAgent]:
82+
"""Returns the (app, root_agent) pair for the given agent module.
83+
84+
If the module exposes an `App` instance via `app`, that App and its
85+
`root_agent` are returned. Otherwise `app` is None and the root agent is
86+
resolved the same way as `get_root_agent`. This lets eval flows participate
87+
in the App's plugin / cache / resumability lifecycle when one is defined,
88+
while preserving the bare-`root_agent` path for projects that don't use App.
89+
"""
8090
agent_module = _get_agent_module(agent_module_file_path)
8191
agent_module_with_agent = getattr(agent_module, "agent", agent_module)
92+
app = getattr(agent_module_with_agent, "app", None)
93+
if isinstance(app, App):
94+
return app, cast(BaseAgent, app.root_agent)
8295
if hasattr(agent_module_with_agent, "root_agent"):
83-
return cast(BaseAgent, agent_module_with_agent.root_agent)
96+
return None, cast(BaseAgent, agent_module_with_agent.root_agent)
8497
elif hasattr(agent_module_with_agent, "get_agent_async"):
8598
root_agent, _ = await agent_module_with_agent.get_agent_async()
86-
return cast(BaseAgent, root_agent)
99+
return None, cast(BaseAgent, root_agent)
87100
raise ValueError(
88101
"Agent module should have either `root_agent` or `get_agent_async`."
89102
)
90103

91104

105+
async def get_root_agent(agent_module_file_path: str) -> BaseAgent:
106+
"""Returns root agent given the agent module.
107+
108+
Kept for backward compatibility. New callers should prefer
109+
`get_app_or_root_agent`, which also surfaces the wrapping `App` (if any)
110+
so plugins, context-cache, and resumability configs are honored.
111+
"""
112+
_, root_agent = await get_app_or_root_agent(agent_module_file_path)
113+
return root_agent
114+
115+
92116
def try_get_reset_func(agent_module_file_path: str) -> Any:
93117
"""Returns reset function for the agent, if present, given the agent module."""
94118
agent_module = _get_agent_module(agent_module_file_path)

src/google/adk/cli/cli_tools_click.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,7 @@ def cli_eval(
10221022
from ..evaluation.simulation.user_simulator_provider import UserSimulatorProvider
10231023
from .cli_eval import _collect_eval_results
10241024
from .cli_eval import _collect_inferences
1025-
from .cli_eval import get_root_agent
1025+
from .cli_eval import get_app_or_root_agent
10261026
from .cli_eval import parse_and_get_evals_to_run
10271027
from .cli_eval import pretty_print_eval_result
10281028
except ModuleNotFoundError as mnf:
@@ -1032,7 +1032,7 @@ def cli_eval(
10321032
print(f"Using evaluation criteria: {eval_config}")
10331033
eval_metrics = get_eval_metrics_from_config(eval_config)
10341034

1035-
root_agent = asyncio.run(get_root_agent(agent_module_file_path))
1035+
app, root_agent = asyncio.run(get_app_or_root_agent(agent_module_file_path))
10361036
app_name = os.path.basename(agent_module_file_path)
10371037
agents_dir = os.path.dirname(agent_module_file_path)
10381038
eval_sets_manager = None
@@ -1124,6 +1124,7 @@ def cli_eval(
11241124
eval_set_results_manager=eval_set_results_manager,
11251125
user_simulator_provider=user_simulator_provider,
11261126
metric_evaluator_registry=metric_evaluator_registry,
1127+
app=app,
11271128
)
11281129

11291130
inference_results = asyncio.run(

src/google/adk/cli/dev_server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import yaml
4949

5050
from . import agent_graph
51+
from ..apps.app import App
5152
from ..errors.not_found_error import NotFoundError
5253
from ..evaluation.base_eval_service import InferenceConfig
5354
from ..evaluation.base_eval_service import InferenceRequest
@@ -1068,6 +1069,7 @@ async def run_eval(
10681069

10691070
agent_or_app = self.agent_loader.load_agent(app_name)
10701071
root_agent = self._get_root_agent(agent_or_app)
1072+
app = agent_or_app if isinstance(agent_or_app, App) else None
10711073

10721074
eval_case_results = []
10731075

@@ -1077,6 +1079,7 @@ async def run_eval(
10771079
eval_set_results_manager=self.eval_set_results_manager,
10781080
session_service=self.session_service,
10791081
artifact_service=self.artifact_service,
1082+
app=app,
10801083
)
10811084
inference_request = InferenceRequest(
10821085
app_name=app_name,

src/google/adk/evaluation/agent_evaluator.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from pydantic import ValidationError
3535

3636
from ..agents.base_agent import BaseAgent
37+
from ..apps.app import App
3738
from ..utils.context_utils import Aclosing
3839
from .constants import MISSING_EVAL_DEPENDENCIES_MESSAGE
3940
from .eval_case import get_all_tool_calls
@@ -147,7 +148,7 @@ async def evaluate_eval_set(
147148
if eval_config is None:
148149
raise ValueError("`eval_config` is required.")
149150

150-
agent_for_eval = await AgentEvaluator._get_agent_for_eval(
151+
agent_for_eval, app = await AgentEvaluator._get_agent_for_eval(
151152
module_name=agent_module, agent_name=agent_name
152153
)
153154
eval_metrics = get_eval_metrics_from_config(eval_config)
@@ -163,6 +164,7 @@ async def evaluate_eval_set(
163164
eval_metrics=eval_metrics,
164165
num_runs=num_runs,
165166
user_simulator_provider=user_simulator_provider,
167+
app=app,
166168
)
167169

168170
# Step 2: Post-process the results!
@@ -496,7 +498,16 @@ def _convert_tool_calls_to_text(
496498
@staticmethod
497499
async def _get_agent_for_eval(
498500
module_name: str, agent_name: Optional[str] = None
499-
) -> BaseAgent:
501+
) -> tuple[BaseAgent, Optional[App]]:
502+
"""Returns the (agent_for_eval, app) pair for the given module.
503+
504+
If the module exposes an `App` instance via `agent.app`, that App is
505+
returned alongside the agent to evaluate, so `app.plugins`, context-cache,
506+
and resumability configs participate in the eval run. Otherwise `app` is
507+
None and only the bare agent is returned. When `agent_name` is provided,
508+
the returned agent is the corresponding sub-agent, but the App (if any) is
509+
still surfaced so its application-wide configuration is honored.
510+
"""
500511
module_path = f"{module_name}"
501512
agent_module = importlib.import_module(module_path)
502513

@@ -522,12 +533,16 @@ async def _get_agent_for_eval(
522533
" get_agent_async method."
523534
)
524535

536+
app = getattr(agent_module_with_agent, "app", None)
537+
if not isinstance(app, App):
538+
app = None
539+
525540
agent_for_eval = root_agent
526541
if agent_name:
527542
agent_for_eval = root_agent.find_agent(agent_name)
528543
assert agent_for_eval, f"Sub-Agent `{agent_name}` not found."
529544

530-
return agent_for_eval
545+
return agent_for_eval, app
531546

532547
@staticmethod
533548
def _get_eval_sets_manager(
@@ -554,6 +569,7 @@ async def _get_eval_results_by_eval_id(
554569
eval_metrics: list[EvalMetric],
555570
num_runs: int,
556571
user_simulator_provider: UserSimulatorProvider,
572+
app: Optional[App] = None,
557573
) -> dict[str, list[EvalCaseResult]]:
558574
"""Returns EvalCaseResults grouped by eval case id.
559575
@@ -578,6 +594,7 @@ async def _get_eval_results_by_eval_id(
578594
app_name=app_name, eval_set=eval_set
579595
),
580596
user_simulator_provider=user_simulator_provider,
597+
app=app,
581598
)
582599

583600
inference_requests = [

src/google/adk/evaluation/evaluation_generator.py

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@
3636
from ..agents.llm_agent import Agent
3737
from ..agents.run_config import RunConfig
3838
from ..agents.run_config import StreamingMode
39+
from ..apps.app import App
3940
from ..artifacts.base_artifact_service import BaseArtifactService
4041
from ..artifacts.in_memory_artifact_service import InMemoryArtifactService
4142
from ..events.event import Event
4243
from ..flows.llm_flows.functions import handle_function_calls_live
4344
from ..memory.base_memory_service import BaseMemoryService
4445
from ..memory.in_memory_memory_service import InMemoryMemoryService
4546
from ..models.llm_request import LlmRequest
47+
from ..plugins.base_plugin import BasePlugin
4648
from ..runners import Runner
4749
from ..sessions.base_session_service import BaseSessionService
4850
from ..sessions.in_memory_session_service import InMemorySessionService
@@ -73,6 +75,39 @@
7375
_DEFAULT_AUTHOR = "agent"
7476

7577

78+
def _build_eval_runner_kwargs(
79+
root_agent: Agent,
80+
app_name: str,
81+
app: Optional[App],
82+
internal_eval_plugins: list[BasePlugin],
83+
) -> dict[str, Any]:
84+
"""Returns the Runner kwargs used to evaluate `root_agent`.
85+
86+
When `app` is provided, the Runner is built from a copy of the App with the
87+
internal eval plugins merged into `app.plugins`, so the App's
88+
`context_cache_config`, `resumability_config`, and any other
89+
application-wide configuration participate in the eval run. The copy leaves
90+
the caller's App instance untouched, and `root_agent` is overridden so the
91+
Runner targets the agent the caller asked to evaluate, which may be a
92+
sub-agent. When `app` is None, the Runner is built from the bare
93+
`root_agent` with only the internal eval plugins.
94+
"""
95+
if app is None:
96+
return {
97+
"app_name": app_name,
98+
"agent": root_agent,
99+
"plugins": internal_eval_plugins,
100+
}
101+
102+
runner_app = app.model_copy(
103+
update={
104+
"plugins": list(app.plugins) + internal_eval_plugins,
105+
"root_agent": root_agent,
106+
}
107+
)
108+
return {"app": runner_app, "app_name": app_name}
109+
110+
76111
class EvalCaseResponses(BaseModel):
77112
"""Contains multiple responses associated with an EvalCase.
78113
@@ -349,20 +384,30 @@ async def _process_query(
349384
"""Process a query using the agent and evaluation dataset."""
350385
module_path = f"{module_name}"
351386
agent_module = importlib.import_module(module_path)
352-
root_agent = agent_module.agent.root_agent
387+
# Prefer the wrapping `App` when the module exposes one, so that
388+
# `app.plugins`, context-cache, and resumability configs participate
389+
# in eval runs the same way they do for `adk web` / `adk run`.
390+
app_obj = getattr(agent_module.agent, "app", None)
391+
if isinstance(app_obj, App):
392+
root_agent = app_obj.root_agent
393+
else:
394+
app_obj = None
395+
root_agent = agent_module.agent.root_agent
353396

354397
reset_func = getattr(agent_module.agent, "reset_data", None)
355398

356399
agent_to_evaluate = root_agent
357400
if agent_name:
358-
agent_to_evaluate = root_agent.find_agent(agent_name)
359-
assert agent_to_evaluate, f"Sub-Agent `{agent_name}` not found."
401+
found_agent = root_agent.find_agent(agent_name)
402+
assert found_agent, f"Sub-Agent `{agent_name}` not found."
403+
agent_to_evaluate = found_agent
360404

361405
return await EvaluationGenerator._generate_inferences_from_root_agent(
362406
agent_to_evaluate,
363407
user_simulator=user_simulator,
364408
reset_func=reset_func,
365409
initial_session=initial_session,
410+
app=app_obj,
366411
)
367412

368413
@staticmethod
@@ -467,8 +512,14 @@ async def _generate_inferences_from_root_agent_live(
467512
artifact_service: Optional[BaseArtifactService] = None,
468513
memory_service: Optional[BaseMemoryService] = None,
469514
live_timeout_seconds: int = DEFAULT_LIVE_TIMEOUT_SECONDS,
515+
app: Optional[App] = None,
470516
) -> list[Invocation]:
471-
"""Scrapes the root agent in coordination with the user simulator in live mode."""
517+
"""Scrapes the root agent in coordination with the user simulator in live mode.
518+
519+
Mirrors `_generate_inferences_from_root_agent`: when `app` is provided the
520+
Runner carries the App's plugins and configuration, otherwise the bare
521+
`root_agent` is used.
522+
"""
472523
if not session_service:
473524
session_service = InMemorySessionService()
474525

@@ -504,13 +555,21 @@ async def _generate_inferences_from_root_agent_live(
504555
request_intercepter_plugin = _RequestIntercepterPlugin(
505556
name="request_intercepter_plugin"
506557
)
507-
async with Runner(
558+
runner_kwargs = _build_eval_runner_kwargs(
559+
root_agent=root_agent,
508560
app_name=app_name,
509-
agent=root_agent,
561+
app=app,
562+
internal_eval_plugins=[
563+
request_intercepter_plugin,
564+
ensure_retry_options_plugin,
565+
],
566+
)
567+
568+
async with Runner(
569+
**runner_kwargs,
510570
artifact_service=artifact_service,
511571
session_service=session_service,
512572
memory_service=memory_service,
513-
plugins=[request_intercepter_plugin, ensure_retry_options_plugin],
514573
) as runner:
515574
events: list[Event] = []
516575

@@ -573,8 +632,17 @@ async def _generate_inferences_from_root_agent(
573632
session_service: Optional[BaseSessionService] = None,
574633
artifact_service: Optional[BaseArtifactService] = None,
575634
memory_service: Optional[BaseMemoryService] = None,
635+
app: Optional[App] = None,
576636
) -> list[Invocation]:
577-
"""Scrapes the root agent in coordination with the user simulator."""
637+
"""Scrapes the root agent in coordination with the user simulator.
638+
639+
If `app` is provided, the eval Runner is built from a copy of the App
640+
with internal eval plugins merged into `app.plugins`, preserving the
641+
App's `context_cache_config`, `resumability_config`, and any other
642+
application-wide configuration. Otherwise the Runner is built from
643+
the bare `root_agent` with only the internal eval plugins, matching
644+
the legacy behavior.
645+
"""
578646

579647
if not session_service:
580648
session_service = InMemorySessionService()
@@ -611,13 +679,21 @@ async def _generate_inferences_from_root_agent(
611679
ensure_retry_options_plugin = EnsureRetryOptionsPlugin(
612680
name="ensure_retry_options"
613681
)
614-
async with Runner(
682+
runner_kwargs = _build_eval_runner_kwargs(
683+
root_agent=root_agent,
615684
app_name=app_name,
616-
agent=root_agent,
685+
app=app,
686+
internal_eval_plugins=[
687+
request_intercepter_plugin,
688+
ensure_retry_options_plugin,
689+
],
690+
)
691+
692+
async with Runner(
693+
**runner_kwargs,
617694
artifact_service=artifact_service,
618695
session_service=session_service,
619696
memory_service=memory_service,
620-
plugins=[request_intercepter_plugin, ensure_retry_options_plugin],
621697
) as runner:
622698
events: list[Event] = []
623699
while True:

0 commit comments

Comments
 (0)