2020
2121from ...agents .context import Context
2222from ...events ._node_path_builder import _NodePathBuilder
23+ from ...events .event import Event
2324from ._rehydration_utils import _ChildScanState
2425from ._rehydration_utils import _reconstruct_node_states
2526from ._rehydration_utils import is_terminal_event
@@ -35,6 +36,8 @@ def __init__(self) -> None:
3536 self ._recovered_executions : dict [str , _ChildScanState ] = {}
3637 self ._sequence_barrier : ReplaySequenceBarrier | None = None
3738 self ._parent_sequence_barriers : dict [str , ReplaySequenceBarrier ] = {}
39+ self ._events_by_parent : dict [str , list [Event ]] = {}
40+ self ._transitive_events_by_parent : dict [str , list [Event ]] = {}
3841
3942 @property
4043 def recovered_executions (self ) -> dict [str , _ChildScanState ]:
@@ -46,18 +49,135 @@ def sequence_barrier(self) -> ReplaySequenceBarrier | None:
4649 """Sequence barrier for deterministic replay ordering."""
4750 return self ._sequence_barrier
4851
52+ def _ensure_index (self , ctx : Context ) -> None :
53+ """Ensures event indexes are initialized and up-to-date with current session.
54+
55+ In multi-turn sessions, new events are added to session history on each turn.
56+ Rebuilding the index whenever event count changes ensures rehydration
57+ always operates on the complete event stream across turns.
58+ """
59+ ic = ctx ._invocation_context
60+ events = ic .session .events
61+ if getattr (self , "_indexed_event_count" , - 1 ) != len (events ):
62+ self ._build_event_index (events , ic .invocation_id )
63+ self ._indexed_event_count = len (events )
64+
65+ def _build_event_index (self , events : list [Event ], invocation_id : str ) -> None :
66+ """Builds index of events grouped by parent path (both direct and transitive)."""
67+ self ._events_by_parent = {}
68+ self ._transitive_events_by_parent = {}
69+ fc_to_parent : dict [str , str ] = {}
70+
71+ from ._workflow_hitl_utils import get_request_input_interrupt_ids
72+
73+ for event in events :
74+ if event .author == "user" :
75+ self ._index_user_event (event , fc_to_parent )
76+ continue
77+
78+ path = event .node_info .path or ""
79+ if not path :
80+ continue
81+
82+ path_builder = _NodePathBuilder .from_string (path )
83+ parent_path = str (path_builder .parent ) if path_builder .parent else ""
84+
85+ self ._add_event_to_index (parent_path , event )
86+
87+ # Track interrupts to route future user responses
88+ interrupt_ids = set (event .long_running_tool_ids or [])
89+ interrupt_ids .update (get_request_input_interrupt_ids (event ))
90+ for fid in interrupt_ids :
91+ fc_to_parent [fid ] = parent_path
92+
93+ def _index_user_event (
94+ self , event : Event , fc_to_parent : dict [str , str ]
95+ ) -> None :
96+ """Routes user response events to parent path based on function call IDs."""
97+ if not event .content or not event .content .parts :
98+ return
99+ matched = False
100+ added_parents : set [str ] = set ()
101+ for part in event .content .parts :
102+ fr = part .function_response
103+ if fr and fr .id and fr .id in fc_to_parent :
104+ parent = fc_to_parent [fr .id ]
105+ if parent not in added_parents :
106+ self ._add_event_to_index (parent , event )
107+ added_parents .add (parent )
108+ matched = True
109+
110+ if not matched :
111+ # General user prompt event: add to root ("")
112+ self ._events_by_parent .setdefault ("" , []).append (event )
113+ self ._transitive_events_by_parent .setdefault ("" , []).append (event )
114+
115+ def get_events_for_rehydration (
116+ self , ctx : Context , node_path : str
117+ ) -> list [Event ]:
118+ """Retrieves pre-filtered session events relevant to rehydrating a node path.
119+
120+ Instead of performing an O(N) linear scan over all session events, this
121+ queries pre-indexed transitive events under the node's parent path. Top-level
122+ user prompts are merged in to ensure multi-turn conversation context is visible
123+ while preserving strict session chronological ordering.
124+ """
125+ if not node_path :
126+ return []
127+
128+ self ._ensure_index (ctx )
129+ path_builder = _NodePathBuilder .from_string (node_path )
130+ parent_builder = path_builder .parent
131+ if not parent_builder or not str (parent_builder ):
132+ return ctx ._invocation_context .session .events
133+ parent_path = str (parent_builder )
134+
135+ node_events = self ._transitive_events_by_parent .get (parent_path , [])
136+ if not node_events :
137+ return ctx ._invocation_context .session .events
138+
139+ # Top-level user text prompts live under root key ("").
140+ # Merge them so multi-turn turn inputs remain visible during state reconstruction.
141+ root_events = self ._events_by_parent .get ("" , [])
142+ user_prompts = [
143+ e for e in root_events if e .author == "user" and e not in node_events
144+ ]
145+
146+ if not user_prompts :
147+ return node_events
148+
149+ # Retain exact chronological ordering of session events.
150+ session_events = ctx ._invocation_context .session .events
151+ event_ids = {id (e ) for e in node_events }.union (id (e ) for e in user_prompts )
152+ return [e for e in session_events if id (e ) in event_ids ]
153+
154+ def _add_event_to_index (self , parent_path : str , event : Event ) -> None :
155+ """Indexes an event under its direct parent and all ancestor paths up to root."""
156+ self ._events_by_parent .setdefault (parent_path , []).append (event )
157+
158+ # Propagate event up through all ancestor paths so parent and grandparent nodes
159+ # can query all sub-tree events in O(1) via _transitive_events_by_parent.
160+ curr : _NodePathBuilder | None = (
161+ _NodePathBuilder .from_string (parent_path ) if parent_path else None
162+ )
163+ while curr is not None and str (curr ):
164+ self ._transitive_events_by_parent .setdefault (str (curr ), []).append (event )
165+ curr = curr .parent
166+
167+ self ._transitive_events_by_parent .setdefault ("" , []).append (event )
168+
49169 def _scan_sequence (
50- self , ctx : Context , base_path : str , strict_direct_child : bool = False
170+ self ,
171+ events : list [Event ],
172+ ctx : Context ,
173+ base_path : str ,
174+ strict_direct_child : bool = False ,
51175 ) -> list [str ]:
52176 """Extract chronological child completion sequence under base_path."""
53- ic = ctx ._invocation_context
54177 base_path_builder = _NodePathBuilder .from_string (base_path )
55178 sequence : list [str ] = []
56179
57- for event in ic .session .events :
58- if event .invocation_id != ic .invocation_id :
59- continue
60-
180+ for event in events :
61181 event_node_path = event .node_info .path or ""
62182 event_path_builder = _NodePathBuilder .from_string (event_node_path )
63183
@@ -82,15 +202,23 @@ def scan_workflow_events(
82202 ) -> tuple [dict [str , _ChildScanState ], list [str ]]:
83203 """Scan session events for direct child workflow nodes and initialize sequence barrier."""
84204 ic = ctx ._invocation_context
205+
206+ # Build the index
207+ self ._build_event_index (ic .session .events , ic .invocation_id )
208+
209+ # Use transitive parent events for static child nodes so deeper descendant events (e.g. delegated outputs/interrupts) are recovered
210+ filtered_events = self ._transitive_events_by_parent .get (ctx .node_path , [])
85211 raw_results = _reconstruct_node_states (
86- events = ic . session . events ,
212+ events = filtered_events ,
87213 base_path = ctx .node_path ,
88214 group_by_direct_child = True ,
89215 invocation_id = ic .invocation_id ,
90216 )
91217
218+ # Use transitive events for sequence (strict_direct_child = False)
219+ transitive_events = self ._transitive_events_by_parent .get (ctx .node_path , [])
92220 sequence = self ._scan_sequence (
93- ctx , ctx .node_path , strict_direct_child = False
221+ transitive_events , ctx , ctx .node_path , strict_direct_child = False
94222 )
95223
96224 self ._recovered_executions = raw_results
@@ -102,7 +230,14 @@ def prepare_parent_sequence_barrier(
102230 ) -> ReplaySequenceBarrier :
103231 """Ensure a sequence barrier is set up for dynamic nodes under parent_path."""
104232 if parent_path not in self ._parent_sequence_barriers :
105- seq = self ._scan_sequence (ctx , parent_path , strict_direct_child = True )
233+ self ._ensure_index (ctx )
234+
235+ # Dynamic parent path uses strict_direct_child=True.
236+ # So we use the direct parent index.
237+ events = self ._events_by_parent .get (parent_path , [])
238+ seq = self ._scan_sequence (
239+ events , ctx , parent_path , strict_direct_child = True
240+ )
106241 self ._parent_sequence_barriers [parent_path ] = ReplaySequenceBarrier (seq )
107242 return self ._parent_sequence_barriers [parent_path ]
108243
0 commit comments