Engineering case · Agent Graph Inspector
A multi-agent run, read as a graph and not as a log
A multi-agent run leaves one transcript per agent and a single wall-clock number. This tool turns that journal into one HTML page where the timings, the bottleneck and the missing agents are visible and checkable.
Python 3.9+No dependenciesOne file in, one page outMIT
01 PROBLEM
A multi-agent run leaves a journal, not a picture. You get N transcripts and a total time. You cannot see what actually ran in parallel, which stage set the total, or whether the step that fans in heard back from everyone it fanned out to.
The last one is the dangerous one. If some agents start and never record a result, the run's summary still reads as finished and the output is quietly incomplete. In a flat log those agents are just missing lines. A run I found on my own machine had 108 agents started and 33 with no result: a session limit.
02 INPUT AND OUTPUT CONTRACT
Input is one Workflow run directory (wf_*). build_graph.py reads three kinds of files from it:
journal.jsonl: rows withtype=startedorresult, keyed byagentId. Start order and each agent's structured result come from here.agent-<agentId>.jsonl: one transcript per agent. First and lasttimestamp, model, the first user prompt, andusagefor tokens.agent-<agentId>.meta.json: optional. Used only as a fallback for the model name.
Output is one self-contained HTML file with three linked views: chain against graph on one scale, a wall-clock timeline, and the execution map. --json also dumps the parsed graph as nodes, edges, meta and domains. Each node carries start, end, dur, layer, model, the question and the output.
python3 build_graph.py --list # runs found on this machine
python3 build_graph.py <run-dir> -o inspector.html # build the page
Two rules keep it honest. A field the run did not record stays empty. And a node with a started row but no result row gets failed and is drawn dashed. One catch on the way: a model message is written to a transcript twice with different uuids, so a naive token sum is exactly double. The parser keeps one usage record per message id.
03 ONE HARD DECISION
The journal says who started and who finished. It does not say who fed whom. So the edges have to come from somewhere. The alternative was to make the user describe the topology by hand. The tool supports that (--groups), but the default is to infer it from timestamps, because a tool that needs a hand-drawn graph before it shows anything does not get used.
The inference is one rule: a node moves to the next layer if it started after the whole previous layer had finished.
layer, horizon = 1, None
for node in sorted(nodes, key=lambda n: n["start"]):
if horizon is not None and node["start"] >= horizon - 0.5:
layer += 1
horizon = None
node["layer"] = layer
horizon = node["end"] if horizon is None else max(horizon, node["end"])
This matches parallel() barriers and pipeline stages. It is a heuristic, and the tool says so in two places: a run whose stages overlap is split differently than its script reads, and the page footer states topology inferred or topology manual, so a reader always knows which one they are looking at.
The critical path follows from it. Start at the node that finished last and walk back through the predecessor that finished last. In the demo that chain is 3 nodes, 59.4 s of the 84.7 s wall-clock. Speed-up is defined plainly as the sum of durations over wall-clock.
04 REPRODUCIBLE CHECK
The repo holds no raw journals, because they contain prompts and answers verbatim. fixtures/run-9nodes-reference.json holds the parsed 9-agent reference run, and demo.html embeds the parsed 25-agent run. The script below recomputes the totals from the stored per-node timings and re-runs the repo's own infer_layers to compare with the stored layers.
import json, sys
sys.path.insert(0, ".")
import build_graph as b
def stats(nodes):
seq = sum(x["dur"] for x in nodes)
wall = max(x["end"] for x in nodes) - min(x["start"] for x in nodes)
return round(seq, 1), round(wall, 1), round(seq / wall, 2)
ref = json.load(open("fixtures/run-9nodes-reference.json"))
n = ref["nodes"]; stored = [x["layer"] for x in n]
print("9 nodes ", stats(n), "layers re-inferred == stored:",
(b.infer_layers(n) and [x["layer"] for x in n] == stored))
html = open("demo.html").read()
i = html.index("const DATA = ") + len("const DATA = ")
demo, _ = json.JSONDecoder().raw_decode(html[i:])
n = demo["nodes"]; stored = [x["layer"] for x in n]
print("25 nodes", stats(n), "layers re-inferred == stored:",
(b.infer_layers(n) and [x["layer"] for x in n] == stored))
Run from the repo root at commit a2ee054 (Python 3.14.8), it prints:
9 nodes (99.4, 52.7, 1.89) layers re-inferred == stored: True
25 nodes (392.1, 84.7, 4.63) layers re-inferred == stored: True
| Run | As a chain | As a graph | Speed-up |
|---|---|---|---|
| 9 agents | 99.4 s | 52.7 s | 1.89x |
| 25 agents | 392.1 s | 84.7 s | 4.63x |
What this proves: the arithmetic and the layering. What it does not: the journal parser itself. The parser was checked against the raw journals when I wrote it, and I did not rerun that for this page.
05 LIMITS
- Layers are a heuristic. Overlapping stages are split differently than the workflow script reads.
- Without
--groups, an edge is "nearest node of the next layer", not a recorded data flow. - It reads Claude Code
Workflowjournals only. It reads finished runs; there is no live view. - The page embeds every prompt and answer verbatim. Read it before publishing.
--titleand--ledeare inserted as HTML, so pass only your own text. - There is no test suite in the repo. The check above is a script, not CI.
06 WHO DID WHAT
I designed and built it with AI-assisted coding. The decisions, the verification and the reference numbers are mine.
Hiring for AI or backend work, or have a project in mind?