|
14 | 14 |
|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
| 17 | +from google.adk.agents.base_agent import BaseAgent |
| 18 | +from google.adk.apps.app import App |
17 | 19 | from google.adk.evaluation.app_details import AgentDetails |
18 | 20 | from google.adk.evaluation.app_details import AppDetails |
19 | 21 | from google.adk.evaluation.evaluation_generator import EvaluationGenerator |
20 | 22 | from google.adk.evaluation.request_intercepter_plugin import _RequestIntercepterPlugin |
| 23 | +from google.adk.plugins.base_plugin import BasePlugin |
21 | 24 | from google.adk.evaluation.simulation.user_simulator import NextUserMessage |
22 | 25 | from google.adk.evaluation.simulation.user_simulator import Status as UserSimulatorStatus |
23 | 26 | from google.adk.evaluation.simulation.user_simulator import UserSimulator |
@@ -479,3 +482,133 @@ async def mock_generate_inferences_side_effect( |
479 | 482 | mock_generate_inferences.assert_called_once() |
480 | 483 | called_with_content = mock_generate_inferences.call_args.args[3] |
481 | 484 | assert called_with_content.parts[0].text == "message 1" |
| 485 | + |
| 486 | + |
| 487 | +class _SpyPlugin(BasePlugin): |
| 488 | + """A user-defined plugin used to assert merge behavior.""" |
| 489 | + |
| 490 | + pass |
| 491 | + |
| 492 | + |
| 493 | +class TestGenerateInferencesFromRootAgentWithApp: |
| 494 | + """Tests that App.plugins / configs are honored when an App is provided.""" |
| 495 | + |
| 496 | + @pytest.fixture |
| 497 | + def runner_cls(self, mocker): |
| 498 | + """Patches Runner and returns the patched class for kwargs inspection.""" |
| 499 | + mock_runner_cls = mocker.patch( |
| 500 | + "google.adk.evaluation.evaluation_generator.Runner" |
| 501 | + ) |
| 502 | + mock_runner_instance = mocker.AsyncMock() |
| 503 | + mock_runner_instance.__aenter__.return_value = mock_runner_instance |
| 504 | + mock_runner_cls.return_value = mock_runner_instance |
| 505 | + yield mock_runner_cls |
| 506 | + |
| 507 | + @pytest.fixture |
| 508 | + def stop_immediately_simulator(self, mocker): |
| 509 | + """Returns a UserSimulator that stops on first call (no inference work).""" |
| 510 | + sim = mocker.MagicMock(spec=UserSimulator) |
| 511 | + sim.get_next_user_message = mocker.AsyncMock( |
| 512 | + return_value=NextUserMessage( |
| 513 | + status=UserSimulatorStatus.STOP_SIGNAL_DETECTED |
| 514 | + ) |
| 515 | + ) |
| 516 | + return sim |
| 517 | + |
| 518 | + @pytest.mark.asyncio |
| 519 | + async def test_runner_built_from_app_when_provided( |
| 520 | + self, runner_cls, mock_session_service, stop_immediately_simulator |
| 521 | + ): |
| 522 | + """When `app` is passed, Runner is built with `app=` (merged) instead of `agent=`.""" |
| 523 | + root_agent = BaseAgent(name="root_agent") |
| 524 | + user_plugin = _SpyPlugin(name="user_plugin") |
| 525 | + app = App(name="my_app", root_agent=root_agent, plugins=[user_plugin]) |
| 526 | + |
| 527 | + await EvaluationGenerator._generate_inferences_from_root_agent( |
| 528 | + root_agent=root_agent, |
| 529 | + user_simulator=stop_immediately_simulator, |
| 530 | + app=app, |
| 531 | + ) |
| 532 | + |
| 533 | + runner_cls.assert_called_once() |
| 534 | + kwargs = runner_cls.call_args.kwargs |
| 535 | + assert "agent" not in kwargs, ( |
| 536 | + "Runner must not receive `agent=` when `app=` is provided " |
| 537 | + "(would raise ValueError)." |
| 538 | + ) |
| 539 | + assert "plugins" not in kwargs, ( |
| 540 | + "Runner must not receive `plugins=` when `app=` is provided " |
| 541 | + "(would raise ValueError)." |
| 542 | + ) |
| 543 | + runner_app = kwargs["app"] |
| 544 | + assert isinstance(runner_app, App) |
| 545 | + plugin_names = [p.name for p in runner_app.plugins] |
| 546 | + assert "user_plugin" in plugin_names, ( |
| 547 | + "User plugin must be preserved in the merged App passed to Runner." |
| 548 | + ) |
| 549 | + assert "request_intercepter_plugin" in plugin_names |
| 550 | + assert "ensure_retry_options" in plugin_names |
| 551 | + |
| 552 | + @pytest.mark.asyncio |
| 553 | + async def test_user_app_is_not_mutated( |
| 554 | + self, runner_cls, mock_session_service, stop_immediately_simulator |
| 555 | + ): |
| 556 | + """The user's App instance must not be mutated across eval runs.""" |
| 557 | + root_agent = BaseAgent(name="root_agent") |
| 558 | + user_plugin = _SpyPlugin(name="user_plugin") |
| 559 | + app = App(name="my_app", root_agent=root_agent, plugins=[user_plugin]) |
| 560 | + original_plugins_id = id(app.plugins) |
| 561 | + |
| 562 | + for _ in range(3): |
| 563 | + await EvaluationGenerator._generate_inferences_from_root_agent( |
| 564 | + root_agent=root_agent, |
| 565 | + user_simulator=stop_immediately_simulator, |
| 566 | + app=app, |
| 567 | + ) |
| 568 | + |
| 569 | + # The user's App instance must still hold exactly its original plugin set, |
| 570 | + # regardless of how many eval runs reused it. |
| 571 | + assert app.plugins == [user_plugin] |
| 572 | + assert id(app.plugins) == original_plugins_id |
| 573 | + |
| 574 | + @pytest.mark.asyncio |
| 575 | + async def test_runner_falls_back_to_bare_agent_when_no_app( |
| 576 | + self, runner_cls, mock_session_service, stop_immediately_simulator |
| 577 | + ): |
| 578 | + """When `app` is None, Runner is built with the legacy `agent=`/`plugins=` shape.""" |
| 579 | + root_agent = BaseAgent(name="root_agent") |
| 580 | + |
| 581 | + await EvaluationGenerator._generate_inferences_from_root_agent( |
| 582 | + root_agent=root_agent, |
| 583 | + user_simulator=stop_immediately_simulator, |
| 584 | + ) |
| 585 | + |
| 586 | + runner_cls.assert_called_once() |
| 587 | + kwargs = runner_cls.call_args.kwargs |
| 588 | + assert "app" not in kwargs |
| 589 | + assert kwargs["agent"] is root_agent |
| 590 | + plugin_names = [p.name for p in kwargs["plugins"]] |
| 591 | + assert plugin_names == [ |
| 592 | + "request_intercepter_plugin", |
| 593 | + "ensure_retry_options", |
| 594 | + ] |
| 595 | + |
| 596 | + @pytest.mark.asyncio |
| 597 | + async def test_root_agent_override_propagates_to_merged_app( |
| 598 | + self, runner_cls, mock_session_service, stop_immediately_simulator |
| 599 | + ): |
| 600 | + """If a sub-agent is passed as root_agent, the merged App reflects that.""" |
| 601 | + full_root = BaseAgent(name="full_root") |
| 602 | + sub_agent = BaseAgent(name="sub_agent") |
| 603 | + app = App(name="my_app", root_agent=full_root) |
| 604 | + |
| 605 | + await EvaluationGenerator._generate_inferences_from_root_agent( |
| 606 | + root_agent=sub_agent, |
| 607 | + user_simulator=stop_immediately_simulator, |
| 608 | + app=app, |
| 609 | + ) |
| 610 | + |
| 611 | + runner_app = runner_cls.call_args.kwargs["app"] |
| 612 | + assert runner_app.root_agent is sub_agent |
| 613 | + # User's App must be untouched. |
| 614 | + assert app.root_agent is full_root |
0 commit comments