Skip to content

Commit ab4dba6

Browse files
DeanChensjcopybara-github
authored andcommitted
feat(workflow): filter rewound invocations during workflow rehydration
Runner.rewind_async records rewinds by appending an event with rewind_before_invocation_id rather than deleting historical events from session.events. ReplayManager._ensure_index, ReplayManager.scan_workflow_events, NodeRunner._create_child_context, _find_unresolved_task_delegations, _find_active_task_scope, restore_branch_from_history, _find_user_message_for_invocation, and _resolve_invocation_id_from_fr previously read raw session.events directly, so re-running or resuming a workflow after rewind_async replayed rewound node outputs, branches, user inputs, and task scopes instead of executing fresh. Filter session.events through _apply_rewinds before indexing or scanning workflow history. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 987118607
1 parent 896a29a commit ab4dba6

8 files changed

Lines changed: 396 additions & 19 deletions

File tree

‎src/google/adk/agents/_agent_router.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,13 @@ def restore_branch_from_history(
194194
(a fresh direct-node turn, or a new invocation continuing a sub-agent), the
195195
most recent matching event across the session is used.
196196
"""
197+
from ..events._rewind_events import _apply_rewinds
197198
from ..workflow._base_node import find_static_node_path
198199

200+
live_events = _apply_rewinds(invocation_context.session.events)
199201
expected_static_path = find_static_node_path(root, node)
200-
tool_call_ids = _collect_function_call_ids(invocation_context.session.events)
201-
for event in reversed(invocation_context.session.events):
202+
tool_call_ids = _collect_function_call_ids(live_events)
203+
for event in reversed(live_events):
202204
if invocation_id is not None and event.invocation_id != invocation_id:
203205
continue
204206
if not event.branch:

‎src/google/adk/runners.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from .auth.credential_service.base_credential_service import BaseCredentialService
5050
from .errors._stale_session_error import StaleSessionError
5151
from .errors.session_not_found_error import SessionNotFoundError
52+
from .events._rewind_events import _apply_rewinds
5253
from .events.event import Event
5354
from .events.event_actions import EventActions
5455
from .flows.llm_flows.context import _contents as contents
@@ -120,8 +121,9 @@ def _find_active_task_scope(session: Session) -> Optional[tuple[str, str]]:
120121
# We must do this in a separate pass because walking backward directly would
121122
# hit post-finish events (like status updates or duplicate FRs) before hitting
122123
# the older success FR, falsely indicating the scope is still active.
124+
live_events = _apply_rewinds(session.events)
123125
finished_scopes: set[str] = set()
124-
for event in session.events:
126+
for event in live_events:
125127
scope = event.isolation_scope
126128
if not scope:
127129
continue
@@ -138,7 +140,7 @@ def _find_active_task_scope(session: Session) -> Optional[tuple[str, str]]:
138140
break
139141

140142
# Pass 2: Walk backward to find the latest active scope that is not finished.
141-
for event in reversed(session.events):
143+
for event in reversed(live_events):
142144
scope = event.isolation_scope
143145
if not scope:
144146
continue
@@ -637,7 +639,7 @@ def _resolve_invocation_id_from_fr(
637639

638640
# Find invocation_id for each FR by matching its FC in session
639641
invocation_ids = set()
640-
for event in reversed(session.events):
642+
for event in reversed(_apply_rewinds(session.events)):
641643
for fc in event.get_function_calls():
642644
if fc.id in fr_ids:
643645
invocation_ids.add(event.invocation_id)
@@ -736,7 +738,7 @@ def _find_user_message_for_invocation(
736738
invocation used to fail outright, because the caller treats "not found" as
737739
an error.
738740
"""
739-
for event in events:
741+
for event in _apply_rewinds(events):
740742
if (
741743
event.invocation_id == invocation_id
742744
and event.author == 'user'

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,12 @@ def _find_unresolved_task_delegations(
174174
current turn's scope would hide the coordinator's own FC from a
175175
prior turn. Author + tool-name filtering is sufficient.
176176
"""
177+
from ..events._rewind_events import _apply_rewinds
177178
from ..tools.agent_tool import _TaskAgentTool
178179

179180
fc_by_id: dict[str, types.FunctionCall] = {}
180181
fr_ids: set[str] = set()
181-
for event in session.events:
182+
for event in _apply_rewinds(session.events):
182183
if event.author != owner and event.author != 'user':
183184
continue
184185
if not event.content or not event.content.parts:

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,14 @@ def _create_child_context(
239239
)
240240

241241
if ic.session and ic.session.events:
242+
from ..events._rewind_events import _apply_rewinds
243+
244+
live_events = _apply_rewinds(ic.session.events)
242245
node_path = ctx.node_path
243246
node_path_builder = _NodePathBuilder.from_string(node_path)
244247
has_prior_node_events = bool(self._prior_interrupt_ids)
245248
if not has_prior_node_events:
246-
for ev in ic.session.events:
249+
for ev in live_events:
247250
if ic.invocation_id and ev.invocation_id != ic.invocation_id:
248251
continue
249252
if ev.node_info is not None and ev.node_info.path:
@@ -257,7 +260,7 @@ def _create_child_context(
257260
from .utils._rehydration_utils import _reconstruct_node_states
258261

259262
states = _reconstruct_node_states(
260-
events=ic.session.events,
263+
events=live_events,
261264
base_path=node_path,
262265
invocation_id=ic.invocation_id,
263266
)

‎src/google/adk/workflow/utils/_replay_manager.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ...agents.context import Context
2222
from ...events._branch_path import _BranchPath
2323
from ...events._node_path_builder import _NodePathBuilder
24+
from ...events._rewind_events import _apply_rewinds
2425
from ...events.event import Event
2526
from ._rehydration_utils import _ChildScanState
2627
from ._rehydration_utils import _reconstruct_node_states
@@ -50,6 +51,7 @@ def __init__(self) -> None:
5051
self._events_by_parent: dict[str, list[Event]] = {}
5152
self._transitive_events_by_parent: dict[str, list[Event]] = {}
5253
self._fc_to_parent: dict[str, str] = {}
54+
self._live_events: list[Event] = []
5355
self._indexed_event_count: int = 0
5456
self._indexed_last_event: Event | None = None
5557

@@ -76,14 +78,25 @@ def _ensure_index(self, ctx: Context) -> list[Event]:
7678
existing buckets then hold events the session no longer has.
7779
"""
7880
ic = ctx._invocation_context
79-
events = ic.session.events
80-
if self._indexed_prefix_is_intact(events):
81-
if len(events) > self._indexed_event_count:
82-
self._index_events(events[self._indexed_event_count :])
83-
self._record_indexed_through(events)
84-
else:
85-
self._build_event_index(events)
86-
return events
81+
raw_events = ic.session.events
82+
if self._indexed_event_count > 0 and self._indexed_prefix_is_intact(
83+
raw_events
84+
):
85+
delta = raw_events[self._indexed_event_count :]
86+
has_new_rewind = any(
87+
ev.actions and ev.actions.rewind_before_invocation_id for ev in delta
88+
)
89+
if not has_new_rewind:
90+
if delta:
91+
self._live_events.extend(delta)
92+
self._index_events(delta)
93+
self._record_indexed_through(raw_events)
94+
return self._live_events
95+
96+
self._live_events = _apply_rewinds(raw_events)
97+
self._build_event_index(self._live_events)
98+
self._record_indexed_through(raw_events)
99+
return self._live_events
87100

88101
def _indexed_prefix_is_intact(self, events: list[Event]) -> bool:
89102
"""Whether the already-indexed events are still a prefix of `events`.
@@ -116,6 +129,7 @@ def _build_event_index(self, events: list[Event]) -> None:
116129
self._events_by_parent = {}
117130
self._transitive_events_by_parent = {}
118131
self._fc_to_parent = {}
132+
self._live_events = list(events)
119133
self._index_events(events)
120134
self._record_indexed_through(events)
121135

@@ -317,8 +331,7 @@ def scan_workflow_events(
317331
"""Scan session events for direct child workflow nodes and initialize sequence barrier."""
318332
ic = ctx._invocation_context
319333

320-
# Build the index
321-
self._build_event_index(ic.session.events)
334+
self._ensure_index(ctx)
322335

323336
# Use transitive parent events for static child nodes so deeper descendant events (e.g. delegated outputs/interrupts) are recovered
324337
filtered_events = self._transitive_events_by_parent.get(ctx.node_path, [])

‎tests/unittests/agents/test_agent_router.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from google.adk.agents.run_config import RunConfig
2626
from google.adk.apps.app import ResumabilityConfig
2727
from google.adk.events.event import Event
28+
from google.adk.events.event_actions import EventActions
2829
from google.adk.sessions.in_memory_session_service import InMemorySessionService
2930
from google.adk.sessions.session import Session
3031
from google.genai import types
@@ -495,3 +496,78 @@ def test_restore_branch_from_history():
495496

496497
_agent_router.restore_branch_from_history(ic, sub1, root=root)
497498
assert ic.branch == "root@1.sub_agent1@1"
499+
500+
501+
def test_restore_branch_from_history_skips_rewound_events():
502+
"""restore_branch_from_history ignores branches authored in rewound invocations."""
503+
session_service = InMemorySessionService()
504+
session = Session(
505+
id="s1",
506+
app_name="app",
507+
user_id="u1",
508+
events=[
509+
Event(
510+
author="sub_agent1",
511+
branch="root@1.sub_agent1@1",
512+
invocation_id="inv_1",
513+
),
514+
Event(
515+
author="sub_agent1",
516+
branch="root@1.sub_agent1@2",
517+
invocation_id="inv_2",
518+
),
519+
Event(
520+
author="user",
521+
invocation_id="inv_3",
522+
actions=EventActions(rewind_before_invocation_id="inv_2"),
523+
),
524+
],
525+
)
526+
root, sub1, _, _ = _make_agent_tree()
527+
528+
ic = InvocationContext(
529+
session_service=session_service,
530+
invocation_id="inv_3",
531+
agent=sub1,
532+
session=session,
533+
run_config=RunConfig(),
534+
)
535+
ic.branch = None
536+
537+
_agent_router.restore_branch_from_history(ic, sub1, root=root)
538+
assert ic.branch == "root@1.sub_agent1@1"
539+
540+
541+
def test_restore_branch_from_history_all_rewound_leaves_branch_none():
542+
"""restore_branch_from_history leaves branch as None if all matches are rewound."""
543+
session_service = InMemorySessionService()
544+
session = Session(
545+
id="s1",
546+
app_name="app",
547+
user_id="u1",
548+
events=[
549+
Event(
550+
author="sub_agent1",
551+
branch="root@1.sub_agent1@1",
552+
invocation_id="inv_1",
553+
),
554+
Event(
555+
author="user",
556+
invocation_id="inv_2",
557+
actions=EventActions(rewind_before_invocation_id="inv_1"),
558+
),
559+
],
560+
)
561+
root, sub1, _, _ = _make_agent_tree()
562+
563+
ic = InvocationContext(
564+
session_service=session_service,
565+
invocation_id="inv_2",
566+
agent=sub1,
567+
session=session,
568+
run_config=RunConfig(),
569+
)
570+
ic.branch = None
571+
572+
_agent_router.restore_branch_from_history(ic, sub1, root=root)
573+
assert ic.branch is None

‎tests/unittests/workflow/test_dynamic_node_scheduler.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def _make_fr_event(fc_id, response, invocation_id='inv-1'):
118118
event.branch = None
119119
event.isolation_scope = None
120120
event.long_running_tool_ids = None
121+
event.actions = None
121122

122123
fr = MagicMock()
123124
fr.id = fc_id

0 commit comments

Comments
 (0)