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