From a2600740e83186262deeeba59eba4bf6c20c0833 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:34:29 -0700 Subject: [PATCH] feat(delegate): tag every subagent progress line with its batch id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent or nested delegation batches (a parent's 9-way fan-out plus a child's own 3-way fan-out) printed interleaved `โœ“ [3/3]` / `โœ“ [3/9]` lines with nothing identifying which batch each belongs to. - CLI: batch header `๐Ÿ”€ [6a66] delegating 9 tasks`; completion lines and child tree-view lines become `[6a66 3/9]`; spinner remaining-count tagged. - Relay: `delegation_id` rides on every `subagent.*` event (TUI gateway payload, api_server SSE subagent.start/complete). - TUI: `[6a66 3/9]` prefix on /agents rows; Desktop Agents pane groups workers by exact delegation_id (heuristic shape/time grouping kept for older backends) and shows the tag on the group header. - Tag = last 4 hex of the deleg_xxxxxxxx id (format_batch_tag), same id returned by the dispatch and used for cache/delegation/live//. --- apps/desktop/src/app/agents/index.tsx | 42 ++++++- apps/desktop/src/store/subagents.ts | 4 + gateway/platforms/api_server_runs.py | 1 + tests/agent/test_subagent_progress.py | 7 +- tests/tools/test_delegate_batch_tag.py | 116 ++++++++++++++++++ tools/delegate_tool.py | 77 ++++++++++-- tui_gateway/server.py | 2 + ui-tui/src/app/turnController.ts | 2 + ui-tui/src/components/thinking.tsx | 9 +- ui-tui/src/gatewayTypes.ts | 3 + ui-tui/src/types.ts | 3 + .../docs/user-guide/features/api-server.md | 5 +- 12 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 tests/tools/test_delegate_batch_tag.py diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index eb74a2d2a7..364bbb1164 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -143,19 +143,56 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => interface RootGroup { id: string delegationIndex: number + /** Short batch tag (`deleg_6a664903` โ†’ `6a66`) when the backend sent one. */ + batchTag?: string nodes: SubagentNode[] taskCount: number } +/** `deleg_6a664903` โ†’ `6a66`; mirrors tools.delegate_tool.format_batch_tag. */ +export const batchTagOf = (delegationId: string | undefined): string | undefined => { + if (!delegationId) { + return undefined + } + + const short = delegationId.split('_').at(-1)?.slice(0, 4) + + return short || undefined +} + function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { const groups: RootGroup[] = [] let n = 0 for (const node of roots) { + // Exact grouping when the backend tags workers with their batch id โ€” + // concurrent or nested fan-outs of the same shape must not merge. + if (node.delegationId) { + const byId = groups.find(g => g.id === `delegation:${node.delegationId}`) + + if (byId) { + byId.nodes.push(node) + + continue + } + + n += 1 + groups.push({ + id: `delegation:${node.delegationId}`, + delegationIndex: n, + batchTag: batchTagOf(node.delegationId), + nodes: [node], + taskCount: node.taskCount + }) + + continue + } + + // Older backends (no delegation_id): heuristic grouping by shape + time. const prev = groups.at(-1) const prevTail = prev?.nodes.at(-1) const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false - const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount + const sameShape = prev && !prev.batchTag && node.taskCount > 1 && prev.taskCount === node.taskCount const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false if (prev && sameShape && closeInTime && uniqueStep) { @@ -248,7 +285,8 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) return (

- {group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '} + {group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''} + {group.batchTag ? [{group.batchTag}] : null}{' '} ยท {t.agents.workers(group.nodes.length)} {activeWorkers > 0 ? ยท {t.agents.workersActive(activeWorkers)} : null}

diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index 7196f14a47..ae923aae8d 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -18,6 +18,9 @@ export interface SubagentProgress { goal: string /** The child's own stored session id โ€” lets UIs open its session window. */ sessionId?: string + /** Batch (delegation) id โ€” exact grouping key for one fan-out's workers, + * so concurrent/nested batches never merge into one group. */ + delegationId?: string model?: string status: SubagentStatus taskCount: number @@ -189,6 +192,7 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined parentId: str(payload.parent_id) || prev?.parentId || null, goal: str(payload.goal) || prev?.goal || 'Subagent', sessionId: str(payload.child_session_id) || prev?.sessionId, + delegationId: str(payload.delegation_id) || prev?.delegationId, model: str(payload.model) || prev?.model, status, taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1, diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index 04b21fefd3..3ec1674426 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -225,6 +225,7 @@ def _make_run_event_callback( "task_index", "subagent_id", "child_session_id", + "delegation_id", "parent_id", "depth", "model", diff --git a/tests/agent/test_subagent_progress.py b/tests/agent/test_subagent_progress.py index 4ec939780b..8fc656ac06 100644 --- a/tests/agent/test_subagent_progress.py +++ b/tests/agent/test_subagent_progress.py @@ -132,11 +132,12 @@ class TestBuildChildProgressCallback: parent._delegate_spinner = spinner parent.tool_progress_callback = None - # task_index=0 in a batch of 3 โ†’ prefix "[1]" + # task_index=0 in a batch of 3 โ†’ prefix "[1/3]" (batch slot; a + # delegation batch tag is prepended when the id is known) cb0 = _build_child_progress_callback(0, "test goal", parent, task_count=3) cb0("tool.started", "web_search", "test", {}) output = buf.getvalue() - assert "[1]" in output + assert "[1/3]" in output # task_index=2 in a batch of 3 โ†’ prefix "[3]" buf.truncate(0) @@ -144,7 +145,7 @@ class TestBuildChildProgressCallback: cb2 = _build_child_progress_callback(2, "test goal", parent, task_count=3) cb2("tool.started", "web_search", "test", {}) output = buf.getvalue() - assert "[3]" in output + assert "[3/3]" in output diff --git a/tests/tools/test_delegate_batch_tag.py b/tests/tools/test_delegate_batch_tag.py new file mode 100644 index 0000000000..f47404c7c1 --- /dev/null +++ b/tests/tools/test_delegate_batch_tag.py @@ -0,0 +1,116 @@ +"""Batch tag on delegation progress lines (#p1-campaign feedback, Sep 2026). + +When a parent fans out N subagents and a child fans out its own M, both +batches print ``[n/N]`` completion lines to the same console. Without a +batch tag ``โœ“ [3/3]`` and ``โœ“ [3/9]`` are indistinguishable. Every progress +surface must carry the short delegation id. +""" +import types + +import pytest + +import tools.delegate_tool as dt +from tools.delegate_tool import _batch_prefix, _build_child_progress_callback, format_batch_tag + + +def test_format_batch_tag_shortens_delegation_handle(): + assert format_batch_tag("deleg_6a664903") == "6a66" + assert format_batch_tag("deleg_") == "" + assert format_batch_tag(None) == "" + assert format_batch_tag("") == "" + + +@pytest.mark.parametrize( + "deleg, idx, count, expected", + [ + ("deleg_6a664903", 2, 9, "[6a66 3/9] "), + (None, 2, 9, "[3/9] "), + ("deleg_6a664903", 0, 1, "[6a66] "), + (None, 0, 1, ""), + ], +) +def test_batch_prefix_shapes(deleg, idx, count, expected): + assert _batch_prefix(deleg, idx, count) == expected + + +class _Spinner: + def __init__(self): + self.lines = [] + + def print_above(self, line): + self.lines.append(line) + + def update_text(self, text): + self.lines.append(f"{text}") + + +def test_child_tree_lines_and_relayed_events_carry_batch_tag(): + relayed = [] + parent = types.SimpleNamespace( + _delegate_spinner=_Spinner(), + tool_progress_callback=lambda et, name=None, preview=None, args=None, **kw: relayed.append((et, kw)), + ) + ref = {} + cb = _build_child_progress_callback(2, "triage cluster", parent, 9, subagent_id="sa-2", session_ref=ref) + # Stamped by delegate_task AFTER the callback is built โ€” must be picked up lazily. + ref["delegation_id"] = "deleg_6a664903" + ref["session_id"] = "child-sess" + + cb("subagent.start") + cb("tool.started", "terminal", "ls") + + tree = parent._delegate_spinner.lines + assert tree[0].startswith(" [6a66 3/9] โ”œโ”€ ๐Ÿ”€ triage cluster") + assert tree[1].startswith(" [6a66 3/9] โ”œโ”€ ") + assert all(kw.get("delegation_id") == "deleg_6a664903" for _, kw in relayed) + assert all(kw.get("child_session_id") == "child-sess" for _, kw in relayed) + + +def test_child_tree_prefix_without_batch_id_is_unchanged(): + parent = types.SimpleNamespace(_delegate_spinner=_Spinner(), tool_progress_callback=None) + cb = _build_child_progress_callback(0, "solo goal", parent, 3, session_ref={}) + cb("subagent.start") + assert parent._delegate_spinner.lines[0].startswith(" [1/3] โ”œโ”€ ๐Ÿ”€ solo goal") + + +def test_batch_completion_lines_are_attributable_across_two_batches(monkeypatch, tmp_path): + """Two interleaved batches: every โœ“ line names its own batch tag, and the + tag equals the delegation_id the dispatch returns.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + lines = [] + parent = types.SimpleNamespace( + session_id="root", model="m", tool_progress_callback=None, _delegate_spinner=None, + _safe_print=lambda line: lines.append(line), + ) + monkeypatch.setattr( + dt, "_run_single_child", + lambda task_index, goal, child=None, parent_agent=None, **kw: { + "task_index": task_index, "status": "completed", "summary": "ok", + "error": None, "api_calls": 1, "duration_seconds": 1, + }, + ) + monkeypatch.setattr(dt, "_build_child_preserving_parent_tools", + lambda **kw: types.SimpleNamespace(tool_progress_callback=None)) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: { + "model": "m", "provider": "openrouter", "base_url": "https://x/v1", + "api_key": "k", "api_mode": "chat_completions"}) + + import re + + for n in (3, 9): + res = dt.delegate_task( + tasks=[{"goal": f"batch of {n}: worker task number {i}"} for i in range(n)], + parent_agent=parent, + ) + assert "error" not in str(res)[:20], res + headers = [re.match(r"\s*๐Ÿ”€ \[([0-9a-f]{4})\] delegating (\d+) tasks", l) for l in lines] + headers = [m for m in headers if m] + assert [int(m.group(2)) for m in headers] == [3, 9] + tags = [m.group(1) for m in headers] + assert len(set(tags)) == 2 + + done = [l for l in lines if "โœ“ [" in l] + assert len(done) == 12 + assert sum(1 for l in done if f"โœ“ [{tags[0]} " in l and "/3]" in l) == 3 + assert sum(1 for l in done if f"โœ“ [{tags[1]} " in l and "/9]" in l) == 9 diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index abbad7cbfb..1313f3e6d4 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1402,6 +1402,31 @@ def _blocked_toolsets_for_role(role: str) -> List[str]: ) +def format_batch_tag(delegation_id: Optional[str]) -> str: + """Short human tag identifying which delegation batch a line belongs to. + + ``deleg_6a664903`` โ†’ ``6a66``. 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. Empty string when + no id is known so callers can concatenate unconditionally. + """ + if not isinstance(delegation_id, str) or not delegation_id: + return "" + short = delegation_id.split("_", 1)[-1][:4] + return f"{short}" if short else "" + + +def _batch_prefix(delegation_id: Optional[str], task_index: int, task_count: int) -> str: + """``[6a66 3/9] `` for batch children, ``[6a66] `` 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. @@ -1454,8 +1479,14 @@ def _build_child_progress_callback( 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) - prefix = f"[{task_index + 1}] " if task_count > 1 else "" + # 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 @@ -1484,6 +1515,8 @@ def _build_child_progress_callback( # 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 @@ -1510,7 +1543,7 @@ def _build_child_progress_callback( (goal_label[:55] + "...") if len(goal_label) > 55 else goal_label ) try: - spinner.print_above(f" {prefix}โ”œโ”€ ๐Ÿ”€ {short}") + spinner.print_above(f" {_prefix()}โ”œโ”€ ๐Ÿ”€ {short}") except Exception as e: logger.debug("Spinner print_above failed: %s", e) _relay("subagent.start", preview=preview or goal_label or "", **kwargs) @@ -1529,7 +1562,7 @@ def _build_child_progress_callback( duration_seconds=kwargs.get("duration_seconds"), ) try: - spinner.print_above(f" {prefix}โ”œโ”€ {_fail_line}") + spinner.print_above(f" {_prefix()}โ”œโ”€ {_fail_line}") except Exception as e: logger.debug("Spinner print_above failed: %s", e) _relay("subagent.complete", preview=preview, **kwargs) @@ -1563,7 +1596,7 @@ def _build_child_progress_callback( if spinner: short = (text[:55] + "...") if len(text) > 55 else text try: - spinner.print_above(f' {prefix}โ”œโ”€ ๐Ÿ’ญ "{short}"') + spinner.print_above(f' {_prefix()}โ”œโ”€ ๐Ÿ’ญ "{short}"') except Exception as e: logger.debug("Spinner print_above failed: %s", e) _relay("subagent.thinking", preview=text) @@ -1583,12 +1616,12 @@ def _build_child_progress_callback( summary_text = tool_name or preview or "" if spinner and summary_text: try: - spinner.print_above(f" {prefix}โ”œโ”€ ๐Ÿ”€ {summary_text}") + spinner.print_above(f" {_prefix()}โ”œโ”€ ๐Ÿ”€ {summary_text}") except Exception as e: logger.debug("Spinner print_above failed: %s", e) if parent_cb: try: - parent_cb("subagent_progress", f"{prefix}{summary_text}") + parent_cb("subagent_progress", f"{_prefix()}{summary_text}") except Exception as e: logger.debug("Parent callback relay failed: %s", e) return @@ -1610,7 +1643,7 @@ def _build_child_progress_callback( from agent.display import get_tool_emoji emoji = get_tool_emoji(tool_name or "") - line = f" {prefix}โ”œโ”€ {emoji} {tool_name}" + line = f" {_prefix()}โ”œโ”€ {emoji} {tool_name}" if short: line += f' "{short}"' try: @@ -1623,14 +1656,14 @@ def _build_child_progress_callback( _batch.append(tool_name or "") if len(_batch) >= _BATCH_SIZE: summary = ", ".join(_batch) - _relay("subagent.progress", preview=f"๐Ÿ”€ {prefix}{summary}") + _relay("subagent.progress", preview=f"๐Ÿ”€ {_prefix()}{summary}") _batch.clear() def _flush(): """Flush remaining batched tool names to gateway on completion.""" if parent_cb and _batch: summary = ", ".join(_batch) - _relay("subagent.progress", preview=f"๐Ÿ”€ {prefix}{summary}") + _relay("subagent.progress", preview=f"๐Ÿ”€ {_prefix()}{summary}") _batch.clear() _callback._flush = _flush @@ -2138,6 +2171,9 @@ def _build_child_agent( # Now the child exists, its session id can ride on every relayed event # (including the spawn_requested below โ€” first emit happens after this). child_session_ref["session_id"] = getattr(child, "session_id", "") or "" + # Same shared ref receives the batch id once delegate_task stamps it, so + # the display prefix and relayed events can tag which batch this is. + child._progress_identity_ref = child_session_ref # Set delegation depth so children can't spawn grandchildren child._delegate_depth = child_depth # Stash the post-degrade role for introspection (leaf if the @@ -4091,6 +4127,18 @@ def delegate_task( live_deleg_id, live_writers, live_paths = create_live_transcripts( task_list, context, model=creds.get("model"), provider=creds.get("provider") ) + # Announce the batch tag once so the later ``[tag n/N]`` completion lines + # (and any nested batch's lines interleaving with them) are attributable. + if n_tasks > 1 and live_deleg_id: + _hdr = f"๐Ÿ”€ [{format_batch_tag(live_deleg_id)}] delegating {n_tasks} tasks" + _hdr_spinner = getattr(parent_agent, "_delegate_spinner", None) + if _hdr_spinner: + try: + _hdr_spinner.print_above(f" {_hdr}") + except Exception: + _emit_parent_console(parent_agent, f" {_hdr}") + else: + _emit_parent_console(parent_agent, f" {_hdr}") # Capture the ORIGINATING session's wake target BEFORE any child agent is # constructed: _build_child_agent() -> AIAgent() -> agent_init calls @@ -4179,6 +4227,9 @@ def delegate_task( # attribution (child-started background processes report under it). if live_deleg_id: setattr(child, "_delegation_id", live_deleg_id) + _ident_ref = getattr(child, "_progress_identity_ref", None) + if isinstance(_ident_ref, dict): + _ident_ref["delegation_id"] = live_deleg_id children.append((i, t, child)) def _execute_and_aggregate(*, honor_parent_interrupt: bool = True) -> dict: @@ -4314,7 +4365,9 @@ def delegate_task( status = entry.get("status", "?") icon = "โœ“" if status == "completed" else "โœ—" remaining = n_tasks - completed_count - completion_line = f"{icon} [{idx+1}/{n_tasks}] {label} ({dur}s)" + _tag = format_batch_tag(live_deleg_id) + _slot = f"{_tag} {idx+1}/{n_tasks}" if _tag else f"{idx+1}/{n_tasks}" + completion_line = f"{icon} [{_slot}] {label} ({dur}s)" # Failed/errored/timed-out children: say WHY on the # same line, cleaned to one short human-readable # fragment โ€” a bare โœ— reads as "silently dropped". @@ -4336,7 +4389,7 @@ def delegate_task( if spinner_ref and remaining > 0: try: spinner_ref.update_text( - f"๐Ÿ”€ {remaining} task{'s' if remaining != 1 else ''} remaining" + f"๐Ÿ”€ {'[' + _tag + '] ' if _tag else ''}{remaining} task{'s' if remaining != 1 else ''} remaining" ) except Exception as e: logger.debug("Spinner update_text failed: %s", e) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a12fed006d..fc982de9e1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8194,6 +8194,8 @@ def _on_tool_progress( payload["parent_id"] = str(_kwargs["parent_id"]) if _kwargs.get("child_session_id"): payload["child_session_id"] = str(_kwargs["child_session_id"]) + if _kwargs.get("delegation_id"): + payload["delegation_id"] = str(_kwargs["delegation_id"]) if _kwargs.get("depth") is not None: payload["depth"] = int(_kwargs["depth"]) if _kwargs.get("model"): diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index ac7f5ca541..f6a1c53ef3 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -1040,6 +1040,7 @@ class TurnController { } const base: SubagentProgress = existing ?? { + delegationId: p.delegation_id, depth: p.depth ?? 0, goal: p.goal, id, @@ -1071,6 +1072,7 @@ class TurnController { ...base, apiCalls: p.api_calls ?? base.apiCalls, costUsd: p.cost_usd ?? base.costUsd, + delegationId: p.delegation_id ?? base.delegationId, depth: p.depth ?? base.depth, filesRead: p.files_read ?? base.filesRead, filesWritten: p.files_written ?? base.filesWritten, diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index d3225bee6a..07d53f2576 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -332,7 +332,14 @@ function SubagentAccordion({ ? 'warn' : 'dim' - const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' + // `[6a66 3/9]` when the gateway tags the batch; `[3/9]` on older gateways. + const batchTag = item.delegationId?.split('_').at(-1)?.slice(0, 4) + const prefix = + item.taskCount > 1 + ? `[${batchTag ? `${batchTag} ` : ''}${item.index + 1}/${item.taskCount}] ` + : batchTag + ? `[${batchTag}] ` + : '' const goalLabel = item.goal || `Subagent ${item.index + 1}` const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index bc08637c14..5b81b2f7d6 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -543,6 +543,9 @@ export interface RollbackRestoreResponse { export interface SubagentEventPayload { api_calls?: number cost_usd?: number + /** Batch (delegation) id this subagent belongs to โ€” distinguishes + * interleaved `[n/N]` progress from concurrent or nested fan-outs. */ + delegation_id?: string depth?: number duration_seconds?: number files_read?: string[] diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 1803402bb5..1b016da56f 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -25,6 +25,9 @@ export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | export interface SubagentProgress { apiCalls?: number costUsd?: number + /** Batch (delegation) id โ€” tags `[n/N]` rows so concurrent/nested fan-outs + * are distinguishable. Absent on older gateways. */ + delegationId?: string depth: number durationSeconds?: number filesRead?: string[] diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 47c0373613..3784d7e715 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -477,8 +477,9 @@ When the agent delegates work to background subagents, the stream also carries `subagent.start` and `subagent.complete` lifecycle events, so clients can observe delegation outcomes โ€” including timeouts and failures โ€” instead of the run going silent while a child works. The `subagent.complete` payload carries -the child's status, summary, duration, token/cost figures, and a -`child_session_id` for correlation; free-text fields pass forced secret +the child's status, summary, duration, token/cost figures, a +`child_session_id` for correlation, and the `delegation_id` of the batch it +belongs to (so concurrent or nested fan-outs stay distinguishable); free-text fields pass forced secret redaction before leaving the process. Per-tool child events (`subagent.tool`, progress ticks) are intentionally **not** forwarded โ€” they are high-volume UI noise; use the per-child live transcript files for