Skip to content

Commit e044bc5

Browse files
committed
Enable parallel subagent execution.
1 parent 48a7584 commit e044bc5

7 files changed

Lines changed: 277 additions & 52 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ A Python port of the Emacs [gptel-agent-harness](https://github.com/beacoder/gpt
1515
- **Tools** — Agent (sub-agents), TodoWrite, Glob (git-aware), Grep
1616
(git grep → rg → grep), Read, Insert, Edit (incl. unified diffs), Write,
1717
Mkdir, Bash, Skill, Question, and PlanExit (registered while in plan
18-
mode) — all OpenAI-compatible tool schemas.
18+
mode) — all OpenAI-compatible tool schemas. Multiple Agent calls in
19+
one round run concurrently (up to `PARALLEL_SUBAGENT_MAX`, default 4).
1920
- **Default agent prompts** — the main agent and sub-agents each get a
2021
distinct default system prompt bundled with the package
2122
(`prompts/agent.txt`, `prompts/subagent.txt`), with YAML frontmatter

python_agent_harness/agent.py

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

python_agent_harness/agent_session.py

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,15 @@ def __init__(
101101
self.bash_policy = BashPolicy()
102102
self.tool_ctx = ToolContext(self)
103103
self._tool_diffs: dict[str, str] = {}
104-
self._active_call_id: str | None = None
104+
# thread-local: parallel sub-agents each execute tools in their
105+
# own pool thread; the "currently executing call" that Edit/Write
106+
# attach their diff to must be per-thread, or concurrent
107+
# sub-agents would clobber each other's diff slot
108+
self._active_call = threading.local()
109+
# serializes the interactive Bash approval prompt: parallel
110+
# sub-agents may hit CONFIRM simultaneously, but the TUI can only
111+
# ask one question at a time
112+
self._bash_lock = threading.Lock()
105113
self.store = SessionStore(
106114
project_dir=project_dir,
107115
model=model,
@@ -195,19 +203,20 @@ def execute_tool(
195203
except SafetyViolation as e:
196204
return str(e)
197205

198-
self._active_call_id = call_id
206+
self._active_call.call_id = call_id
199207
try:
200208
result = self.registry.execute(name, args, self.tool_ctx)
201209
finally:
202-
self._active_call_id = None
210+
self._active_call.call_id = None
203211

204212
self.notify("tool")
205213
return result
206214

207215
def record_diff(self, diff_text: str) -> None:
208216
"""Attach a unified diff to the tool call currently executing."""
209-
if self._active_call_id and diff_text:
210-
self._tool_diffs[self._active_call_id] = diff_text
217+
call_id = getattr(self._active_call, "call_id", None)
218+
if call_id and diff_text:
219+
self._tool_diffs[call_id] = diff_text
211220

212221
def take_diff(self, call_id: str) -> str | None:
213222
"""Pop and return the diff recorded for CALL_ID, if any."""
@@ -242,24 +251,31 @@ def guard_path(self, path: str, tool_name: str) -> None:
242251
check_path(path, tool_name)
243252

244253
def verify_bash(self, command: str) -> str | None:
245-
"""Return an error string to deliver, or None to run."""
246-
self.bash_policy.plan_mode = self.plan_mode.is_plan
247-
verdict = self.bash_policy.verdict(command)
248-
if verdict != "CONFIRM":
249-
return verdict
250-
if self.bash_approval_fn:
251-
run, answer = self.bash_approval_fn(command)
252-
else:
253-
run, answer = self._ask_via_tui(command)
254-
if answer == "allow":
255-
self.bash_policy.record(command, "allow")
256-
return None
257-
if answer == "deny":
258-
self.bash_policy.record(command, "deny")
259-
return "Error: Bash command rejected by user approval (denied for this session)."
260-
if run:
261-
return None
262-
return "Error: Bash command rejected by user approval."
254+
"""Return an error string to deliver, or None to run.
255+
256+
The interactive approval prompt is serialized: parallel
257+
sub-agents may reach CONFIRM simultaneously, but the user can
258+
only answer one question at a time. Command *execution* stays
259+
parallel — the lock is released before the process starts.
260+
"""
261+
with self._bash_lock:
262+
self.bash_policy.plan_mode = self.plan_mode.is_plan
263+
verdict = self.bash_policy.verdict(command)
264+
if verdict != "CONFIRM":
265+
return verdict
266+
if self.bash_approval_fn:
267+
run, answer = self.bash_approval_fn(command)
268+
else:
269+
run, answer = self._ask_via_tui(command)
270+
if answer == "allow":
271+
self.bash_policy.record(command, "allow")
272+
return None
273+
if answer == "deny":
274+
self.bash_policy.record(command, "deny")
275+
return "Error: Bash command rejected by user approval (denied for this session)."
276+
if run:
277+
return None
278+
return "Error: Bash command rejected by user approval."
263279

264280
def _ask_via_tui(self, command: str) -> tuple[bool, str]:
265281
prompt = (

python_agent_harness/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@
151151

152152
# ---- sub-agents ---------------------------------------------------------------
153153
SUBAGENT_MAX_ROUNDS = 60
154+
# Max sub-agents that may run CONCURRENTLY in one tool round (Agent calls
155+
# issued together are executed in parallel; excess calls queue).
156+
PARALLEL_SUBAGENT_MAX = 4
154157
# Tools a sub-agent must NOT see or call: it runs autonomously as a
155158
# one-shot task inside the parent's tool round, so it cannot spawn
156159
# further sub-agents (Agent), ask the user questions (Question), nor

python_agent_harness/tools/agent_tool.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
parent as a single tool result string. Errors are contained: an
66
unexpected sub-agent response becomes an error string fed to the parent,
77
never a crash.
8+
9+
Multiple Agent calls issued in the same round run CONCURRENTLY — each
10+
sub-agent is fully isolated (own loop, own history, own stream), so
11+
independent tasks can be delegated in parallel.
812
"""
913

1014
from __future__ import annotations
@@ -15,7 +19,9 @@
1519
"Launch a specialized sub-agent to handle complex, multi-step tasks "
1620
"autonomously. Sub-agents run independently and return results in one "
1721
"message. Use for open-ended searches, complex research, or when "
18-
"uncertain about finding results in the first few tries."
22+
"uncertain about finding results in the first few tries.\n\n"
23+
"Multiple Agent calls issued in the same round run concurrently, so "
24+
"delegate independent tasks in parallel for efficiency."
1925
)
2026

2127
PARAMETERS = {

python_agent_harness/undo.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import random
1313
import shutil
1414
import string
15+
import threading
1516
import time
1617
from dataclasses import dataclass
1718

@@ -34,9 +35,16 @@ def __init__(self, backup_dir: str | None = None, depth: int = config.UNDO_DEPTH
3435
temp_dir(), "python-agent-harness-undo"
3536
)
3637
self.entries: list[UndoEntry] = []
38+
# parallel sub-agents (and the top-level round) snapshot files
39+
# concurrently: the entries list must be serialized
40+
self._lock = threading.Lock()
3741

3842
def snapshot(self, path: str, tool: str) -> None:
3943
"""Snapshot PATH before a write; records absent files separately."""
44+
with self._lock:
45+
self._snapshot_locked(path, tool)
46+
47+
def _snapshot_locked(self, path: str, tool: str) -> None:
4048
path = os.path.abspath(path)
4149
if os.path.isfile(path):
4250
os.makedirs(self.backup_dir, exist_ok=True)
@@ -62,13 +70,18 @@ def snapshot(self, path: str, tool: str) -> None:
6270

6371
def record_absent(self, path: str, tool: str) -> None:
6472
"""Record a file that did not exist before a Write."""
65-
path = os.path.abspath(path)
66-
if os.path.exists(path) or any(e.path == path for e in self.entries):
67-
return
68-
self.entries.append(UndoEntry(path, None, False, tool, time.time()))
73+
with self._lock:
74+
path = os.path.abspath(path)
75+
if os.path.exists(path) or any(e.path == path for e in self.entries):
76+
return
77+
self.entries.append(UndoEntry(path, None, False, tool, time.time()))
6978

7079
def undo_last(self) -> tuple[bool, str]:
7180
"""Restore the newest entry. Returns (ok, message)."""
81+
with self._lock:
82+
return self._undo_last_locked()
83+
84+
def _undo_last_locked(self) -> tuple[bool, str]:
7285
if not self.entries:
7386
return False, "Nothing to undo."
7487
entry = self.entries[-1]
@@ -95,11 +108,12 @@ def undo_last(self) -> tuple[bool, str]:
95108
return False, f"Error: remove failed — {e}"
96109

97110
def history(self) -> list[str]:
98-
out = []
99-
for e in reversed(self.entries):
100-
stamp = time.strftime("%H:%M:%S", time.localtime(e.time))
101-
out.append(f"{stamp} {e.tool} {e.path}")
102-
return out
111+
with self._lock:
112+
out = []
113+
for e in reversed(self.entries):
114+
stamp = time.strftime("%H:%M:%S", time.localtime(e.time))
115+
out.append(f"{stamp} {e.tool} {e.path}")
116+
return out
103117

104118

105119
def temp_dir() -> str:

0 commit comments

Comments
 (0)