feat: Add Graceful Plugin Shutdown to Runner

This change introduces a shutdown lifecycle hook for plugins. The `PluginManager` now has an `async def shutdown()` method that will call `await plugin.shutdown()` on any registered plugins that implement the method. This is called from `Runner.close()`, allowing plugins to perform cleanup tasks like flushing logs or closing connections when the runner instance is being closed. This improves the reliability of plugins that perform background operations.

PiperOrigin-RevId: 831037737
This commit is contained in:
Google Team Member
2025-11-11 13:05:03 -08:00
committed by Copybara-Service
parent 01bac62f0c
commit 249216e890
7 changed files with 152 additions and 7 deletions
@@ -513,10 +513,8 @@ class TestBigQueryAgentAnalyticsPlugin:
mock_write_client.append_rows.assert_called_once()
@pytest.mark.asyncio
async def test_shutdown(
self, bq_plugin_inst, mock_bq_client, mock_write_client
):
await bq_plugin_inst.shutdown()
async def test_close(self, bq_plugin_inst, mock_bq_client, mock_write_client):
await bq_plugin_inst.close()
mock_write_client.transport.close.assert_called_once()
mock_bq_client.close.assert_called_once()
@@ -16,6 +16,8 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock
from unittest.mock import Mock
from google.adk.models.llm_response import LlmResponse
@@ -267,3 +269,51 @@ async def test_all_callbacks_are_supported(
"on_model_error_callback",
]
assert set(plugin1.call_log) == set(expected_callbacks)
@pytest.mark.asyncio
async def test_close_calls_plugin_close(
service: PluginManager, plugin1: TestPlugin
):
"""Tests that close calls the close method on registered plugins."""
plugin1.close = AsyncMock()
service.register_plugin(plugin1)
await service.close()
plugin1.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_raises_runtime_error_on_plugin_exception(
service: PluginManager, plugin1: TestPlugin
):
"""Tests that close raises a RuntimeError if a plugin's close fails."""
plugin1.close = AsyncMock(side_effect=ValueError("Shutdown error"))
service.register_plugin(plugin1)
with pytest.raises(
RuntimeError, match="Failed to close plugins: 'plugin1': ValueError"
):
await service.close()
plugin1.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_with_timeout(plugin1: TestPlugin):
"""Tests that close respects the timeout and raises on failure."""
service = PluginManager(close_timeout=0.1)
async def slow_close():
await asyncio.sleep(0.2)
plugin1.close = slow_close
service.register_plugin(plugin1)
with pytest.raises(RuntimeError) as excinfo:
await service.close()
assert "Failed to close plugins: 'plugin1': TimeoutError" in str(
excinfo.value
)
+24
View File
@@ -15,6 +15,7 @@
from pathlib import Path
import textwrap
from typing import Optional
from unittest.mock import AsyncMock
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.context_cache_config import ContextCacheConfig
@@ -562,6 +563,29 @@ class TestRunnerWithPlugins:
assert modified_event_message == MockPlugin.ON_EVENT_CALLBACK_MSG
@pytest.mark.asyncio
async def test_runner_close_calls_plugin_close(self):
"""Test that runner.close() calls plugin manager close."""
# Mock the plugin manager's close method
self.runner.plugin_manager.close = AsyncMock()
await self.runner.close()
self.runner.plugin_manager.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_runner_passes_plugin_close_timeout(self):
"""Test that runner passes plugin_close_timeout to PluginManager."""
runner = Runner(
app_name="test_app",
agent=MockLlmAgent("test_agent"),
session_service=self.session_service,
artifact_service=self.artifact_service,
plugins=[self.plugin],
plugin_close_timeout=10.0,
)
assert runner.plugin_manager._close_timeout == 10.0
def test_runner_init_raises_error_with_app_and_app_name_and_agent(self):
"""Test that ValueError is raised when app, app_name and agent are provided."""
with pytest.raises(