Skip to content

Commit 8ee010d

Browse files
author
Your Name
committed
Fix sub-agent SwitchCoderSignal propagation
1 parent 0f888c8 commit 8ee010d

3 files changed

Lines changed: 107 additions & 39 deletions

File tree

cecli/helpers/agents/service.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,11 +571,13 @@ def start_generate_task(self, info: SubAgentInfo, user_message: str) -> asyncio.
571571
Returns:
572572
The ``asyncio.Task`` wrapping ``generate()``.
573573
"""
574+
from cecli.commands import SwitchCoderSignal
574575

575576
async def _run_generate():
576577
info.status = SubAgentStatus.RUNNING
577578
try:
578579
await info.coder.generate(user_message=user_message, preproc=True)
580+
579581
if info.status == SubAgentStatus.RUNNING:
580582
info.status = SubAgentStatus.FINISHED
581583
info.summary = info.summary or DEFAULT_SUMMARY_COMPLETED
@@ -597,15 +599,24 @@ async def _run_generate():
597599
)
598600
await self._inject_sub_agent_result(info)
599601
raise
602+
except SwitchCoderSignal:
603+
raise
600604

601605
# Cancel any previous generate task to prevent duplicate concurrent generates
602606
if info.generate_task is not None and not info.generate_task.done():
603607
info.generate_task.cancel()
604608

605609
task = asyncio.create_task(_run_generate())
606610
info.generate_task = task
611+
612+
def _raise_if_signal(exc):
613+
if isinstance(exc, SwitchCoderSignal):
614+
raise exc
615+
607616
# Suppress "Task exception was never retrieved" for fire-and-forget tasks
608-
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
617+
task.add_done_callback(
618+
lambda t: _raise_if_signal(t.exception()) if not t.cancelled() else None
619+
)
609620
return task
610621

611622
async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:

cecli/tui/app.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,8 +1167,16 @@ def _switch_to_container(self, uuid: str, suppress_input_enable: bool = False) -
11671167

11681168
def create_sub_agent_container(self, uuid: str, name: str) -> None:
11691169
"""Create an OutputContainer for a sub-agent."""
1170+
from cecli.helpers.agents.service import AgentService
1171+
11701172
if uuid in self._sub_agent_containers:
1173+
agent_service = AgentService.get_instance(self.worker.coder)
1174+
sub_agent_info = agent_service.sub_agents.get(uuid)
1175+
if sub_agent_info:
1176+
sub_agent_info.coder.show_announcements()
1177+
11711178
return
1179+
11721180
container = OutputContainer(id=f"output-{uuid}", classes="subagent-output")
11731181
container.display = False # Hidden initially
11741182
self._sub_agent_containers[uuid] = container
@@ -1184,8 +1192,6 @@ def create_sub_agent_container(self, uuid: str, name: str) -> None:
11841192

11851193
# Show announcements from the sub-agent's coder
11861194
try:
1187-
from cecli.helpers.agents.service import AgentService
1188-
11891195
agent_service = AgentService.get_instance(self.worker.coder)
11901196
sub_agent_info = agent_service.sub_agents.get(uuid)
11911197
if sub_agent_info:

cecli/tui/worker.py

Lines changed: 87 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from cecli.commands import SwitchCoderSignal
1111
from cecli.helpers.conversation import ConversationService, MessageTag
1212

13+
logger = logging.getLogger(__name__)
1314
# Suppress asyncio task destroyed warnings during shutdown
1415
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
1516

@@ -46,6 +47,7 @@ def _run_thread(self):
4647
"""Thread entry point - creates event loop and runs coder."""
4748
self.loop = asyncio.new_event_loop()
4849
asyncio.set_event_loop(self.loop)
50+
self.loop.set_exception_handler(self.worker_loop_exception_handler)
4951

5052
try:
5153
self.loop.run_until_complete(self._async_run())
@@ -96,46 +98,95 @@ async def _async_run(self):
9698
except KeyboardInterrupt:
9799
continue
98100
except SwitchCoderSignal as switch:
99-
# Handle chat mode switches (e.g., /chat-mode architect)
100-
try:
101-
await self.coder.auto_save_session(force=True)
102-
kwargs = dict(io=self.coder.io, from_coder=self.coder)
103-
kwargs.update(switch.kwargs)
104-
if "show_announcements" in kwargs:
105-
del kwargs["show_announcements"]
106-
kwargs["num_cache_warming_pings"] = 0
107-
kwargs["args"] = self.coder.args
108-
# Skip summarization to avoid blocking LLM calls during mode switch
109-
kwargs["summarize_from_coder"] = False
110-
111-
new_coder = await Coder.create(**kwargs)
112-
new_coder.args = self.coder.args
113-
114-
for tag in [MessageTag.SYSTEM, MessageTag.EXAMPLES, MessageTag.STATIC]:
115-
ConversationService.get_manager(new_coder).clear_tag(tag)
116-
117-
if switch.kwargs.get("show_announcements") is False:
118-
new_coder.suppress_announcements_for_next_prompt = True
119-
120-
# Notify TUI of mode change
121-
self.coder = new_coder
122-
edit_format = getattr(self.coder, "edit_format", "code") or "code"
123-
self.output_queue.put(
124-
{
125-
"type": "mode_change",
126-
"mode": edit_format,
127-
}
128-
)
129-
except Exception as e:
130-
self.output_queue.put(
131-
{"type": "error", "message": f"Failed to switch mode: {e}"}
132-
)
133-
break
101+
await self._handle_switch_coder_signal(switch)
134102
# Continue the loop with the new coder
135103
except Exception as e:
136-
self.output_queue.put({"type": "error", "message": str(e)})
104+
self.output_queue.put(
105+
{
106+
"type": "error",
107+
"message": str(e),
108+
"coder_uuid": self.coder.uuid,
109+
}
110+
)
137111
break
138112

113+
async def _handle_switch_coder_signal(self, switch):
114+
"""Handle a SwitchCoderSignal, creating a new coder and notifying the TUI."""
115+
try:
116+
from cecli.helpers.agents.service import AgentService
117+
118+
# Determine the active coder — could be a sub-agent in the foreground
119+
target_coder = self.coder
120+
try:
121+
agent_service = AgentService.get_instance(target_coder)
122+
foreground = agent_service.foreground_coder
123+
if foreground is not None:
124+
target_coder = foreground
125+
except Exception:
126+
pass
127+
128+
await target_coder.auto_save_session(force=True)
129+
kwargs = dict(io=target_coder.io, from_coder=target_coder)
130+
kwargs.update(switch.kwargs)
131+
if "show_announcements" in kwargs:
132+
del kwargs["show_announcements"]
133+
kwargs["num_cache_warming_pings"] = 0
134+
kwargs["args"] = target_coder.args
135+
# Skip summarization to avoid blocking LLM calls during mode switch
136+
kwargs["summarize_from_coder"] = False
137+
138+
new_coder = await Coder.create(**kwargs)
139+
new_coder.args = target_coder.args
140+
141+
for tag in [MessageTag.SYSTEM, MessageTag.EXAMPLES, MessageTag.STATIC]:
142+
ConversationService.get_manager(new_coder).clear_tag(tag)
143+
144+
if switch.kwargs.get("show_announcements") is False:
145+
new_coder.suppress_announcements_for_next_prompt = True
146+
147+
# Notify TUI of mode change
148+
if target_coder == self.coder:
149+
self.coder = new_coder
150+
else:
151+
new_coder.show_announcements()
152+
153+
edit_format = getattr(target_coder, "edit_format", "code") or "code"
154+
self.output_queue.put(
155+
{
156+
"type": "mode_change",
157+
"mode": edit_format,
158+
"coder_uuid": new_coder.uuid,
159+
}
160+
)
161+
except Exception as e:
162+
self.output_queue.put(
163+
{
164+
"type": "error",
165+
"message": f"Failed to switch mode: {e}",
166+
"coder_uuid": target_coder,
167+
}
168+
)
169+
170+
def worker_loop_exception_handler(self, loop, context):
171+
"""
172+
This runs directly on the worker thread whenever an unhandled
173+
exception occurs in a task or callback.
174+
175+
Catches SwitchCoderSignal from fire-and-forget tasks and dispatches
176+
them to the dedicated handler so mode switches work even when the
177+
signal is raised outside the main coder.run() loop.
178+
"""
179+
exception = context.get("exception")
180+
181+
if isinstance(exception, SwitchCoderSignal):
182+
logger.info("Worker thread caught SwitchCoderSignal in global handler.")
183+
# Schedule a coroutine to handle the switch logic on the loop
184+
loop.create_task(self._handle_switch_coder_signal(exception))
185+
else:
186+
# Always fall back to the default handler so you don't swallow
187+
# normal bugs, tracebacks, or connection errors.
188+
loop.default_exception_handler(context)
189+
139190
def interrupt(self):
140191
"""Cancel the current output task on the active (foreground) coder.
141192

0 commit comments

Comments
 (0)