Render nested sub-agent hierarchies (#213)#218
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds 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. ChangesNested Agent Hierarchies Support
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winKeep subagent block lookup session-scoped.
Lines 2103-2106 collapse
{trunk}#agent-{agentId}down to the bareagentId. 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
📒 Files selected for processing (31)
claude_code_log/converter.pyclaude_code_log/factories/meta_factory.pyclaude_code_log/models.pyclaude_code_log/renderer.pydev-docs/agents.mddev-docs/dag.mddev-docs/message-hierarchy.mdscripts/gen_nested_agents_fixture.pytest/test_data/nested_agents/33330000-0000-4000-8000-000000000001.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain1.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain2.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nschain3.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsintr01.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf11.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf12.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf21.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsleaf22.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid001.meta.jsontest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.jsonltest/test_data/nested_agents/33330000-0000-4000-8000-000000000001/subagents/agent-nsmid002.meta.jsontest/test_nested_agents.pywork/agent-hierarchies-design.md
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>
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)
<sid>/subagents/agent-<id>.jsonl(+agent-<id>.meta.json) at any nesting depth — nesting never creates subdirectories.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).meta.json'stoolUseIdnames the spawning tool_use — the only link that works at every depth and survives interrupts.Changes
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 syntheticspawnedAgentIdfield. Inside an agent transcript,agentIdis 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 legacyagentIdbackpatch 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.renderer.py): the level-stack's boolean-sidechain model (assistant 4 / tools 5) becomes the depth-1 block; a depth-dtranscript's levels shift by2*(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_blocksemits blocks recursively so an anchor inside another agent's block works.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.agents.md§5 as-built reference (on-disk shape, linkage directions, level shift, dedup collapse, practical recursion bound) + cross-refs indag.mdandmessage-hierarchy.md.Verification
just cigreen (unit + TUI + browser, ruff, pyright, ty); strict docs build green; zero snapshot deltas.Closes nothing yet — #213 stays open for the visual follow-up.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes / Stability
Chores
Documentation
Tests