Reuse native parallel child logs
Pass each parent-visible child log into direct parallel worker execution so tool before_execution reuses it instead of creating a second generic tool log. This keeps native badge types such as wait/progress (HDL) intact through execution, updates the parallel helper DOX contract, and adds a regression test for log reuse.
Alessandro committed
Jul 9, 2026 at 17:42 UTC
fa05710da708ebdda1ecb8708fc6ccf0e60bce7d
3 files changed
+82
-2
helpers/parallel_tools.py
+20
-2
@@ -487,13 +487,24 @@ async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
487
488
worker_agent = worker_context.agent0
489
worker_agent.loop_data = LoopData()
490
- return await execute_tool_call(worker_agent, job.tool_name, job.tool_args)
490
+ return await execute_tool_call(
491
+ worker_agent,
492
+ job.tool_name,
493
+ job.tool_args,
494
+ log_item=job.log_item,
495
+ )
496
finally:
497
if worker_context:
498
await _remove_context(worker_context.id)
499
500
496
-async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str, Any]) -> str:
501
+async def execute_tool_call(
502
+ agent: "Agent",
503
+ tool_name: str,
504
+ tool_args: dict[str, Any],
505
+ *,
506
+ log_item: "LogItem | None" = None,
507
+) -> str:
508
if tool_name == "parallel":
509
raise ValueError("`parallel` cannot be nested inside a parallel worker.")
510
@@ -501,6 +512,11 @@ async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str,
512
if not tool:
513
raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
514
515
+ original_get_log_object = None
516
+ if log_item is not None:
517
+ original_get_log_object = tool.get_log_object
518
+ tool.get_log_object = lambda: log_item
519
+
520
agent.loop_data.current_tool = tool
521
try:
522
await agent.handle_intervention()
@@ -524,6 +540,8 @@ async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str,
540
await agent.handle_intervention()
541
return response.message
542
finally:
543
+ if original_get_log_object is not None:
544
+ tool.get_log_object = original_get_log_object
545
agent.loop_data.current_tool = None
546
547
helpers/parallel_tools.py.dox.md
+1
@@ -32,6 +32,7 @@
32
- 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.
33
- 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.
34
- Wrapped tool child logs use each tool's native `get_log_object()` output when available, preserving special log rendering (for example: `code_execution_tool` uses `code_exe`, `wait` uses `progress`, MCP tools use `mcp`, and regular tools use `tool`).
35
+- Direct parallel worker execution reuses the parent-visible child log item so tool `before_execution()` cannot create a second generic worker log or lose the native badge type.
36
- Job IDs are stable handles for later await, collect, or cancel operations.
37
- Prompt extras must stay bounded and expose only job IDs, tool names, status, and compact result/error summaries.
38
tests/test_parallel_tool.py
+61
@@ -551,6 +551,67 @@ async def test_parallel_wait_child_uses_wait_log_type(monkeypatch) -> None:
551
assert agent.context.log.items[0].kvps == {"seconds": 1}
552
553
554
+@pytest.mark.asyncio
555
+async def test_parallel_execute_reuses_child_log_object(monkeypatch) -> None:
556
+ class FakeTool:
557
+ def __init__(self, agent, args):
558
+ self.agent = agent
559
+ self.args = args
560
+
561
+ def get_log_object(self):
562
+ return self.agent.context.log.log(
563
+ type="tool",
564
+ heading="generic tool log",
565
+ content="",
566
+ kvps=self.args,
567
+ )
568
+
569
+ async def before_execution(self, **kwargs):
570
+ self.log = self.get_log_object()
571
+
572
+ async def execute(self, **kwargs):
573
+ return Response(message="done", break_loop=False)
574
+
575
+ async def after_execution(self, response):
576
+ self.log.update(content=response.message)
577
+
578
+ class FakeWorkerAgent(_FakeAgent):
579
+ def __init__(self) -> None:
580
+ super().__init__()
581
+ self.loop_data = SimpleNamespace(current_tool=None)
582
+
583
+ def get_tool(self, **kwargs):
584
+ return FakeTool(self, kwargs["args"])
585
+
586
+ async def handle_intervention(self):
587
+ pass
588
+
589
+ async def noop_extensions(*_args, **_kwargs):
590
+ pass
591
+
592
+ monkeypatch.setattr(parallel_tools, "call_extensions_async", noop_extensions)
593
+
594
+ agent = FakeWorkerAgent()
595
+ child_log = agent.context.log.log(
596
+ type="progress",
597
+ heading="icon://timer Wait: Waiting...",
598
+ content="",
599
+ kvps={"seconds": 1},
600
+ )
601
+
602
+ result = await parallel_tools.execute_tool_call(
603
+ agent, # type: ignore[arg-type]
604
+ "wait",
605
+ {"seconds": 1},
606
+ log_item=child_log,
607
+ )
608
+
609
+ assert result == "done"
610
+ assert agent.context.log.items == [child_log]
611
+ assert child_log.type == "progress"
612
+ assert child_log.content == "done"
613
+
614
+
615
@pytest.mark.asyncio
616
async def test_parallel_tool_keeps_wrapper_out_of_visible_log() -> None:
617
from tools.parallel import ParallelTool