chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
@@ -0,0 +1,936 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for on_agent_error_callback and on_run_error_callback.
Validates RFC #5044: agent-level and runner-level error callbacks.
"""
import asyncio
from typing import AsyncGenerator
from typing import Optional
from unittest.mock import Mock
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.invocation_context import InvocationContext
from google.adk.events.event import Event
from google.adk.plugins.base_plugin import BasePlugin
from google.adk.plugins.plugin_manager import PluginManager
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow._base_node import BaseNode
from google.genai import types
import pytest
from typing_extensions import override
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _CrashingNode(BaseNode):
"""A workflow node whose _run_impl always raises.
A root ``BaseNode`` (that is not a ``BaseAgent``) is executed through the
node runtime (``Runner._run_node_async``), so this exercises the
``on_run_error_callback`` dispatch site added there.
"""
__test__ = False
@override
async def _run_impl(self, *, ctx, node_input):
raise RuntimeError("node crashed")
yield # pragma: no cover - makes this an async generator
class _CrashingAgent(BaseAgent):
"""Agent whose _run_async_impl always raises."""
crash_error: Exception = RuntimeError("agent crashed")
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
raise self.crash_error
yield # make it an async generator
@override
async def _run_live_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
raise self.crash_error
yield
class _SuccessAgent(BaseAgent):
"""Agent that completes successfully."""
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
branch=ctx.branch,
invocation_id=ctx.invocation_id,
content=types.Content(parts=[types.Part(text="ok")]),
)
@override
async def _run_live_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
author=self.name,
branch=ctx.branch,
invocation_id=ctx.invocation_id,
content=types.Content(parts=[types.Part(text="ok live")]),
)
class _ErrorTrackingPlugin(BasePlugin):
"""Plugin that records which error callbacks were called."""
__test__ = False
def __init__(self, name: str = "error_tracker"):
super().__init__(name)
self.agent_errors: list[tuple[str, Exception]] = []
self.run_errors: list[Exception] = []
self.after_agent_called = False
self.after_run_called = False
async def on_agent_error_callback(
self,
*,
agent: BaseAgent,
callback_context: CallbackContext,
error: Exception,
) -> None:
self.agent_errors.append((agent.name, error))
async def on_run_error_callback(
self,
*,
invocation_context: InvocationContext,
error: Exception,
) -> None:
self.run_errors.append(error)
async def after_agent_callback(
self,
*,
agent: BaseAgent,
callback_context: CallbackContext,
) -> Optional[types.Content]:
self.after_agent_called = True
return None
async def after_run_callback(
self,
*,
invocation_context: InvocationContext,
) -> None:
self.after_run_called = True
async def _create_ctx(
agent: BaseAgent,
plugins: list[BasePlugin] | None = None,
) -> InvocationContext:
session_service = InMemorySessionService()
session = await session_service.create_session(
app_name="test_app", user_id="test_user"
)
return InvocationContext(
invocation_id="test_invocation",
agent=agent,
session=session,
session_service=session_service,
plugin_manager=PluginManager(plugins=plugins or []),
)
# ---------------------------------------------------------------------------
# Agent-level error callback tests
# ---------------------------------------------------------------------------
class TestAgentErrorCallback:
"""Tests for on_agent_error_callback in base_agent.py."""
@pytest.mark.asyncio
async def test_agent_error_callback_fires_on_crash(self):
"""Error callback fires when _run_async_impl raises."""
plugin = _ErrorTrackingPlugin()
agent = _CrashingAgent(name="crash_agent")
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(RuntimeError, match="agent crashed"):
_ = [e async for e in agent.run_async(ctx)]
assert len(plugin.agent_errors) == 1
assert plugin.agent_errors[0][0] == "crash_agent"
assert str(plugin.agent_errors[0][1]) == "agent crashed"
@pytest.mark.asyncio
async def test_agent_error_callback_fires_on_live_crash(self):
"""Error callback fires when _run_live_impl raises."""
plugin = _ErrorTrackingPlugin()
agent = _CrashingAgent(name="crash_agent")
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(RuntimeError, match="agent crashed"):
_ = [e async for e in agent.run_live(ctx)]
assert len(plugin.agent_errors) == 1
assert plugin.agent_errors[0][0] == "crash_agent"
@pytest.mark.asyncio
async def test_after_agent_not_called_on_crash(self):
"""after_agent_callback (success-only) is NOT called on failure."""
plugin = _ErrorTrackingPlugin()
agent = _CrashingAgent(name="crash_agent")
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(RuntimeError):
_ = [e async for e in agent.run_async(ctx)]
assert not plugin.after_agent_called
@pytest.mark.asyncio
async def test_agent_error_callback_fires_on_before_callback_failure(self):
"""Error callback fires when before_agent_callback raises.
The error handler wraps the full agent lifecycle, so lifecycle-callback
failures (not just _run_async_impl) are surfaced to on_agent_error_callback.
"""
plugin = _ErrorTrackingPlugin()
def _boom(callback_context):
raise RuntimeError("before boom")
agent = _SuccessAgent(name="good_agent", before_agent_callback=_boom)
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(RuntimeError, match="before boom"):
_ = [e async for e in agent.run_async(ctx)]
assert len(plugin.agent_errors) == 1
assert plugin.agent_errors[0][0] == "good_agent"
assert not plugin.after_agent_called
@pytest.mark.asyncio
async def test_agent_error_callback_fires_on_after_callback_failure(self):
"""Error callback fires when after_agent_callback raises."""
plugin = _ErrorTrackingPlugin()
def _boom(callback_context):
raise RuntimeError("after boom")
agent = _SuccessAgent(name="good_agent", after_agent_callback=_boom)
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(RuntimeError, match="after boom"):
_ = [e async for e in agent.run_async(ctx)]
assert len(plugin.agent_errors) == 1
assert plugin.agent_errors[0][0] == "good_agent"
@pytest.mark.asyncio
async def test_exception_is_reraised_after_agent_error_callback(self):
"""The original exception propagates after the error callback."""
plugin = _ErrorTrackingPlugin()
err = ValueError("specific error")
agent = _CrashingAgent(name="crash_agent", crash_error=err)
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(ValueError, match="specific error"):
_ = [e async for e in agent.run_async(ctx)]
@pytest.mark.asyncio
async def test_agent_error_callback_not_fired_on_success(self):
"""Error callback does NOT fire when agent succeeds."""
plugin = _ErrorTrackingPlugin()
agent = _SuccessAgent(name="good_agent")
ctx = await _create_ctx(agent, plugins=[plugin])
events = [e async for e in agent.run_async(ctx)]
assert len(events) > 0
assert len(plugin.agent_errors) == 0
# after_agent_callback should still fire on success
assert plugin.after_agent_called
@pytest.mark.asyncio
async def test_cancelled_error_does_not_trigger_agent_error_callback(
self,
):
"""asyncio.CancelledError (BaseException) does NOT trigger error callback."""
class _CancellingAgent(BaseAgent):
@override
async def _run_async_impl(self, ctx):
raise asyncio.CancelledError()
yield
@override
async def _run_live_impl(self, ctx):
raise asyncio.CancelledError()
yield
plugin = _ErrorTrackingPlugin()
agent = _CancellingAgent(name="cancel_agent")
ctx = await _create_ctx(agent, plugins=[plugin])
with pytest.raises(asyncio.CancelledError):
_ = [e async for e in agent.run_async(ctx)]
assert len(plugin.agent_errors) == 0
# ---------------------------------------------------------------------------
# Runner-level error callback tests
# ---------------------------------------------------------------------------
class TestRunErrorCallback:
"""Tests for on_run_error_callback in runners.py."""
@pytest.mark.asyncio
async def test_run_error_callback_fires_on_crash(self):
"""on_run_error_callback fires when execute_fn raises."""
from google.adk.runners import Runner
plugin = _ErrorTrackingPlugin()
agent = _CrashingAgent(name="crash_agent")
runner = Runner(
agent=agent,
app_name="test_app",
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="agent crashed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
assert len(plugin.run_errors) == 1
assert str(plugin.run_errors[0]) == "agent crashed"
@pytest.mark.asyncio
async def test_after_run_not_called_on_crash(self):
"""after_run_callback (success-only) is NOT called on failure."""
from google.adk.runners import Runner
plugin = _ErrorTrackingPlugin()
agent = _CrashingAgent(name="crash_agent")
runner = Runner(
agent=agent,
app_name="test_app",
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
assert not plugin.after_run_called
@pytest.mark.asyncio
async def test_run_error_callback_not_fired_on_success(self):
"""on_run_error_callback does NOT fire on success."""
from google.adk.runners import Runner
plugin = _ErrorTrackingPlugin()
agent = _SuccessAgent(name="good_agent")
runner = Runner(
agent=agent,
app_name="test_app",
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
events = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
assert len(events) > 0
assert len(plugin.run_errors) == 0
assert plugin.after_run_called
@pytest.mark.asyncio
async def test_run_error_callback_fires_on_after_run_failure(self):
"""An after_run_callback failure notifies on_run_error and re-raises.
after_run runs on the success path, after the main-execution catch. A
failing after_run plugin (which PluginManager surfaces as a RuntimeError)
is an unhandled runner error, so it must still notify on_run_error_callback
exactly once while the original error propagates.
"""
from google.adk.runners import Runner
class _FailingAfterRunPlugin(BasePlugin):
async def after_run_callback(
self, *, invocation_context: InvocationContext
) -> None:
raise RuntimeError("after_run failed")
tracker = _ErrorTrackingPlugin()
agent = _SuccessAgent(name="good_agent")
runner = Runner(
agent=agent,
app_name="test_app",
session_service=InMemorySessionService(),
plugins=[_FailingAfterRunPlugin(name="failing_after_run"), tracker],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="after_run failed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
# Exactly one run-error notification for the after_run failure.
assert len(tracker.run_errors) == 1
# PluginManager wraps plugin exceptions with plugin/callback context.
assert "after_run failed" in str(tracker.run_errors[0])
# ---------------------------------------------------------------------------
# Exactly-once-per-layer tests
# ---------------------------------------------------------------------------
class TestExactlyOncePerLayer:
"""Verify each error callback fires exactly once at its own layer."""
@pytest.mark.asyncio
async def test_agent_crash_fires_both_callbacks_once_each(self):
"""A crashing agent fires on_agent_error_callback once AND
on_run_error_callback once (the re-raised exception propagates)."""
from google.adk.runners import Runner
plugin = _ErrorTrackingPlugin()
agent = _CrashingAgent(name="crash_agent")
runner = Runner(
agent=agent,
app_name="test_app",
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="agent crashed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
# Agent error callback: exactly 1 call
assert len(plugin.agent_errors) == 1
assert plugin.agent_errors[0][0] == "crash_agent"
# Run error callback: exactly 1 call (same exception bubbled up)
assert len(plugin.run_errors) == 1
assert plugin.run_errors[0] is plugin.agent_errors[0][1]
# Neither after callback should fire
assert not plugin.after_agent_called
assert not plugin.after_run_called
# ---------------------------------------------------------------------------
# PluginManager dispatch tests
# ---------------------------------------------------------------------------
class TestPluginManagerErrorCallbackDispatch:
"""Test PluginManager correctly dispatches the new error callbacks."""
@pytest.mark.asyncio
async def test_run_on_agent_error_callback_dispatches(self):
"""run_on_agent_error_callback calls all plugins."""
plugin1 = _ErrorTrackingPlugin(name="p1")
plugin2 = _ErrorTrackingPlugin(name="p2")
pm = PluginManager(plugins=[plugin1, plugin2])
mock_agent = Mock(spec=BaseAgent)
mock_agent.name = "test_agent"
mock_ctx = Mock(spec=CallbackContext)
err = RuntimeError("boom")
await pm.run_on_agent_error_callback(
agent=mock_agent,
callback_context=mock_ctx,
error=err,
)
assert len(plugin1.agent_errors) == 1
assert len(plugin2.agent_errors) == 1
@pytest.mark.asyncio
async def test_run_on_run_error_callback_dispatches(self):
"""run_on_run_error_callback calls all plugins."""
plugin1 = _ErrorTrackingPlugin(name="p1")
plugin2 = _ErrorTrackingPlugin(name="p2")
pm = PluginManager(plugins=[plugin1, plugin2])
mock_ctx = Mock(spec=InvocationContext)
err = RuntimeError("boom")
await pm.run_on_run_error_callback(
invocation_context=mock_ctx,
error=err,
)
assert len(plugin1.run_errors) == 1
assert len(plugin2.run_errors) == 1
@pytest.mark.asyncio
async def test_agent_error_callback_does_not_short_circuit(self):
"""on_agent_error_callback is notification-only: a non-None return
from one plugin does NOT skip subsequent plugins."""
class _ReturningPlugin(BasePlugin):
__test__ = False
def __init__(self, name):
super().__init__(name)
self.agent_error_called = False
async def on_agent_error_callback(self, **kwargs):
self.agent_error_called = True
return "should be ignored"
p1 = _ReturningPlugin(name="p1")
p2 = _ReturningPlugin(name="p2")
pm = PluginManager(plugins=[p1, p2])
await pm.run_on_agent_error_callback(
agent=Mock(spec=BaseAgent),
callback_context=Mock(spec=CallbackContext),
error=RuntimeError("x"),
)
# Both plugins must be called even though p1 returns non-None.
assert p1.agent_error_called
assert p2.agent_error_called
@pytest.mark.asyncio
async def test_run_error_callback_does_not_short_circuit(self):
"""on_run_error_callback is notification-only: a non-None return
from one plugin does NOT skip subsequent plugins."""
class _ReturningPlugin(BasePlugin):
__test__ = False
def __init__(self, name):
super().__init__(name)
self.run_error_called = False
async def on_run_error_callback(self, **kwargs):
self.run_error_called = True
return "should be ignored"
p1 = _ReturningPlugin(name="p1")
p2 = _ReturningPlugin(name="p2")
pm = PluginManager(plugins=[p1, p2])
await pm.run_on_run_error_callback(
invocation_context=Mock(spec=InvocationContext),
error=RuntimeError("x"),
)
# Both plugins must be called even though p1 returns non-None.
assert p1.run_error_called
assert p2.run_error_called
@pytest.mark.asyncio
async def test_plugin_callback_failure_does_not_mask_app_error(self):
"""When a plugin's error callback raises, iteration continues
and the original application exception is what the caller sees."""
class _FailingPlugin(BasePlugin):
__test__ = False
def __init__(self, name):
super().__init__(name)
self.agent_error_called = False
self.run_error_called = False
async def on_agent_error_callback(self, **kwargs):
self.agent_error_called = True
raise ValueError("plugin boom")
async def on_run_error_callback(self, **kwargs):
self.run_error_called = True
raise ValueError("plugin boom")
p1 = _FailingPlugin(name="p1")
p2 = _ErrorTrackingPlugin(name="p2")
pm = PluginManager(plugins=[p1, p2])
# Agent error callback: p1 raises, p2 must still be notified.
mock_agent = Mock(spec=BaseAgent)
mock_agent.name = "test_agent"
await pm.run_on_agent_error_callback(
agent=mock_agent,
callback_context=Mock(spec=CallbackContext),
error=RuntimeError("app crash"),
)
assert p1.agent_error_called
assert len(p2.agent_errors) == 1
# Run error callback: same behavior.
await pm.run_on_run_error_callback(
invocation_context=Mock(spec=InvocationContext),
error=RuntimeError("app crash"),
)
assert p1.run_error_called
assert len(p2.run_errors) == 1
@pytest.mark.asyncio
async def test_original_exception_propagates_despite_agent_plugin_failure(
self,
):
"""End-to-end: a crashing plugin error callback does not mask
the original agent exception seen by the caller."""
class _FailingPlugin(BasePlugin):
__test__ = False
def __init__(self, name):
super().__init__(name)
async def on_agent_error_callback(self, **kwargs):
raise ValueError("plugin internal error")
plugin = _FailingPlugin(name="bad_plugin")
agent = _CrashingAgent(name="crash_agent")
ctx = await _create_ctx(agent, plugins=[plugin])
# The caller must see the original RuntimeError("agent crashed"),
# NOT the plugin's ValueError.
with pytest.raises(RuntimeError, match="agent crashed"):
_ = [e async for e in agent.run_async(ctx)]
@pytest.mark.asyncio
async def test_original_exception_propagates_despite_run_plugin_failure(
self,
):
"""End-to-end: a crashing plugin on_run_error_callback does not mask
the original agent exception seen by the runner caller."""
from google.adk.runners import Runner
class _FailingRunPlugin(BasePlugin):
__test__ = False
def __init__(self, name):
super().__init__(name)
async def on_run_error_callback(self, **kwargs):
raise ValueError("plugin internal error")
plugin = _FailingRunPlugin(name="bad_plugin")
agent = _CrashingAgent(name="crash_agent")
runner = Runner(
agent=agent,
app_name="test_app",
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
# The caller must see the original RuntimeError("agent crashed"),
# NOT the plugin's ValueError.
with pytest.raises(RuntimeError, match="agent crashed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
# ---------------------------------------------------------------------------
# Node-runtime run-error coverage
# ---------------------------------------------------------------------------
class TestNodeRuntimeRunErrorCallback:
"""on_run_error_callback fires for the node runtime path (_run_node_async).
Runner(node=...) with a non-agent BaseNode root routes through
_run_node_async rather than the legacy _exec_with_plugin path.
"""
@pytest.mark.asyncio
async def test_run_error_callback_fires_via_node_runtime(self):
from google.adk.runners import Runner
plugin = _ErrorTrackingPlugin()
node = _CrashingNode(name="crash_node")
runner = Runner(
app_name="test_app",
node=node,
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="node crashed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
# Exactly one run-error notification from the node runtime path.
assert len(plugin.run_errors) == 1
assert str(plugin.run_errors[0]) == "node crashed"
# after_run stays success-only.
assert not plugin.after_run_called
@pytest.mark.asyncio
async def test_run_error_callback_not_fired_on_node_success(self):
"""No run-error notification for a successful node-runtime run."""
from google.adk.runners import Runner
plugin = _ErrorTrackingPlugin()
class _OkNode(BaseNode):
__test__ = False
@override
async def _run_impl(self, *, ctx, node_input):
yield Event(
author=self.name,
invocation_id=ctx.get_invocation_context().invocation_id,
content=types.Content(parts=[types.Part(text="ok")]),
)
runner = Runner(
app_name="test_app",
node=_OkNode(name="ok_node"),
session_service=InMemorySessionService(),
plugins=[plugin],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
assert len(plugin.run_errors) == 0
assert plugin.after_run_called
@pytest.mark.asyncio
async def test_run_error_callback_fires_on_node_before_run_failure(self):
"""A before_run_callback failure on the node path notifies on_run_error.
The setup hooks in _run_node_async run before the main event loop; their
failures must still be surfaced to on_run_error_callback.
"""
from google.adk.runners import Runner
class _FailingBeforeRunPlugin(BasePlugin):
async def before_run_callback(
self, *, invocation_context: InvocationContext
) -> Optional[types.Content]:
raise RuntimeError("before_run failed")
tracker = _ErrorTrackingPlugin()
runner = Runner(
app_name="test_app",
node=_CrashingNode(name="never_runs"),
session_service=InMemorySessionService(),
plugins=[_FailingBeforeRunPlugin(name="failing_before_run"), tracker],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="before_run failed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
assert len(tracker.run_errors) == 1
# PluginManager wraps plugin exceptions with plugin/callback context.
assert "before_run failed" in str(tracker.run_errors[0])
# after_run stays success-only.
assert not tracker.after_run_called
@pytest.mark.asyncio
async def test_run_error_callback_fires_on_node_user_message_failure(self):
"""An on_user_message_callback failure on the node path notifies on_run_error."""
from google.adk.runners import Runner
class _FailingUserMessagePlugin(BasePlugin):
async def on_user_message_callback(
self,
*,
invocation_context: InvocationContext,
user_message: types.Content,
) -> Optional[types.Content]:
raise RuntimeError("user_message failed")
tracker = _ErrorTrackingPlugin()
runner = Runner(
app_name="test_app",
node=_CrashingNode(name="never_runs"),
session_service=InMemorySessionService(),
plugins=[_FailingUserMessagePlugin(name="failing_user_msg"), tracker],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="user_message failed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
assert len(tracker.run_errors) == 1
# PluginManager wraps plugin exceptions with plugin/callback context.
assert "user_message failed" in str(tracker.run_errors[0])
assert not tracker.after_run_called
@pytest.mark.asyncio
async def test_run_error_callback_fires_on_node_after_run_failure(self):
"""An after_run_callback failure on the node path notifies on_run_error.
after_run runs on the node-runtime success path, outside the main-loop
catch. A failing after_run plugin (which PluginManager surfaces as a
RuntimeError) is an unhandled runner error, so it must still notify
on_run_error_callback exactly once while the original error propagates.
"""
from google.adk.runners import Runner
class _OkNode(BaseNode):
__test__ = False
@override
async def _run_impl(self, *, ctx, node_input):
yield Event(
author=self.name,
invocation_id=ctx.get_invocation_context().invocation_id,
content=types.Content(parts=[types.Part(text="ok")]),
)
class _FailingAfterRunPlugin(BasePlugin):
async def after_run_callback(
self, *, invocation_context: InvocationContext
) -> None:
raise RuntimeError("after_run failed")
tracker = _ErrorTrackingPlugin()
runner = Runner(
app_name="test_app",
node=_OkNode(name="ok_node"),
session_service=InMemorySessionService(),
plugins=[_FailingAfterRunPlugin(name="failing_after_run"), tracker],
)
session = await runner.session_service.create_session(
app_name="test_app", user_id="test_user"
)
with pytest.raises(RuntimeError, match="after_run failed"):
_ = [
e
async for e in runner.run_async(
user_id="test_user",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hello")]),
)
]
# Exactly one run-error notification for the after_run failure.
assert len(tracker.run_errors) == 1
# PluginManager wraps plugin exceptions with plugin/callback context.
assert "after_run failed" in str(tracker.run_errors[0])