|
| 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) |
0 commit comments