Skip to content

Render nested sub-agent hierarchies (#213)#218

Open
cboos wants to merge 12 commits into
mainfrom
dev/agent-hierarchies
Open

Render nested sub-agent hierarchies (#213)#218
cboos wants to merge 12 commits into
mainfrom
dev/agent-hierarchies

Conversation

@cboos

@cboos cboos commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Structural support for nested sub-agents (Claude Code 2.1.172+, where sub-agents can spawn their own sub-agents) — part 1 of #213. A follow-up PR adds the visual layer (depth badges, optional per-depth border colors).

Background (validated against real CC 2.1.173 sessions)

  • All agent transcripts live flat in <sid>/subagents/agent-<id>.jsonl (+ agent-<id>.meta.json) at any nesting depth — nesting never creates subdirectories.
  • A nested spawn's tool_result carries no toolUseResult.agentId (that enrichment is trunk-only), and an interrupted spawn has no usable tool_result at all — so only depth-1 agents were discovered before this change (2 of 86 transcripts in a real nested session).
  • The sidecar meta.json's toolUseId names the spawning tool_use — the only link that works at every depth and survives interrupts.
  • The announced "up to 5 levels deep" cap is not enforced (a real linear chain reached depth 79), so depth is treated as unbounded throughout.

Changes

  • Loader (converter.py, models.py): scan the meta.json sidecars once per load (memoized per directory) and stamp the resolved id on the spawning entry as the new synthetic spawnedAgentId field. Inside an agent transcript, agentId is membership (whose transcript the entry belongs to) while the spawn anchor needs a reference — one field can't carry both, hence the new one. Trunk anchors keep the legacy agentId backpatch so existing machinery is untouched. Discovered ids feed the (already recursive) agent-file loading, and each child's entries insert right after its spawn entry — nesting sub-sub-agents at the right flat-order position.
  • Hierarchy (renderer.py): the level-stack's boolean-sidechain model (assistant 4 / tools 5) becomes the depth-1 block; a depth-d transcript's levels shift by 2*(d-1), with depth chased through the spawn links. Agent lines without a spawn link default to depth 1, reproducing pre-existing behavior exactly (zero snapshot deltas). _relocate_subagent_blocks emits blocks recursively so an anchor inside another agent's block works.
  • Fixture + tests: synthesized test/test_data/nested_agents/ (generator: scripts/gen_nested_agents_fixture.py) — a 2×2 fan-out, a 3-deep chain, and an interrupted spawn linkable only via its sidecar. 12 tests pin discovery, DAG parentage/depths, the spawner-path nesting invariant, dedup-collapse semantics, and HTML/Markdown output.
  • dev-docs: new agents.md §5 as-built reference (on-disk shape, linkage directions, level shift, dedup collapse, practical recursion bound) + cross-refs in dag.md and message-hierarchy.md.

Verification

  • just ci green (unit + TUI + browser, ruff, pyright, ty); strict docs build green; zero snapshot deltas.
  • Real-session check: a 86-agent session with a depth-79 chain loads completely (was 2/86) and renders 740 KB in 0.4 s with correct nesting throughout.

Closes nothing yet — #213 stays open for the visual follow-up.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Robust nested sub-agent support: sidecar metadata now links spawned agents so nested transcripts render at correct nesting depths.
  • Bug Fixes / Stability

    • Deterministic anchoring and relocation of nested transcripts to prevent mis-nesting, duplication, and orphaned entries.
  • Chores

    • Cache validation enhanced to detect added/changed subagent sidecars; migration stores fingerprint info to ensure correct invalidation.
  • Documentation

    • Expanded guides on nested-agent discovery, on-disk shape, and rendering/depth behavior.
  • Tests

    • New fixtures, generator, and end-to-end/browser tests covering nested spawning, DAG parenting, cache invalidation, and rendering.

cboos and others added 7 commits June 11, 2026 23:15
Ground truth from generated CC 2.1.173 sessions: flat subagents/
layout at every depth, tail/meta.json linkage, unenforced 5-level
cap, no Workflow tool on sub-agents. Verified breaks: discovery
(toolUseResult-only collection), boolean-sidechain hierarchy levels,
trunk-only relocation anchors, depth-blind group-border CSS.
Proposes loader linking via meta.json (spawned_agent_id), depth-
parameterized hierarchy levels, nested-aware relocation, and a
5-color depth cycle for group borders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o upstream report, slicing OK)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude Code 2.1.172+ lets sub-agents spawn their own sub-agents; all
transcripts land flat in the trunk's subagents/ dir, but a nested
spawn's tool_result carries no toolUseResult.agentId (trunk-only
enrichment) and an interrupted spawn has no usable tool_result at all
- so only depth-1 agents were discovered.

The agent-<id>.meta.json sidecar's toolUseId links every spawn at any
depth. The loader scans the sidecars once per load (memoized per
directory), stamps the resolved id on the spawning entry as the new
spawnedAgentId field (membership stays in agentId - inside an agent
transcript the two necessarily differ), unions the ids into the
agent-file loading, and inserts each child's entries right after its
spawn entry, which nests recursively loaded sub-sub-agents at the
right flat-order position. _integrate_agent_entries prefers these
sidecar anchors over the legacy heuristics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The level-stack hard-coded a boolean-sidechain model (assistant 4 /
tools 5), so a nested agent's entries flattened to its parent agent's
level; and _relocate_subagent_blocks required its anchor to sit
outside any agent block, so a nested block fell into the defensive
tail-append.

Levels now shift by 2 per extra nesting depth (the span the depth-1
sidechain rules already use), with depth chased through the
spawned_agent_id links; agent lines without one default to depth 1,
reproducing the previous behavior exactly. Relocation emits blocks
recursively, so each block lands right after its spawn anchor even
when that anchor lives inside another agent's block. Depth is treated
as unbounded throughout - the advertised 5-level cap is not enforced
by the harness (observed depth 79 in the wild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Synthesized fixture mirroring the CC 2.1.173 on-disk shape (flat
subagents/ dir, in-band agentId tails, trunk-only toolUseResult,
meta.json sidecars): a 2x2 fan-out, a 3-deep chain, and an
interrupted spawn whose transcript links via the sidecar alone.

Tests pin sidecar discovery at every depth, DAG parentage and depth
histogram, the spawner-path nesting invariant on the template tree
(incl. the full-collapse of verbatim transcripts vs. the survival of
a divergent one), and HTML/Markdown rendering of nested content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New agents.md §5 (flat on-disk shape, the two linkage directions,
spawnedAgentId semantics, depth-shifted levels, dedup collapse,
fixture pointer) + cross-refs in dag.md and message-hierarchy.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (review advisory)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 713f2e70-b01d-4c2e-9edc-a04575a63ff2

📥 Commits

Reviewing files that changed from the base of the PR and between d8c84d4 and 7b773b1.

📒 Files selected for processing (3)
  • claude_code_log/html/templates/components/message_styles.css
  • test/__snapshots__/test_snapshot_html.ambr
  • test/test_nested_agents_browser.py

📝 Walkthrough

Walkthrough

Adds sidecar-driven spawnedAgentId discovery, stamps spawn links during transcript load, prefers spawn anchors when integrating agent transcripts, computes nesting depth in renderer to shift hierarchy levels, introduces cache fingerprinting for subagent sidecars, and adds fixtures, tests, and docs.

Changes

Nested Agent Hierarchies Support

Layer / File(s) Summary
Data model and factory for spawn tracking
claude_code_log/models.py, claude_code_log/factories/meta_factory.py
BaseTranscriptEntry and MessageMeta gain optional spawnedAgentId fields to track which sub-agent a tool entry spawned. The meta factory propagates spawnedAgentId from transcripts into rendering metadata.
Renderer: nested relocation & hierarchy depth
claude_code_log/renderer.py
Subagent relocation now keys anchors on meta.spawned_agent_id (legacy fallback to meta.agent_id), emits nested blocks recursively, and _build_message_hierarchy computes agent nesting depth via parent mappings and shifts levels by 2*(depth-1).
Sidecar-driven spawn linkage and loader integration
claude_code_log/converter.py
load_transcript accepts a per-load _meta_maps memo and uses _subagent_meta_map to build toolUseId→agentId maps from agent-*.meta.json sidecars; _apply_subagent_meta_links stamps spawnedAgentId (and backpatches trunk agentId when applicable), updates agent_ids, and insertion logic prefers spawnedAgentId anchors. _integrate_agent_entries collects and prefers spawn anchors when merging.
Cache fingerprint & migration
claude_code_log/cache.py, claude_code_log/migrations/007_subagents_fingerprint.sql
Adds subagents_fingerprint(jsonl_path) computed from sidecar count/newest mtime, compares it during is_file_cached validation, persists it on save_cached_entries, and adds DB migration to store cached_files.subagents_fingerprint.
Fixture generator and on-disk test data
scripts/gen_nested_agents_fixture.py, test/test_data/nested_agents/...
Adds a generator script plus a committed nested_agents fixture (trunk + per-agent JSONL and agent-*.meta.json sidecars) covering fan-out, 3-deep chain, leaves, mids, and an interrupted spawn scenario.
Comprehensive test suite for discovery, DAG, tree nesting, cache, and rendering
test/test_nested_agents.py, test/test_nested_agents_browser.py
New tests verify sidecar-driven discovery, exact DAG depth histogram and parentage, per-agent ancestry and dedup/collapse rules, cache invalidation on added sidecars (including TOCTOU), multi-spawn guard behavior, and HTML/Markdown rendering and browser-validated CSS of nested content including interrupted spawns.
CSS selector & snapshots
claude_code_log/html/templates/components/message_styles.css, test/__snapshots__/test_snapshot_html.ambr
Update sidechain CSS selectors to match nested spawn boundaries and update snapshots to reflect the broader selector coverage.
Developer documentation and design notes
dev-docs/agents.md, dev-docs/dag.md, dev-docs/message-hierarchy.md, work/agent-hierarchies-design.md
Docs explain nested-agent discovery via spawnedAgentId, rendering folding semantics across depths, DAG reparenting preference for spawn anchors, and record the design investigation and phased plan for visuals and further work.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

🐰 Sidecars whisper spawn IDs through the night,
Chains unfold in files, nesting by their light,
Loader stamps and renderer shifts each tier—
Deep rabbits hop, and every sub-agent's here! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Render nested sub-agent hierarchies (#213)' clearly and specifically summarizes the main change: adding support for rendering nested sub-agent hierarchies to resolve issue #213.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/agent-hierarchies

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
claude_code_log/renderer.py (1)

2097-2145: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep subagent block lookup session-scoped.

Lines 2103-2106 collapse {trunk}#agent-{agentId} down to the bare agentId. In a combined render, two sessions that reuse the same agent id will be merged into one block, and the first matching anchor will consume both transcripts. Key the block map by the full synthetic sid (or (trunk_sid, agent_id)) and have the anchor resolver return that same namespaced key.

Proposed fix
-    blocks: dict[str, list[TemplateMessage]] = {}
+    blocks: dict[str, list[TemplateMessage]] = {}
     block_ids: set[int] = set()
     for msg in messages:
         if msg.is_session_header:
             continue
         sid = msg.meta.session_id or ""
         if "`#agent-`" in sid:
-            agent_id = sid.rsplit("`#agent-`", 1)[-1]
-            blocks.setdefault(agent_id, []).append(msg)
+            blocks.setdefault(sid, []).append(msg)
             block_ids.add(id(msg))
@@
-    def _spawned_id(msg: TemplateMessage) -> Optional[str]:
+    def _spawned_sid(msg: TemplateMessage) -> Optional[str]:
         """The agent spawned at this message, if it's a spawn anchor.
@@
-        if msg.meta.spawned_agent_id:
-            return msg.meta.spawned_agent_id
+        sid = msg.meta.session_id or ""
+        trunk = sid.split("`#agent-`", 1)[0]
+        if msg.meta.spawned_agent_id:
+            return f"{trunk}`#agent-`{msg.meta.spawned_agent_id}"
         if (
             isinstance(msg.content, ToolResultMessage)
             and msg.meta.agent_id
             and "`#agent-`" not in (msg.meta.session_id or "")
         ):
-            return msg.meta.agent_id
+            return f"{trunk}`#agent-`{msg.meta.agent_id}"
         return None
@@
-        spawned = _spawned_id(msg)
+        spawned = _spawned_sid(msg)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude_code_log/renderer.py` around lines 2097 - 2145, The current block map
uses bare agent_id as the key (blocks.setdefault(agent_id,...)) and _spawned_id
returns just agent_id in the fallback, which causes blocks from different
sessions to collide; change the keying to use the full synthetic session id (the
original sid string or a tuple like (sid, agent_id)) when populating blocks and
update _spawned_id to return that same namespaced identifier (e.g., preserve
msg.meta.session_id + "`#agent-`" + msg.meta.agent_id) for the fallback branch so
anchors and blocks match session-scoped keys; ensure all references to
blocks.pop(spawned, ...) and blocks.setdefault(...) use the same namespaced key
format and keep the existing behavior for messages with meta.spawned_agent_id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@claude_code_log/converter.py`:
- Around line 392-400: Cached session loads can remain stale because
ensure_fresh_cache currently only watches top-level *.jsonl changes and ignores
sidecar agent-*.meta.json files used by
_apply_subagent_meta_links/_subagent_meta_map; update ensure_fresh_cache to
include agent-*.meta.json (or the directory glob that matches
agent-<id>.meta.json) in its file-change checks or cache-key computation so that
writes/updates to those sidecar files invalidate the cache and trigger reloads
of nested/interrupted subagents referenced via _apply_subagent_meta_links and
_subagent_meta_map.

In `@claude_code_log/models.py`:
- Around line 211-218: The field spawnedAgentId on the model is lossy for
entries that can spawn multiple sub-agents; change the model's spawnedAgentId
(and any duplicate declarations around lines noted) to support multiple values
(e.g., spawnedAgentIds: Optional[List[str]] or a mapping) and update the
stamping logic in claude_code_log/converter.py so it appends or merges child
agent IDs instead of overwriting (ensure places that read/write spawnedAgentId
are updated to handle the list/mapping and preserve existing entries when
stamping multiple Task/Agent tool blocks).

In `@scripts/gen_nested_agents_fixture.py`:
- Around line 311-395: The main() function writes new subagent files but never
removes old fixture artifacts, causing stale agent-*.jsonl and agent-*-meta.json
files to persist; before the loop that writes files (using subagents,
_write_jsonl, and the files dict), delete existing files under subagents that
match agent-*.jsonl and agent-*.meta.json (or remove and recreate the subagents
directory) so only the current set of agents is present; then proceed to write
the new files and compute n_files as before.

---

Outside diff comments:
In `@claude_code_log/renderer.py`:
- Around line 2097-2145: The current block map uses bare agent_id as the key
(blocks.setdefault(agent_id,...)) and _spawned_id returns just agent_id in the
fallback, which causes blocks from different sessions to collide; change the
keying to use the full synthetic session id (the original sid string or a tuple
like (sid, agent_id)) when populating blocks and update _spawned_id to return
that same namespaced identifier (e.g., preserve msg.meta.session_id + "`#agent-`"
+ msg.meta.agent_id) for the fallback branch so anchors and blocks match
session-scoped keys; ensure all references to blocks.pop(spawned, ...) and
blocks.setdefault(...) use the same namespaced key format and keep the existing
behavior for messages with meta.spawned_agent_id.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d92824e-c856-4b4c-a5d0-914c62e7e03e

📥 Commits

Reviewing files that changed from the base of the PR and between f4f743d and e914644.

📒 Files selected for processing (31)
  • claude_code_log/converter.py
  • claude_code_log/factories/meta_factory.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • dev-docs/agents.md
  • dev-docs/dag.md
  • dev-docs/message-hierarchy.md
  • scripts/gen_nested_agents_fixture.py
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.meta.json
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.jsonl
  • test/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.meta.json
  • test/test_nested_agents.py
  • work/agent-hierarchies-design.md

Comment thread claude_code_log/converter.py
Comment thread claude_code_log/models.py
Comment thread scripts/gen_nested_agents_fixture.py
cboos and others added 5 commits June 12, 2026 01:50
Since spawn discovery reads the agent-*.meta.json sidecars, a sidecar
appearing after a transcript was cached (nested spawns never touch the
trunk file's mtime) would be served stale forever. cached_files gains
a subagents_fingerprint column (migration 007): sidecar count + newest
sidecar mtime, stored at save and compared on every read. Pre-007 NULL
rows stay valid only for files with no sidecars today, so legacy
caches don't mass-invalidate while sessions WITH sidecars reparse once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single spawnedAgentId per entry matches the data: Claude Code
streams one content block per assistant entry (0 multi-spawn entries
across 300 recent transcripts; parallel spawns arrive as separate
entries) and tool_results anchor 1:1 on their own entries. The only
theoretical collision - one entry carrying several RESULTLESS spawn
tool_uses - now keeps the first link deterministically (sorted
iteration) instead of silently overwriting, and the extra transcript
still loads via the relocation tail-append. Invariant documented on
the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the TOCTOU window in the cache invalidation: a sidecar landing
between the parse's sidecar scan and save_cached_entries was
fingerprinted as covered without having been parsed, validating a
stale parse until the NEXT sidecar change. The fingerprint is now
captured before the parse and threaded to the save, so a mid-parse
sidecar mismatches on the next read and forces a reparse
(over-invalidation, never under). Also documents the deliberate
trunk-candidate divergence between the fingerprint and the parse scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidechain group rule's :not(.sidechain) parent filter meant only
the depth-1 boundary got its 2em indent and tool-green line - nested
agent transcripts rendered completely flat, with no depth cue at all.
The rule now matches at every depth (each spawn boundary adds its own
step + line, accumulating through the DOM), covers tool_use parents
too (running/interrupted spawns attach there), and excludes workflow
agent cards (also tool_use-classed; their side-channel line stays
grey, pinned by the existing workflow browser test). New browser test
pins the per-depth contract. Per-depth line colors + depth badges
follow in the visual PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant