Skip to content

Commit 97aefd8

Browse files
DeanChensjcopybara-github
authored andcommitted
fix(workflow): resume interrupted agent transfers in static workflows
When a static workflow node transferred control to a sub-agent via transfer_to_agent and the target agent paused on an interrupt, resuming the workflow saw should_run=False on the parent node and fast-forwarded it with a mock context. That short-circuit bypassed DynamicNodeScheduler and its transfer-following loop, leaving the interrupted target agent unresumed and producing an empty turn. Skip the mock-context fast-forward in Workflow._start_node_task when a recovered execution has both a pending transfer_to_agent and recorded interrupt_ids so the scheduler can fast-forward the parent node and follow the transfer to resume the target agent. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 985041479
1 parent ddbb59e commit 97aefd8

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,14 @@ def _start_node_task(
613613
recovered=recovered,
614614
)
615615

616-
if not result.should_run:
616+
# Use _run_node_internal so the scheduler follows the recovered transfer.
617+
strands_pending_transfer = (
618+
not result.should_run
619+
and result.transfer_to_agent is not None
620+
and bool(recovered.interrupt_ids)
621+
)
622+
623+
if not result.should_run and not strands_pending_transfer:
617624
ancestor_path = ctx.node_path if is_terminal else None
618625

619626
if ancestor_path:

‎tests/unittests/workflow/test_workflow_llm_agent_interruptions.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,3 +1058,77 @@ async def test_workflow_mixed_turn_lro_pause(
10581058
and any(p.text and 'Parent all done' in p.text for p in e.content.parts)
10591059
]
10601060
assert parent_finished_events2, 'Parent did not finish after resume!'
1061+
1062+
1063+
@pytest.mark.asyncio
1064+
async def test_static_node_fast_forward_resumes_transfer_target(
1065+
request: pytest.FixtureRequest,
1066+
):
1067+
"""Resuming a fast-forwarded static node still resumes its transfer target."""
1068+
beta_model = testing_utils.MockModel.create(
1069+
responses=[
1070+
types.Part.from_function_call(name='long_running_tool_func', args={}),
1071+
types.Part.from_text(text='BETA_RESUMED_OUTPUT'),
1072+
]
1073+
)
1074+
alpha_model = testing_utils.MockModel.create(
1075+
responses=[
1076+
types.Part.from_function_call(
1077+
name='transfer_to_agent', args={'agent_name': 'beta'}
1078+
),
1079+
]
1080+
)
1081+
1082+
beta = LlmAgent(
1083+
name='beta',
1084+
model=beta_model,
1085+
tools=[LongRunningFunctionTool(func=long_running_tool_func)],
1086+
)
1087+
alpha = LlmAgent(name='alpha', model=alpha_model, sub_agents=[beta])
1088+
1089+
wf = Workflow(name='wf', edges=[(START, alpha)])
1090+
app = App(
1091+
name=request.function.__name__,
1092+
root_agent=wf,
1093+
resumability_config=ResumabilityConfig(is_resumable=True),
1094+
)
1095+
runner = testing_utils.InMemoryRunner(app=app)
1096+
1097+
# Turn 1: alpha transfers to beta, beta pauses on the long-running tool.
1098+
events1 = await runner.run_async(testing_utils.get_user_content('go'))
1099+
assert any(e.long_running_tool_ids for e in events1)
1100+
1101+
invocation_id = events1[0].invocation_id
1102+
fc_event = workflow_testing_utils.find_function_call_event(
1103+
events1, 'long_running_tool_func'
1104+
)
1105+
assert fc_event is not None
1106+
function_call_id = fc_event.content.parts[0].function_call.id
1107+
1108+
tool_response = testing_utils.UserContent(
1109+
types.Part(
1110+
function_response=types.FunctionResponse(
1111+
id=function_call_id,
1112+
name='long_running_tool_func',
1113+
response={'result': 'tool done'},
1114+
)
1115+
)
1116+
)
1117+
1118+
# Turn 2: resume. alpha is fast-forwarded but must still reach beta.
1119+
events2 = await runner.run_async(
1120+
new_message=tool_response,
1121+
invocation_id=invocation_id,
1122+
)
1123+
1124+
resumed_texts = [
1125+
p.text
1126+
for e in events2
1127+
if e.content and e.content.parts
1128+
for p in e.content.parts
1129+
if p.text
1130+
]
1131+
assert any('BETA_RESUMED_OUTPUT' in t for t in resumed_texts), (
1132+
'Transfer target was not resumed after the static node was'
1133+
f' fast-forwarded; got {resumed_texts!r}'
1134+
)

0 commit comments

Comments
 (0)