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();