fix(runners): honor before_run_callback early-exit for Workflow runs - #6032
fix(runners): honor before_run_callback early-exit for Workflow runs#6032garyzava wants to merge 4 commits into
Conversation
In the Workflow run path (_run_node_async), the before_run plugin callback was awaited but its return value was discarded, so a callback returning a Content (the documented signal to halt the run) was ignored and execution continued into the workflow. Capture the callback result and, when it is a Content, emit it as the early-exit event and stop — matching the existing behavior of the non-workflow run path. Adds a regression test: a plugin whose before_run_callback returns Content halts the workflow and the node never executes. Fixes google#6013
🔍 ADK Pull Request Analysis: PR #6032Title: fix(runners): honor before_run_callback early-exit for Workflow runs Executive Summary
Detailed Findings & Analysis1. Objectives & Impact ("What does it do?")
2. Justification & Value ("Is it a valid and useful change?")
3. Principle & Style Alignment Checklist ("Does it follow rules?")
Summary of Triage & Findings
|
|
Hi @garyzava , Thank you for your contribution! We appreciate you taking the time to submit this pull request. Your PR has been received by the team and is currently under review. Can you meanwhile address the adk bot nits suggestions. |
|
Hi @GWeale , can you please review this. |
|
Hi @rohityan, thanks for taking a look! The bot's nit (missing type annotations on the |
…ion path The node execution path (_run_node_async) invoked plugin_manager.run_before_run_callback but discarded its return value, so a plugin returning types.Content (the documented signal to halt the run) was ignored and execution continued. The legacy path (_exec_with_plugin) already honors this contract. This affects every root that dispatches through the node path: a Workflow root and a root LlmAgent (chat/task mode), which means plugin-based guardrails (e.g. safety filters that block a turn in before_run_callback) are silently bypassed for those shapes. Fix mirrors the _exec_with_plugin early-exit contract: the returned Content becomes the final response event (with RunConfig custom_metadata applied), is appended to the session, and the run ends. after_run callbacks and compaction still execute via the enclosing finally, matching the success path. Adapted from the stale PR google#6032 by @garyzava (rebased onto current main and extended with a root-LlmAgent regression test). Fixes google#6828
…ion path The node execution path (_run_node_async) invoked plugin_manager.run_before_run_callback but discarded its return value, so a plugin returning types.Content (the documented signal to halt the run) was ignored and execution continued. The legacy path (_exec_with_plugin) already honors this contract. This affects every root that dispatches through the node path: a Workflow root and a root LlmAgent (chat/task mode), which means plugin-based guardrails (e.g. safety filters that block a turn in before_run_callback) are silently bypassed for those shapes. Fix mirrors the _exec_with_plugin early-exit contract: the returned Content becomes the final response event (with RunConfig custom_metadata applied), is appended to the session, and the run ends. after_run callbacks and post-invocation compaction are run explicitly (the finally that normally runs them belongs to the main loop, which a halted run never enters), matching the success path and the legacy early-exit behavior. Adapted from the stale PR google#6032 by @garyzava (rebased onto current main and extended with a root-LlmAgent regression test). Fixes google#6828
Summary
In the Workflow run path (
Runner._run_node_async), thebefore_runplugincallback was awaited but its return value was discarded. Per the plugin contract,
returning a
Contentfrombefore_run_callbackshould halt the run and end itwith that content — but for Workflow root agents the value was ignored and
execution continued into the workflow.
This captures the callback result and, when it is a
Content, emits it as theearly-exit event and stops — matching the existing behavior of the non-workflow
run path (
run_async).Fixes #6013
What changed
src/google/adk/runners.py— in_run_node_async, capturerun_before_run_callback(...)'s result; if it's atypes.Content, build theearly-exit
Event, append it (subject to_should_append_event), yield it, andreturn — instead of unconditionally proceeding. Mirrors the non-workflow path.
tests/unittests/workflow/test_workflow_failures.py— regression test: a pluginwhose
before_run_callbackreturnsContenthalts the workflow and the nodenever executes.
Why this approach
The non-workflow path already implements the documented early-exit contract
(capture the result, emit an early-exit event, skip execution). This change makes
the Workflow path consistent with it rather than introducing new behavior, so the
fix is minimal and the intended semantics are unambiguous.
How I tested
The new test (
test_workflow_halts_when_before_run_callback_returns_content)fails before the change (the node executes; no early-exit event) and passes
after. Existing workflow-failure tests and the non-workflow plugin tests
(
tests/unittests/test_runners.py -k plugin) still pass.pyinkandisortreport no changes.
Backward compatibility
No behavior change when
before_run_callbackreturnsNone(the common case) —the workflow runs exactly as before. Only the documented halt-on-
Contentcase isaffected, and it now matches the non-workflow path.