You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
before_run_callback early-exit is silently ignored on the node execution path (root LlmAgent and Workflow) — reopen of #6013 #6828
Describe the bug Runner has two execution paths. The legacy path (_exec_with_plugin) honors the documented before_run_callback contract: a plugin returning types.Content halts the run and that content becomes the final response. The node execution path (_run_node_async) calls plugin_manager.run_before_run_callback(...) but discards the return value, so the run continues as if the plugin had returned None.
This was reported in #6013 (confirmed by adk-bot as high priority) but that issue was closed by the stale bot on 2026-07-04 without a fix, and the fix PR #6032 has been waiting for human review since June and no longer merges cleanly.
Why this matters
Every root that dispatches through the node path is affected: a Workflow root and, since 2.7.0, any root LlmAgent. In practice, plugin-based guardrails (safety filters that block a turn in before_run_callback, e.g. Model Armor-style input screening) are silently bypassed for the most common application shape. We hit this while hardening a production healthcare analytics assistant: the blocked turn reaches the model anyway.
To Reproduce
Minimal repro on google-adk 2.7.0 (also reproduces on current main):
Expected behavior
The run halts with the plugin's content as the final response and the model is never called — the behavior _exec_with_plugin already implements, and what the plugin docs promise ("Returning a value to halt execution of the runner and ends the runner with that event").
Actual behavior
The plugin's content is discarded and the model generates a normal response.
Desktop:
OS: macOS 15
Python version: 3.12
ADK version: 2.7.0 (also reproduced on main @ 4599a52)
Additional context
We have prepared a PR that rebases the stale fix #6032 onto current main (with credit to its author) and extends it with a root-LlmAgent regression test. Both new tests fail without the fix and pass with it; the full tests/unittests/test_runners.py and tests/unittests/workflow/ suites pass (738 passed). Happy to iterate on review feedback.
Independent confirmation against 2.7.1, plus what it cost in production — posting here rather than opening a third issue, since this one is already tracked pending a fix.
Still present in 2.7.1
runners.py:
_run_node_async → await ic.plugin_manager.run_before_run_callback(invocation_context=ic) — the return value is awaited and dropped.
_exec_with_plugin → early_exit_result = await plugin_manager.run_before_run_callback(...), then if isinstance(early_exit_result, types.Content): ... — honoured.
run_async selects mode='chat' for a plain LlmAgent, which routes to _run_node_async. So a plugin that returns a Content from before_run_callback on the most common agent shape has its return silently discarded, while base_plugin.py's docstring states the opposite: "The content to return to the user. When the content is present, the provided content will be used as agent response and appended to event history as agent response."
Why it is worth prioritising: it is silent in the worst way
We used before_run_callback for a Gemini-as-a-judge prompt-injection screen. Its documented contract — the attack never reaches the agent — did not hold, and nothing surfaced that:
the plugin logged BLOCKED user message: category=jailbreak and returned its refusal Content, so the logs said the screen worked;
the model call started ~15 ms later anyway;
the user got the model's answer, not the refusal.
A guardrail that logs success while doing nothing is worse than one that raises. The only reason we found it is that we happened to be measuring token usage on those turns.
Workaround, for anyone hitting this before the fix lands
Move the short-circuit to before_model_callback and return an LlmResponse instead of a Content. That is honoured on both modes because the code lives in the shared BaseLlmFlow (_handle_before_model_callback runs plugin callbacks first and, on a non-None return, yields the response and returns before the model is called), and base_plugin.py documents returning an LlmResponse there as the supported way to skip the model call.
Two caveats that cost us time and are not obvious from the docs:
before_model_callback fires once per MODEL CALL, not once per turn — so a per-turn screen re-runs on every post-tool continuation. We latch the invocation id in temp: state (which BaseSessionService._apply_temp_state keeps in-memory for the invocation and trims before persistence) so the judge classifies once. Without that, a 3-tool turn pays 4 classifier round trips on the same user message.
The screen now runs after the LlmRequest has been assembled rather than before the runner starts. No model call is made, so the cost is request-building only — but it is not a free move if anything in your request assembly is expensive.
Suggestion
If the fix ends up being "honour the return in _run_node_async too", it would be worth adding a test that covers the branch on both paths — run_async with a plain LlmAgent is the shape most users have, and it is the one that was uncovered.
Activity
fix(runners): honor before_run_callback early-exit on the node execut…
fix(runners): honor before_run_callback early-exit on the node execut…
Dratatus commentedon Aug 20, 2026
Closing for now — will be resubmitted together with the fix by the colleague who did the original investigation.
javiervillam-axiom-robotics commentedon Aug 21, 2026
Independent confirmation against 2.7.1, plus what it cost in production — posting here rather than opening a third issue, since this one is already tracked pending a fix.
Still present in 2.7.1
runners.py:_run_node_async→await ic.plugin_manager.run_before_run_callback(invocation_context=ic)— the return value is awaited and dropped._exec_with_plugin→early_exit_result = await plugin_manager.run_before_run_callback(...), thenif isinstance(early_exit_result, types.Content): ...— honoured.run_asyncselectsmode='chat'for a plainLlmAgent, which routes to_run_node_async. So a plugin that returns aContentfrombefore_run_callbackon the most common agent shape has its return silently discarded, whilebase_plugin.py's docstring states the opposite: "The content to return to the user. When the content is present, the provided content will be used as agent response and appended to event history as agent response."Why it is worth prioritising: it is silent in the worst way
We used
before_run_callbackfor a Gemini-as-a-judge prompt-injection screen. Its documented contract — the attack never reaches the agent — did not hold, and nothing surfaced that:BLOCKED user message: category=jailbreakand returned its refusalContent, so the logs said the screen worked;A guardrail that logs success while doing nothing is worse than one that raises. The only reason we found it is that we happened to be measuring token usage on those turns.
Workaround, for anyone hitting this before the fix lands
Move the short-circuit to
before_model_callbackand return anLlmResponseinstead of aContent. That is honoured on both modes because the code lives in the sharedBaseLlmFlow(_handle_before_model_callbackruns plugin callbacks first and, on a non-Nonereturn, yields the response and returns before the model is called), andbase_plugin.pydocuments returning anLlmResponsethere as the supported way to skip the model call.Two caveats that cost us time and are not obvious from the docs:
before_model_callbackfires once per MODEL CALL, not once per turn — so a per-turn screen re-runs on every post-tool continuation. We latch the invocation id intemp:state (whichBaseSessionService._apply_temp_statekeeps in-memory for the invocation and trims before persistence) so the judge classifies once. Without that, a 3-tool turn pays 4 classifier round trips on the same user message.LlmRequesthas been assembled rather than before the runner starts. No model call is made, so the cost is request-building only — but it is not a free move if anything in your request assembly is expensive.Suggestion
If the fix ends up being "honour the return in
_run_node_asynctoo", it would be worth adding a test that covers the branch on both paths —run_asyncwith a plainLlmAgentis the shape most users have, and it is the one that was uncovered.