507 lines
21 KiB
Python
507 lines
21 KiB
Python
"""Child progress relay, console formatting and child system-prompt construction for delegate_task.
|
|
|
|
Split out of ``tools/delegate_tool.py``; every moved name is re-imported there, so
|
|
``tools.delegate_tool.<name>`` keeps resolving (and monkeypatching) as before.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import enum
|
|
import os
|
|
import threading
|
|
from typing import Any, Dict, List, Optional
|
|
from tools.delegate_tool_registry import _active_subagents, _active_subagents_lock
|
|
|
|
# Log-record parity with the origin module.
|
|
logger = logging.getLogger("tools.delegate_tool")
|
|
|
|
# Terminal child statuses that mean "the subagent did NOT deliver a usable
|
|
# result". Shared by the CLI spinner echo, the gateway failure notice, and
|
|
# the parent-facing failure summary so every surface agrees on what counts
|
|
# as a failure.
|
|
SUBAGENT_FAILURE_STATUSES = frozenset({"failed", "error", "timeout"})
|
|
|
|
|
|
def _clean_error_text(error: Any, max_chars: int = 200) -> str:
|
|
"""Reduce an arbitrary error payload to one clean human-readable line.
|
|
|
|
Provider/SDK errors routinely arrive as multi-line tracebacks or JSON
|
|
walls. For a chat-facing notice we want the single most informative
|
|
line: the exception message (last line of a traceback) or the first
|
|
non-empty line otherwise, hard-capped in length.
|
|
"""
|
|
text = str(error or "").strip()
|
|
if not text:
|
|
return ""
|
|
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
if not lines:
|
|
return ""
|
|
# A traceback's last line is the actual exception message.
|
|
line = lines[-1] if lines[0].startswith("Traceback") else lines[0]
|
|
if len(line) > max_chars:
|
|
line = line[: max_chars - 3] + "..."
|
|
return line
|
|
|
|
|
|
def format_subagent_failure_line(
|
|
goal: Optional[str],
|
|
status: Optional[str],
|
|
error: Any = None,
|
|
duration_seconds: Any = None,
|
|
) -> str:
|
|
"""One clean, human-readable line describing a failed subagent.
|
|
|
|
Rendered directly to the user (CLI spinner echo, gateway platform
|
|
notice) — no JSON, no traceback, no internal field names. Example:
|
|
|
|
⚠️ Subagent failed — "research competitor pricing": Error code: 404 —
|
|
model not found (after 12s)
|
|
"""
|
|
goal_label = (goal or "").strip().replace("\n", " ")
|
|
if len(goal_label) > 60:
|
|
goal_label = goal_label[:57] + "..."
|
|
verb = "timed out" if status == "timeout" else "failed"
|
|
line = f"⚠️ Subagent {verb}"
|
|
if goal_label:
|
|
line += f' — "{goal_label}"'
|
|
err = _clean_error_text(error)
|
|
if err:
|
|
line += f": {err}"
|
|
if isinstance(duration_seconds, (int, float)) and duration_seconds > 0:
|
|
line += f" (after {round(duration_seconds)}s)"
|
|
return line
|
|
|
|
|
|
class DelegateEvent(str, enum.Enum):
|
|
"""Formal event types emitted during delegation progress.
|
|
|
|
_build_child_progress_callback normalises incoming legacy strings
|
|
(``tool.started``, ``_thinking``, …) to these enum values via
|
|
``_LEGACY_EVENT_MAP``. External consumers (gateway SSE, ACP adapter,
|
|
CLI) still receive the legacy strings during the deprecation window.
|
|
|
|
TASK_SPAWNED / TASK_COMPLETED / TASK_FAILED are reserved for
|
|
future orchestrator lifecycle events and are not currently emitted.
|
|
"""
|
|
|
|
TASK_SPAWNED = "delegate.task_spawned"
|
|
TASK_PROGRESS = "delegate.task_progress"
|
|
TASK_COMPLETED = "delegate.task_completed"
|
|
TASK_FAILED = "delegate.task_failed"
|
|
TASK_THINKING = "delegate.task_thinking"
|
|
TASK_TOOL_STARTED = "delegate.tool_started"
|
|
TASK_TOOL_COMPLETED = "delegate.tool_completed"
|
|
|
|
|
|
# Legacy event strings → DelegateEvent mapping.
|
|
# Incoming child-agent events use the old names; the callback normalises them.
|
|
_LEGACY_EVENT_MAP: Dict[str, DelegateEvent] = {
|
|
"_thinking": DelegateEvent.TASK_THINKING,
|
|
"reasoning.available": DelegateEvent.TASK_THINKING,
|
|
"tool.started": DelegateEvent.TASK_TOOL_STARTED,
|
|
"tool.completed": DelegateEvent.TASK_TOOL_COMPLETED,
|
|
"subagent_progress": DelegateEvent.TASK_PROGRESS,
|
|
}
|
|
|
|
|
|
def _build_child_system_prompt(
|
|
goal: str,
|
|
context: Optional[str] = None,
|
|
*,
|
|
workspace_path: Optional[str] = None,
|
|
role: str = "leaf",
|
|
max_spawn_depth: int = 2,
|
|
child_depth: int = 1,
|
|
) -> str:
|
|
"""Build a focused system prompt for a child agent.
|
|
|
|
When role='orchestrator', appends a delegation-capability block
|
|
modeled on OpenClaw's buildSubagentSystemPrompt (canSpawn branch at
|
|
inspiration/openclaw/src/agents/subagent-system-prompt.ts:63-95).
|
|
The depth note is literal truth (grounded in the passed config) so
|
|
the LLM doesn't confabulate nesting capabilities that don't exist.
|
|
"""
|
|
parts = [
|
|
"You are a focused subagent working on a specific delegated task.",
|
|
"",
|
|
f"YOUR TASK:\n{goal}",
|
|
]
|
|
if context and context.strip():
|
|
parts.append(f"\nCONTEXT:\n{context}")
|
|
if workspace_path and str(workspace_path).strip():
|
|
parts.append(
|
|
"\nWORKSPACE PATH:\n"
|
|
f"{workspace_path}\n"
|
|
"Use this exact path for local repository/workdir operations unless the task explicitly says otherwise."
|
|
)
|
|
# Project context files (AGENTS.md / CLAUDE.md / .cursorrules ...)
|
|
# from the workspace, via the SAME discovery/priority/cap logic the
|
|
# main agent's system prompt uses. Children are constructed with
|
|
# skip_context_files=True (their prompt is this focused one), so
|
|
# without this a subagent works in a repo without the repo's own
|
|
# conventions unless it thinks to go read them. SOUL.md is skipped —
|
|
# identity belongs to the parent. workspace_path comes only from
|
|
# explicit sources (_resolve_workspace_hint: TERMINAL_CWD / agent cwd
|
|
# hints, never bare getcwd), so the #64590 install-tree-fallback leak
|
|
# doesn't apply here. Best-effort: on any failure the child prompt is
|
|
# simply built without the block.
|
|
try:
|
|
from agent.prompt_builder import build_context_files_prompt
|
|
|
|
_ctx_files = build_context_files_prompt(
|
|
cwd=str(workspace_path), skip_soul=True
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"subagent: workspace context-files load failed", exc_info=True
|
|
)
|
|
_ctx_files = ""
|
|
if _ctx_files.strip():
|
|
parts.append(
|
|
"\nThe workspace's project context files are reproduced "
|
|
"below. Their conventions and invariants are binding for "
|
|
"your work in this workspace.\n\n" + _ctx_files.strip()
|
|
)
|
|
parts.append(
|
|
"\nComplete this task using the tools available to you. "
|
|
"When finished, provide a clear, concise summary of:\n"
|
|
"- What you did\n"
|
|
"- What you found or accomplished\n"
|
|
"- Any files you created or modified\n"
|
|
"- Any issues encountered\n\n"
|
|
"Important workspace rule: Never assume a repository lives at /workspace/... or any other container-style path unless the task/context explicitly gives that path. "
|
|
"If no exact local path is provided, discover it first before issuing git/workdir-specific commands.\n\n"
|
|
"Keep your final summary tight: lead with outcomes, prefer bullet "
|
|
"points over paragraphs, and don't replay your whole process. Your "
|
|
"response is returned to the parent agent as a summary, and overlong "
|
|
"summaries crowd out the parent's context window."
|
|
)
|
|
if role == "orchestrator":
|
|
child_note = (
|
|
"Your own children MUST be leaves (cannot delegate further) "
|
|
"because they would be at the depth floor — you cannot pass "
|
|
"role='orchestrator' to your own delegate_task calls."
|
|
if child_depth + 1 >= max_spawn_depth
|
|
else "Your own children can themselves be orchestrators or leaves, "
|
|
"depending on the `role` you pass to delegate_task. Default is "
|
|
"'leaf'; pass role='orchestrator' explicitly when a child "
|
|
"needs to further decompose its work."
|
|
)
|
|
parts.append(
|
|
"\n## Subagent Spawning (Orchestrator Role)\n"
|
|
"You have access to the `delegate_task` tool and CAN spawn "
|
|
"your own subagents to parallelize independent work.\n\n"
|
|
"WHEN to delegate:\n"
|
|
"- The goal decomposes into 2+ independent subtasks that can "
|
|
"run in parallel (e.g. research A and B simultaneously).\n"
|
|
"- A subtask is reasoning-heavy and would flood your context "
|
|
"with intermediate data.\n\n"
|
|
"WHEN NOT to delegate:\n"
|
|
"- Single-step mechanical work — do it directly.\n"
|
|
"- Trivial tasks you can execute in one or two tool calls.\n"
|
|
"- Re-delegating your entire assigned goal to one worker "
|
|
"(that's just pass-through with no value added).\n\n"
|
|
"Coordinate your workers' results and synthesize them before "
|
|
"reporting back to your parent. You are responsible for the "
|
|
"final summary, not your workers.\n\n"
|
|
f"NOTE: You are at depth {child_depth}. The delegation tree "
|
|
f"is capped at max_spawn_depth={max_spawn_depth}. {child_note}"
|
|
)
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _resolve_workspace_hint(parent_agent) -> Optional[str]:
|
|
"""Best-effort local workspace hint for child prompts.
|
|
|
|
We only inject a path when we have a concrete absolute directory. This avoids
|
|
teaching subagents a fake container path while still helping them avoid
|
|
guessing `/workspace/...` for local repo tasks.
|
|
"""
|
|
candidates = [
|
|
os.getenv("TERMINAL_CWD"),
|
|
getattr(
|
|
getattr(parent_agent, "_subdirectory_hints", None), "working_dir", None
|
|
),
|
|
getattr(parent_agent, "terminal_cwd", None),
|
|
getattr(parent_agent, "cwd", None),
|
|
]
|
|
for candidate in candidates:
|
|
if not candidate:
|
|
continue
|
|
try:
|
|
text = os.path.abspath(os.path.expanduser(str(candidate)))
|
|
except Exception:
|
|
continue
|
|
if os.path.isabs(text) and os.path.isdir(text):
|
|
return text
|
|
return None
|
|
|
|
|
|
_BATCH_ORDINALS: Dict[str, int] = {}
|
|
_BATCH_ORDINALS_LOCK = threading.Lock()
|
|
|
|
|
|
def format_batch_tag(delegation_id: Optional[str]) -> str:
|
|
"""Short human tag identifying which delegation batch a line belongs to.
|
|
|
|
``deleg_6a664903`` → ``set 1`` (first batch seen in this process),
|
|
the next distinct id → ``set 2``, and so on. Several batches (a parent's
|
|
fan-out plus a child's nested fan-out, or two concurrent tools) print
|
|
interleaved ``[n/N]`` progress lines to the same console; without a batch
|
|
tag a ``✓ [3/3]`` and a ``✓ [3/9]`` are indistinguishable, and a raw hex
|
|
slice (``[b2ac 3/9]``) is attributable but unreadable. Empty string when
|
|
no id is known so callers can concatenate unconditionally.
|
|
"""
|
|
if not isinstance(delegation_id, str) or not delegation_id:
|
|
return ""
|
|
with _BATCH_ORDINALS_LOCK:
|
|
n = _BATCH_ORDINALS.get(delegation_id)
|
|
if n is None:
|
|
n = len(_BATCH_ORDINALS) + 1
|
|
_BATCH_ORDINALS[delegation_id] = n
|
|
return f"set {n}"
|
|
|
|
|
|
def _batch_prefix(delegation_id: Optional[str], task_index: int, task_count: int) -> str:
|
|
"""``[set 2 · 3/9] `` for batch children, ``[set 2] `` for a lone child,
|
|
``[3/9] `` / ``""`` when the batch id is unknown."""
|
|
tag = format_batch_tag(delegation_id)
|
|
if task_count > 1:
|
|
inner = f"{tag} · {task_index + 1}/{task_count}" if tag else f"{task_index + 1}/{task_count}"
|
|
return f"[{inner}] "
|
|
return f"[{tag}] " if tag else ""
|
|
|
|
|
|
def _emit_parent_console(parent_agent, line: str) -> None:
|
|
"""Emit a human-readable progress line to the parent's console.
|
|
|
|
Routes through ``parent_agent._safe_print`` when available so headless
|
|
stdio hosts (ACP, gateway API) can redirect non-protocol output to
|
|
stderr via their configured ``_print_fn``. A bare ``print()`` would
|
|
otherwise land on stdout and corrupt JSON-RPC framing.
|
|
"""
|
|
printer = getattr(parent_agent, "_safe_print", None)
|
|
if callable(printer):
|
|
try:
|
|
printer(line)
|
|
return
|
|
except Exception:
|
|
pass
|
|
print(line)
|
|
|
|
|
|
def _build_child_progress_callback(
|
|
task_index: int,
|
|
goal: str,
|
|
parent_agent,
|
|
task_count: int = 1,
|
|
*,
|
|
subagent_id: Optional[str] = None,
|
|
parent_id: Optional[str] = None,
|
|
depth: Optional[int] = None,
|
|
model: Optional[str] = None,
|
|
toolsets: Optional[List[str]] = None,
|
|
session_ref: Optional[Dict[str, Any]] = None,
|
|
) -> Optional[callable]:
|
|
"""Build a callback that relays child agent tool calls to the parent display.
|
|
|
|
Two display paths:
|
|
CLI: prints tree-view lines above the parent's delegation spinner
|
|
Gateway: batches tool names and relays to parent's progress callback
|
|
|
|
The identity kwargs (``subagent_id``, ``parent_id``, ``depth``, ``model``,
|
|
``toolsets``) are threaded into every relayed event so the TUI can
|
|
reconstruct the live spawn tree and route per-branch controls (kill,
|
|
pause) back by ``subagent_id``. All are optional for backward compat —
|
|
older callers that ignore them still produce a flat list on the TUI.
|
|
|
|
Returns None if no display mechanism is available, in which case the
|
|
child agent runs with no progress callback (identical to current behavior).
|
|
"""
|
|
spinner = getattr(parent_agent, "_delegate_spinner", None)
|
|
parent_cb = getattr(parent_agent, "tool_progress_callback", None)
|
|
|
|
if not spinner and not parent_cb:
|
|
return None # No display → no callback → zero behavior change
|
|
|
|
# Show 1-indexed prefix only in batch mode (multiple tasks). The batch tag
|
|
# (short delegation id) is resolved lazily from session_ref because the
|
|
# callback is built before delegate_task stamps ``_delegation_id`` on the
|
|
# child; delegate_task drops the id into the same shared ref.
|
|
def _prefix() -> str:
|
|
deleg = session_ref.get("delegation_id") if session_ref else None
|
|
return _batch_prefix(deleg, task_index, task_count)
|
|
|
|
goal_label = (goal or "").strip()
|
|
|
|
# Gateway: batch tool names, flush periodically
|
|
_BATCH_SIZE = 5
|
|
_batch: List[str] = []
|
|
_tool_count = [0] # per-subagent running counter (list for closure mutation)
|
|
|
|
def _identity_kwargs() -> Dict[str, Any]:
|
|
kw: Dict[str, Any] = {
|
|
"task_index": task_index,
|
|
"task_count": task_count,
|
|
"goal": goal_label,
|
|
}
|
|
if subagent_id is not None:
|
|
kw["subagent_id"] = subagent_id
|
|
if parent_id is not None:
|
|
kw["parent_id"] = parent_id
|
|
if depth is not None:
|
|
kw["depth"] = depth
|
|
if model is not None:
|
|
kw["model"] = model
|
|
if toolsets is not None:
|
|
kw["toolsets"] = list(toolsets)
|
|
# The child's own session id — filled into the shared ref once the
|
|
# child agent exists (the callback is built first), so every relayed
|
|
# event lets UIs open/inspect the subagent's session directly.
|
|
if session_ref and session_ref.get("session_id"):
|
|
kw["child_session_id"] = str(session_ref["session_id"])
|
|
if session_ref and session_ref.get("delegation_id"):
|
|
kw["delegation_id"] = str(session_ref["delegation_id"])
|
|
kw["tool_count"] = _tool_count[0]
|
|
return kw
|
|
|
|
def _relay(
|
|
event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs
|
|
):
|
|
if not parent_cb:
|
|
return
|
|
payload = _identity_kwargs()
|
|
payload.update(kwargs) # caller overrides (e.g. status, duration_seconds)
|
|
try:
|
|
parent_cb(event_type, tool_name, preview, args, **payload)
|
|
except Exception as e:
|
|
logger.debug("Parent callback failed: %s", e)
|
|
|
|
def _tree_line(text: str) -> None:
|
|
"""Print one tree-view line above the CLI spinner (no-op without a spinner)."""
|
|
if not spinner:
|
|
return
|
|
try:
|
|
spinner.print_above(f" {_prefix()}├─ {text}")
|
|
except Exception as e:
|
|
logger.debug("Spinner print_above failed: %s", e)
|
|
|
|
def _flush_batch() -> None:
|
|
if parent_cb and _batch:
|
|
_relay("subagent.progress", preview=f"🔀 {_prefix()}{', '.join(_batch)}")
|
|
_batch.clear()
|
|
|
|
def _short(text: str, n: int) -> str:
|
|
return (text[:n] + "...") if len(text) > n else text
|
|
|
|
# ── Lifecycle events emitted by the orchestrator itself (not DelegateEvent) ──
|
|
def _on_start(tool_name, preview, args, kwargs):
|
|
if goal_label:
|
|
_tree_line(f"🔀 {_short(goal_label, 55)}")
|
|
_relay("subagent.start", preview=preview or goal_label or "", **kwargs)
|
|
|
|
def _on_complete(tool_name, preview, args, kwargs):
|
|
# Failed child: echo one clean reason line into the CLI tree so the human
|
|
# sees WHY, not just a vanished branch. Gateway-side rendering happens in
|
|
# TurnRunner.progress_callback off the relayed event.
|
|
if kwargs.get("status") in SUBAGENT_FAILURE_STATUSES:
|
|
_tree_line(
|
|
format_subagent_failure_line(
|
|
goal_label,
|
|
kwargs.get("status"),
|
|
error=kwargs.get("summary") or preview,
|
|
duration_seconds=kwargs.get("duration_seconds"),
|
|
)
|
|
)
|
|
_relay("subagent.complete", preview=preview, **kwargs)
|
|
|
|
def _on_text(tool_name, preview, args, kwargs):
|
|
# Streamed child reply text, relayed verbatim so a gateway watch window
|
|
# mirrors the child "talking". No spinner echo: CLI/TUI progress
|
|
# handlers ignore non-tool events, so this is inert there.
|
|
_relay("subagent.text", preview=preview)
|
|
|
|
# ── DelegateEvent handlers ──
|
|
def _on_thinking(tool_name, preview, args, kwargs):
|
|
text = preview or tool_name or ""
|
|
_tree_line(f'💭 "{_short(text, 55)}"')
|
|
_relay("subagent.thinking", preview=text)
|
|
|
|
def _on_progress(tool_name, preview, args, kwargs):
|
|
# Pre-batched summary from a nested orchestrator's grandchild; upstream
|
|
# emits parent_cb("subagent_progress", summary) with the summary in the
|
|
# tool_name slot. Pass through: render distinctly (not via the tool
|
|
# emoji lookup) and relay upward without re-batching.
|
|
summary_text = tool_name or preview or ""
|
|
if summary_text:
|
|
_tree_line(f"🔀 {summary_text}")
|
|
if parent_cb:
|
|
try:
|
|
parent_cb("subagent_progress", f"{_prefix()}{summary_text}")
|
|
except Exception as e:
|
|
logger.debug("Parent callback relay failed: %s", e)
|
|
|
|
def _on_tool_started(tool_name, preview, args, kwargs):
|
|
_tool_count[0] += 1
|
|
if subagent_id is not None:
|
|
with _active_subagents_lock:
|
|
rec = _active_subagents.get(subagent_id)
|
|
if rec is not None:
|
|
rec["tool_count"] = _tool_count[0]
|
|
rec["last_tool"] = tool_name or ""
|
|
if spinner:
|
|
from agent.display import get_tool_emoji
|
|
|
|
line = f"{get_tool_emoji(tool_name or '')} {tool_name}"
|
|
short = _short(preview, 35) if preview else ""
|
|
if short:
|
|
line += f' "{short}"'
|
|
_tree_line(line)
|
|
if parent_cb:
|
|
_relay("subagent.tool", tool_name, preview, args)
|
|
_batch.append(tool_name or "")
|
|
if len(_batch) >= _BATCH_SIZE:
|
|
_flush_batch()
|
|
|
|
_lifecycle_handlers = {
|
|
"subagent.start": _on_start,
|
|
"subagent.complete": _on_complete,
|
|
"subagent.text": _on_text,
|
|
}
|
|
# Any other DelegateEvent (TASK_TOOL_STARTED and the reserved TASK_* values)
|
|
# takes the tool-started path, as before the table.
|
|
_event_handlers = {
|
|
DelegateEvent.TASK_THINKING: _on_thinking,
|
|
DelegateEvent.TASK_PROGRESS: _on_progress,
|
|
DelegateEvent.TASK_TOOL_COMPLETED: None,
|
|
}
|
|
|
|
def _callback(
|
|
event_type, tool_name: str = None, preview: str = None, args=None, **kwargs
|
|
):
|
|
handler = _lifecycle_handlers.get(event_type) if isinstance(event_type, str) else None
|
|
if handler is None:
|
|
# Normalise legacy strings, "delegate.*" strings and DelegateEvent
|
|
# values to one DelegateEvent; unknown events are ignored.
|
|
if isinstance(event_type, DelegateEvent):
|
|
event = event_type
|
|
else:
|
|
event = _LEGACY_EVENT_MAP.get(event_type)
|
|
if event is None:
|
|
try:
|
|
event = DelegateEvent(event_type)
|
|
except (ValueError, TypeError):
|
|
return
|
|
handler = _event_handlers.get(event, _on_tool_started)
|
|
if handler is None:
|
|
return
|
|
handler(tool_name, preview, args, kwargs)
|
|
|
|
def _flush():
|
|
"""Flush remaining batched tool names to gateway on completion."""
|
|
_flush_batch()
|
|
|
|
_callback._flush = _flush
|
|
return _callback
|