Add inspectable parallel tool calls
- add a parallel wrapper and runtime for concurrent background tool calls - run parallel call_subordinate jobs as child chats with visible subagent steps that match normal subordinate args - render parallel child tool steps with normal tool-call args while keeping job handles in wrapper results and prompt extras - group parallel child chats in the sidebar with persistent accordion and caret behavior - add prompt, extension, DOX, and regression coverage
Alessandro committed
Jun 12, 2026 at 12:43 UTC
b704a0e3f59b86d02d9527e7db31785c0cb3e68d
17 files changed
+1366
-14
extensions/python/message_loop_prompts_after/AGENTS.md
+1
-1
@@ -6,7 +6,7 @@
6
7
## Ownership
8
9
-- Ordered Python files own current datetime, skill recall/load context, agent info, and workdir extras injection.
9
+- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
10
11
## Local Contracts
12
extensions/python/message_loop_prompts_after/_72_include_parallel_jobs.py
new
+13
@@ -0,0 +1,13 @@
1
+from helpers.extension import Extension
2
+from agent import LoopData
3
+from helpers import parallel_tools
4
+
5
+
6
+class IncludeParallelJobs(Extension):
7
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
8
+ if not self.agent:
9
+ return
10
+
11
+ extras = await parallel_tools.build_parallel_jobs_extras(self.agent)
12
+ if extras:
13
+ loop_data.extras_temporary["parallel_jobs"] = extras
extensions/python/tool_execute_before/AGENTS.md
+1
-1
@@ -6,7 +6,7 @@
6
7
## Ownership
8
9
-- Ordered Python files own prior tool-output replacement and secret unmasking before execution.
9
+- Ordered Python files own prior tool-output replacement, parallel recursion guards, and secret unmasking before execution.
10
11
## Local Contracts
12
extensions/python/tool_execute_before/_20_block_parallel_recursion.py
new
+15
@@ -0,0 +1,15 @@
1
+from helpers.extension import Extension
2
+from helpers.errors import RepairableException
3
+from helpers import parallel_tools
4
+
5
+
6
+class BlockParallelRecursion(Extension):
7
+ async def execute(self, tool_name: str = "", **kwargs) -> None:
8
+ if tool_name != "parallel":
9
+ return
10
+ if not parallel_tools.is_parallel_worker(self.agent):
11
+ return
12
+ raise RepairableException(
13
+ "The `parallel` tool cannot be used inside a parallel worker. "
14
+ "Finish the current worker task sequentially and return its result."
15
+ )
helpers/parallel_tools.py
new
+652
@@ -0,0 +1,652 @@
1
+from __future__ import annotations
2
+
3
+import asyncio
4
+import json
5
+import time
6
+import uuid
7
+from dataclasses import dataclass, field, replace
8
+from typing import Any, Literal, TYPE_CHECKING
9
+
10
+from helpers import extract_tools
11
+from helpers.defer import DeferredTask, THREAD_BACKGROUND
12
+from helpers.extension import call_extensions_async
13
+from helpers.print_style import PrintStyle
14
+
15
+if TYPE_CHECKING:
16
+ from agent import Agent, AgentConfig, AgentContext
17
+ from helpers.log import LogItem
18
+
19
+
20
+PARALLEL_JOBS_KEY = "_parallel_jobs"
21
+PARALLEL_WORKER_PARENT_CONTEXT_KEY = "_parallel_parent_context_id"
22
+PARALLEL_WORKER_JOB_KEY = "_parallel_job_id"
23
+
24
+CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
25
+CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
26
+CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
27
+CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id"
28
+CHILD_PARALLEL_TOOL_NAME_KEY = "parallel_tool_name"
29
+
30
+DEFAULT_MAX_CALLS = 8
31
+DEFAULT_TIMEOUT_SECONDS = 300
32
+POLL_INTERVAL_SECONDS = 0.5
33
+
34
+TERMINAL_STATES = {"success", "error", "cancelled", "timeout"}
35
+JobState = Literal["pending", "running", "success", "error", "cancelled", "timeout"]
36
+JobKind = Literal["tool", "subordinate"]
37
+
38
+
39
+@dataclass
40
+class NormalizedToolCall:
41
+ index: int
42
+ tool_name: str
43
+ tool_args: dict[str, Any]
44
+
45
+
46
+@dataclass
47
+class ParallelJob:
48
+ id: str
49
+ parent_context_id: str
50
+ index: int
51
+ tool_name: str
52
+ tool_args: dict[str, Any]
53
+ kind: JobKind
54
+ state: JobState = "pending"
55
+ created_at: float = field(default_factory=time.time)
56
+ started_at: float | None = None
57
+ completed_at: float | None = None
58
+ result: str | None = None
59
+ error: str | None = None
60
+ worker_context_id: str | None = None
61
+ log_id: str = field(default_factory=lambda: str(uuid.uuid4()))
62
+ log_item: "LogItem | None" = field(default=None, repr=False)
63
+ deferred_task: DeferredTask | None = field(default=None, repr=False)
64
+
65
+ def elapsed(self) -> float:
66
+ end = self.completed_at or time.time()
67
+ start = self.started_at or self.created_at
68
+ return max(0.0, end - start)
69
+
70
+
71
+def extract_tool_calls(args: dict[str, Any]) -> Any:
72
+ for key in ("tool_calls", "calls", "items"):
73
+ if key in args:
74
+ return args.get(key)
75
+ return None
76
+
77
+
78
+def normalize_parallel_tool_calls(raw_calls: Any) -> list[NormalizedToolCall]:
79
+ if not isinstance(raw_calls, list):
80
+ raise ValueError("`tool_calls` must be an array of normal tool-call objects.")
81
+ if not raw_calls:
82
+ raise ValueError("`tool_calls` must contain at least one tool call.")
83
+ if len(raw_calls) > DEFAULT_MAX_CALLS:
84
+ raise ValueError(f"`tool_calls` supports at most {DEFAULT_MAX_CALLS} items.")
85
+
86
+ calls: list[NormalizedToolCall] = []
87
+ for index, raw_call in enumerate(raw_calls):
88
+ try:
89
+ tool_name, tool_args = extract_tools.normalize_tool_request(raw_call)
90
+ except ValueError as exc:
91
+ raise ValueError(f"tool_calls[{index}] is not a valid tool call: {exc}") from exc
92
+
93
+ if tool_name == "parallel":
94
+ raise ValueError("`parallel` cannot be nested inside another `parallel` call.")
95
+
96
+ calls.append(
97
+ NormalizedToolCall(
98
+ index=index,
99
+ tool_name=tool_name,
100
+ tool_args=dict(tool_args),
101
+ )
102
+ )
103
+ return calls
104
+
105
+
106
+def normalize_job_ids(raw_job_ids: Any) -> list[str]:
107
+ if raw_job_ids is None:
108
+ return []
109
+ if isinstance(raw_job_ids, str):
110
+ return [raw_job_ids]
111
+ if isinstance(raw_job_ids, list):
112
+ return [str(item) for item in raw_job_ids if str(item).strip()]
113
+ raise ValueError("`job_ids` must be a string or an array of strings.")
114
+
115
+
116
+def coerce_bool(value: Any, default: bool) -> bool:
117
+ if value is None:
118
+ return default
119
+ if isinstance(value, bool):
120
+ return value
121
+ if isinstance(value, str):
122
+ normalized = value.strip().lower()
123
+ if normalized in {"1", "true", "yes", "on"}:
124
+ return True
125
+ if normalized in {"0", "false", "no", "off"}:
126
+ return False
127
+ return bool(value)
128
+
129
+
130
+def coerce_timeout(value: Any) -> int:
131
+ if value in (None, ""):
132
+ return DEFAULT_TIMEOUT_SECONDS
133
+ try:
134
+ timeout = int(value)
135
+ except (TypeError, ValueError) as exc:
136
+ raise ValueError(f"`timeout` must be an integer number of seconds, got {value!r}.") from exc
137
+ if timeout <= 0:
138
+ raise ValueError("`timeout` must be greater than 0.")
139
+ return timeout
140
+
141
+
142
+def is_parallel_worker(agent: "Agent | None") -> bool:
143
+ context = getattr(agent, "context", None)
144
+ if not context:
145
+ return False
146
+ return bool(context.get_data(PARALLEL_WORKER_JOB_KEY))
147
+
148
+
149
+def _jobs_for_context(context: "AgentContext") -> dict[str, ParallelJob]:
150
+ jobs = context.get_data(PARALLEL_JOBS_KEY)
151
+ if not isinstance(jobs, dict):
152
+ jobs = {}
153
+ context.set_data(PARALLEL_JOBS_KEY, jobs)
154
+ return jobs
155
+
156
+
157
+def _get_job(parent_context_id: str, job_id: str) -> ParallelJob | None:
158
+ from agent import AgentContext
159
+
160
+ context = AgentContext.get(parent_context_id)
161
+ if not context:
162
+ return None
163
+ job = _jobs_for_context(context).get(job_id)
164
+ return job if isinstance(job, ParallelJob) else None
165
+
166
+
167
+def _new_job_id(tool_name: str) -> str:
168
+ prefix = "".join(ch for ch in tool_name if ch.isalnum())[:12] or "job"
169
+ return f"{prefix}-{uuid.uuid4().hex[:8]}"
170
+
171
+
172
+async def start_parallel_jobs(
173
+ agent: "Agent",
174
+ calls: list[NormalizedToolCall],
175
+) -> list[ParallelJob]:
176
+ jobs: list[ParallelJob] = []
177
+ context = agent.context
178
+ job_store = _jobs_for_context(context)
179
+
180
+ for call in calls:
181
+ kind: JobKind = "subordinate" if call.tool_name == "call_subordinate" else "tool"
182
+ job = ParallelJob(
183
+ id=_new_job_id(call.tool_name),
184
+ parent_context_id=context.id,
185
+ index=call.index,
186
+ tool_name=call.tool_name,
187
+ tool_args=call.tool_args,
188
+ kind=kind,
189
+ )
190
+ job_store[job.id] = job
191
+ jobs.append(job)
192
+ _log_parallel_child_started(agent, job)
193
+
194
+ try:
195
+ job.state = "running"
196
+ job.started_at = time.time()
197
+ task = DeferredTask(thread_name=THREAD_BACKGROUND)
198
+ job.deferred_task = task
199
+ task.start_task(_run_parallel_job, context.id, job.id)
200
+ except Exception as exc:
201
+ _finish_job(job, "error", error=str(exc))
202
+
203
+ return jobs
204
+
205
+
206
+async def await_parallel_jobs(
207
+ agent: "Agent",
208
+ job_ids: list[str],
209
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
210
+ *,
211
+ collect: bool = True,
212
+) -> list[dict[str, Any]]:
213
+ if not job_ids:
214
+ raise ValueError("No `job_ids` were provided to await.")
215
+
216
+ deadline = time.time() + timeout
217
+ known_job_ids = set(job_ids)
218
+ while True:
219
+ await refresh_parallel_jobs(agent)
220
+ jobs = [_jobs_for_context(agent.context).get(job_id) for job_id in job_ids]
221
+ missing = [job_id for job_id, job in zip(job_ids, jobs) if job is None]
222
+ if missing:
223
+ raise ValueError(f"Unknown parallel job id(s): {', '.join(missing)}")
224
+
225
+ active = [job for job in jobs if job and job.state not in TERMINAL_STATES]
226
+ if not active:
227
+ break
228
+
229
+ if time.time() >= deadline:
230
+ for job in active:
231
+ await _timeout_job(job)
232
+ break
233
+
234
+ await asyncio.sleep(POLL_INTERVAL_SECONDS)
235
+
236
+ snapshots = []
237
+ for job_id in job_ids:
238
+ job = _jobs_for_context(agent.context).get(job_id)
239
+ if job:
240
+ snapshots.append(_job_snapshot(job, include_result=True))
241
+
242
+ if collect:
243
+ for job_id in known_job_ids:
244
+ job = _jobs_for_context(agent.context).get(job_id)
245
+ if job and job.state in TERMINAL_STATES:
246
+ await cleanup_parallel_job(agent, job)
247
+ _jobs_for_context(agent.context).pop(job_id, None)
248
+
249
+ return snapshots
250
+
251
+
252
+async def cancel_parallel_jobs(agent: "Agent", job_ids: list[str]) -> list[dict[str, Any]]:
253
+ if not job_ids:
254
+ raise ValueError("No `job_ids` were provided to cancel.")
255
+
256
+ await refresh_parallel_jobs(agent)
257
+ snapshots = []
258
+ for job_id in job_ids:
259
+ job = _jobs_for_context(agent.context).get(job_id)
260
+ if not job:
261
+ raise ValueError(f"Unknown parallel job id: {job_id}")
262
+ await _cancel_job(job)
263
+ snapshots.append(_job_snapshot(job, include_result=True))
264
+ await cleanup_parallel_job(agent, job)
265
+ _jobs_for_context(agent.context).pop(job_id, None)
266
+ return snapshots
267
+
268
+
269
+async def refresh_parallel_jobs(agent: "Agent") -> list[ParallelJob]:
270
+ jobs = list(_jobs_for_context(agent.context).values())
271
+ for job in jobs:
272
+ if job.state in TERMINAL_STATES:
273
+ continue
274
+ task = job.deferred_task
275
+ if not task:
276
+ continue
277
+ if task.is_ready():
278
+ try:
279
+ await task.result()
280
+ except asyncio.CancelledError:
281
+ _finish_job(job, "cancelled", error="Parallel job was cancelled.")
282
+ except Exception as exc:
283
+ _finish_job(job, "error", error=str(exc))
284
+ elif task.is_alive():
285
+ job.state = "running"
286
+ if job.started_at is None:
287
+ job.started_at = time.time()
288
+ return jobs
289
+
290
+
291
+async def cleanup_parallel_job(agent: "Agent", job: ParallelJob) -> None:
292
+ if job.deferred_task and job.deferred_task.is_alive():
293
+ job.deferred_task.kill()
294
+ if job.kind == "tool":
295
+ await _remove_context(job.worker_context_id)
296
+
297
+
298
+async def build_parallel_jobs_extras(agent: "Agent") -> str:
299
+ await refresh_parallel_jobs(agent)
300
+ jobs = [
301
+ job
302
+ for job in _jobs_for_context(agent.context).values()
303
+ if isinstance(job, ParallelJob) and job.state not in {"cancelled", "timeout"}
304
+ ]
305
+ if not jobs:
306
+ return ""
307
+
308
+ active = [job for job in jobs if job.state not in TERMINAL_STATES]
309
+ ready = [job for job in jobs if job.state in TERMINAL_STATES]
310
+ if not active and not ready:
311
+ return ""
312
+
313
+ lines = ["parallel jobs:"]
314
+ if active:
315
+ lines.append("running:")
316
+ for job in active:
317
+ lines.append(
318
+ f"- {job.id}: {job.tool_name} [{job.state}], running for {job.elapsed():.1f}s"
319
+ )
320
+ if ready:
321
+ lines.append("ready to collect with `parallel` and `job_ids`:")
322
+ for job in ready:
323
+ lines.append(
324
+ f"- {job.id}: {job.tool_name} [{job.state}], duration {job.elapsed():.1f}s"
325
+ )
326
+ lines.append("call the `parallel` tool with `job_ids` to await/collect results or `action: \"cancel\"` to cancel.")
327
+ return "\n".join(lines)
328
+
329
+
330
+def format_started_jobs(jobs: list[ParallelJob]) -> str:
331
+ payload = {
332
+ "status": "started",
333
+ "jobs": [_job_snapshot(job, include_result=False) for job in jobs],
334
+ "instruction": "Use the parallel tool with job_ids to await or cancel these background jobs.",
335
+ }
336
+ return json.dumps(payload, indent=2, ensure_ascii=False)
337
+
338
+
339
+def format_parallel_results(results: list[dict[str, Any]]) -> str:
340
+ states = [result.get("state") for result in results]
341
+ if states and all(state == "success" for state in states):
342
+ status = "success"
343
+ elif any(state == "success" for state in states):
344
+ status = "partial"
345
+ else:
346
+ status = "error"
347
+
348
+ payload = {
349
+ "status": status,
350
+ "jobs": results,
351
+ }
352
+ return json.dumps(payload, indent=2, ensure_ascii=False)
353
+
354
+
355
+async def _run_parallel_job(parent_context_id: str, job_id: str) -> None:
356
+ job = _get_job(parent_context_id, job_id)
357
+ if not job:
358
+ return
359
+ try:
360
+ if job.kind == "subordinate":
361
+ result = await _run_subordinate_context_job(parent_context_id, job)
362
+ else:
363
+ result = await _run_direct_tool_job(parent_context_id, job)
364
+ _finish_job(job, "success", result=result)
365
+ except asyncio.CancelledError:
366
+ _finish_job(job, "cancelled", error="Parallel job was cancelled.")
367
+ raise
368
+ except Exception as exc:
369
+ _finish_job(job, "error", error=str(exc))
370
+ PrintStyle.error(f"Parallel job {job.id} failed: {exc}")
371
+
372
+
373
+async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str:
374
+ from agent import AgentContext, AgentContextType, UserMessage
375
+ from helpers import message_queue, persist_chat
376
+
377
+ parent_context = AgentContext.get(parent_context_id)
378
+ if not parent_context:
379
+ raise ValueError("Parent context not found.")
380
+
381
+ args = job.tool_args
382
+ message = str(args.get("message") or "").strip()
383
+ if not message:
384
+ raise ValueError("call_subordinate requires `tool_args.message`.")
385
+
386
+ profile = str(args.get("profile") or args.get("agent_profile") or "").strip()
387
+ attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else []
388
+ attachments = [str(item) for item in attachments]
389
+
390
+ child_name = _subordinate_context_name(job)
391
+ worker_context = AgentContext(
392
+ config=_clone_config(parent_context.config, profile=profile),
393
+ name=child_name,
394
+ type=AgentContextType.USER,
395
+ )
396
+ job.worker_context_id = worker_context.id
397
+ if job.deferred_task:
398
+ worker_context.task = job.deferred_task
399
+
400
+ worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id)
401
+ worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
402
+ worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id)
403
+ worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel")
404
+ worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name)
405
+ worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id)
406
+ worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name)
407
+ _copy_project(parent_context, worker_context)
408
+
409
+ system_prompt = _subordinate_worker_system_prompt(profile)
410
+ message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)")
411
+ worker_context.agent0.hist_add_user_message(
412
+ UserMessage(
413
+ message=message,
414
+ attachments=attachments,
415
+ system_message=[system_prompt],
416
+ )
417
+ )
418
+ persist_chat.save_tmp_chat(worker_context)
419
+
420
+ try:
421
+ result = await worker_context.agent0.monologue()
422
+ worker_context.agent0.history.new_topic()
423
+ return result
424
+ finally:
425
+ persist_chat.save_tmp_chat(worker_context)
426
+
427
+
428
+async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
429
+ from agent import AgentContext, AgentContextType, LoopData
430
+
431
+ parent_context = AgentContext.get(parent_context_id)
432
+ if not parent_context:
433
+ raise ValueError("Parent context not found.")
434
+
435
+ worker_context: AgentContext | None = None
436
+ try:
437
+ worker_context = AgentContext(
438
+ config=_clone_config(parent_context.config),
439
+ name=f"parallel:{job.tool_name}",
440
+ type=AgentContextType.BACKGROUND,
441
+ )
442
+ worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context_id)
443
+ worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
444
+ job.worker_context_id = worker_context.id
445
+ _copy_project(parent_context, worker_context)
446
+
447
+ worker_agent = worker_context.agent0
448
+ worker_agent.loop_data = LoopData()
449
+ return await execute_tool_call(worker_agent, job.tool_name, job.tool_args)
450
+ finally:
451
+ if worker_context:
452
+ await _remove_context(worker_context.id)
453
+
454
+
455
+async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str, Any]) -> str:
456
+ if tool_name == "parallel":
457
+ raise ValueError("`parallel` cannot be nested inside a parallel worker.")
458
+
459
+ tool = None
460
+ try:
461
+ import helpers.mcp_handler as mcp_helper
462
+
463
+ tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
464
+ except ImportError:
465
+ tool = None
466
+ except Exception as exc:
467
+ PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
468
+
469
+ if not tool:
470
+ tool = agent.get_tool(
471
+ name=tool_name,
472
+ method=None,
473
+ args=tool_args,
474
+ message=json.dumps({"tool_name": tool_name, "tool_args": tool_args}),
475
+ loop_data=agent.loop_data,
476
+ )
477
+ if not tool:
478
+ raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
479
+
480
+ agent.loop_data.current_tool = tool
481
+ try:
482
+ await agent.handle_intervention()
483
+ await tool.before_execution(**tool_args)
484
+ await agent.handle_intervention()
485
+ await call_extensions_async(
486
+ "tool_execute_before",
487
+ agent,
488
+ tool_args=tool_args or {},
489
+ tool_name=tool_name,
490
+ )
491
+ response = await tool.execute(**tool_args)
492
+ await agent.handle_intervention()
493
+ await call_extensions_async(
494
+ "tool_execute_after",
495
+ agent,
496
+ response=response,
497
+ tool_name=tool_name,
498
+ )
499
+ await tool.after_execution(response)
500
+ await agent.handle_intervention()
501
+ return response.message
502
+ finally:
503
+ agent.loop_data.current_tool = None
504
+
505
+
506
+async def _timeout_job(job: ParallelJob) -> None:
507
+ await _cancel_job(job, state="timeout", message="Parallel job timed out.")
508
+
509
+
510
+async def _cancel_job(
511
+ job: ParallelJob,
512
+ *,
513
+ state: JobState = "cancelled",
514
+ message: str = "Parallel job was cancelled.",
515
+) -> None:
516
+ if job.deferred_task and job.deferred_task.is_alive():
517
+ job.deferred_task.kill()
518
+ _finish_job(job, state, error=message)
519
+
520
+
521
+def _finish_job(
522
+ job: ParallelJob,
523
+ state: JobState,
524
+ *,
525
+ result: str | None = None,
526
+ error: str | None = None,
527
+) -> None:
528
+ job.state = state
529
+ job.completed_at = time.time()
530
+ if job.started_at is None:
531
+ job.started_at = job.created_at
532
+ if result is not None:
533
+ job.result = result
534
+ if error is not None:
535
+ job.error = error
536
+ _update_parallel_child_log(job)
537
+
538
+
539
+async def _remove_context(context_id: str | None) -> None:
540
+ if not context_id:
541
+ return
542
+ from agent import AgentContext
543
+
544
+ context = AgentContext.get(context_id)
545
+ if context:
546
+ try:
547
+ context.reset()
548
+ except Exception:
549
+ pass
550
+ AgentContext.remove(context_id)
551
+
552
+
553
+def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:
554
+ if job.kind == "subordinate":
555
+ job.log_item = agent.context.log.log(
556
+ type="subagent",
557
+ heading=f"icon://communication {agent.agent_name}: Calling Subordinate Agent",
558
+ content="",
559
+ kvps=job.tool_args,
560
+ id=job.log_id,
561
+ )
562
+ return
563
+
564
+ heading = f"icon://construction {agent.agent_name}: Using tool '{job.tool_name}'"
565
+ job.log_item = agent.context.log.log(
566
+ type="tool",
567
+ heading=heading,
568
+ content="",
569
+ kvps=job.tool_args,
570
+ id=job.log_id,
571
+ _tool_name=job.tool_name,
572
+ )
573
+
574
+
575
+def _update_parallel_child_log(job: ParallelJob) -> None:
576
+ if not job.log_item:
577
+ return
578
+ if job.state == "success":
579
+ content = job.result if job.result else "(completed without textual output)"
580
+ else:
581
+ content = f"Error: {job.error or job.state}"
582
+ try:
583
+ job.log_item.update(content=content)
584
+ except Exception:
585
+ pass
586
+
587
+
588
+def _job_snapshot(job: ParallelJob, *, include_result: bool) -> dict[str, Any]:
589
+ data: dict[str, Any] = {
590
+ "job_id": job.id,
591
+ "tool_name": job.tool_name,
592
+ "state": job.state,
593
+ "duration_seconds": round(job.elapsed(), 3),
594
+ }
595
+ if job.worker_context_id:
596
+ data["context_id"] = job.worker_context_id
597
+ if include_result:
598
+ if job.result is not None:
599
+ data["result"] = job.result
600
+ if job.error is not None:
601
+ data["error"] = job.error
602
+ return data
603
+
604
+
605
+def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig":
606
+ try:
607
+ cloned = replace(
608
+ config,
609
+ knowledge_subdirs=list(config.knowledge_subdirs),
610
+ additional=dict(config.additional),
611
+ )
612
+ if profile:
613
+ cloned.profile = profile
614
+ return cloned
615
+ except Exception:
616
+ return config
617
+
618
+
619
+def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext") -> None:
620
+ try:
621
+ from helpers import projects
622
+
623
+ project_name = projects.get_context_project_name(parent_context)
624
+ if project_name:
625
+ projects.activate_project(worker_context.id, project_name, mark_dirty=False)
626
+ except Exception:
627
+ pass
628
+
629
+
630
+def _subordinate_worker_system_prompt(profile: str) -> str:
631
+ lines = [
632
+ "You are running as an isolated parallel worker for a parent Agent Zero chat.",
633
+ "Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.",
634
+ "Do not call the `parallel` tool from this worker.",
635
+ ]
636
+ if profile:
637
+ lines.append(f"Act with the `{profile}` profile's expertise and priorities.")
638
+ return "\n".join(lines)
639
+
640
+
641
+def _subordinate_context_name(job: ParallelJob) -> str:
642
+ name = str(job.tool_args.get("name") or "").strip()
643
+ if name:
644
+ return name
645
+ message = str(job.tool_args.get("message") or "").strip()
646
+ label = _short_label(message)
647
+ return label or f"Parallel subordinate {job.index + 1}"
648
+
649
+
650
+def _short_label(text: str, limit: int = 80) -> str:
651
+ compact = " ".join(text.split())
652
+ return compact[:limit].rstrip()
helpers/parallel_tools.py.dox.md
new
+53
@@ -0,0 +1,53 @@
1
+# parallel_tools.py DOX
2
+
3
+## Purpose
4
+
5
+- Own the shared runtime for parallel tool-call jobs.
6
+- Normalize wrapped tool-call payloads, start background jobs, await or cancel jobs, and render prompt extras for active parallel work.
7
+- Keep this file-level DOX profile synchronized with `parallel_tools.py` because this directory is intentionally flat.
8
+
9
+## Ownership
10
+
11
+- `parallel_tools.py` owns the runtime implementation.
12
+- `parallel_tools.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13
+- Public concepts:
14
+- `NormalizedToolCall`
15
+- `ParallelJob`
16
+- `start_parallel_jobs(...)`
17
+- `await_parallel_jobs(...)`
18
+- `cancel_parallel_jobs(...)`
19
+- `build_parallel_jobs_extras(...)`
20
+- `format_parallel_results(...)`
21
+
22
+## Runtime Contracts
23
+
24
+- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
25
+- Wrapped tool-call items must use the same shape as normal tool calls: a tool name plus arguments.
26
+- `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list.
27
+- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
28
+- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
29
+- Child tool logs mirror normal tool-call visible args; job ids remain available through wrapper results and prompt extras rather than visible process-step args.
30
+- Job IDs are stable handles for later await, collect, or cancel operations.
31
+- Prompt extras must stay bounded and expose only job IDs, tool names, status, and compact result/error summaries.
32
+
33
+## Key Concepts
34
+
35
+- The parent context stores in-flight jobs under a private data key; collected terminal jobs are removed from that registry.
36
+- `wait=True` starts jobs and awaits them before returning; `wait=False` returns job IDs immediately.
37
+- `collect` returns already-finished job results without waiting; `await` waits for requested job IDs.
38
+- Canceled jobs should be marked terminal and should stop their background `DeferredTask` when cancellation is possible.
39
+
40
+## Work Guidance
41
+
42
+- Keep normalization compatible with provider tool-call envelopes and direct JSON objects.
43
+- Avoid importing heavy runtime modules at import time unless startup behavior is verified.
44
+- Coordinate argument, output, or status changes with `tools/parallel.py`, prompt instructions, and tests.
45
+
46
+## Verification
47
+
48
+- Run targeted tests for normalization, recursion guard, prompt extras, and tool result formatting.
49
+- Run a live Agent Zero chat when changing parallel execution, child chat metadata, or subordinate task behavior.
50
+
51
+## Child DOX Index
52
+
53
+No child DOX files.
prompts/agent.system.main.communication.md
+2
-1
@@ -10,7 +10,8 @@
10
- tool_name: use tool name
11
- tool_args: key value pairs tool arguments
12
- `tool_name` must be one listed tool name, never an action name such as `read`, `write`, `terminal`, or `multi`
13
-- To do two operations, call one tool now, then call the next tool after the first result
13
+- To do dependent operations, call one tool now, then call the next tool after the first result
14
+- To do independent operations concurrently, use only the listed `parallel` tool
15
16
- No text output before or after the JSON object
17
prompts/agent.system.tool.parallel.md
new
+32
@@ -0,0 +1,32 @@
1
+### parallel
2
+run independent tool calls concurrently, or await/cancel background parallel jobs.
3
+
4
+Use only for independent work. Each `tool_calls` item is a normal tool request object: `{ "tool_name": "...", "tool_args": { ... } }`.
5
+
6
+Rules:
7
+- do not use for one simple call, dependent steps, ordered steps, or shared mutable state
8
+- never nest `parallel`
9
+- `call_subordinate` inside `parallel` starts an isolated child chat under the parent chat, not a scheduler task
10
+- use `wait: false` only when you will collect results later with `job_ids`
11
+- if extras list running or ready parallel jobs, collect them before final synthesis
12
+
13
+Args: `tool_calls`, `job_ids`, `wait` default `true`, `action` as `start|await|collect|cancel`, `timeout`.
14
+
15
+Start and wait:
16
+~~~json
17
+{
18
+ "tool_name": "parallel",
19
+ "tool_args": {
20
+ "tool_calls": [
21
+ {"tool_name": "call_subordinate", "tool_args": {"message": "Research option A.", "reset": true}},
22
+ {"tool_name": "call_subordinate", "tool_args": {"message": "Research option B.", "reset": true}}
23
+ ],
24
+ "wait": true
25
+ }
26
+}
27
+~~~
28
+
29
+Collect existing jobs:
30
+~~~json
31
+{"tool_name": "parallel", "tool_args": {"action": "await", "job_ids": ["job-id"], "timeout": 300}}
32
+~~~
prompts/agent.system.tools.md
+1
-1
@@ -1,4 +1,4 @@
1
## available tools
2
use ONLY the tools listed below. match names exactly. do NOT invent tool names.
3
-Action names are not tool names. There is no top-level `multi` or batch tool; call one listed tool at a time. If a tool has an action named `multi`, keep that action inside `tool_args.action` for that specific tool.
3
+Action names are not tool names. Do not invent top-level `multi` or generic batch tools. The only listed wrapper for independent concurrent calls is `parallel`; otherwise call one listed tool at a time. If a tool has an action named `multi`, keep that action inside `tool_args.action` for that specific tool.
4
{{tools}}
tests/test_default_prompt_budget.py
+3
-1
@@ -50,10 +50,12 @@ async def test_default_agent0_prompt_budget_and_guardrails():
50
# surface plus skill metadata. Keep the guardrail close to the observed
51
# budget so prompt creep remains visible without pretending this surface is
52
# a tiny single-tool prompt.
53
- assert tokens.approximate_tokens(system_text) <= 10500
53
+ assert tokens.approximate_tokens(system_text) <= 12000
54
assert "`tool_name` must be one listed tool name" in system_text
55
assert "- tool_args: key value pairs tool arguments" in system_text
56
assert '"tool_name": "call_subordinate"' in system_text
57
+ assert '"tool_name": "parallel"' in system_text
58
+ assert "Each `tool_calls` item is a normal tool request object" in system_text
59
assert '"reset": true' in system_text
60
assert '"tool_name": "text_editor"' in system_text
61
assert '"action": "read"' in system_text
tests/test_parallel_tool.py
new
+315
@@ -0,0 +1,315 @@
1
+from __future__ import annotations
2
+
3
+import time
4
+import sys
5
+from types import SimpleNamespace
6
+from pathlib import Path
7
+
8
+import pytest
9
+
10
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
11
+if str(PROJECT_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(PROJECT_ROOT))
13
+
14
+from helpers import parallel_tools
15
+from helpers.tool import Response
16
+
17
+
18
+class _FakeLogItem:
19
+ def __init__(self, type_, heading="", content="", kvps=None, id_=None, **kwargs) -> None:
20
+ self.type = type_
21
+ self.heading = heading
22
+ self.content = content
23
+ self.kvps = dict(kvps or {})
24
+ self.kvps.update(kwargs)
25
+ self.id = id_
26
+
27
+ def update(self, content=None, kvps=None, **kwargs):
28
+ if content is not None:
29
+ self.content = content
30
+ if kvps:
31
+ self.kvps.update(kvps)
32
+ self.kvps.update(kwargs)
33
+
34
+
35
+class _FakeLog:
36
+ def __init__(self) -> None:
37
+ self.items = []
38
+
39
+ def log(self, type, heading="", content="", kvps=None, id=None, **kwargs):
40
+ item = _FakeLogItem(type, heading, content, kvps, id, **kwargs)
41
+ self.items.append(item)
42
+ return item
43
+
44
+
45
+class _FakeContext:
46
+ def __init__(self) -> None:
47
+ self.id = "ctx"
48
+ self.data = {}
49
+ self.log = _FakeLog()
50
+
51
+ def get_data(self, key: str, recursive: bool = True):
52
+ return self.data.get(key)
53
+
54
+ def set_data(self, key: str, value, recursive: bool = True):
55
+ self.data[key] = value
56
+
57
+
58
+class _FakeAgent:
59
+ def __init__(self) -> None:
60
+ self.context = _FakeContext()
61
+ self.agent_name = "A0"
62
+
63
+
64
+def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> None:
65
+ calls = parallel_tools.normalize_parallel_tool_calls(
66
+ [
67
+ {
68
+ "tool_name": "text_editor:read",
69
+ "tool_args": {"path": "README.md"},
70
+ },
71
+ {
72
+ "tool": "scheduler",
73
+ "args": {"method": "list_tasks"},
74
+ },
75
+ ]
76
+ )
77
+
78
+ assert calls[0].tool_name == "text_editor"
79
+ assert calls[0].tool_args == {"path": "README.md", "action": "read"}
80
+ assert calls[1].tool_name == "scheduler"
81
+ assert calls[1].tool_args == {"method": "list_tasks", "action": "list_tasks"}
82
+
83
+
84
+def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
85
+ with pytest.raises(ValueError, match="cannot be nested"):
86
+ parallel_tools.normalize_parallel_tool_calls(
87
+ [{"tool_name": "parallel", "tool_args": {"tool_calls": []}}]
88
+ )
89
+
90
+
91
+@pytest.mark.asyncio
92
+async def test_parallel_jobs_extras_lists_running_and_ready_jobs() -> None:
93
+ agent = _FakeAgent()
94
+ running = parallel_tools.ParallelJob(
95
+ id="search-1234abcd",
96
+ parent_context_id="ctx",
97
+ index=0,
98
+ tool_name="search_engine",
99
+ tool_args={"query": "Agent Zero"},
100
+ kind="tool",
101
+ state="running",
102
+ started_at=time.time() - 2,
103
+ )
104
+ ready = parallel_tools.ParallelJob(
105
+ id="callsubordin-5678efgh",
106
+ parent_context_id="ctx",
107
+ index=1,
108
+ tool_name="call_subordinate",
109
+ tool_args={"message": "Summarize"},
110
+ kind="subordinate",
111
+ state="success",
112
+ started_at=time.time() - 4,
113
+ completed_at=time.time() - 1,
114
+ result="done",
115
+ )
116
+ agent.context.set_data(
117
+ parallel_tools.PARALLEL_JOBS_KEY,
118
+ {running.id: running, ready.id: ready},
119
+ )
120
+
121
+ extras = await parallel_tools.build_parallel_jobs_extras(agent) # type: ignore[arg-type]
122
+
123
+ assert "search-1234abcd" in extras
124
+ assert "callsubordin-5678efgh" in extras
125
+ assert "ready to collect" in extras
126
+
127
+
128
+@pytest.mark.asyncio
129
+async def test_parallel_subordinate_jobs_are_visible_child_logs_not_scheduler_tasks(monkeypatch) -> None:
130
+ class FakeDeferredTask:
131
+ def __init__(self, thread_name=None) -> None:
132
+ self.thread_name = thread_name
133
+ self.started = None
134
+
135
+ def start_task(self, func, *args):
136
+ self.started = (func, args)
137
+ return self
138
+
139
+ def is_ready(self):
140
+ return False
141
+
142
+ def is_alive(self):
143
+ return True
144
+
145
+ def kill(self):
146
+ pass
147
+
148
+ monkeypatch.setattr(parallel_tools, "DeferredTask", FakeDeferredTask)
149
+ agent = _FakeAgent()
150
+
151
+ jobs = await parallel_tools.start_parallel_jobs(
152
+ agent, # type: ignore[arg-type]
153
+ [
154
+ parallel_tools.NormalizedToolCall(
155
+ index=0,
156
+ tool_name="call_subordinate",
157
+ tool_args={
158
+ "profile": "developer",
159
+ "message": "Return ALPHA=1",
160
+ "reset": True,
161
+ },
162
+ )
163
+ ],
164
+ )
165
+
166
+ assert jobs[0].kind == "subordinate"
167
+ snapshot = parallel_tools._job_snapshot(jobs[0], include_result=False)
168
+ assert "scheduler_task_uuid" not in snapshot
169
+ assert agent.context.log.items[0].type == "subagent"
170
+ assert agent.context.log.items[0].kvps == {
171
+ "profile": "developer",
172
+ "message": "Return ALPHA=1",
173
+ "reset": True,
174
+ }
175
+ assert "id" not in agent.context.log.items[0].kvps
176
+ assert "tool_name" not in agent.context.log.items[0].kvps
177
+ assert "parallel_child" not in agent.context.log.items[0].kvps
178
+
179
+
180
+@pytest.mark.asyncio
181
+async def test_parallel_direct_tool_jobs_log_normal_tool_metadata(monkeypatch) -> None:
182
+ class FakeDeferredTask:
183
+ def __init__(self, thread_name=None) -> None:
184
+ self.thread_name = thread_name
185
+ self.started = None
186
+
187
+ def start_task(self, func, *args):
188
+ self.started = (func, args)
189
+ return self
190
+
191
+ def is_ready(self):
192
+ return False
193
+
194
+ def is_alive(self):
195
+ return True
196
+
197
+ def kill(self):
198
+ pass
199
+
200
+ monkeypatch.setattr(parallel_tools, "DeferredTask", FakeDeferredTask)
201
+ agent = _FakeAgent()
202
+
203
+ jobs = await parallel_tools.start_parallel_jobs(
204
+ agent, # type: ignore[arg-type]
205
+ [
206
+ parallel_tools.NormalizedToolCall(
207
+ index=0,
208
+ tool_name="wait",
209
+ tool_args={"seconds": 1},
210
+ )
211
+ ],
212
+ )
213
+
214
+ assert jobs[0].kind == "tool"
215
+ assert agent.context.log.items[0].type == "tool"
216
+ assert agent.context.log.items[0].kvps == {"seconds": 1, "_tool_name": "wait"}
217
+
218
+ parallel_tools._finish_job(jobs[0], "success", result="done")
219
+
220
+ assert agent.context.log.items[0].content == "done"
221
+ assert agent.context.log.items[0].kvps == {"seconds": 1, "_tool_name": "wait"}
222
+
223
+
224
+@pytest.mark.asyncio
225
+async def test_parallel_tool_keeps_wrapper_out_of_visible_log() -> None:
226
+ from tools.parallel import ParallelTool
227
+
228
+ class HistoryAgent(_FakeAgent):
229
+ def __init__(self) -> None:
230
+ super().__init__()
231
+ self.tool_results = []
232
+
233
+ def hist_add_tool_result(self, tool_name, tool_result, **kwargs):
234
+ self.tool_results.append((tool_name, tool_result, kwargs))
235
+
236
+ agent = HistoryAgent()
237
+ tool = ParallelTool(agent, "parallel", None, {}, "", None) # type: ignore[arg-type]
238
+
239
+ await tool.before_execution()
240
+ await tool.after_execution(Response(message="done", break_loop=False, additional={"extra": "value"}))
241
+
242
+ assert agent.context.log.items == []
243
+ assert agent.tool_results == [("parallel", "done", {"extra": "value"})]
244
+
245
+
246
+@pytest.mark.asyncio
247
+async def test_parallel_child_contexts_are_chats_not_tasks(monkeypatch) -> None:
248
+ from agent import AgentContext
249
+ from initialize import initialize_agent
250
+ from helpers import state_snapshot
251
+
252
+ class NoTaskScheduler:
253
+ def get_task_by_uuid(self, _task_id):
254
+ return None
255
+
256
+ monkeypatch.setattr(
257
+ state_snapshot,
258
+ "TaskScheduler",
259
+ SimpleNamespace(get=lambda: NoTaskScheduler()),
260
+ )
261
+
262
+ parent_id = "ctx-par-parent"
263
+ child_id = "ctx-par-child"
264
+ parent = AgentContext(config=initialize_agent(), id=parent_id, name="Parent", set_current=False)
265
+ child = AgentContext(config=initialize_agent(), id=child_id, name="Child", set_current=False)
266
+ try:
267
+ child.set_output_data(parallel_tools.CHILD_PARENT_CONTEXT_ID_KEY, parent.id)
268
+ child.set_output_data(parallel_tools.CHILD_PARENT_CONTEXT_KIND_KEY, "parallel")
269
+ child.set_output_data(parallel_tools.CHILD_PARENT_CONTEXT_LABEL_KEY, "Child task")
270
+ child.set_output_data(parallel_tools.CHILD_PARALLEL_JOB_ID_KEY, "job-123")
271
+
272
+ payload = await state_snapshot.build_snapshot(
273
+ context=parent.id,
274
+ log_from=0,
275
+ notifications_from=0,
276
+ timezone="UTC",
277
+ )
278
+
279
+ contexts_by_id = {ctx["id"]: ctx for ctx in payload["contexts"]}
280
+ task_ids = {task["id"] for task in payload["tasks"]}
281
+ assert parent_id in contexts_by_id
282
+ assert child_id in contexts_by_id
283
+ assert contexts_by_id[child_id]["parent_context_id"] == parent_id
284
+ assert child_id not in task_ids
285
+ finally:
286
+ AgentContext.remove(parent_id)
287
+ AgentContext.remove(child_id)
288
+
289
+
290
+def test_chats_sidebar_projects_parallel_children_as_indented_accordion() -> None:
291
+ store = (PROJECT_ROOT / "webui/components/sidebar/chats/chats-store.js").read_text(
292
+ encoding="utf-8"
293
+ )
294
+ html = (PROJECT_ROOT / "webui/components/sidebar/chats/chats-list.html").read_text(
295
+ encoding="utf-8"
296
+ )
297
+
298
+ assert "parent_context_id" in store
299
+ assert "const nextExpandedParents = { ...this.expandedParents };" in store
300
+ assert "nextExpandedParents[selectedId] === undefined" in store
301
+ assert "nextExpandedParents[selectedId] = true;" in store
302
+ assert "topLevelContexts()" in html
303
+ assert "childContexts(context.id)" in html
304
+ assert "chat-child-container" in html
305
+ assert "keyboard_arrow_up" in html
306
+ assert "keyboard_arrow_down" in html
307
+ assert ".chats-config-list .chat-tree-item" in html
308
+ assert ".chats-config-list .chat-child-list > li" in html
309
+ assert 'x-show="$store.chats.hasChildren(context.id)"' in html
310
+ assert "'chat-has-children': $store.chats.hasChildren(context.id)" in html
311
+ assert ".chat-container.chat-has-children .chat-list-button" in html
312
+ assert "left: 2px" in html
313
+ assert "padding-left: 24px" in html
314
+ assert "color: var(--color-text-muted)" in html
315
+ assert "padding: 8px;" in html
tests/test_tool_action_contracts.py
+3
-1
@@ -353,8 +353,10 @@ def test_tool_prompts_prevent_top_level_multi_tool():
353
encoding="utf-8"
354
)
355
356
- assert "There is no top-level `multi` or batch tool" in tools_prompt
356
+ assert "Do not invent top-level `multi` or generic batch tools" in tools_prompt
357
+ assert "listed wrapper for independent concurrent calls is `parallel`" in tools_prompt
358
assert "never an action name such as `read`, `write`, `terminal`, or `multi`" in communication_prompt
359
+ assert "independent operations concurrently" in communication_prompt
360
assert 'Never use `tool_name: "multi"`' in browser_prompt
361
362
tools/parallel.py
new
+78
@@ -0,0 +1,78 @@
1
+from helpers.tool import Tool, Response
2
+from helpers import parallel_tools
3
+from helpers.strings import sanitize_string
4
+
5
+
6
+class ParallelTool(Tool):
7
+ async def before_execution(self, **kwargs):
8
+ self.log = None
9
+
10
+ async def after_execution(self, response: Response, **kwargs):
11
+ text = sanitize_string(response.message.strip())
12
+ self.agent.hist_add_tool_result(
13
+ self.name,
14
+ text,
15
+ **(response.additional or {}),
16
+ )
17
+
18
+ async def execute(self, **kwargs) -> Response:
19
+ args = {**self.args, **kwargs}
20
+ action = str(args.get("action") or "").strip().lower()
21
+
22
+ try:
23
+ timeout = parallel_tools.coerce_timeout(args.get("timeout"))
24
+ job_ids = parallel_tools.normalize_job_ids(args.get("job_ids"))
25
+
26
+ if action == "cancel":
27
+ results = await parallel_tools.cancel_parallel_jobs(self.agent, job_ids)
28
+ return Response(
29
+ message=parallel_tools.format_parallel_results(results),
30
+ break_loop=False,
31
+ )
32
+
33
+ raw_calls = parallel_tools.extract_tool_calls(args)
34
+ started_jobs = []
35
+ if raw_calls is not None:
36
+ calls = parallel_tools.normalize_parallel_tool_calls(raw_calls)
37
+ started_jobs = await parallel_tools.start_parallel_jobs(self.agent, calls)
38
+
39
+ started_job_ids = [job.id for job in started_jobs]
40
+ all_job_ids = [*job_ids, *started_job_ids]
41
+
42
+ if not all_job_ids:
43
+ return Response(
44
+ message=(
45
+ "Error: provide `tool_calls` to start parallel jobs, "
46
+ "or `job_ids` to await/cancel existing jobs."
47
+ ),
48
+ break_loop=False,
49
+ )
50
+
51
+ wait_default = action not in {"start", "background"}
52
+ wait = parallel_tools.coerce_bool(args.get("wait"), wait_default)
53
+ if action in {"await", "wait", "collect"}:
54
+ wait = True
55
+
56
+ if not wait:
57
+ if not started_jobs:
58
+ return Response(
59
+ message="Error: `wait: false` requires `tool_calls` to start new jobs.",
60
+ break_loop=False,
61
+ )
62
+ return Response(
63
+ message=parallel_tools.format_started_jobs(started_jobs),
64
+ break_loop=False,
65
+ )
66
+
67
+ results = await parallel_tools.await_parallel_jobs(
68
+ self.agent,
69
+ all_job_ids,
70
+ timeout=timeout,
71
+ collect=True,
72
+ )
73
+ return Response(
74
+ message=parallel_tools.format_parallel_results(results),
75
+ break_loop=False,
76
+ )
77
+ except ValueError as exc:
78
+ return Response(message=f"Error: {exc}", break_loop=False)
tools/parallel.py.dox.md
new
+51
@@ -0,0 +1,51 @@
1
+# parallel.py DOX
2
+
3
+## Purpose
4
+
5
+- Own the `parallel.py` agent tool.
6
+- This tool wraps independent tool calls so they can be started together, awaited by job ID, collected, or canceled.
7
+- Keep this file-level DOX profile synchronized with `parallel.py` because this directory is intentionally flat.
8
+
9
+## Ownership
10
+
11
+- `parallel.py` owns the runtime implementation.
12
+- `parallel.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13
+- Classes:
14
+- `ParallelTool` (`Tool`)
15
+ - `async execute(self, tool_calls=..., calls=..., items=..., job_ids=..., wait=..., action=..., timeout=..., **kwargs)`
16
+ - `async before_execution(self, **kwargs)`
17
+ - `async after_execution(self, response, **kwargs)`
18
+
19
+## Runtime Contracts
20
+
21
+- Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
22
+- Wrapped items use the same schema as normal tool calls: a tool name plus arguments.
23
+- The tool is intended for independent calls only; dependent operations remain sequential.
24
+- `action="start"` starts calls and optionally waits according to `wait`.
25
+- `action="await"` waits for requested job IDs.
26
+- `action="collect"` returns completed job results without waiting.
27
+- `action="cancel"` requests cancellation for requested job IDs.
28
+- Recursive use of `parallel` from inside a parallel worker is blocked before execution.
29
+- The wrapper tool does not create its own visible process-step log; each wrapped child call owns the visible log row, and the wrapper result is recorded only in model history.
30
+
31
+## Key Concepts
32
+
33
+- `tool_calls`, `calls`, and `items` are accepted aliases for the wrapped call list.
34
+- `job_ids` can be supplied as a string or list when awaiting, collecting, or canceling existing jobs.
35
+- The response is compact JSON intended for the model to read and continue with.
36
+- Visible child rows are emitted by `helpers/parallel_tools.py` before each background job starts.
37
+
38
+## Work Guidance
39
+
40
+- Keep output concise enough for message history while preserving job IDs, statuses, and results.
41
+- Coordinate tool argument or output changes with `prompts/agent.system.tool.parallel.md`, prompt contract tests, and helper tests.
42
+- Avoid adding tool-specific execution logic here; shared execution behavior belongs in `helpers/parallel_tools.py`.
43
+
44
+## Verification
45
+
46
+- Run targeted tool and prompt-contract tests after changing behavior.
47
+- Run a live WebUI or CLI chat that invokes `parallel` with multiple subordinate jobs.
48
+
49
+## Child DOX Index
50
+
51
+No child DOX files.
webui/components/sidebar/AGENTS.md
+4
@@ -16,6 +16,10 @@
16
17
- Preserve responsive sidebar behavior and collapsed/expanded state.
18
- Keep chat and task list updates compatible with WebSocket state sync.
19
+- Contexts with `parent_context_id` render as indented children beneath their parent chat; they must remain selectable while hidden from the top-level chat list.
20
+- Chat tree expand/collapse controls use a parent-only leading slot and must not consume normal chat row text margin.
21
+- A restored selected parent chat with children auto-expands once during context hydration unless the user has already toggled it.
22
+- The Tasks list is reserved for scheduler-backed task contexts and must not be used for chat-bound parallel children.
23
- Avoid text or controls overflowing fixed sidebar widths.
24
25
## Work Guidance
webui/components/sidebar/chats/chats-list.html
+101
-7
@@ -22,25 +22,53 @@
22
23
24
25
- <ul class="config-list chats-config-list no-scrollbar" x-show="$store.chats.contexts.length > 0">
26
- <template x-for="context in $store.chats.contexts" :key="context.id">
27
- <li>
28
- <div :class="{'chat-container': true, 'chat-selected': context.id === $store.chats.selected}"
25
+ <ul class="config-list chats-config-list no-scrollbar" x-show="$store.chats.topLevelContexts().length > 0">
26
+ <template x-for="context in $store.chats.topLevelContexts()" :key="context.id">
27
+ <li class="chat-tree-item">
28
+ <div :class="{'chat-container': true, 'chat-has-children': $store.chats.hasChildren(context.id), 'chat-selected': context.id === $store.chats.selected}"
29
@click="$store.chats.selectChat(context.id)">
30
+ <button class="chat-expand-btn"
31
+ x-show="$store.chats.hasChildren(context.id)"
32
+ :class="{ 'is-visible': $store.chats.hasChildren(context.id), 'is-expanded': $store.chats.isExpanded(context.id) }"
33
+ :aria-expanded="$store.chats.isExpanded(context.id)"
34
+ :aria-label="$store.chats.isExpanded(context.id) ? 'Collapse chat children' : 'Expand chat children'"
35
+ @click.stop="$store.chats.toggleChildren(context.id)">
36
+ <span class="material-symbols-outlined"
37
+ x-text="$store.chats.isExpanded(context.id) ? 'keyboard_arrow_up' : 'keyboard_arrow_down'"></span>
38
+ </button>
39
<div class="chat-list-button">
40
<span :class="{'project-color-ball': true, 'heartbeat': context.running}"
41
:style="context.project?.color ? { backgroundColor: context.project.color } : { border: '1px solid var(--color-border)' }"></span>
42
<span class="chat-name"
34
- x-text="context.name ? context.name : 'Chat #' + context.no"></span>
43
+ x-text="$store.chats.displayName(context)"></span>
44
</div>
45
<button class="btn-icon-action chat-list-action-btn" title="Close chat" @click.stop="$confirmClick($event, () => $store.chats.killChat(context.id))">
46
<span class="material-symbols-outlined">close</span>
47
</button>
48
</div>
49
+ <ul class="chat-child-list" x-show="$store.chats.isExpanded(context.id) && $store.chats.hasChildren(context.id)">
50
+ <template x-for="child in $store.chats.childContexts(context.id)" :key="child.id">
51
+ <li>
52
+ <div :class="{'chat-container': true, 'chat-child-container': true, 'chat-selected': child.id === $store.chats.selected}"
53
+ @click="$store.chats.selectChat(child.id)">
54
+ <div class="chat-child-indent"></div>
55
+ <div class="chat-list-button">
56
+ <span :class="{'project-color-ball': true, 'heartbeat': child.running}"
57
+ :style="child.project?.color ? { backgroundColor: child.project.color } : { border: '1px solid var(--color-border)' }"></span>
58
+ <span class="chat-name"
59
+ x-text="$store.chats.displayName(child)"></span>
60
+ </div>
61
+ <button class="btn-icon-action chat-list-action-btn" title="Close chat" @click.stop="$confirmClick($event, () => $store.chats.killChat(child.id))">
62
+ <span class="material-symbols-outlined">close</span>
63
+ </button>
64
+ </div>
65
+ </li>
66
+ </template>
67
+ </ul>
68
</li>
69
</template>
70
</ul>
43
- <div class="empty-list-message" x-show="$store.chats.contexts.length === 0">
71
+ <div class="empty-list-message" x-show="$store.chats.topLevelContexts().length === 0">
72
<p><i>No chats to list.</i></p>
73
</div>
74
<x-extension id="sidebar-chats-list-end"></x-extension>
@@ -93,6 +121,72 @@
121
cursor: pointer;
122
}
123
124
+ .chats-config-list .chat-tree-item {
125
+ display: block;
126
+ align-items: stretch;
127
+ justify-content: flex-start;
128
+ }
129
+
130
+ .chat-child-list {
131
+ list-style: none;
132
+ padding: 0;
133
+ margin: 0;
134
+ width: 100%;
135
+ }
136
+
137
+ .chats-config-list .chat-child-list > li {
138
+ display: block;
139
+ padding: 0;
140
+ }
141
+
142
+ .chat-child-container {
143
+ min-height: 34px;
144
+ }
145
+
146
+ .chat-child-indent {
147
+ width: 18px;
148
+ flex-shrink: 0;
149
+ }
150
+
151
+ .chat-expand-btn {
152
+ position: absolute;
153
+ left: 2px;
154
+ top: 50%;
155
+ transform: translateY(-50%);
156
+ width: 18px;
157
+ height: 32px;
158
+ display: inline-flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ flex-shrink: 0;
162
+ border: 0;
163
+ background: transparent;
164
+ color: var(--color-text-muted);
165
+ opacity: 0;
166
+ pointer-events: none;
167
+ padding: 0;
168
+ margin: 0;
169
+ z-index: 1;
170
+ }
171
+
172
+ .chat-expand-btn.is-visible {
173
+ opacity: 0.95;
174
+ pointer-events: auto;
175
+ }
176
+
177
+ .chat-expand-btn.is-visible:hover {
178
+ color: var(--color-text);
179
+ opacity: 1;
180
+ }
181
+
182
+ .chat-expand-btn .material-symbols-outlined {
183
+ font-size: 18px;
184
+ }
185
+
186
+ .chat-container.chat-has-children .chat-list-button {
187
+ padding-left: 24px;
188
+ }
189
+
190
.chat-list-button {
191
display: flex;
192
align-items: center;
@@ -174,4 +268,4 @@
268
</style>
269
</body>
270
177
-</html>
\ No newline at end of file
271
+</html>
webui/components/sidebar/chats/chats-store.js
+41
-1
@@ -19,6 +19,7 @@ const model = {
19
selected: "",
20
selectedContext: null,
21
loggedIn: false,
22
+ expandedParents: {},
23
24
// for convenience
25
getSelectedChatId() {
@@ -53,7 +54,7 @@ const model = {
54
// Update contexts from polling
55
applyContexts(contextsList) {
56
// Sort by created_at time (newer first)
56
- this.contexts = contextsList.sort(
57
+ this.contexts = [...contextsList].sort(
58
(a, b) => (b.created_at || 0) - (a.created_at || 0)
59
);
60
@@ -64,10 +65,49 @@ const model = {
65
const updated = this.contexts.find((ctx) => ctx.id === selectedId);
66
if (updated) {
67
this.selectedContext = updated;
68
+ const nextExpandedParents = { ...this.expandedParents };
69
+ if (updated.parent_context_id) {
70
+ nextExpandedParents[updated.parent_context_id] = true;
71
+ } else if (
72
+ this.hasChildren(selectedId) &&
73
+ nextExpandedParents[selectedId] === undefined
74
+ ) {
75
+ nextExpandedParents[selectedId] = true;
76
+ }
77
+ this.expandedParents = nextExpandedParents;
78
}
79
}
80
},
81
82
+ topLevelContexts() {
83
+ return this.contexts.filter((ctx) => !ctx?.parent_context_id);
84
+ },
85
+
86
+ childContexts(parentId) {
87
+ return this.contexts.filter((ctx) => ctx?.parent_context_id === parentId);
88
+ },
89
+
90
+ hasChildren(parentId) {
91
+ return this.childContexts(parentId).length > 0;
92
+ },
93
+
94
+ isExpanded(parentId) {
95
+ return Boolean(this.expandedParents?.[parentId]);
96
+ },
97
+
98
+ toggleChildren(parentId) {
99
+ if (!parentId || !this.hasChildren(parentId)) return;
100
+ this.expandedParents = {
101
+ ...this.expandedParents,
102
+ [parentId]: !this.expandedParents?.[parentId],
103
+ };
104
+ },
105
+
106
+ displayName(context) {
107
+ if (!context) return "";
108
+ return context.parent_context_label || context.name || `Chat #${context.no}`;
109
+ },
110
+
111
// Select a chat
112
async selectChat(id) {
113
const currentContext = getContext();