Skip to content

Commit 8a8b3a2

Browse files
caohy1988claude
andcommitted
fix(plugins): use notification-only dispatch for error callbacks
Error callbacks are documented as notification-only (all plugins must be notified), but were dispatched through _run_callbacks() which short-circuits on the first non-None return. A buggy plugin returning a value would prevent subsequent plugins from seeing the error. Add _run_notification_callbacks() that always iterates all plugins regardless of return values, and switch run_on_agent_error_callback() and run_on_run_error_callback() to use it. Update tests to assert both plugins are always called even when one returns non-None. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c76fb92 commit 8a8b3a2

File tree

2 files changed

+65
-21
lines changed

2 files changed

+65
-21
lines changed

src/google/adk/plugins/plugin_manager.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ async def run_on_agent_error_callback(
267267
error: Exception,
268268
) -> None:
269269
"""Runs the `on_agent_error_callback` for all plugins."""
270-
await self._run_callbacks(
270+
await self._run_notification_callbacks(
271271
"on_agent_error_callback",
272272
agent=agent,
273273
callback_context=callback_context,
@@ -281,7 +281,7 @@ async def run_on_run_error_callback(
281281
error: Exception,
282282
) -> None:
283283
"""Runs the `on_run_error_callback` for all plugins."""
284-
await self._run_callbacks(
284+
await self._run_notification_callbacks(
285285
"on_run_error_callback",
286286
invocation_context=invocation_context,
287287
error=error,
@@ -336,6 +336,35 @@ async def _run_callbacks(
336336

337337
return None
338338

339+
async def _run_notification_callbacks(
340+
self, callback_name: PluginCallbackName, **kwargs: Any
341+
) -> None:
342+
"""Executes a notification-only callback for all registered plugins.
343+
344+
Unlike ``_run_callbacks``, this method **always iterates all plugins**
345+
regardless of return values. It is intended for error callbacks where
346+
every plugin must be notified (the return value is discarded).
347+
348+
Args:
349+
callback_name: The name of the callback method to execute.
350+
**kwargs: Keyword arguments to be passed to the callback method.
351+
352+
Raises:
353+
RuntimeError: If a plugin encounters an unhandled exception during
354+
execution. The original exception is chained.
355+
"""
356+
for plugin in self.plugins:
357+
callback_method = getattr(plugin, callback_name)
358+
try:
359+
await callback_method(**kwargs)
360+
except Exception as e:
361+
error_message = (
362+
f"Error in plugin '{plugin.name}' during '{callback_name}'"
363+
f" callback: {e}"
364+
)
365+
logger.error(error_message, exc_info=True)
366+
raise RuntimeError(error_message) from e
367+
339368
async def close(self) -> None:
340369
"""Calls the close method on all registered plugins concurrently.
341370

tests/unittests/plugins/test_error_callbacks.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -443,45 +443,60 @@ async def test_run_on_run_error_callback_dispatches(self):
443443
assert len(plugin2.run_errors) == 1
444444

445445
@pytest.mark.asyncio
446-
async def test_error_callbacks_do_not_short_circuit(self):
447-
"""Error callbacks are notification-only: a non-None return from
448-
one plugin does NOT skip subsequent plugins."""
446+
async def test_agent_error_callback_does_not_short_circuit(self):
447+
"""on_agent_error_callback is notification-only: a non-None return
448+
from one plugin does NOT skip subsequent plugins."""
449449

450450
class _ReturningPlugin(BasePlugin):
451451
__test__ = False
452452

453453
def __init__(self, name):
454454
super().__init__(name)
455455
self.agent_error_called = False
456-
self.run_error_called = False
457456

458457
async def on_agent_error_callback(self, **kwargs):
459458
self.agent_error_called = True
460459
return "should be ignored"
461460

462-
async def on_run_error_callback(self, **kwargs):
463-
self.run_error_called = True
464-
return "should be ignored"
465-
466461
p1 = _ReturningPlugin(name="p1")
467462
p2 = _ReturningPlugin(name="p2")
468463
pm = PluginManager(plugins=[p1, p2])
469464

470-
# The _run_callbacks early-exit logic returns on non-None,
471-
# but for notification-only callbacks the base class returns None.
472-
# However, if a plugin DOES return non-None, the current
473-
# _run_callbacks stops. This test documents that behavior.
474-
# The base class default returns None (pass), so in practice
475-
# all plugins are always called.
476465
await pm.run_on_agent_error_callback(
477466
agent=Mock(spec=BaseAgent),
478467
callback_context=Mock(spec=CallbackContext),
479468
error=RuntimeError("x"),
480469
)
481470

482-
# p1 returns non-None which triggers early exit in _run_callbacks.
483-
# This is an inherent behavior of the generic dispatch.
471+
# Both plugins must be called even though p1 returns non-None.
484472
assert p1.agent_error_called
485-
# Note: p2 may or may not be called depending on _run_callbacks
486-
# early-exit. Since base class returns None (pass), normal usage
487-
# always calls all plugins. This test just verifies p1 was called.
473+
assert p2.agent_error_called
474+
475+
@pytest.mark.asyncio
476+
async def test_run_error_callback_does_not_short_circuit(self):
477+
"""on_run_error_callback is notification-only: a non-None return
478+
from one plugin does NOT skip subsequent plugins."""
479+
480+
class _ReturningPlugin(BasePlugin):
481+
__test__ = False
482+
483+
def __init__(self, name):
484+
super().__init__(name)
485+
self.run_error_called = False
486+
487+
async def on_run_error_callback(self, **kwargs):
488+
self.run_error_called = True
489+
return "should be ignored"
490+
491+
p1 = _ReturningPlugin(name="p1")
492+
p2 = _ReturningPlugin(name="p2")
493+
pm = PluginManager(plugins=[p1, p2])
494+
495+
await pm.run_on_run_error_callback(
496+
invocation_context=Mock(spec=InvocationContext),
497+
error=RuntimeError("x"),
498+
)
499+
500+
# Both plugins must be called even though p1 returns non-None.
501+
assert p1.run_error_called
502+
assert p2.run_error_called

0 commit comments

Comments
 (0)