Skip to content

Commit c88115d

Browse files
committed
fix(workflow): record a waiting wait_for_output run that emitted nothing
The completion marker skipped every wait_for_output node, so a run that ended WAITING without emitting any event left nothing in the session. On resume it was treated as a new run and executed again with the input it already handled. Only agents (task or chat mode) and rerun_on_resume nodes rerun on resume to take new input, so only they skip the marker now. Other wait_for_output nodes record that their run ended. A ctx.run_node child recorded this way still reports no output, so raise_on_wait keeps its parent WAITING. See #7905.
1 parent 9c01b02 commit c88115d

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

‎src/google/adk/events/event.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ class NodeInfo(BaseModel):
8181
8282
Such a run leaves no other trace in the session, so this event records that
8383
it finished. On resume the run is replayed from history instead of running
84-
again.
84+
again. For a ``wait_for_output`` node this means the run ended ``WAITING``;
85+
agents and ``rerun_on_resume`` nodes, which rerun on resume, get no marker.
8586
"""
8687

8788
@property

‎src/google/adk/workflow/_node_runner.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ async def _record_completion_without_result(self, ctx: Context) -> None:
560560
nothing in the session that shows it ran, so resume would run it (and its
561561
side effects) again. The marker lets the scan replay it instead.
562562
"""
563+
from ..agents.base_agent import BaseAgent
563564
from ..events.event import Event
564565
from ._workflow import Workflow
565566

@@ -574,9 +575,14 @@ async def _record_completion_without_result(self, ctx: Context) -> None:
574575
or ctx._output_delegated
575576
or ctx.route is not None
576577
or ctx.actions.transfer_to_agent is not None
577-
# Finishing without output means a wait_for_output node is still
578-
# waiting for more input, not that it is done.
579-
or self._node.wait_for_output
578+
# A waiting agent (task or chat mode) or rerun_on_resume node reruns on
579+
# resume to take new input, so its run must not be recorded as ended.
580+
or (
581+
self._node.wait_for_output
582+
and (
583+
self._node.rerun_on_resume or isinstance(self._node, BaseAgent)
584+
)
585+
)
580586
# Tests wrap Context around invocation-context stubs that lack
581587
# is_aborted or return a truthy Mock; neither means aborted.
582588
or getattr(ctx._invocation_context, "is_aborted", False) is True

‎tests/unittests/workflow/test_workflow_hitl.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3194,3 +3194,99 @@ async def parent(*, ctx: Context, node_input: Any):
31943194

31953195
assert emails_sent == ['email']
31963196
assert {'ok': True} in [e.output for e in events2 if e.output is not None]
3197+
3198+
3199+
class _QuietWaitingNode(BaseNode):
3200+
"""A wait_for_output node that never emits, so it always ends WAITING."""
3201+
3202+
wait_for_output: bool = True
3203+
calls: list[Any] = Field(default_factory=list)
3204+
3205+
@override
3206+
async def _run_impl(
3207+
self, *, ctx: Context, node_input: Any
3208+
) -> AsyncGenerator[Any, None]:
3209+
self.calls.append(node_input)
3210+
return
3211+
yield # pylint: disable=unreachable
3212+
3213+
3214+
@pytest.mark.parametrize(
3215+
('rerun_on_resume', 'expect_marker'), [(False, True), (True, False)]
3216+
)
3217+
@pytest.mark.asyncio
3218+
async def test_waiting_node_run_is_recorded_unless_it_reruns(
3219+
rerun_on_resume: bool, expect_marker: bool
3220+
):
3221+
"""A waiting run gets the marker, except on a node that reruns on resume."""
3222+
3223+
def ask():
3224+
return RequestInput(message='Approve?')
3225+
3226+
waiting = _QuietWaitingNode(name='waiting', rerun_on_resume=rerun_on_resume)
3227+
wf = Workflow(name='wf', edges=[(START, waiting), (START, ask)])
3228+
runner = testing_utils.InMemoryRunner(app=_app('waiting_marker', wf, False))
3229+
3230+
await runner.run_async('start')
3231+
3232+
markers = [
3233+
e
3234+
for e in runner.session.events
3235+
if e.node_info.name == 'waiting' and e.node_info.completed
3236+
]
3237+
assert bool(markers) == expect_marker
3238+
3239+
3240+
@pytest.mark.parametrize('resumable', [False, True])
3241+
@pytest.mark.asyncio
3242+
async def test_waiting_dynamic_child_keeps_parent_waiting_on_resume(
3243+
resumable: bool,
3244+
):
3245+
"""A ctx.run_node child that ended WAITING is not handed back as a result.
3246+
3247+
With raise_on_wait=True the parent stays WAITING in the first turn, and it
3248+
must still be WAITING after another branch's request is answered, rather
3249+
than continuing as if the child had finished with None.
3250+
"""
3251+
child = _QuietWaitingNode(name='child')
3252+
after_child = []
3253+
3254+
@node(rerun_on_resume=True)
3255+
async def parent(*, ctx: Context, node_input: Any):
3256+
await ctx.run_node(child, raise_on_wait=True)
3257+
after_child.append('ran')
3258+
yield 'parent done'
3259+
3260+
def ask():
3261+
return RequestInput(message='Approve?')
3262+
3263+
wf = Workflow(name='wf', edges=[(START, parent), (START, ask)])
3264+
runner = testing_utils.InMemoryRunner(
3265+
app=_app('waiting_dynamic_child', wf, resumable)
3266+
)
3267+
3268+
events2 = await _run_and_answer_first_request(runner)
3269+
3270+
assert after_child == []
3271+
assert 'parent done' not in [e.output for e in events2]
3272+
# The child's waiting run is replayed, not run again.
3273+
assert child.calls == [None]
3274+
3275+
3276+
@pytest.mark.parametrize('resumable', [False, True])
3277+
@pytest.mark.asyncio
3278+
async def test_quiet_waiting_node_is_not_rerun_on_resume(resumable: bool):
3279+
"""A waiting run that emitted no event is replayed, not run again."""
3280+
waiting = _QuietWaitingNode(name='waiting')
3281+
3282+
def ask():
3283+
return RequestInput(message='Approve?')
3284+
3285+
wf = Workflow(name='wf', edges=[(START, waiting), (START, ask)])
3286+
runner = testing_utils.InMemoryRunner(
3287+
app=_app('quiet_waiting_not_rerun', wf, resumable)
3288+
)
3289+
3290+
await _run_and_answer_first_request(runner)
3291+
3292+
assert len(waiting.calls) == 1

0 commit comments

Comments
 (0)