Files
kandev/docs/specs/ui/subagent-observability.md
T

169 lines
9.0 KiB
Markdown

---
status: building
created: 2026-08-03
owner: nova28
---
# Subagent Observability
## Why
An agent can dispatch subagents — a review wave, a research fan-out, a parallel
migration — and each one is a substantial unit of work: minutes of wall time,
tens of thousands of tokens, its own tool transcript. Kandev already ingests all
of that. It normalizes the ACP subagent frames, stores per-subagent identity and
metrics, nests each subagent's child tool calls under it, and derives a live
`active_subagent_count` per session and per task.
None of it is reachable in practice.
A real session (task `93ff1109`, two review waves, four subagents) renders its
reviewers only after the user scrolls back ~50 messages and expands a row
labelled `4 tool calls, 2 subagents` — the subagent cards are collapsed inside a
turn group that treats a 243-second reviewer as equivalent to
`cat > /tmp/prompt.txt`. The kanban board shows nothing at all:
`active_subagent_count` reaches the frontend store and no component reads it,
because rendering it was an explicit non-goal of
[background-work-liveness](../platform/background-work-liveness.md) and
[ADR 0049](../../decisions/0049-fine-grained-foreground-idle-busy-signal.md)
deliberately preserved subagent identities "for a future subagent list without
committing the current UI to one". This spec is that follow-up.
The sharpest gap is that the card records what a subagent *did* but never what
it *returned*. For a review wave that is the whole point: an orchestrator's claim
to have run N reviewers cannot be checked against anything, because the verdicts
exist only in the orchestrator's own prose. A wire capture (`acpdbg`,
claude-agent-acp 0.49.0) confirms the returned text is already on the frame
kandev parses — `_meta.claudeCode.toolResponse.content` sits beside the
`totalTokens` and `totalDurationMs` the adapter already reads, and is stepped
over.
## What
### Capture
- The Claude subagent result parser reads two additional fields off
`_meta.claudeCode.toolResponse`, the same map it already reads six fields from:
- `content` — an array of ACP content blocks. Text blocks are concatenated in
order (newline-separated) into the subagent payload's `result_text`. Non-text
blocks (image, audio) are skipped. Absent, empty, or all-non-text `content`
leaves `result_text` empty.
- `resolvedModel` — a string, into the payload's `model`.
- `result_text` is a general subagent-payload field, not a Claude-specific one:
its existing Auggie producer is unchanged, and a value already set by an
earlier frame is not overwritten by a later empty one.
- The captured text is stored verbatim on the message payload. Truncation is a
rendering concern, not a persistence one.
### Transcript
- A subagent is never collapsed into a turn group. When a turn group would
contain subagent messages, those messages render at the group's own level, in
chronological position, and the group collapses only the remaining tool calls.
A group left with no non-subagent tool calls renders no collapsed row.
- The collapsed face of a subagent card shows, when the data is present: the
subagent type, a description, the child tool-call count, duration, token count,
model, and the **first line of `result_text`** as a single-line result summary.
- The result summary is the first non-empty line of `result_text`, truncated with
an ellipsis to fit one line. The full `result_text` is shown in the expanded
body, above any nested child messages.
- A subagent with no captured `result_text` shows no result summary line and no
placeholder. Absence is silent — a subagent that returned nothing and an agent
that does not report results must not be made to look like a failure.
- The description is not rendered when it merely restates the subagent type:
the existing exact-match suppression is extended to also suppress a
description whose leading words are the subagent type (case- and
separator-insensitive, so `test-supervisor` suppresses the prefix of
`Test-supervisor review of new invariant tests`, leaving
`review of new invariant tests`).
- The `tools` metadata chip is suppressed when the reported `tool_use_count`
equals the rendered child-message count, since the header already states it.
A divergence between the two keeps both.
### Turn-group label
- A collapsed turn group's label states the counts it actually describes. The
leading numeral is the number of collapsed tool calls, and pluralization
agrees with it.
- Because subagents are no longer collapsed into groups, the label never
mentions subagents.
### Board
- A kanban card shows a subagent indicator whenever the task's
`active_subagent_count` is greater than zero: a small chip carrying the count,
next to the existing status affordances.
- The chip is live — it appears, updates, and disappears from the same
`active_subagent_count` already delivered by `task.updated` and the boot
payload, with no new API surface.
- At zero, or when the field is absent, no chip renders.
## Scenarios
- **GIVEN** a Claude subagent completes with `toolResponse.content` carrying text
blocks, **WHEN** the result is parsed, **THEN** the payload's `result_text` is
the concatenated text and its `model` is `resolvedModel`.
- **GIVEN** a `toolResponse` whose `content` is absent, empty, or contains only
non-text blocks, **WHEN** the result is parsed, **THEN** `result_text` is empty
and no placeholder text is substituted.
- **GIVEN** a payload whose `result_text` was already set by an earlier frame,
**WHEN** a later frame reports empty content, **THEN** the existing value is
retained.
- **GIVEN** a turn containing two subagents and four tool calls, **WHEN** the
transcript renders, **THEN** both subagent cards appear at top level and the
collapsed group reads `4 tool calls`.
- **GIVEN** a turn containing one tool call, **WHEN** the transcript renders,
**THEN** the collapsed group reads `1 tool call`.
- **GIVEN** a turn whose only tool activity is subagents, **WHEN** the transcript
renders, **THEN** the subagent cards appear and no collapsed group row renders.
- **GIVEN** a completed subagent with `result_text` spanning several lines,
**WHEN** its card is collapsed, **THEN** a single-line summary shows the first
non-empty line, and **WHEN** expanded, **THEN** the full text is shown above
any child messages.
- **GIVEN** a subagent with no `result_text`, **WHEN** its card renders, **THEN**
no result summary line appears.
- **GIVEN** a subagent of type `test-supervisor` described as
`Test-supervisor review of new invariant tests`, **WHEN** its header renders,
**THEN** the description shows without the redundant leading type.
- **GIVEN** a subagent reporting `tool_use_count` of 10 with 10 rendered child
messages, **WHEN** its metadata row renders, **THEN** the `tools` chip is
omitted; **GIVEN** a reported count that differs from the child count, **THEN**
both are shown.
- **GIVEN** a task whose `active_subagent_count` is two, **WHEN** its kanban card
renders, **THEN** a chip shows the count; **WHEN** the count drops to zero,
**THEN** the chip disappears without a reload.
## Out of scope
- Modelling a subagent as a kandev task or task session. Subagents stay inside
the parent session; this spec surfaces them, it does not promote them.
- `toolResponse.toolStats` (per-kind tool breakdown and line counts). Available
on the same map and worth a later pass; not captured here.
- Parsing a verdict taxonomy (APPROVE / REQUEST_CHANGES) out of `result_text`.
The text is surfaced verbatim; kandev does not interpret it.
- Result capture for non-Claude subagent dialects beyond the existing Auggie
path. OpenCode, Cursor, and Amp continue to report only what they report.
- A dedicated subagent list, drill-down panel, or per-subagent filtering on the
board. The board gets a count chip only.
- Any change to `active_subagent_count` derivation, liveness accounting, or the
busy-signal contract, all owned by
[background-work-liveness](../platform/background-work-liveness.md).
## Notes
- Wire evidence: `_meta.claudeCode.toolResponse` on a Claude Task completion
carries `agentId`, `agentType`, `status`, `content`, `prompt`, `resolvedModel`,
`toolStats`, `totalDurationMs`, `totalTokens`, `totalToolUseCount`, `usage`.
The adapter reads six of these in `claudeSubagentResponse`
(`apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go`).
- The same text also arrives on the terminal frame's `content[]` and
`rawOutput`, but that path is unreachable: `rawOutput` is a JSON array there
and `extractSubagentResult` type-asserts it to a map. The `toolResponse` path
is the one this spec uses; the array case is left alone.
- Frontend seams: `filterVisibleMessages` / turn grouping in
`apps/web/hooks/use-processed-messages.ts`, the card in
`apps/web/components/task/chat/messages/tool-subagent-message.tsx`, chips in
`subagent-meta.ts`, the group label in `turn-group-message.tsx`, and the board
chip in `apps/web/components/kanban-card-content.tsx` reading the
`activeSubagentCount` already mapped in `apps/web/lib/kanban/map-task.ts`.