Fix parallel child log typing
Resolve tool-specific log objects for all parallel child jobs, removing the code_execution_tool-only special case and using native get_log_object() when available, with generic fallback. Update parallel_tools docs to reflect the shared logging contract and add coverage for wait/ fallback behavior in parallel-tool tests.
Alessandro committed
Jul 9, 2026 at 17:34 UTC
8d79f556c60e1b314773ac0333a693e075dc5f74
3 files changed
+143
-32
helpers/parallel_tools.py
+63
-30
@@ -497,24 +497,7 @@ async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str,
497
if tool_name == "parallel":
498
raise ValueError("`parallel` cannot be nested inside a parallel worker.")
499
500
- tool = None
501
- try:
502
- import helpers.mcp_handler as mcp_helper
503
-
504
- tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
505
- except ImportError:
506
- tool = None
507
- except Exception as exc:
508
- PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
509
-
510
- if not tool:
511
- tool = agent.get_tool(
512
- name=tool_name,
513
- method=None,
514
- args=tool_args,
515
- message=json.dumps({"tool_name": tool_name, "tool_args": tool_args}),
516
- loop_data=agent.loop_data,
517
- )
500
+ tool = _resolve_parallel_tool(agent, tool_name, tool_args, strict=True)
501
if not tool:
502
raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
503
@@ -544,6 +527,58 @@ async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str,
527
agent.loop_data.current_tool = None
528
529
530
+def _resolve_parallel_tool(
531
+ agent: "Agent",
532
+ tool_name: str,
533
+ tool_args: dict[str, Any],
534
+ *,
535
+ strict: bool = False,
536
+):
537
+ message = json.dumps({"tool_name": tool_name, "tool_args": tool_args})
538
+
539
+ tool = None
540
+ try:
541
+ import helpers.mcp_handler as mcp_helper
542
+
543
+ tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
544
+ except ImportError:
545
+ tool = None
546
+ except Exception as exc:
547
+ if strict:
548
+ raise
549
+ PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
550
+
551
+ if not tool:
552
+ get_tool = getattr(agent, "get_tool", None)
553
+ if not callable(get_tool):
554
+ if strict:
555
+ raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
556
+ return None
557
+ try:
558
+ tool = get_tool(
559
+ name=tool_name,
560
+ method=None,
561
+ args=tool_args,
562
+ message=message,
563
+ loop_data=getattr(agent, "loop_data", None),
564
+ )
565
+ except Exception as exc:
566
+ if strict:
567
+ raise
568
+ PrintStyle.warning(f"Failed to initialize tool '{tool_name}' for parallel job: {exc}")
569
+ tool = None
570
+
571
+ if not tool:
572
+ return None
573
+
574
+ try:
575
+ tool.args = dict(tool_args)
576
+ except Exception:
577
+ pass
578
+
579
+ return tool
580
+
581
+
582
async def _cancel_job(
583
job: ParallelJob,
584
*,
@@ -600,18 +635,16 @@ def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:
635
)
636
return
637
603
- if job.tool_name == "code_execution_tool":
604
- runtime = job.tool_args.get("runtime", "unknown")
605
- session = job.tool_args.get("session", None)
606
- session_text = f"[{session}] " if session or session == 0 else ""
607
- job.log_item = agent.context.log.log(
608
- type="code_exe",
609
- heading=f"icon://terminal {session_text}code_execution_tool - {runtime}",
610
- content="",
611
- kvps=job.tool_args,
612
- id=job.log_id,
613
- )
614
- return
638
+ tool = _resolve_parallel_tool(agent, job.tool_name, job.tool_args)
639
+ if tool is not None:
640
+ try:
641
+ job.log_item = tool.get_log_object()
642
+ if job.log_item is not None:
643
+ return
644
+ except Exception as exc:
645
+ PrintStyle.warning(
646
+ f"Failed to derive parallel child log for {job.tool_name}: {exc}"
647
+ )
648
649
heading = f"icon://construction {agent.agent_name}: Using tool '{job.tool_name}'"
650
job.log_item = agent.context.log.log(
helpers/parallel_tools.py.dox.md
+1
-1
@@ -31,7 +31,7 @@
31
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
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 `code_execution_tool` child logs use the normal `code_exe` WebUI message type and terminal heading so they render like direct code execution calls.
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
- Job IDs are stable handles for later await, collect, or cancel operations.
36
- Prompt extras must stay bounded and expose only job IDs, tool names, status, and compact result/error summaries.
37
tests/test_parallel_tool.py
+79
-1
@@ -384,7 +384,7 @@ async def test_parallel_subordinate_jobs_are_visible_child_logs_not_scheduler_ta
384
385
386
@pytest.mark.asyncio
387
-async def test_parallel_direct_tool_jobs_log_normal_tool_metadata(monkeypatch) -> None:
387
+async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
388
class FakeDeferredTask:
389
def __init__(self, thread_name=None) -> None:
390
self.thread_name = thread_name
@@ -404,6 +404,7 @@ async def test_parallel_direct_tool_jobs_log_normal_tool_metadata(monkeypatch) -
404
pass
405
406
monkeypatch.setattr(parallel_tools, "DeferredTask", FakeDeferredTask)
407
+ monkeypatch.setattr(parallel_tools, "_resolve_parallel_tool", lambda *_args, **_kwargs: None)
408
agent = _FakeAgent()
409
410
jobs = await parallel_tools.start_parallel_jobs(
@@ -445,7 +446,28 @@ async def test_parallel_code_execution_child_uses_code_exe_log_type(monkeypatch)
446
def kill(self):
447
pass
448
449
+ class FakeCodeExecutionTool:
450
+ def __init__(self, agent, args):
451
+ self.agent = agent
452
+ self.args = args
453
+
454
+ def get_log_object(self):
455
+ runtime = self.args.get("runtime", "unknown")
456
+ session = self.args.get("session", None)
457
+ session_text = f"[{session}] " if session or session == 0 else ""
458
+ return self.agent.context.log.log(
459
+ type="code_exe",
460
+ heading=f"icon://terminal {session_text}code_execution_tool - {runtime}",
461
+ content="",
462
+ kvps=self.args,
463
+ )
464
+
465
monkeypatch.setattr(parallel_tools, "DeferredTask", FakeDeferredTask)
466
+ monkeypatch.setattr(
467
+ parallel_tools,
468
+ "_resolve_parallel_tool",
469
+ lambda _agent, _tool_name, args: FakeCodeExecutionTool(_agent, args),
470
+ )
471
agent = _FakeAgent()
472
473
jobs = await parallel_tools.start_parallel_jobs(
@@ -473,6 +495,62 @@ async def test_parallel_code_execution_child_uses_code_exe_log_type(monkeypatch)
495
}
496
497
498
+@pytest.mark.asyncio
499
+async def test_parallel_wait_child_uses_wait_log_type(monkeypatch) -> None:
500
+ class FakeDeferredTask:
501
+ def __init__(self, thread_name=None) -> None:
502
+ self.thread_name = thread_name
503
+
504
+ def start_task(self, func, *args):
505
+ return self
506
+
507
+ def is_ready(self):
508
+ return False
509
+
510
+ def is_alive(self):
511
+ return True
512
+
513
+ def kill(self):
514
+ pass
515
+
516
+ class FakeWaitTool:
517
+ def __init__(self, agent, args):
518
+ self.agent = agent
519
+ self.args = args
520
+
521
+ def get_log_object(self):
522
+ return self.agent.context.log.log(
523
+ type="progress",
524
+ heading="icon://timer Wait: Waiting...",
525
+ content="",
526
+ kvps=self.args,
527
+ )
528
+
529
+ monkeypatch.setattr(parallel_tools, "DeferredTask", FakeDeferredTask)
530
+ monkeypatch.setattr(
531
+ parallel_tools,
532
+ "_resolve_parallel_tool",
533
+ lambda _agent, _tool_name, args: FakeWaitTool(_agent, args),
534
+ )
535
+ agent = _FakeAgent()
536
+
537
+ jobs = await parallel_tools.start_parallel_jobs(
538
+ agent, # type: ignore[arg-type]
539
+ [
540
+ parallel_tools.NormalizedToolCall(
541
+ index=0,
542
+ tool_name="wait",
543
+ tool_args={"seconds": 1},
544
+ )
545
+ ],
546
+ )
547
+
548
+ assert jobs[0].kind == "tool"
549
+ assert agent.context.log.items[0].type == "progress"
550
+ assert agent.context.log.items[0].heading == "icon://timer Wait: Waiting..."
551
+ assert agent.context.log.items[0].kvps == {"seconds": 1}
552
+
553
+
554
@pytest.mark.asyncio
555
async def test_parallel_tool_keeps_wrapper_out_of_visible_log() -> None:
556
from tools.parallel import ParallelTool