99import threading
1010import time
1111from dataclasses import dataclass , field , replace
12- from typing import Any , Dict , Iterable , List , Optional
12+ from typing import Any , Dict , Iterable , List , Optional , Set
1313
1414from .tool_result_store import FileToolResultStore
1515from .tools import Tool , ToolContext , ToolResult , ToolSet , error_result
2323 EVENT_TYPE_AGENT_TOOL_USE ,
2424 EVENT_TYPE_SESSION_DELETED ,
2525 EVENT_TYPE_SESSION_STATUS_IDLE ,
26+ EVENT_TYPE_SESSION_STATUS_RESCHEDULED ,
27+ EVENT_TYPE_SESSION_STATUS_RUNNING ,
2628 EVENT_TYPE_SESSION_STATUS_TERMINATED ,
2729 EVENT_TYPE_USER_CUSTOM_TOOL_RESULT ,
2830 EVENT_TYPE_USER_TOOL_CONFIRMATION ,
2931 EVENT_TYPE_USER_TOOL_RESULT ,
3032 PERMISSION_ALLOW ,
3133 PERMISSION_DENY ,
3234 SESSION_STOP_REASON_END_TURN ,
35+ SESSION_STOP_REASON_REQUIRES_ACTION ,
3336 ContentBlock ,
3437 Event ,
3538 EventStreamUnsupported ,
@@ -128,6 +131,7 @@ def run(self) -> List[ToolCallResult]:
128131 if self .options .result_store is not None :
129132 pending , processed = self .options .result_store .recover ()
130133 self ._state .pending_results .update (pending )
134+ self ._state .recovered_results .update (pending )
131135 self ._state .processed .update (processed )
132136 self ._state .answered .update (processed )
133137 if self .options .prefer_stream and hasattr (self .api , "stream_events" ):
@@ -200,8 +204,8 @@ def _put_stream_item(self, event_queue: "queue.Queue[object]", item: object) ->
200204
201205 def _consume_list (self ) -> None :
202206 while not self ._is_stopped ():
203- self ._state .flush_results ()
204207 self ._state .reconcile (reconcile = False )
208+ self ._state .flush_results ()
205209 self ._raise_if_idle_expired ()
206210 self ._sleep_or_idle (self .options .event_poll_interval_seconds )
207211
@@ -231,9 +235,14 @@ def __init__(self, runner: SessionToolRunner) -> None:
231235 self .seen : Dict [str , bool ] = {}
232236 self .answered : Dict [str , bool ] = {}
233237 self .pending_results : Dict [str , Event ] = {}
238+ self .recovered_results : Set [str ] = set ()
234239 self .pending_ask : Dict [str , Event ] = {}
235240 self .confirmations : Dict [str , Event ] = {}
236241 self .external_tools : Dict [str , Event ] = {}
242+ self .session_tool_uses : Set [str ] = set ()
243+ self .tool_uses_since_status : Set [str ] = set ()
244+ self .blocking_event_ids : Set [str ] = set ()
245+ self .blocking_events_known = False
237246 self .idle_armed_at = 0.0
238247 self .idle_arm_pending = False
239248
@@ -281,6 +290,7 @@ def process_listed_events(self, events: Iterable[Event], reconcile: bool = False
281290 seen_now = self .mark_event_seen (event )
282291 if not reconcile and not seen_now :
283292 continue
293+ self .observe_session_state (event )
284294 if seen_now and event .type != EVENT_TYPE_USER_TOOL_CONFIRMATION :
285295 touched_idle = True
286296 last_was_end_turn = (
@@ -298,10 +308,12 @@ def process_listed_events(self, events: Iterable[Event], reconcile: bool = False
298308 pending_ids [call_id ] = True
299309 elif event .type in (EVENT_TYPE_SESSION_STATUS_TERMINATED , EVENT_TYPE_SESSION_DELETED ):
300310 raise SessionTerminated ("session terminated" )
311+ self .reconcile_recovered_results ()
301312 if touched_idle :
302313 self .disarm_idle ()
303314 for event in pending :
304- if self .is_answered (tool_use_call_id (event )):
315+ call_id = tool_use_call_id (event )
316+ if self .is_answered (call_id ) or not self .should_handle_tool_use (call_id ):
305317 continue
306318 self .handle_tool_use (event , event .type == EVENT_TYPE_AGENT_CUSTOM_TOOL_USE )
307319 self .release_confirmed_tool_uses ()
@@ -322,6 +334,8 @@ def note_idle_event(self, event: Event) -> None:
322334 def handle_stream_event (self , event : Event ) -> None :
323335 if not self .mark_event_seen (event ):
324336 return
337+ self .observe_session_state (event )
338+ self .reconcile_recovered_results ()
325339 self .note_idle_event (event )
326340 self .handle_event (event )
327341
@@ -351,6 +365,7 @@ def mark_answered(self, call_id: str) -> None:
351365 self .answered [call_id ] = True
352366 self .processed [call_id ] = True
353367 self .pending_results .pop (call_id , None )
368+ self .recovered_results .discard (call_id )
354369 self .pending_ask .pop (call_id , None )
355370 self .external_tools .pop (call_id , None )
356371 self .maybe_arm_pending_idle ()
@@ -372,7 +387,7 @@ def release_confirmed_tool_uses(self) -> None:
372387 def has_unblocked_outstanding_tool (self , pending : Iterable [Event ]) -> bool :
373388 for event in pending :
374389 call_id = tool_use_call_id (event )
375- if not call_id or self .is_answered (call_id ):
390+ if not call_id or self .is_answered (call_id ) or not self . should_handle_tool_use ( call_id ) :
376391 continue
377392 if call_id in self .pending_ask or call_id in self .pending_results :
378393 continue
@@ -385,6 +400,8 @@ def handle_tool_use(self, event: Event, custom: bool) -> None:
385400 return
386401 pending = self .pending_results .get (call_id )
387402 if pending is not None :
403+ if call_id in self .recovered_results :
404+ return
388405 self .send_result (call_id , event , custom , "" , pending )
389406 return
390407 if not self .owns_tool (event , custom ):
@@ -541,6 +558,8 @@ def retry_send_event(self, event: Event, call_id: str) -> bool:
541558
542559 def flush_results (self ) -> None :
543560 for call_id , event in list (self .pending_results .items ()):
561+ if call_id in self .recovered_results :
562+ continue
544563 if self .retry_send_event (event , call_id ):
545564 self .mark_answered (call_id )
546565 if self .runner .options .result_store is not None :
@@ -555,6 +574,54 @@ def flush_results(self) -> None:
555574 )
556575 self .maybe_arm_pending_idle ()
557576
577+ def observe_session_state (self , event : Event ) -> None :
578+ if event .type in (EVENT_TYPE_AGENT_TOOL_USE , EVENT_TYPE_AGENT_CUSTOM_TOOL_USE ):
579+ call_id = tool_use_call_id (event )
580+ if call_id :
581+ self .session_tool_uses .add (call_id )
582+ self .tool_uses_since_status .add (call_id )
583+ return
584+ if event .type == EVENT_TYPE_SESSION_STATUS_IDLE :
585+ self .blocking_events_known = True
586+ self .blocking_event_ids = set ()
587+ if event .stop_reason_type () == SESSION_STOP_REASON_REQUIRES_ACTION :
588+ self .blocking_event_ids .update (event .stop_reason_event_ids ())
589+ self .tool_uses_since_status .clear ()
590+ return
591+ if event .type in (EVENT_TYPE_SESSION_STATUS_RUNNING , EVENT_TYPE_SESSION_STATUS_RESCHEDULED ):
592+ self .blocking_events_known = True
593+ self .blocking_event_ids .clear ()
594+ self .tool_uses_since_status .clear ()
595+
596+ def should_handle_tool_use (self , call_id : str ) -> bool :
597+ if not self .blocking_events_known :
598+ return True
599+ return call_id in self .blocking_event_ids or call_id in self .tool_uses_since_status
600+
601+ def reconcile_recovered_results (self ) -> None :
602+ if not self .blocking_events_known :
603+ return
604+ for call_id in list (self .recovered_results ):
605+ if call_id in self .blocking_event_ids and call_id in self .session_tool_uses :
606+ self .recovered_results .discard (call_id )
607+ continue
608+ if call_id in self .tool_uses_since_status :
609+ continue
610+ self .recovered_results .discard (call_id )
611+ self .pending_results .pop (call_id , None )
612+ self .runner .options .logger .warning ("discard stale recovered tool result tool_use_id=%s" , call_id )
613+ if self .runner .options .result_store is not None :
614+ discard = getattr (self .runner .options .result_store , "discard" , None )
615+ if not callable (discard ):
616+ continue
617+ try :
618+ discard (call_id )
619+ except Exception as exc : # noqa: BLE001 - optional custom store cleanup must not stop the runner.
620+ self .runner .options .logger .warning (
621+ "discard persisted tool result failed tool_use_id=%s err=%s" , call_id , exc
622+ )
623+ self .maybe_arm_pending_idle ()
624+
558625 def arm_idle (self ) -> None :
559626 if not self .max_idle_seconds ():
560627 return
0 commit comments