Skip to content

Commit fd0efdc

Browse files
committed
Add dedicated client for each subagent.
1 parent 32779d5 commit fd0efdc

7 files changed

Lines changed: 818 additions & 13 deletions

File tree

python_agent_harness/agent.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,15 @@ def __init__(
6666
top_level: bool = True,
6767
system: str | None = None,
6868
max_rounds: int = 60,
69+
client: Any | None = None,
6970
) -> None:
7071
self.session = session
7172
self.messages: list[Message] = messages if messages is not None else []
7273
self.top_level = top_level
74+
# an explicit per-run client (a dedicated clone for this
75+
# sub-agent invocation, see AgentSession.run_subagent) wins
76+
# over the session's shared sub-agent client
77+
self._client = client
7378
# fall back to the session's prompt so a run never loses it;
7479
# sub-agent loops use the session's SUB-AGENT prompt (their own),
7580
# never the parent's system prompt (which carries the parent's
@@ -619,18 +624,19 @@ def safe_delta(text: str) -> None:
619624
exclude=config.SUBAGENT_EXCLUDED_TOOLS if not self.top_level else ()
620625
)
621626
# sub-agent runs use their own LLM when one is configured
622-
# (session.subagent_client, mirroring gptel-agent-harness-
623-
# subagent-model/-backend); everything unset inherits the
624-
# main agent's settings, so the sub-agent path is identical
625-
# when no separate LLM is configured
627+
# (a per-invocation clone of session.subagent_client,
628+
# mirroring gptel-agent-harness-subagent-model/-backend);
629+
# everything unset inherits the main agent's settings, so
630+
# the sub-agent path is identical when no separate LLM is
631+
# configured
626632
if self.top_level:
627633
client = session.client
628634
temperature = session.temperature
629635
max_tokens = session.max_tokens
630636
reasoning_effort = session.reasoning_effort
631637
stream = session.stream
632638
else:
633-
client = session.subagent_client
639+
client = self._client or session.subagent_client
634640
temperature = session.subagent_temperature
635641
max_tokens = session.subagent_max_tokens
636642
reasoning_effort = session.subagent_reasoning_effort
@@ -795,14 +801,19 @@ def run_agent_loop(
795801
top_level: bool = True,
796802
system: str | None = None,
797803
max_rounds: int = 60,
804+
client: Any | None = None,
798805
) -> str | None:
799-
"""Convenience wrapper running a full agent run (FSM)."""
806+
"""Convenience wrapper running a full agent run (FSM).
807+
808+
``client`` (when given) overrides the session's client for this
809+
run — the per-invocation sub-agent clone."""
800810
return AgentLoop(
801811
session,
802812
messages=messages,
803813
top_level=top_level,
804814
system=system,
805815
max_rounds=max_rounds,
816+
client=client,
806817
).run()
807818

808819

python_agent_harness/agent_session.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

python_agent_harness/client.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import json
1212
import os
1313
import random
14+
import threading
1415
import time
1516
from collections.abc import Callable, Iterator
1617
from pathlib import Path
@@ -21,6 +22,12 @@
2122
from . import config
2223
from .models import Message, ToolCall, ToolSpec, Usage
2324

25+
# serializes appends to the shared LLM log file: concurrent sub-agents
26+
# (each with its own client but ONE shared log_path, see Client.clone)
27+
# finish their interactions in parallel, and interleaved write() calls
28+
# would corrupt the JSON stream
29+
_log_lock = threading.Lock()
30+
2431

2532
class ApiError(Exception):
2633
"""Raised when the API call itself fails (network/HTTP)."""
@@ -134,7 +141,7 @@ def _log_llm_interaction(
134141
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
135142
}
136143

137-
with open(log_file, "a", encoding="utf-8") as f:
144+
with _log_lock, open(log_file, "a", encoding="utf-8") as f:
138145
f.write(json.dumps(marker, indent=2, ensure_ascii=False) + "\n")
139146
f.write(json.dumps(body, indent=2, ensure_ascii=False) + "\n")
140147
except Exception: # noqa: BLE001 - logging must never break the agent
@@ -174,6 +181,7 @@ def __init__(
174181
retry_base_delay: float | None = None,
175182
retry_max_delay: float | None = None,
176183
config_path: str | None = None,
184+
log_path: Path | None = None,
177185
) -> None:
178186
self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
179187
self.api_key = api_key or _default_api_key()
@@ -194,11 +202,43 @@ def __init__(
194202
# the user asked to stop. Cleared at the start of each chat()
195203
# so a fresh turn may retry normally.
196204
self._aborted = False
197-
self.log_path: Path | None = _llm_log_path() if config.LLM_LOG_ENABLED else None
205+
# an explicit log file is inherited by clones so every request
206+
# of one session (main + all sub-agents) lands in a single log
207+
self.log_path = (
208+
log_path
209+
if log_path is not None
210+
else (_llm_log_path() if config.LLM_LOG_ENABLED else None)
211+
)
198212

199213
def close(self) -> None:
200214
self._http.close()
201215

216+
def clone(self) -> Client:
217+
"""A fresh Client with identical settings (no shared state).
218+
219+
Concurrent requests must never share one Client: ``_reset_http``
220+
and ``abort`` swap and close the underlying httpx pool, and
221+
``_aborted`` is per-request flag state — so one request's
222+
connection failure (or Ctrl-C abort) would tear down a
223+
sibling's in-flight request on the same client. Each
224+
concurrent sub-agent clones its own client (see
225+
``AgentSession.run_subagent``), keeping pools and the abort
226+
flag strictly per-request. The log file is shared so one
227+
session's LLM interactions stay in one log.
228+
"""
229+
return Client(
230+
base_url=self.base_url,
231+
api_key=self.api_key,
232+
model=self.model,
233+
timeout=self.timeout,
234+
verify=self.verify,
235+
retry_max=self.retry_max,
236+
retry_base_delay=self.retry_base_delay,
237+
retry_max_delay=self.retry_max_delay,
238+
config_path=self._config_path,
239+
log_path=self.log_path,
240+
)
241+
202242
def abort(self) -> None:
203243
"""Abort the in-flight request (called on cancel).
204244

python_agent_harness/subagent.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ def run_subagent(
3131
parent_session: object,
3232
description: str,
3333
prompt: str,
34+
client: object | None = None,
3435
) -> str:
35-
"""Run a sub-agent task; return a result string (never raises)."""
36+
"""Run a sub-agent task; return a result string (never raises).
37+
38+
``client`` (when given) is the per-invocation dedicated client
39+
(see ``AgentSession.run_subagent``); the loop falls back to the
40+
session's shared sub-agent client otherwise.
41+
"""
3642
session = parent_session
3743
try:
3844
messages = [Message(role="user", content=prompt)]
@@ -46,6 +52,7 @@ def run_subagent(
4652
top_level=False,
4753
system=_subagent_system_prompt(session),
4854
max_rounds=config.SUBAGENT_MAX_ROUNDS,
55+
client=client,
4956
)
5057
if isinstance(result, str):
5158
return result

0 commit comments

Comments
 (0)