4141AGENT_INHERIT_PREFIXES = ("ws:" , "wf:" )
4242
4343
44+ def _subagent_event (coder : Any , agent_uuid : Any , event : str , ** kwargs : Any ) -> None :
45+ """Report a sub-agent's lifecycle to the IO (see ``InputOutput.subagent_event``)."""
46+ io = getattr (coder , "io" , None )
47+ if io is None :
48+ return
49+ try :
50+ io .subagent_event (str (agent_uuid ), event , ** kwargs )
51+ except Exception :
52+ logger .warning ("Failed to report sub-agent event %r" , event , exc_info = True )
53+
54+
55+ def _was_cancelled (info : "SubAgentInfo" ) -> bool :
56+ """Whether the current run was cancelled, even if the coder absorbed it as a ^C."""
57+ if info .cancel_requested :
58+ return True
59+ cancelling = getattr (asyncio .current_task (), "cancelling" , None ) # Python 3.11+
60+ return bool (cancelling and cancelling ())
61+
62+
4463class SubAgentStatus (Enum ):
4564 """Status of a sub-agent."""
4665
@@ -66,6 +85,7 @@ class SubAgentInfo:
6685 auto_reap : bool = True # If True, agent may be automatically reaped when FINISHED
6786 independent : bool = False
6887 detached : bool = False
88+ cancel_requested : bool = False # the current run was cancelled (see _was_cancelled)
6989
7090
7191class AgentService :
@@ -522,6 +542,8 @@ def _cleanup_sub_agent(self, agent_uuid: str) -> None:
522542 except (AttributeError , RuntimeError ):
523543 logger .warning ("Failed to notify TUI to remove sub-agent container" , exc_info = True )
524544
545+ _subagent_event (self .coder , info .coder .uuid , "removed" )
546+
525547 # Cancel any tracked generate task to avoid floating tasks
526548 if info .generate_task is not None and not info .generate_task .done ():
527549 info .generate_task .cancel ()
@@ -733,6 +755,16 @@ async def _create_sub_agent_coder(
733755 except Exception :
734756 logger .warning ("Failed to notify TUI to create sub-agent container" , exc_info = True )
735757
758+ _subagent_event (
759+ parent_coder ,
760+ new_coder .uuid ,
761+ "created" ,
762+ parent_uuid = str (parent_coder .uuid ),
763+ name = name ,
764+ description = metadata .get ("description" ) if isinstance (metadata , dict ) else None ,
765+ root = str (getattr (new_coder , "root" , "" ) or "" ),
766+ )
767+
736768 # Initialize system prompt from config
737769 system_prompt = getattr (config , "prompt" , "" )
738770 from cecli .helpers .conversation .service import ConversationService
@@ -807,8 +839,14 @@ def start_generate_task(self, info: SubAgentInfo, user_message: str) -> asyncio.
807839
808840 async def _run_generate ():
809841 info .status = SubAgentStatus .RUNNING
842+ info .cancel_requested = False
843+ _subagent_event (info .coder , info .coder .uuid , "running" )
844+ stop_reason , error = "end_turn" , None
810845 try :
811846 await info .coder .generate (user_message = user_message , preproc = True )
847+ if _was_cancelled (info ):
848+ stop_reason = "cancelled"
849+ info .summary = info .summary or DEFAULT_SUMMARY_INTERRUPTED
812850 await self ._inject_sub_agent_result (info )
813851 if info .status == SubAgentStatus .RUNNING :
814852 info .status = SubAgentStatus .FINISHED
@@ -831,12 +869,14 @@ async def _cleanup():
831869 fire_and_forget (_cleanup ())
832870
833871 except asyncio .CancelledError :
872+ stop_reason = "cancelled"
834873 await self ._inject_sub_agent_result (info )
835874 info .status = SubAgentStatus .FINISHED
836875 info .summary = info .summary or DEFAULT_SUMMARY_INTERRUPTED
837876 logger .debug ("Sub-agent %s generate cancelled (interrupted)" , info .name )
838877 raise
839878 except Exception as exc :
879+ stop_reason , error = "error" , str (exc ) or exc .__class__ .__name__
840880 await self ._inject_sub_agent_result (info )
841881 info .status = SubAgentStatus .ERROR
842882 info .error = str (exc )
@@ -849,6 +889,10 @@ async def _cleanup():
849889 raise
850890 except SwitchCoderSignal :
851891 raise
892+ finally :
893+ _subagent_event (
894+ info .coder , info .coder .uuid , "idle" , stop_reason = stop_reason , error = error
895+ )
852896
853897 # Cancel any previous generate task to prevent duplicate concurrent generates
854898 if info .generate_task is not None and not info .generate_task .done ():
@@ -986,6 +1030,10 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
9861030 force = True ,
9871031 )
9881032
1033+ _subagent_event (
1034+ parent_coder , parent_coder .uuid , "message" , sender_uuid = coder_uuid , text = user_content
1035+ )
1036+
9891037 async def invoke (
9901038 self ,
9911039 name : str ,
@@ -1011,8 +1059,20 @@ async def invoke(
10111059 return None
10121060
10131061 # Blocking: run the sub-agent with the prompt using start_generate_task
1062+ sender = parent if parent is not None else self .coder
1063+ _subagent_event (
1064+ sender , new_coder .uuid , "message" , sender_uuid = str (sender .uuid ), text = prompt
1065+ )
10141066 task = self .start_generate_task (info , prompt )
1015- await task
1067+ try :
1068+ await asyncio .shield (task )
1069+ except asyncio .CancelledError :
1070+ if not task .done ():
1071+ # The caller was cancelled: stop the sub-agent it was waiting for
1072+ info .cancel_requested = True
1073+ task .cancel ()
1074+ raise
1075+ # Only the sub-agent was cancelled; its summary says it was interrupted
10161076 return info .summary
10171077
10181078 async def spawn (
@@ -1040,6 +1100,10 @@ async def spawn(
10401100 name , auto_reap = auto_reap , parent = parent , independent = independent
10411101 )
10421102 if prompt :
1103+ sender = parent if parent is not None else self .coder
1104+ _subagent_event (
1105+ sender , new_coder .uuid , "message" , sender_uuid = str (sender .uuid ), text = prompt
1106+ )
10431107 self .start_generate_task (info , prompt )
10441108 return new_coder , info
10451109
@@ -1079,6 +1143,19 @@ async def wait(self, parent: Any) -> List[str]:
10791143
10801144 return [info .summary for info in children ]
10811145
1146+ def cancel_sub_agent (self , agent_uuid : str ) -> bool :
1147+ """Stop a sub-agent's current work, leaving its parent running.
1148+
1149+ A parent waiting on it (a blocking delegation) gets the interrupted
1150+ summary. Returns False if the sub-agent was not working.
1151+ """
1152+ info = self .sub_agents .get (str (agent_uuid ))
1153+ if info is None or info .generate_task is None or info .generate_task .done ():
1154+ return False
1155+ info .cancel_requested = True
1156+ info .generate_task .cancel ()
1157+ return True
1158+
10821159 def get_active_agents (self ) -> List [Dict [str , Any ]]:
10831160 """Return list of active sub-agents for display."""
10841161 return [
0 commit comments