Skip to content

Commit 2a68c4e

Browse files
thachtGWeale
authored andcommitted
fix(tools): don't close parent's plugins from AgentTool's sub-Runner
AgentTool.run_async spins up a sub-Runner per call that, with the default include_plugins=True, shares the parent runner's plugin instances. On exit the sub-Runner's close() tore down those shared plugins, so long-lived plugins (e.g. observability exporters) hit the 5s plugin_close_timeout and surfaced as "RuntimeError: Failed to close plugins: 'X': TimeoutError" on the parent's tool call. Add PluginManager.set_skip_closing_plugins(value); when set to True, close() is a no-op. AgentTool calls it on the sub-Runner's plugin_manager after construction (only when include_plugins=True) so inherited plugins are no longer closed by the sub-Runner. Change-Id: I94f81a33e7f6acef728855eaa9237a93a17b66ec
1 parent e896c62 commit 2a68c4e

4 files changed

Lines changed: 133 additions & 0 deletions

File tree

src/google/adk/plugins/plugin_manager.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,25 @@ def __init__(
8585
"""
8686
self.plugins: List[BasePlugin] = []
8787
self._close_timeout = close_timeout
88+
self._skip_closing_plugins = False
8889
if plugins:
8990
for plugin in plugins:
9091
self.register_plugin(plugin)
9192

93+
def set_skip_closing_plugins(self, value: bool) -> None:
94+
"""Controls whether `close()` will tear down the registered plugins.
95+
96+
Set to True when the plugins are owned by another component (e.g. a parent
97+
`Runner` whose plugin list this manager is sharing). When set, subsequent
98+
calls to `close()` become a no-op so the shared plugins are not torn down
99+
while still in use.
100+
101+
Args:
102+
value: True to skip closing the plugins; False (default) to close them
103+
normally.
104+
"""
105+
self._skip_closing_plugins = value
106+
92107
def register_plugin(self, plugin: BasePlugin) -> None:
93108
"""Registers a new plugin.
94109
@@ -309,10 +324,19 @@ async def _run_callbacks(
309324
async def close(self) -> None:
310325
"""Calls the close method on all registered plugins concurrently.
311326
327+
If this manager was constructed with `skip_closing_plugins=True`, this
328+
method is a no-op so plugins owned by another component (e.g. a parent
329+
`Runner`) are not torn down while still in use.
330+
312331
Raises:
313332
RuntimeError: If one or more plugins failed to close, containing
314333
details of all failures.
315334
"""
335+
if self._skip_closing_plugins:
336+
logger.debug(
337+
"Skipping plugin close; plugins are owned by another component."
338+
)
339+
return
316340
exceptions = {}
317341
# We iterate sequentially to avoid creating new tasks which can cause issues
318342
# with some libraries (like anyio/mcp) that rely on task-local context.

src/google/adk/tools/agent_tool.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ async def run_async(
249249
credential_service=tool_context._invocation_context.credential_service,
250250
plugins=plugins,
251251
)
252+
# When plugins are inherited from the parent runner, the parent still owns
253+
# them; tell the sub-Runner's plugin manager to skip closing them on exit
254+
# so shared plugins (e.g. observability exporters) are not torn down while
255+
# the parent is still using them.
256+
if self.include_plugins:
257+
runner.plugin_manager.set_skip_closing_plugins(True)
252258

253259
state_dict = {
254260
k: v

tests/unittests/plugins/test_plugin_manager.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,49 @@ async def slow_close():
317317
assert "Failed to close plugins: 'plugin1': TimeoutError" in str(
318318
excinfo.value
319319
)
320+
321+
322+
@pytest.mark.asyncio
323+
async def test_close_is_noop_after_set_skip_closing_plugins(
324+
plugin1: TestPlugin,
325+
):
326+
"""Tests that close is a no-op after set_skip_closing_plugins(True)."""
327+
plugin1.close = AsyncMock()
328+
service = PluginManager(plugins=[plugin1])
329+
service.set_skip_closing_plugins(True)
330+
331+
await service.close()
332+
333+
plugin1.close.assert_not_awaited()
334+
335+
336+
@pytest.mark.asyncio
337+
async def test_set_skip_closing_plugins_bypasses_close_timeout(
338+
plugin1: TestPlugin,
339+
):
340+
"""Tests that set_skip_closing_plugins(True) bypasses close timeout."""
341+
342+
async def slow_close():
343+
await asyncio.sleep(10) # Would otherwise time out.
344+
345+
plugin1.close = slow_close
346+
service = PluginManager(plugins=[plugin1], close_timeout=0.01)
347+
service.set_skip_closing_plugins(True)
348+
349+
# Should return immediately without raising.
350+
await service.close()
351+
352+
353+
@pytest.mark.asyncio
354+
async def test_set_skip_closing_plugins_false_reverts_to_closing(
355+
plugin1: TestPlugin,
356+
):
357+
"""Tests that set_skip_closing_plugins(False) re-enables closing."""
358+
plugin1.close = AsyncMock()
359+
service = PluginManager(plugins=[plugin1])
360+
service.set_skip_closing_plugins(True)
361+
service.set_skip_closing_plugins(False)
362+
363+
await service.close()
364+
365+
plugin1.close.assert_awaited_once()

tests/unittests/tools/test_agent_tool.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
from typing import Any
1617
from typing import Optional
1718

@@ -30,6 +31,7 @@
3031
from google.adk.models.llm_response import LlmResponse
3132
from google.adk.plugins.base_plugin import BasePlugin
3233
from google.adk.plugins.plugin_manager import PluginManager
34+
from google.adk.runners import Runner
3335
from google.adk.sessions.in_memory_session_service import InMemorySessionService
3436
from google.adk.tools.agent_tool import AgentTool
3537
from google.adk.tools.tool_context import ToolContext
@@ -727,6 +729,61 @@ async def before_agent_callback(self, **kwargs):
727729
assert tracking_plugin.before_agent_calls == 1
728730

729731

732+
@pytest.mark.asyncio
733+
async def test_include_plugins_true_sub_runner_does_not_close_parent_plugins():
734+
"""Sub-Runner must not close plugins owned by the parent runner."""
735+
736+
class SlowClosePlugin(BasePlugin):
737+
738+
def __init__(self, name: str):
739+
super().__init__(name)
740+
self.close_calls = 0
741+
742+
async def close(self):
743+
self.close_calls += 1
744+
# Would otherwise blow past the sub-Runner's plugin_close_timeout.
745+
await asyncio.sleep(10)
746+
747+
parent_plugin = SlowClosePlugin(name='parent_plugin')
748+
749+
mock_model = testing_utils.MockModel.create(
750+
responses=[function_call_no_schema, 'response1', 'response2']
751+
)
752+
753+
tool_agent = Agent(name='tool_agent', model=mock_model)
754+
root_agent = Agent(
755+
name='root_agent',
756+
model=mock_model,
757+
tools=[AgentTool(agent=tool_agent, include_plugins=True)],
758+
)
759+
760+
runner = Runner(
761+
app_name='test_app',
762+
agent=root_agent,
763+
artifact_service=InMemoryArtifactService(),
764+
session_service=InMemorySessionService(),
765+
memory_service=InMemoryMemoryService(),
766+
plugins=[parent_plugin],
767+
# Tight timeout amplifies the bug if it regresses; with the fix, the
768+
# sub-Runner's close skips the parent's plugins entirely.
769+
plugin_close_timeout=0.01,
770+
)
771+
session = await runner.session_service.create_session(
772+
app_name='test_app', user_id='test_user'
773+
)
774+
# Must not raise RuntimeError("Failed to close plugins: ...") from the
775+
# sub-Runner closing the parent's slow-to-close plugin.
776+
async for _ in runner.run_async(
777+
user_id=session.user_id,
778+
session_id=session.id,
779+
new_message=testing_utils.get_user_content('test1'),
780+
):
781+
pass
782+
783+
# The sub-Runner must not have closed the parent's plugin.
784+
assert parent_plugin.close_calls == 0
785+
786+
730787
def test_agent_tool_description_with_input_schema():
731788
"""Test that agent description is propagated when using input_schema."""
732789

0 commit comments

Comments
 (0)