@@ -104,8 +104,10 @@ def __init__(
104104 # timeout) and per-request options when a different LLM is
105105 # configured for sub-agents (mirrors gptel-agent-harness-
106106 # subagent-model/-backend); every unset option inherits the
107- # main agent's value. The Agent tool's sub-agent loop uses
108- # these instead of the main client.
107+ # main agent's value. The sub-agent loop never uses this
108+ # client directly — each Agent tool invocation clones it
109+ # (see run_subagent) so concurrent sub-agents never share a
110+ # Client's pool/abort state.
109111 self .subagent_client = subagent_client or client
110112 self .subagent_temperature = (
111113 temperature if subagent_temperature is None else subagent_temperature
@@ -132,6 +134,13 @@ def __init__(
132134 # serializes interactive prompts (Question tool, PlanExit
133135 # confirmation): the TUI can only ask one question at a time
134136 self ._interactive_lock = threading .Lock ()
137+ # dedicated per-invocation sub-agent clients (see run_subagent):
138+ # concurrent sub-agents each run on their own Client clone, so
139+ # one sub-agent's connection failure / abort can never tear
140+ # down a sibling's in-flight request on a shared client. The
141+ # active clones are tracked so cancel()/close() can reach them.
142+ self ._subagent_clients_lock = threading .Lock ()
143+ self ._active_subagent_clients : list [Client ] = []
135144 self .store = SessionStore (
136145 project_dir = project_dir ,
137146 model = model ,
@@ -310,8 +319,42 @@ def run_subagent(self, subagent_type: str, description: str, prompt: str) -> str
310319
311320 The sub-agent has no TodoWrite (parent-only), so it can never
312321 touch the parent's todo list.
322+
323+ Each invocation runs on a DEDICATED client, cloned from the
324+ configured sub-agent client: concurrent Agent tool calls share
325+ this session, and a shared Client would race — ``_reset_http``
326+ / ``abort`` swap and close the underlying httpx pool and
327+ ``_aborted`` is per-request state, so one sub-agent's
328+ connection failure (or a Ctrl-C) would tear down a sibling's
329+ in-flight request. The clone is tracked for cancel/close and
330+ released when the sub-agent finishes.
313331 """
314- return run_subagent (self , description , prompt )
332+ client , owned = self ._new_subagent_client ()
333+ if owned :
334+ with self ._subagent_clients_lock :
335+ self ._active_subagent_clients .append (client )
336+ try :
337+ return run_subagent (self , description , prompt , client = client )
338+ finally :
339+ if owned :
340+ with self ._subagent_clients_lock :
341+ if client in self ._active_subagent_clients :
342+ self ._active_subagent_clients .remove (client )
343+ client .close ()
344+
345+ def _new_subagent_client (self ) -> tuple [Any , bool ]:
346+ """A dedicated Client for one sub-agent invocation.
347+
348+ Real Clients are cloned (fresh httpx pool, own ``_aborted``
349+ flag, same endpoint/credentials/log). A non-Client
350+ ``subagent_client`` (a test double) is passed through
351+ untouched — the isolation concern does not apply to it, and
352+ custom clients keep working as-is.
353+ """
354+ base = self .subagent_client
355+ if isinstance (base , Client ):
356+ return base .clone (), True
357+ return base , False
315358
316359 def plan_exit (self ) -> str :
317360 """PlanExit tool implementation.
@@ -460,6 +503,16 @@ def close(self) -> None:
460503 self .client .close ()
461504 if self .subagent_client is not self .client and hasattr (self .subagent_client , "close" ):
462505 self .subagent_client .close ()
506+ # defensive: sub-agent workers close their own clones in
507+ # run_subagent's finally; close any stragglers (e.g. a worker
508+ # still winding down after cancel) so no pool leaks
509+ with self ._subagent_clients_lock :
510+ strays = list (self ._active_subagent_clients )
511+ self ._active_subagent_clients .clear ()
512+ for c in strays :
513+ if hasattr (c , "close" ):
514+ with contextlib .suppress (Exception ): # best effort
515+ c .close ()
463516
464517 def cancel (self ) -> None :
465518 """Cancel the in-flight agent run (Ctrl-C).
@@ -478,10 +531,14 @@ def cancel(self) -> None:
478531 # A sub-agent streams on its own client when a separate LLM is
479532 # configured — abort BOTH pools so a blocked sub-agent read is
480533 # interrupted too (see Client.abort for why close() alone is
481- # not enough). A shared client is aborted once.
534+ # not enough). A shared client is aborted once; dedicated
535+ # per-invocation sub-agent clones (see run_subagent) are each
536+ # aborted so every in-flight sub-agent request is interrupted.
482537 clients = [self .client ]
483538 if self .subagent_client is not self .client :
484539 clients .append (self .subagent_client )
540+ with self ._subagent_clients_lock :
541+ clients .extend (self ._active_subagent_clients )
485542 for c in clients :
486543 if hasattr (c , "abort" ):
487544 with contextlib .suppress (Exception ): # best effort
0 commit comments