Repository navigation
Custom memory persistence silently no-ops on Workflow (BaseNode) roots in 2.x — no working post-run hook #5282
Description
Edit 2026-04-11 (after @surajksharma07's triage + source re-verification): scope is narrower than this body originally claimed. Only
plugin_manager.run_after_run_callbackis missing dispatch on the BaseNode path.run_on_event_callbackdoes fire via_consume_event_queueatrunners.py:619, andafter_model_callbackis dispatched byLlmAgentinternals on the model-call boundary independently of the Runner. The primary ask stands — land therunners.py:427TODO forrun_after_run_callback— but it's a one-dispatch-call fix, not the broad "plugin lifecycle is incomplete" re-architecture this body originally framed. Overclaims in the sections below are struck through. Verified dispatch matrix + working workaround + drop-in regression test in this comment below.
Environment: google-adk==2.0.0a3 (released 2026-04-09), Python 3.14, Linux/macOS. Feature flags NEW_WORKFLOW, V1_LLM_AGENT, PLUGGABLE_AUTH default-on (the a3 defaults).
The problem we ran into
We have a multi-turn chat agent backed by a custom SSE server (FastAPI + google.adk.Runner). The agent is a graph workflow — App(root_agent=Workflow(edges=[...])) — classifying, guarding, and routing requests, and we persist each completed session to a custom MyMemoryService(BaseMemoryService) for long-term recall across sessions. The implementation details of the memory backend are not relevant here; the issue reproduces identically with InMemoryMemoryService.
We followed the canonical pattern from the memory docs:
async def save_to_memory(callback_context):
await callback_context.add_session_to_memory()
root_agent = Workflow(
name="FrontDoorWorkflow",
after_agent_callback=save_to_memory, # ← docs pattern
edges=[...],
)Sessions and events were persisting normally (via DatabaseSessionService). The memory store's write count was stuck at its pre-upgrade value — no new rows after the a3 upgrade. No exception, no warning, no log line. Looked like the store had stopped accepting writes; it hadn't.
The next thing we tried was the BasePlugin.after_run_callback pattern, which reads as the more ADK-native approach:
class MemoryPersistPlugin(BasePlugin):
async def after_run_callback(self, *, invocation_context):
if invocation_context.memory_service is None:
return
await invocation_context.memory_service.add_session_to_memory(
invocation_context.session
)
app = App(root_agent=root_agent, plugins=[MemoryPersistPlugin()])This also silently no-ops. We traced it all the way through the installed source and found the real story, which is that [Edit: overclaim. The precise gap is BaseNode root paths in 2.0.0a3 have no post-run lifecycle hook surface exposed to users — not via callbacks, not via plugins.run_after_run_callback. See top-of-issue note.]
This is a blocker for any cross-cutting post-run concern on Workflow roots: memory persistence, metrics emission, audit logging, final telemetry, cleanup. Users migrating toward the BaseNode-rooted architecture that 2.0 is steering them onto silently lose every after-hook they had on [Edit: overclaim. The blocker is specifically on LlmAgent.after_run_callback-based patterns, which still rules out most ADK-native cross-cutting wiring, but on_event_callback-based observability works today.]
Root cause (source trace)
1. Workflow is a BaseNode, not a BaseAgent
google/adk/workflow/_workflow_class.py:143—class Workflow(BaseNode).BaseNodehas noafter_agent_callbackfield.Workflowdoesn't either.- Pydantic v2 default
extra="ignore"→ passingafter_agent_callback=save_to_memorytoWorkflow(...)is silently dropped at construction. No error, no warning. - The dispatch mechanism for the field,
BaseAgent._handle_after_agent_callbackatgoogle/adk/agents/base_agent.py:491, is agent-only and unreachable from a BaseNode root.
This alone produces the symptom. But the deeper issue is what happens if users try the ADK-native alternative:
2. run_after_run_callback is not dispatched on the BaseNode path
Runner.run_async at google/adk/runners.py:760 branches on root type:
- Branch A (
isinstance(self.agent, LlmAgent), line 807): wraps in_V1LlmAgentWrapper, calls_run_node_async,returnat line 834. - Branch B (
isinstance(self.agent, BaseNode) and not LlmAgent, line 838): calls_run_node_async,returnat line 848. This is the path Workflow roots take. - Branch C (legacy fallthrough
_run_with_trace, line 851+): uses_exec_with_pluginwhich dispatchesrun_after_run_callbackatrunners.py:1230. Neither root shape reaches this path.
So the only code path that dispatches after_run_callback is the one that nobody hits in 2.0. Both LlmAgent and Workflow roots funnel into _run_node_async.
3. _run_node_async has not yet wired run_after_run_callback
google/adk/runners.py:413 _run_node_async():
- Line 427:
# TODO: Add tracing and plugin lifecycle for the node runtime path.— explicit acknowledgement this is incomplete. - Line 467:
run_on_user_message_callback✓ - Line 482:
run_before_run_callback✓ - Line 506:
async for event in self._consume_event_queue(ic, done_sentinel)— drains events via_consume_event_queue, which dispatchesrun_on_event_callbackatrunners.py:619✓ [Edit: this was wrong in the original text below;on_event_callbackdoes fire for Workflow roots.] - No call to
run_after_run_callbackanywhere in the function.[Edit:run_after_agent_callback,run_after_model_callback, orrun_on_event_callbackrun_on_event_callbackfires via_consume_event_queue:619;after_model_callbackis dispatched byLlmAgenton the model-call boundary, not the Runner;after_agent_callbackis agent-only and not a_run_node_asyncconcern.]
Pre-run plugin hooks (on_user_message, before_run) and per-event hooks (on_event_callback) DO fire for BaseNode roots. run_after_run_callback does not. A BasePlugin overriding after_run_callback loads successfully, registers with the Runner, and then never executes.
Minimal reproducer
# pip install google-adk==2.0.0a3
import asyncio
from google.adk import Event, Workflow
from google.adk.apps import App
from google.adk.events import EventActions # noqa: F401
from google.adk.plugins.base_plugin import BasePlugin
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.genai import types
def terminal_node(ctx) -> Event:
return Event(state={"done": True})
class TracerPlugin(BasePlugin):
def __init__(self):
super().__init__(name="tracer")
self.fired = {
"on_user_message_callback": 0,
"before_run_callback": 0,
"after_run_callback": 0,
"after_agent_callback": 0,
"on_event_callback": 0,
}
async def on_user_message_callback(self, *, invocation_context, user_message):
self.fired["on_user_message_callback"] += 1
async def before_run_callback(self, *, invocation_context):
self.fired["before_run_callback"] += 1
async def after_run_callback(self, *, invocation_context):
self.fired["after_run_callback"] += 1
async def after_agent_callback(self, *, agent, callback_context):
self.fired["after_agent_callback"] += 1
async def on_event_callback(self, *, invocation_context, event):
self.fired["on_event_callback"] += 1
async def main():
plugin = TracerPlugin()
workflow = Workflow(name="Demo", edges=[("START", terminal_node)])
app = App(name="demo", root_agent=workflow, plugins=[plugin])
runner = Runner(
app_name="demo",
app=app,
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
session = await runner.session_service.create_session(app_name="demo", user_id="u1")
async for _ in runner.run_async(
user_id="u1",
session_id=session.id,
new_message=types.Content(parts=[types.Part(text="hi")], role="user"),
):
pass
print(plugin.fired)
asyncio.run(main())[Edit: the reproducer above has a bug — terminal_node yields Event(state=...) with no content, which skews on_event_callback counts, and the original "Actual" output below reflected that. A corrected drop-in test case with a content-bearing terminal node and a working WorkaroundRunner is in this comment.]
Expected: every hook fires at least once.
Actual (on 2.0.0a3):
~~ ~~{'on_user_message_callback': 1, 'before_run_callback': 1,~~ ~~ 'after_run_callback': 0, 'after_agent_callback': 0, 'on_event_callback': 0}~~ ~~
Corrected actual (2.0.0a3, content-bearing terminal event):
{'on_user_message_callback': 1, 'before_run_callback': 1,
'on_event_callback': 1, 'after_run_callback': 0}
Only after_run_callback stays at 0.
Impact
Every cross-cutting post-invocation concern silently no-ops on Workflow roots: [Edit: narrower than the table originally claimed. Corrected:]
| Concern | Typical wiring | ADK 2.0.0a3 Workflow root status |
|---|---|---|
Long-term memory (add_session_to_memory) |
after_agent_callback or after_run_callback |
Silent no-op (this issue) |
| Metrics emission (latency, token counts, success rate) | after_run_callback |
Silent no-op (this issue) |
| Audit logging / compliance trails | after_run_callback |
Silent no-op via after_run_callback (this issue). on_event_callback-based audit works today. |
| Final state cleanup, resource release | after_run_callback |
Silent no-op (this issue) |
after_model_callback |
after_model_callback is dispatched by LlmAgent on the model-call boundary, not by the Runner. Fires on Workflow roots that contain LlmAgent nodes. Not part of this issue.] |
|
| Per-event telemetry / event rewriting | on_event_callback |
Works via _consume_event_queue:619. |
And because there is no warning, teams discover the problem only when empty dashboards, missing memory rows, or absent audit trails are flagged downstream — often after shipping.
Our workaround was to embed memory persistence as a terminal FunctionNode inside the graph (async def persist_memory(ctx) wired as a final edge after every terminal specialist). It works, but it's specific to memory and doesn't generalize to token accounting, metrics, or audit hooks, which can't live as graph nodes cleanly. [Edit: a cleaner interim — subclass Runner and wrap run_async to dispatch run_after_run_callback after the generator drains — also works for teams instantiating Runner directly. Not viable when adk api_server owns the Runner instantiation (hardcoded at adk_web_server.py:737). Working example in the comment below.]
Open question — how should custom memory persistence work on Workflow roots today?
The memory docs page shows one canonical pattern: Agent(..., after_agent_callback=auto_save_session_to_memory_callback). With Workflow(BaseNode) being a flagship 2.0 feature and the intended future root type for non-LLM graph agents (per the runners.py:836-837 TODO to collapse LlmAgent into the BaseNode path), it would be very helpful if maintainers could confirm the supported answer to one of:
- Is there a hook we missed? If there's a canonical surface we haven't found for "run code at end of invocation on a BaseNode root," please point at it — we searched
BasePlugin,BaseNode,Workflow,App, andRunnermethods and could not find one that dispatches. Docs/example link welcome. - Is the terminal-
FunctionNode-in-graph pattern the intended interim answer? If so, it's worth documenting on the memory page next to theAgentexample, so new users land on it instead of the silently-droppedafter_agent_callbackkwarg. - If neither — what's the recommended path? We would like to build correctly against a path maintainers endorse, not against whichever private API happens to work.
Worth flagging: we believe every team building custom memory persistence in 2.x will hit this, not just us. The direction of travel in 2.0 is explicit — Workflow(BaseNode) is the flagship graph root type, and the TODO at runners.py:836-837 says LlmAgent itself will be refactored to inherit from BaseNode, at which point _run_node_async becomes the single dispatch path for all Runner invocations. Any team that:
- subclasses
BaseMemoryService(the documented extension point for custom memory backends), AND - uses a
Workflowroot (or, post-LlmAgent-migration, any 2.x root at all), AND - follows the memory docs page to wire persistence via
after_agent_callbackOR the ADK-nativeBasePlugin.after_run_callback
…will land in exactly the silent no-op we did. There is no "lucky" configuration that avoids it on 2.0.0a3 with a BaseNode root. And because the failure mode is silent (no exception, no warning, no log), downstream symptoms — empty memory store, missing cross-session recall, flat retrieval quality — are easy to attribute to embedding tuning, retrieval scoring, or chunk sizing instead of "the write never happened."
If the runners.py:427 TODO sits for multiple release cycles, which is entirely plausible given Runner-dispatch completeness does not currently appear to be a top-priority area, then custom memory persistence on Workflow roots is effectively unsupported in 2.x until either the TODO lands or the docs adopt the in-graph terminal-node pattern as an interim answer. The current situation — "docs pattern silently drops, Plugin alternative silently no-ops, no documented workaround, open TODO of unknown priority" — leaves every new adopter either guessing or reverse-engineering the Runner internals. A one-paragraph note on the memory docs page would save each of them the same multi-hour source trace.
Related issues
- #4181 (open) — "before_model_callback and after_model_callback not invoked for live streaming sessions (run_live)". Same structural pattern: a specific Runner code path (
run_live) bypasses the callback dispatch that the standard path calls. This issue is the BaseNode-root analogue — same structural pattern, different Runner branch (_run_node_asyncinstead ofrun_live). A fix for one does not automatically cover the other; each alternate dispatch path has to be wired individually. - #4774 (open) — "Add Lifecycle Error Callbacks (
on_agent_error,on_run_error) to ADK Framework". Motivates whyafter_run_callback/after_agent_callbackare load-bearing for enterprise observability: "AGENT_COMPLETEDandINVOCATION_COMPLETEDevents are never emitted to observability sinks… failed runs disappear from the denominator in standard reports." Same concern applies whenafter_run_callbackdoesn't fire at all on BaseNode roots — telemetry plugins depending on it miss every successful run, not just failed ones.
The ask
Primary — land the runners.py:427 TODO
Wire run_after_run_callback into _run_node_async, mirroring the dispatch that _exec_with_plugin already performs on the legacy path at runners.py:1230. Concretely: after the async for event in self._consume_event_queue(...) loop at line 506 drains, before the function returns, call await ic.plugin_manager.run_after_run_callback(invocation_context=ic).
- run_after_run_callback at the end of the function (after the event queue drains, before return) — mirrors runners.py:1230
- run_after_agent_callback when a node finishes
- run_after_model_callback at the model-call boundary
- run_on_event_callback per yielded event — mirrors runners.py:1216
[Edit: the other three bullets are either already handled (run_on_event_callback via _consume_event_queue:619) or out of scope for _run_node_async (after_agent_callback is BaseAgent-only; after_model_callback is dispatched by LlmAgent on the model-call boundary). Scope is one dispatch call.]
This is structurally the same ask as #4181: identify an alternate Runner dispatch path that the current plugin wiring skipped, and add the callback call. The fix pattern is well-understood.
This lets a BasePlugin work the same on BaseNode roots as it does on the legacy _exec_with_plugin path today, and unblocks the whole class of after_run_callback-based cross-cutting concerns (memory persistence, metrics, audit, cleanup).
Related: the TODO at runners.py:836-837 ("remove not isinstance(self.agent, LlmAgent) after LLM agent is refactored to inherit from BaseNode") shows _run_node_async is the intended single dispatch path going forward. Closing this gap removes the last pre-condition to collapsing Branch A and Branch B into one.
Secondary — defensive UX while the TODO is open
Log a one-time warning at Runner.__init__ (or first run_async call) when:
- the root is a
BaseNode(not aBaseAgentsubclass), AND app.pluginscontains any plugin that overridesafter_run_callback.
Something like:
WARNING: Plugin 'MemoryPersistPlugin' defines after_run_callback, but the
BaseNode root path in Runner does not currently dispatch it (see #5282
and runners.py:427). This callback will not fire. For memory writes on
Workflow roots, use a terminal FunctionNode until this is resolved.
This turns a silent data-loss bug into a startup-time warning. Users find out immediately, not after shipping and checking empty dashboards.
Tertiary — documentation
Update adk.dev/sessions/memory/. The current memory page shows only the Agent(..., after_agent_callback=auto_save_session_to_memory_callback) pattern, with no mention that Workflow roots behave differently. Given Workflow(BaseNode) is a flagship 2.0 feature, the page should:
- either add a Workflow section with the interim terminal-node workaround
- or, ideally, after the primary fix lands, show one unified
BasePluginpattern that works for both root shapes
Non-ask (for the record)
We considered two alternative framings and explicitly do NOT recommend them:
- "Make
Workflowacceptafter_agent_callback" — patches deprecated API.BaseAgentis on the way out perrunners.py:836-837. - "Make
BaseNodeuseextra='forbid'" — would have caught our silent-drop trap at construction, but likely breaks kwargs forwarded from subclasses and is an API break. The secondary warning above gets the same DX benefit without the compatibility cost.
The primary ask — finishing _run_node_async's run_after_run_callback dispatch — is aligned with the direction 2.0 is already headed.
Activity
surajksharma07 commentedon Apr 11, 2026
@surfai The runners.py:427 TODO is the gap, and run_after_run_callback is the only post-run hook not wired into _run_node_async.
Worth noting that on_event_callback is already dispatched via _consume_event_queue, so this is an isolated miss rather than a broader wiring issue.
As an interim workaround, try subclassing Runner to capture the InvocationContext via _new_invocation_context, then calling ic.plugin_manager.run_after_run_callback(invocation_context=ic) after the _run_node_async generator drains.
That should unblock both memory persistence and post-run plugins.
Could you verify that on your 2.0.0a3 setup and confirm it resolves the issue?
If it holds up, it'd be worth including as a test case in the PR that closes that TODO!
surfai commentedon Apr 11, 2026
@surajksharma07 Thanks for the quick triage. Verified on my 2.0.0a3 setup; three things to report back.
1. You're right on
on_event_callback— scope is narrower than my repro suggestedTraced it in the installed source:
_consume_event_queueatrunners.py:619does callrun_on_event_callback:My original
TracerPluginreportedon_event_callback: 0because of two bugs in the repro — wrong kwarg signatures and a terminal node that yieldedEvent(state={...})with nocontent. When I fixed both (content-bearing event, signatures matchingbase_plugin.py:155),on_event_callbackfires normally on the Workflow root. Verified dispatch matrix on stock 2.0.0a3:So the gap is isolated to
run_after_run_callbackat the Runner dispatch level, exactly as you said.after_agent_callbackisBaseAgent-only (not a_run_node_asyncconcern), andafter_model_callbackis dispatched byLlmAgenton the model-call boundary independently. I've edited the issue body to strike through the overclaims — scope is one dispatch call, not a sprawling "plugin lifecycle is incomplete" re-architecture.2. Workaround verified — with one shape note
It works. One thing worth flagging before anyone copies the sketch:
_new_invocation_contextcapture alone isn't sufficient becauseicis local to_run_node_asyncandrun_asyncreturns atrunners.py:849immediately after the generator drains — there's no post-drain hook exposed to a subclass from the outside. You need to overriderun_asyncitself and wrap the generator, using_new_invocation_contextas an observation point to stashic. Under concurrentrun_asynccalls on a shared Runner instance, the stash wants to live on acontextvars.ContextVarrather thanself, but for a single-invocation test an instance attribute is fine.Shape that works end-to-end:
Non-viable for my production path because
adk api_serverhardcodesRunner(...)atadk_web_server.py:737— no injection point for a subclass without monkey-patchinggoogle.adk.runners.Runnerglobally, which has the same maintenance cost as the in-graph terminal-node workaround I'm already running. I'm staying on that until the TODO lands. But the subclass above is a clean interim answer for any team instantiatingRunnerdirectly in a custom server.3. Test case for the fix PR
Here's a standalone pytest file — drop it wherever upstream's Plugin lifecycle tests live. Three tests: (a) baseline assertion that pre-run and event hooks fire, (b) strict-
xfailregression anchor onafter_run_callback, (c) workaround proof. Whenrunners.py:427is wired, delete the@pytest.mark.xfaildecorator on test (b) and it flips green as the signal the fix is complete.test_issue_5282.py(click to expand)Current output on stock
google-adk==2.0.0a3:Happy to move the file into a draft PR instead if that's easier to review than an issue comment — just let me know where you want it.
surajksharma07 commentedon Apr 12, 2026
@surfai Thanks for the verification and for flagging the ContextVar catch — that's an important detail for anyone copying the WorkaroundRunner pattern under concurrent load, and definitely worth calling out clearly in the PR.
The api_server hardcoding at adk_web_server.py:737 is a genuine gap, not just a workaround limitation — I'll flag that alongside the runners.py:427 TODO to make sure the fix lands in a way that covers both the custom-server and api_server instantiation paths.
Go ahead and open the draft PR with your test file — the xfail→pass structure is clean and exactly the form the fix needs.
I'll get it tagged for review!
surajksharma07 commentedon Apr 12, 2026
@Jacksunwei Please have a look into it once.
test: regression tests for google#5282 (after_run_callback on BaseNod…
surfai commentedon Apr 13, 2026
@surajksharma07 Thanks again for the fast triage and for flagging both the
ContextVarcatch and theadk_web_server.py:737hardcoding alongside therunners.py:427TODO — really appreciate you taking this seriously and moving it forward.Draft PR is up: #5301 — test file only, placed at
tests/unittests/runners/test_issue_5282.py, upstream conventions (Apache header, 2-space indent,__test__ = False). Three tests verified on 2.0.0a3: baseline passes,after_run_callbackxfail (strict),WorkaroundRunnerpasses. PR body calls out both the ContextVar concurrency note and theadk api_serverinjection gap as we discussed.CI status:
cla/googleandheader-checkgreen. The unit test / pyink / mypy / isort jobs are gated on first-time-contributor approval (action_required) — needs a maintainer click to run. Theagent-triage-pull-requestcheck failed on a transient Gemini 503, unrelated to the PR.Ready for review whenever.
surajksharma07 commentedon Apr 14, 2026
@surfai PR structure looks good — test-only, correct path, clean xfail anchor.
we will unblock the gated CI jobs now; the Gemini 503 on the triage check is a transient infra flake, not a signal on your PR.
Once those pass, @Jacksunwei or team will take a look.
The natural next step after this merges is the fix PR — wiring run_after_run_callback into _run_node_async at runners.py:427.
If you want to take that on too, go for it; otherwise we will track it from here!
fix(runners): dispatch run_after_run_callback in _run_node_async (goo…
fix(runners): dispatch run_after_run_callback in _run_node_async (goo…
surfai commentedon Apr 15, 2026
Thanks @surajksharma07 for the triage, workaround sketch, and clean guidance throughout, and @Jacksunwei for picking it up for review — appreciated.
Courtesy note: the follow-up fix PR is up at #5323. It wires
run_after_run_callbackinto_run_node_asyncat the location you flagged (mirroring_exec_with_plugin:1230), narrows therunners.py:427TODO to just tracing, and replaces the xfail anchor intest_issue_5282.pywith a direct positive assertion. Companion to test-only #5301. CLA green, tests green locally, gated CI awaiting first-time-contributor approval.surajksharma07 commentedon Apr 15, 2026
@surfai Nice work — the fix looks exactly right. Wiring run_after_run_callback after the event queue drains at runners.py:427, narrowing the TODO to just tracing, and flipping the xfail to a positive assertion is exactly the shape this needed.
we will unblock the gated CI on #5323 now — once those pass, @Jacksunwei will pick it up for review.
Thanks for seeing this all the way through; test PR + fix PR is a clean paper trail!
Team is looking more into it and it is under review.
test: cover after_run_callback dispatch on Workflow node root
test: cover after_run_callback dispatch on Workflow node root