@@ -736,6 +736,8 @@ def run_sync(
736736
737737 if already_running_loop is not None :
738738 # This method is only expected to run when no loop is already active.
739+ # (Each thread has its own default loop; concurrent sync runs should happen on
740+ # different threads. In a single thread use the async API to interleave work.)
739741 raise RuntimeError (
740742 "AgentRunner.run_sync() cannot be called when an event loop is already running."
741743 )
@@ -752,6 +754,7 @@ def run_sync(
752754 # We intentionally leave the default loop open even if we had to create one above. Session
753755 # instances and other helpers stash loop-bound primitives between calls and expect to find
754756 # the same default loop every time run_sync is invoked on this thread.
757+ # Schedule the async run on the default loop so that we can manage cancellation explicitly.
755758 task = default_loop .create_task (
756759 self .run (
757760 starting_agent ,
@@ -767,13 +770,22 @@ def run_sync(
767770 )
768771
769772 try :
773+ # Drive the coroutine to completion, harvesting the final RunResult.
770774 return default_loop .run_until_complete (task )
771775 except BaseException :
776+ # If the sync caller aborts (KeyboardInterrupt, etc.), make sure the scheduled task
777+ # does not linger on the shared loop by cancelling it and waiting for completion.
772778 if not task .done ():
773779 task .cancel ()
774780 with contextlib .suppress (asyncio .CancelledError ):
775781 default_loop .run_until_complete (task )
776782 raise
783+ finally :
784+ if not default_loop .is_closed ():
785+ # The loop stays open for subsequent runs, but we still need to flush any pending
786+ # async generators so their cleanup code executes promptly.
787+ with contextlib .suppress (RuntimeError ):
788+ default_loop .run_until_complete (default_loop .shutdown_asyncgens ())
777789
778790 def run_streamed (
779791 self ,
0 commit comments