@@ -202,11 +202,65 @@ def _execute_tool_call(self, call: ToolCall) -> str:
202202 args = {}
203203 return self .session .execute_tool (call .name , args , call_id = call .id )
204204
205+ def _deliver_tool_result (self , p : ToolCall , result : str ) -> None :
206+ """Append one tool result message for call P (parent thread only)."""
207+ p .result = result
208+ if hasattr (self .session , "take_diff" ):
209+ p .diff = self .session .take_diff (p .id )
210+ self .messages .append (
211+ Message (
212+ role = "tool" ,
213+ content = result ,
214+ tool_call_id = p .id ,
215+ name = p .name ,
216+ )
217+ )
218+ if self .top_level and not self ._is_cancelled ():
219+ # only the top-level loop mirrors its messages onto the
220+ # shared session: a sub-agent runs inside the parent's
221+ # tool round and must never clobber the parent's
222+ # conversation history (the TUI renders from it)
223+ self .session .last_messages = list (self .messages )
224+
225+ def _run_subagents_parallel (
226+ self , calls : list [ToolCall ], results : dict [str , str ]
227+ ) -> None :
228+ """Run several Agent calls concurrently, filling RESULTS.
229+
230+ Each sub-agent is fully isolated (its own agent loop, message
231+ list, client stream, and thread-local diff slot), so Agent calls
232+ issued in the same round execute in parallel. Delivery happens
233+ later, in original tool-call order, by the parent thread.
234+ """
235+ from concurrent .futures import ThreadPoolExecutor , as_completed
236+
237+ with ThreadPoolExecutor (
238+ max_workers = min (len (calls ), config .PARALLEL_SUBAGENT_MAX ),
239+ thread_name_prefix = "subagent" ,
240+ ) as pool :
241+ futures = {pool .submit (self ._execute_tool_call , p ): p for p in calls }
242+ for fut in as_completed (futures ):
243+ p = futures [fut ]
244+ try :
245+ results [p .id ] = sanitize_tool_result (fut .result ())
246+ except Exception as e : # noqa: BLE001 - containment boundary
247+ results [p .id ] = (
248+ f"Error: Task { p .name !r} crashed in a sub-agent "
249+ f"thread — { e } "
250+ )
251+
205252 def _run_tool_round (self ) -> None :
206253 """Execute all pending tool calls; deliver results as messages.
207254
208255 The assistant message carrying the tool calls was already
209256 appended by the main loop; here we add the per-call results.
257+
258+ Agent calls (sub-agent spawns) run CONCURRENTLY in a thread
259+ pool — sub-agents are isolated by design, so several Agent calls
260+ in one round execute in parallel. All other tools stay
261+ sequential: they are fast and mutate shared session state
262+ (undo, diff slots). Results are delivered in the original
263+ tool-call order regardless of execution order.
210264 """
211265 pending = list (self .pending )
212266 if not pending :
@@ -217,30 +271,27 @@ def _run_tool_round(self) -> None:
217271 # worker must never mirror its partial history over the next
218272 # run's `session.last_messages`.
219273 return
274+ agent_calls = [p for p in pending if p .name == "Agent" ]
275+ results : dict [str , str ] = {}
220276 for p in pending :
277+ if p .name == "Agent" :
278+ continue
221279 if self ._is_cancelled ():
222280 # cancelled mid-round: stop running further tools; the
223281 # results already delivered stay local to this (dead) run
224282 self .pending = []
225283 return
226- result = sanitize_tool_result (self ._execute_tool_call (p ))
227- p .result = result
228- if hasattr (self .session , "take_diff" ):
229- p .diff = self .session .take_diff (p .id )
230- self .messages .append (
231- Message (
232- role = "tool" ,
233- content = result ,
234- tool_call_id = p .id ,
235- name = p .name ,
236- )
237- )
238- if self .top_level and not self ._is_cancelled ():
239- # only the top-level loop mirrors its messages onto the
240- # shared session: a sub-agent runs inside the parent's
241- # tool round and must never clobber the parent's
242- # conversation history (the TUI renders from it)
243- self .session .last_messages = list (self .messages )
284+ results [p .id ] = sanitize_tool_result (self ._execute_tool_call (p ))
285+ if agent_calls :
286+ self ._run_subagents_parallel (agent_calls , results )
287+ if self ._is_cancelled ():
288+ self .pending = []
289+ return
290+ for p in pending :
291+ if self ._is_cancelled ():
292+ self .pending = []
293+ return
294+ self ._deliver_tool_result (p , results [p .id ])
244295 self .pending = []
245296 self .session .notify ("tools" )
246297
0 commit comments