Skip to content

Commit 2fb877c

Browse files
GWealecopybara-github
authored andcommitted
fix: rerun a paused workflow node after the user answers it
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 970952078
1 parent d0d5ade commit 2fb877c

2 files changed

Lines changed: 72 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ def get_owner_key(event_path_builder: _NodePathBuilder) -> str | None:
266266
if owner not in scan_states:
267267
scan_states[owner] = _ChildScanState()
268268
scan_states[owner].resolved_ids.add(fr.id)
269+
# The node paused mid-run to ask this, so what it emitted earlier
270+
# in the scan is not its result. Its route stays: a node that does
271+
# not rerun never gets to pick an edge again.
272+
scan_states[owner].output = None
269273
response_data = _unwrap_response(fr.response)
270274

271275
schema = schemas_by_id.get(fr.id)

tests/unittests/workflow/test_workflow_hitl.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def long_running_tool_func():
8787
return None
8888

8989

90+
def request_approval_tool() -> dict[str, str]:
91+
"""A test tool that asks a human to approve and answers only later."""
92+
return {'status': 'awaiting_approval'}
93+
94+
9095
@pytest.mark.parametrize('resumable', [False, True])
9196
@pytest.mark.asyncio
9297
async def test_workflow_pause_and_resume(
@@ -229,6 +234,69 @@ async def test_workflow_pause_and_resume(
229234
assert len(end_events) == 1
230235

231236

237+
@pytest.mark.asyncio
238+
async def test_agent_node_that_spoke_before_pausing_reruns_on_resume(
239+
request: pytest.FixtureRequest,
240+
):
241+
"""An agent node that says something before it pauses still reruns.
242+
243+
The message is recorded as the node's output, so replaying it as a finished
244+
node skips the node on resume: the answer never reaches the model and
245+
approving or refusing reads the same.
246+
"""
247+
mock_model = testing_utils.MockModel.create(
248+
responses=[
249+
types.Part.from_function_call(name='request_approval_tool', args={}),
250+
types.Part.from_text(text='Waiting for you to approve.'),
251+
types.Part.from_text(text='Approved, so I sent it.'),
252+
]
253+
)
254+
sender = LlmAgent(
255+
name='sender',
256+
model=mock_model,
257+
tools=[LongRunningFunctionTool(func=request_approval_tool)],
258+
)
259+
app = App(
260+
name=request.function.__name__,
261+
root_agent=Workflow(name='approval_workflow', edges=[(START, sender)]),
262+
resumability_config=ResumabilityConfig(is_resumable=True),
263+
)
264+
runner = testing_utils.InMemoryRunner(app=app)
265+
266+
events1 = await runner.run_async(testing_utils.get_user_content('send it'))
267+
fc_event = workflow_testing_utils.find_function_call_event(
268+
events1, 'request_approval_tool'
269+
)
270+
interrupt_id = fc_event.content.parts[0].function_call.id
271+
requests_before_resume = len(mock_model.requests)
272+
273+
events2 = await runner.run_async(
274+
new_message=testing_utils.UserContent(
275+
types.Part(
276+
function_response=types.FunctionResponse(
277+
id=interrupt_id,
278+
name='request_approval_tool',
279+
response={'result': 'approved'},
280+
)
281+
)
282+
),
283+
invocation_id=events1[0].invocation_id,
284+
)
285+
286+
# A replayed node hands back its old message without ever reaching the
287+
# model, so a fresh request is what proves the node ran again.
288+
assert len(mock_model.requests) == requests_before_resume + 1
289+
resumed_texts = [
290+
part.text
291+
for event in events2
292+
if event.content and event.content.parts
293+
for part in event.content.parts
294+
if part.text
295+
]
296+
assert resumed_texts == ['Approved, so I sent it.']
297+
assert 'Waiting for you to approve.' not in [e.output for e in events2]
298+
299+
232300
@pytest.mark.asyncio
233301
async def test_workflow_interrupt_allows_parallel_execution(
234302
request: pytest.FixtureRequest,

0 commit comments

Comments
 (0)