Skip to content

Commit 6d14518

Browse files
DeanChensjcopybara-github
authored andcommitted
fix: Support multi-turn nested HITL pause and resumption across workflows and agent tools
Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 973238301
1 parent 8cdbbb1 commit 6d14518

4 files changed

Lines changed: 624 additions & 25 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Deciding how a resumable LLM flow continues from the events it already has.
16+
17+
A resumed invocation replays the branch's events and has to answer one
18+
question before it may call the LLM again: is this branch still waiting on a
19+
tool, does it owe a tool call that was never executed, or is it free to carry
20+
on? The matching that answers it is fiddly -- ids, names, long-running calls
21+
and HITL answers that come back on a sub-branch rather than against the
22+
original call -- so it lives here rather than inline in the flow.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import dataclasses
28+
import enum
29+
from typing import Any
30+
from typing import TYPE_CHECKING
31+
32+
from google.genai import types
33+
34+
from ...events._branch_path import _BranchPath
35+
from ...events.event import Event
36+
37+
if TYPE_CHECKING:
38+
from ...agents.invocation_context import InvocationContext
39+
40+
41+
class ResumeAction(enum.Enum):
42+
"""What the flow should do with the events it resumed from."""
43+
44+
CONTINUE = 'continue'
45+
"""Nothing outstanding; proceed to the LLM call."""
46+
47+
PAUSE = 'pause'
48+
"""A tool call is still unanswered; stop without emitting anything."""
49+
50+
REPLAY_CALLS = 'replay_calls'
51+
"""A tool call was never executed; run the calls on `ResumeDecision.event`."""
52+
53+
54+
@dataclasses.dataclass(frozen=True)
55+
class ResumeDecision:
56+
"""The action to take, and the event it applies to."""
57+
58+
action: ResumeAction
59+
event: Event | None = None
60+
61+
def replay_event(self) -> Event:
62+
"""The event whose calls to run. Only a REPLAY_CALLS decision carries one.
63+
64+
Raises:
65+
ValueError: If the decision names no event, which would mean
66+
`decide_resume` returned REPLAY_CALLS without saying what to replay.
67+
"""
68+
if self.event is None:
69+
raise ValueError(f'{self.action} decision carries no event to replay')
70+
return self.event
71+
72+
73+
def _branch_carries_call(
74+
branch: str | None, function_calls: list[types.FunctionCall]
75+
) -> bool:
76+
"""Whether `branch` was opened by one of `function_calls`.
77+
78+
A branch is a dot-joined `name@run_id` path, so the run ids are parsed out and
79+
compared whole: testing `id in branch` as a substring matches any id that
80+
merely contains this one.
81+
"""
82+
if not branch:
83+
return False
84+
run_ids = _BranchPath.from_string(branch).run_ids
85+
return any(fc.id in run_ids for fc in function_calls if fc.id is not None)
86+
87+
88+
def _pause_left_calls_unanswered(
89+
invocation_context: InvocationContext, events: list[Event]
90+
) -> bool:
91+
"""Whether a pause earlier in `events` is still waiting on a response.
92+
93+
Every event before the last is considered, not just the previous one: an LRO
94+
followed by several text responses leaves the pausing call further back than
95+
a two-event window can see.
96+
"""
97+
pause_events = [
98+
ev for ev in events[:-1] if invocation_context.should_pause_invocation(ev)
99+
]
100+
if not pause_events:
101+
return False
102+
awaited = {
103+
fc.id for ev in pause_events for fc in ev.get_function_calls() if fc.id
104+
}
105+
for ev in pause_events:
106+
if ev.long_running_tool_ids:
107+
awaited.update(ev.long_running_tool_ids)
108+
answered = {
109+
fr.id for ev in events for fr in ev.get_function_responses() if fr.id
110+
}
111+
# `issubset`, not `&`: this asks whether *any* awaited id is still open, so a
112+
# partially answered pause keeps waiting. `decide_resume` asks the opposite
113+
# question of its own ids -- whether *none* are answered -- and drops
114+
# `issubset` for that reason. The two are not interchangeable.
115+
return bool(awaited) and not awaited.issubset(answered)
116+
117+
118+
def _find_target_call_event(
119+
events: list[Event], tools_dict: dict[str, Any]
120+
) -> Event | None:
121+
"""The most recent event before the last that calls a tool this flow owns."""
122+
for ev in reversed(events[:-1]):
123+
calls = ev.get_function_calls()
124+
if calls and any(fc.name in tools_dict for fc in calls):
125+
return ev
126+
return None
127+
128+
129+
def _find_answer_event(
130+
events: list[Event],
131+
call_event: Event,
132+
call_idx: int,
133+
call_ids: set[str | None],
134+
call_names: set[str | None],
135+
) -> Event:
136+
"""The event answering `call_event`, or the last event when none does.
137+
138+
A response counts when it carries a matching id, or a matching name with no
139+
id, or is a HITL prompt raised on a branch that one of the calls opened --
140+
the nested case, where the answer arrives against the sub-branch instead of
141+
against the original call id.
142+
"""
143+
# Imported here, not at module scope: google.adk.workflow imports back into
144+
# the flows package.
145+
# pylint: disable=g-import-not-at-top
146+
from ...workflow.utils._workflow_hitl_utils import REQUEST_CREDENTIAL_FUNCTION_CALL_NAME
147+
from ...workflow.utils._workflow_hitl_utils import REQUEST_INPUT_FUNCTION_CALL_NAME
148+
149+
# pylint: enable=g-import-not-at-top
150+
151+
hitl_names = {
152+
REQUEST_INPUT_FUNCTION_CALL_NAME,
153+
REQUEST_CREDENTIAL_FUNCTION_CALL_NAME,
154+
}
155+
calls = call_event.get_function_calls()
156+
# `call_idx` is passed in rather than searched for again: the caller has
157+
# already located `call_event`, and this runs on every resumable step.
158+
start = call_idx + 1
159+
for ev in reversed(events[start:]):
160+
for fr in ev.get_function_responses():
161+
if (
162+
(fr.id is not None and fr.id in call_ids)
163+
or (fr.id is None and fr.name in call_names)
164+
or (fr.name in hitl_names and _branch_carries_call(ev.branch, calls))
165+
):
166+
return ev
167+
return events[-1]
168+
169+
170+
def _is_sub_branch_answer(answer_event: Event, call_event: Event) -> bool:
171+
"""Whether the answer came back from a branch the call opened."""
172+
return answer_event.author == 'user' and _branch_carries_call(
173+
answer_event.branch, call_event.get_function_calls()
174+
)
175+
176+
177+
def _needs_call_replay(
178+
call_names: set[str | None],
179+
answers: list[types.FunctionResponse],
180+
from_sub_branch: bool,
181+
) -> bool:
182+
"""Whether the calls named by `call_names` still have to be run.
183+
184+
`call_names` holds every name on the call event, not just the first: one
185+
event can carry parallel calls, and an answer to the second is not evidence
186+
the first never ran.
187+
"""
188+
if not call_names:
189+
return False
190+
return (
191+
not answers
192+
or any(fr.name not in call_names for fr in answers)
193+
or from_sub_branch
194+
)
195+
196+
197+
def decide_resume(
198+
invocation_context: InvocationContext,
199+
events: list[Event],
200+
tools_dict: dict[str, Any],
201+
) -> ResumeDecision:
202+
"""Decides how a resumable flow continues from `events`.
203+
204+
Args:
205+
invocation_context: Supplies `should_pause_invocation`.
206+
events: The current branch's events for this invocation, oldest first, and
207+
containing at least two events.
208+
tools_dict: The tools this flow can run, by name.
209+
210+
Returns:
211+
PAUSE when a call is still unanswered, REPLAY_CALLS (naming the event whose
212+
calls to run) when a call was never executed, else CONTINUE.
213+
"""
214+
paused_by_last = invocation_context.should_pause_invocation(events[-1])
215+
if not paused_by_last and _pause_left_calls_unanswered(
216+
invocation_context, events
217+
):
218+
return ResumeDecision(ResumeAction.PAUSE)
219+
220+
pause = paused_by_last
221+
call_event = _find_target_call_event(events, tools_dict)
222+
if call_event:
223+
call_idx = next(i for i, ev in enumerate(events) if ev is call_event)
224+
calls = call_event.get_function_calls()
225+
call_names = {fc.name for fc in calls}
226+
lro_ids = {
227+
lro
228+
for ev in events[call_idx:]
229+
for lro in ev.long_running_tool_ids or []
230+
}
231+
call_ids = {fc.id for fc in calls} | lro_ids
232+
answer_event = _find_answer_event(
233+
events, call_event, call_idx, call_ids, call_names
234+
)
235+
answered_ids = {
236+
fr.id
237+
for ev in events[call_idx + 1 :]
238+
for fr in ev.get_function_responses()
239+
if fr.id is not None
240+
}
241+
# An answer on a sub-branch resolves the call however its ids look, so it
242+
# short-circuits both unanswered tests rather than being repeated in each.
243+
from_sub_branch = _is_sub_branch_answer(answer_event, call_event)
244+
answers = answer_event.get_function_responses()
245+
# `ids & answered` alone decides these: a set that is a subset of the
246+
# answered ids necessarily intersects it, so testing `issubset` as well
247+
# never changes the outcome.
248+
lro_unanswered = bool(lro_ids) and not lro_ids & answered_ids
249+
call_unanswered = (
250+
bool(call_ids)
251+
and not call_ids & answered_ids
252+
and not any(fr.name in call_names for fr in answers)
253+
)
254+
if not from_sub_branch and (lro_unanswered or call_unanswered):
255+
pause = True
256+
elif _needs_call_replay(call_names, answers, from_sub_branch):
257+
return ResumeDecision(ResumeAction.REPLAY_CALLS, call_event)
258+
259+
return ResumeDecision(ResumeAction.PAUSE if pause else ResumeAction.CONTINUE)

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
from ._invocation_utils import require_agent as _require_agent
6363
from ._invocation_utils import require_run_config as _require_run_config
6464
from ._invocation_utils import run_config_for_new_live_session
65+
from ._resume_utils import decide_resume
66+
from ._resume_utils import ResumeAction
6567
from .functions import build_auth_request_event
6668

6769
# Prefix used by toolset auth credential IDs
@@ -1271,6 +1273,28 @@ async def run_async(
12711273
logger.warning('The last event is partial, which is not expected.')
12721274
break
12731275

1276+
async def _replay_function_calls(
1277+
self,
1278+
invocation_context: InvocationContext,
1279+
model_response_event: Event,
1280+
llm_request: LlmRequest,
1281+
) -> AsyncGenerator[Event, None]:
1282+
"""Runs `model_response_event`'s function calls, re-issuing event ids.
1283+
1284+
A node that interrupts mid-call raises `NodeInterruptedError`, which is a
1285+
`BaseException` specifically so intermediate handlers do not swallow it.
1286+
It is left to propagate: `NodeRunner` catches it and reads the interrupt
1287+
ids off the context, which `ctx.run_node` populated before raising.
1288+
"""
1289+
async with Aclosing(
1290+
self._postprocess_handle_function_calls_async(
1291+
invocation_context, model_response_event, llm_request
1292+
)
1293+
) as agen:
1294+
async for event in agen:
1295+
event.id = Event.new_id()
1296+
yield event
1297+
12741298
async def _run_one_step_async(
12751299
self,
12761300
invocation_context: InvocationContext,
@@ -1295,24 +1319,22 @@ async def _run_one_step_async(
12951319
current_invocation=True, current_branch=True
12961320
)
12971321

1298-
# Long running tool calls should have been handled before this point.
1299-
# If there are still long running tool calls, it means the agent is paused
1300-
# before, and its branch hasn't been resumed yet.
1322+
# For a multi-event branch, decide whether to pause (unanswered tool
1323+
# calls or LROs), replay unexecuted tool calls, or continue to the LLM.
13011324
if invocation_context.is_resumable and events and len(events) > 1:
1302-
pause = False
1303-
if invocation_context.should_pause_invocation(events[-1]):
1304-
pause = True
1305-
elif invocation_context.should_pause_invocation(events[-2]):
1306-
# NOTE: This only checks the last 2 events. If an LRO is followed by
1307-
# multiple text responses, this check may not trigger correctly.
1308-
# This is a known limitation of the current 2-event window.
1309-
# Check if the function call in events[-2] is resolved by events[-1]
1310-
fc_ids = {fc.id for fc in events[-2].get_function_calls()}
1311-
fr_ids = {fr.id for fr in events[-1].get_function_responses()}
1312-
if fc_ids and not fc_ids.issubset(fr_ids):
1313-
pause = True
1314-
1315-
if pause:
1325+
decision = decide_resume(
1326+
invocation_context, events, llm_request.tools_dict
1327+
)
1328+
if decision.action is ResumeAction.PAUSE:
1329+
return
1330+
if decision.action is ResumeAction.REPLAY_CALLS:
1331+
async with Aclosing(
1332+
self._replay_function_calls(
1333+
invocation_context, decision.replay_event(), llm_request
1334+
)
1335+
) as agen:
1336+
async for event in agen:
1337+
yield event
13161338
return
13171339

13181340
if (
@@ -1321,16 +1343,14 @@ async def _run_one_step_async(
13211343
and not events[-1].partial
13221344
and events[-1].get_function_calls()
13231345
):
1324-
model_response_event = events[-1]
13251346
async with Aclosing(
1326-
self._postprocess_handle_function_calls_async(
1327-
invocation_context, model_response_event, llm_request
1347+
self._replay_function_calls(
1348+
invocation_context, events[-1], llm_request
13281349
)
13291350
) as agen:
13301351
async for event in agen:
1331-
event.id = Event.new_id()
13321352
yield event
1333-
return
1353+
return
13341354

13351355
# Calls the LLM.
13361356
model_response_event = Event(

0 commit comments

Comments
 (0)