Skip to content

Commit fd006db

Browse files
DeanChensjcopybara-github
authored andcommitted
fix(workflow): fix task agent resumption in nested workflows
Fix an issue where task-mode LLM agents invoked inside nested workflows or custom nodes with raise_on_wait=True fail to resume upon user reply. - Ensure raise_on_wait=True in Context._run_node_internal triggers NodeInterruptedError when child output is None regardless of wait_for_output defaults. - Update ReplayInterceptor Case 5 to allow re-running Workflow instances and rerun_on_resume nodes when recovered output is None. - Add unit test test_nested_workflow_with_task_agent to cover task agent resumption in nested workflows. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 949367240
1 parent 54344ed commit fd006db

5 files changed

Lines changed: 120 additions & 13 deletions

File tree

src/google/adk/agents/context.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -613,18 +613,18 @@ async def _run_node_internal(
613613
curr_parent_ctx._interrupt_ids.update(child_ctx.interrupt_ids)
614614
raise NodeInterruptedError()
615615
# When the caller passes raise_on_wait=True, surface a child
616-
# that's WAITING (wait_for_output, no output, not transferring)
616+
# execution that's WAITING (wait_for_output, no output, not transferring)
617617
# as NodeInterruptedError so the parent's NodeRunner records
618618
# the parent as WAITING instead of falsely COMPLETED.
619-
if (
620-
raise_on_wait
621-
and curr_node.wait_for_output
622-
and child_ctx.output is None
623-
and not transfer_to_agent
624-
):
625-
from ..workflow._errors import NodeInterruptedError
619+
if raise_on_wait and child_ctx.output is None and not transfer_to_agent:
620+
from ..workflow._workflow import Workflow
626621

627-
raise NodeInterruptedError()
622+
if isinstance(curr_node, Workflow) or getattr(
623+
curr_node, 'wait_for_output', False
624+
):
625+
from ..workflow._errors import NodeInterruptedError
626+
627+
raise NodeInterruptedError()
628628

629629
# Handle Agent Transfer: If a transfer was requested, we resolve the target agent
630630
# and its parent context, update loop pointers, and continue to the next iteration.

src/google/adk/tools/_node_tool.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,16 @@ async def run_async(
142142
tool_branch = f'{base_branch}.{segment}' if base_branch else segment
143143

144144
try:
145-
return await tool_context.run_node(
145+
res = await tool_context.run_node(
146146
self.node,
147147
node_input=node_input,
148148
override_branch=tool_branch,
149149
use_sub_branch=False,
150150
raise_on_wait=True,
151151
)
152+
if res is None:
153+
return {'result': None}
154+
return res
152155
except NodeInterruptedError as nie:
153156
# Propagates the interrupt up so the runner pauses the invocation
154157
raise nie

src/google/adk/workflow/utils/_replay_interceptor.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def check_interception(
6161
current_run: DynamicNodeRun | None = None,
6262
) -> InterceptionResult:
6363
"""Determine if a node execution should be intercepted based on history."""
64+
from .._workflow import Workflow # pylint: disable=g-import-not-at-top
6465

6566
# Case 1: Same-turn completed or waiting interception (dynamic nodes only).
6667
# If a node already successfully executed or is currently blocked in the
@@ -127,8 +128,14 @@ def check_interception(
127128

128129
else:
129130
# Case 5: Cross-turn no events, or events contain no output, route, or interrupts.
130-
# Rerun Workflow nodes to guide nested children; otherwise fall through.
131-
if getattr(node, "wait_for_output", False) and recovered.output is None:
131+
# Rerun Workflow nodes, wait_for_output nodes, and rerun_on_resume nodes
132+
# with no prior output so they can guide nested children or resume execution;
133+
# otherwise fall through.
134+
if (
135+
isinstance(node, Workflow)
136+
or getattr(node, "wait_for_output", False)
137+
or getattr(node, "rerun_on_resume", False)
138+
) and recovered.output is None:
132139
should_run = True
133140
resume_inputs = recovered.resolved_responses
134141
else:

tests/unittests/workflow/test_node_tool.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,53 @@ def greet_node(request: str) -> str:
328328
].function_response.response == {'result': 'Hello, world!'}
329329

330330

331+
@pytest.mark.asyncio
332+
async def test_function_node_wrapped_as_tool_no_output(
333+
request: pytest.FixtureRequest,
334+
):
335+
"""NodeTool wrapping a function node that returns None completes with None result."""
336+
337+
@node
338+
def no_output_node(request: str):
339+
yield Event(output=None)
340+
341+
no_output_node.input_schema = GreetRequest
342+
no_output_tool = NodeTool(node=no_output_node, name='no_output_tool')
343+
344+
parent_agent = LlmAgent(
345+
name='parent_agent',
346+
model=testing_utils.MockModel.create(
347+
responses=[
348+
types.Part.from_function_call(
349+
name='no_output_tool',
350+
args={'request': 'world'},
351+
),
352+
types.Part.from_text(text='Processed no output.'),
353+
]
354+
),
355+
tools=[no_output_tool],
356+
)
357+
358+
app = App(
359+
name=request.function.__name__,
360+
root_agent=parent_agent,
361+
)
362+
runner = testing_utils.InMemoryRunner(app=app)
363+
events = await runner.run_async(
364+
testing_utils.get_user_content('Run no output')
365+
)
366+
367+
func_response_events = [
368+
e
369+
for e in events
370+
if e.content and e.content.parts and e.content.parts[0].function_response
371+
]
372+
assert len(func_response_events) == 1
373+
assert func_response_events[0].content.parts[
374+
0
375+
].function_response.response == {'result': None}
376+
377+
331378
@pytest.mark.asyncio
332379
async def test_workflow_tool_with_join_node(request: pytest.FixtureRequest):
333380
"""WorkflowTool containing a JoinNode works correctly when wrapped as a tool."""

tests/unittests/workflow/test_workflow_nested.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
from google.adk.tools.long_running_tool import LongRunningFunctionTool
2828
from google.adk.workflow import BaseNode
2929
from google.adk.workflow import JoinNode
30+
from google.adk.workflow import node
31+
from google.adk.workflow import Workflow
3032
from google.adk.workflow._base_node import START
3133
from google.adk.workflow._node_status import NodeStatus
32-
from google.adk.workflow._workflow import Workflow
3334
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response
3435
from google.adk.workflow.utils._workflow_hitl_utils import get_request_input_interrupt_ids
3536
from google.adk.workflow.utils._workflow_hitl_utils import has_request_input_function_call
@@ -1122,3 +1123,52 @@ async def _run_impl(
11221123
if e.long_running_tool_ids:
11231124
final_interrupts.update(e.long_running_tool_ids)
11241125
assert not final_interrupts
1126+
1127+
1128+
@pytest.mark.asyncio
1129+
async def test_nested_workflow_with_task_agent(request: pytest.FixtureRequest):
1130+
"""Tests that a task-mode LlmAgent inside a nested Workflow re-runs on user reply."""
1131+
mock_model = testing_utils.MockModel.create(
1132+
responses=[
1133+
types.Part.from_text(text='Please provide the secret code:'),
1134+
types.Part.from_function_call(
1135+
name='finish_task',
1136+
args={'result': 'Success with secret'},
1137+
),
1138+
]
1139+
)
1140+
task_agent = LlmAgent(
1141+
name='inner_task_agent',
1142+
model=mock_model,
1143+
mode='task',
1144+
)
1145+
inner_wf = Workflow(
1146+
name='inner_wf',
1147+
edges=[('START', task_agent)],
1148+
)
1149+
1150+
@node(rerun_on_resume=True)
1151+
async def outer_driver(ctx: Context, node_input: Any):
1152+
res = await ctx.run_node(inner_wf, node_input='start', raise_on_wait=True)
1153+
yield Event(output=f'outer: {res}')
1154+
1155+
outer_wf = Workflow(
1156+
name='outer_wf',
1157+
edges=[('START', outer_driver)],
1158+
)
1159+
1160+
app = App(name=request.function.__name__, root_agent=outer_wf)
1161+
runner = testing_utils.InMemoryRunner(app=app)
1162+
1163+
events1 = await runner.run_async('hello')
1164+
texts1 = [
1165+
p.text
1166+
for e in events1
1167+
if e.content and e.content.parts
1168+
for p in e.content.parts
1169+
if p.text
1170+
]
1171+
assert 'Please provide the secret code:' in texts1
1172+
1173+
events2 = await runner.run_async('secret_code_123')
1174+
assert any('outer: ' in str(e.output) for e in events2)

0 commit comments

Comments
 (0)