agent-tracegraph 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/CHANGELOG.md +197 -0
  2. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/PKG-INFO +57 -6
  3. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/README.md +56 -5
  4. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/pyproject.toml +1 -1
  5. agent_tracegraph-0.6.0/src/tracegraph/_errors.py +35 -0
  6. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/langgraph_checkpoint.py +37 -8
  7. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/otlp_spans.py +4 -1
  8. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/check.py +2 -0
  9. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/diagnose.py +61 -5
  10. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/memo.py +4 -1
  11. agent_tracegraph-0.6.0/src/tracegraph/analysis/motifs.py +272 -0
  12. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/patterns.py +95 -48
  13. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/artifact.py +40 -30
  14. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/cli.py +153 -8
  15. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/graph_ref.py +18 -8
  16. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/model.py +49 -0
  17. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/ladybug.py +31 -6
  18. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/.gitignore +0 -0
  19. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/LICENSE +0 -0
  20. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/SECURITY.md +0 -0
  21. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/analysis-report.schema.json +0 -0
  22. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/check-report.schema.json +0 -0
  23. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/phoenix-runtime.json +0 -0
  24. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/review-candidates.schema.json +0 -0
  25. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/__init__.py +0 -0
  26. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/_atomic_write.py +0 -0
  27. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/__init__.py +0 -0
  28. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/base.py +0 -0
  29. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/phoenix_export.py +0 -0
  30. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/__init__.py +0 -0
  31. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/ahu.py +0 -0
  32. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/explain.py +0 -0
  33. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/input_validation.py +0 -0
  34. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/normalize.py +0 -0
  35. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/py.typed +0 -0
  36. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/review_candidates.py +0 -0
  37. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/sqlite_snapshot.py +0 -0
  38. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/__init__.py +0 -0
  39. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/base.py +0 -0
  40. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/in_memory.py +0 -0
@@ -6,6 +6,203 @@ The **artifact** schema version and the **report**/**review-candidate** JSON sch
6
6
  separate contracts from this version number; changes to any of them are called out
7
7
  explicitly.
8
8
 
9
+ ## 0.6.0 - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - **`tracegraph summarize <preset> <artifacts...>`** (#60) groups a preset's matches across an
14
+ artifact batch by observed shape, so an operator can pick the recurring failure shape to look at
15
+ first. A group's signature (`motif-signature/v1`) is the matched steps' kind, status, source and
16
+ safe-v1 name, the `CAUSED_BY` origins between them, and the trace's source kind, causal
17
+ fidelity, link setting and whether it discloses heuristically chosen edges (per trace, not per
18
+ edge). It has no ids, error text or artifact file paths; an identifier-shaped name that looks
19
+ like a path is kept, as in `analyze` reports. Each group reports its match count and trace count
20
+ separately, plus up to three representatives (artifact digest and step ids). Only matches
21
+ confirmed by `check`'s rule are grouped; the rest are counted as unconfirmed. Fields that are
22
+ not known are kept as unknown. A coverage section always reports skipped artifacts, traces that
23
+ may hide a match, disclosed capture gaps, traces with no capture record and unconfirmed matches.
24
+ `--out` writes the `tracegraph.motif-summary` schema-v1 JSON with the same input protection as
25
+ `export-review-candidates`. Only strictly adjacent presets are accepted; the three gap presets
26
+ exit 2. Memory holds one trace and one entry per group, never the matches. A 160-wide retry fan
27
+ (25,600 matches) adds about 27 KiB over a 40-wide one, where collecting the matches adds about
28
+ 2 MiB. The issue suggested validating demand first; this is the minimal version.
29
+
30
+ ### Changed
31
+
32
+ - **`query --limit` no longer holds every match of the trace it is reading** (#79). Each
33
+ trace's matches were collected and sorted in full before the cap was applied, so one trace
34
+ with millions of matches took about 1 GiB at `--limit 1`. The matchers now produce matches
35
+ one at a time and `query` keeps only those still under the cap. The footer's total stays
36
+ exact, so every match is still counted. Measured on macOS/arm64 at `--limit 1` with
37
+ synthetic traces: a 2500-step chain of failing `x` agents (`repeated-agent-failure`, 3.1M
38
+ matches), and 1500 tools feeding one `retry:x` step that feeds 1500 failing tools
39
+ (`tool-retry-failure` and `tool-retry-failure-near`, 2.25M matches each):
40
+
41
+ | trace, preset | memory backend | `--backend ladybug` |
42
+ | --- | --- | --- |
43
+ | chain, `repeated-agent-failure` | 976 → 70 MiB, 5.5 → 2.8 s | 1226 → 308 MiB (Python fallback) |
44
+ | fan, `tool-retry-failure` | 1097 → 74 MiB, 2.8 → 1.6 s | 1753 → 757 MiB (Cypher) |
45
+ | fan, `tool-retry-failure-near` | 927 → 74 MiB, 3.2 → 2.1 s | 1470 → 613 MiB (Cypher) |
46
+
47
+ The Cypher rows stay high because LadybugDB buffers the full result set; an empty store
48
+ already takes about 300 MiB. The matches selected, their order, the footer total, exit codes
49
+ and error precedence are unchanged, and `find_matches` returns the same list for every caller.
50
+ Time still grows with the match
51
+ count. A mode that stops early and reports no total was considered and not built.
52
+ `LadybugStore` gains `first_matches(pattern, n)`; `tracegraph.analysis.patterns` gains
53
+ `iter_matches` and `first_matches`.
54
+
55
+ ### Fixed
56
+
57
+ - **Boundaries that promise `ValueError` no longer leak an error from describing the one they
58
+ caught** (#109). Naming a caught exception runs caller code: `str(exc)` calls its `__str__`,
59
+ and a metaclass can make the type's `__name__` a property. When that raised, its error
60
+ escaped instead: from LangGraph ingest when the saver's listing or a supplied `schedule_graph`
61
+ fails, and from `--graph` when the module fails to import. The validated copy that
62
+ `artifact.dumps()` and `analyze()` use since #82 and #93 (both in this release) named its
63
+ caught exception the same way and is hardened too. These now describe the exception through
64
+ one internal guard (`tracegraph._errors`) that falls back to fixed text, and uses only a name or
65
+ message that is a plain `str`.
66
+ Ordinary exceptions are described exactly as before. `artifact.from_obj()` on a schema-v1
67
+ payload also refuses, as `ValueError`, an edge sequence that raises `SystemExit` or another
68
+ `BaseException` (the current schema already did for `SystemExit`), or whose type check
69
+ raises; `KeyboardInterrupt` still propagates.
70
+
71
+ - **`--graph` loads a module file whose path is not valid UTF-8** (#110). On POSIX such a path
72
+ arrives surrogate-escaped, and naming the module from its strict UTF-8 encoding refused it:
73
+ `ingest --graph` exited 2 with a `UnicodeEncodeError` for a file it could import. Such a path
74
+ is now named from its file-system bytes, under its own prefix so it cannot take another
75
+ file's name. Every path that loaded before keeps its module name.
76
+
77
+ - **Cost totals and their baseline delta are exact** (#111). They were computed at `Decimal`'s
78
+ default precision of 28 significant digits and rounded without a note, while the total was
79
+ still reported as complete: a cost of `0.12345678901234567890123456789` was totalled as
80
+ `0.1234567890123456789012345679`, and `10000000000000000000000000000` plus `0.01` as
81
+ `10000000000000000000000000000`. They are now computed at a precision of 160, which is exact
82
+ for any sum of costs within the 64-character bound up to about 10^32 steps. Past that, the total
83
+ (or only the delta) is withheld with a note instead of rounded. Ordinary costs report the same
84
+ digits as before (`1.00 + 1.00` is still `2.00`).
85
+
86
+ - **A cost too long to write out is no longer summed or stored** (#100). A few characters of
87
+ exponent stand for a number of any size. A model-valid `total_cost` of `"1e1000000"` made
88
+ `analyze` fail with `decimal.Overflow`, an `ArithmeticError` rather than the promised
89
+ `ValueError`, with or without a baseline; `"9e999999"` did not overflow but wrote a
90
+ million-digit total into the report. OTLP ingest wrote such a value out in full, so a 10-byte
91
+ attribute `"1e99999999"` became a 100,000,000-character `total_cost` in the artifact. A cost is
92
+ now read only if, written out in full, it is at most 64 characters (`model.MAX_COST_CHARS`),
93
+ checked from its digits and exponent without writing it out (`model.cost_is_bounded(Decimal)`).
94
+ `analyze` withholds the total
95
+ with its own note, as for an unreadable cost; OTLP ingest treats a longer cost as unreadable,
96
+ so the total and component costs are disclosed as `otlp.unreadable_total_cost` and
97
+ `otlp.unreadable_component_cost` as before, and a readable alias is still used. Artifacts are
98
+ not refused at load. The bound is on each cost read, not on the total: summed exactly (#111),
99
+ two 64-character costs can make a 127-character total, and a million of them 133.
100
+
101
+ - **A lone surrogate in a producer string no longer ends a command in a traceback** (#101). JSON
102
+ can carry one (`"\ud800"`), and the artifact loads and validates, but strict UTF-8 refused it
103
+ in several places: `analyze` (and `check` and the new `summarize`, which share the helper)
104
+ hashing a name into its `redacted:` alias; `ingest-otlp` without `--out` hashing the trace id
105
+ into the default file
106
+ name; `export-incident-memo` hashing the run id; `inspect`, `explain` and `query` writing the
107
+ name to the terminal; and LangGraph ingest hashing a routing channel into a candidate task id.
108
+ The three hashes now encode with `surrogatepass`, so every other string hashes to the same
109
+ alias, file name and digest as before. The LangGraph candidate is skipped, because LangGraph
110
+ could not have produced a task id from it. The CLI escapes what its output streams cannot
111
+ encode instead of failing.
112
+
113
+ - **CLI output prints an emoji code in a producer string as written** (#102). The CLI's Rich
114
+ consoles kept emoji substitution on, and escaping markup does not turn it off, so a step named
115
+ `:ok:` printed as 🆗 in `query`, `inspect`, `explain` and `analyze` while the artifact and the
116
+ JSON reports held `:ok:`. Both consoles now have it off, so the new `summarize` prints the same
117
+ way. No text the CLI prints itself used an emoji code.
118
+
119
+ - **`artifact.from_obj()` on a schema-v1 payload raises only `ValueError`** (#103). When reading
120
+ a caller-supplied lazy edge sequence failed, the migration formatted the exception into its
121
+ message, so an exception whose `__str__` raises escaped as that error instead. The message no
122
+ longer formats the exception at all (its type's name can run caller code too, through a
123
+ metaclass); the original stays on the chain as `__cause__`. `loads()` and the CLI parse JSON
124
+ into lists, so neither could reach this.
125
+
126
+ - **`analyze()`, `check.evaluate()` and `build_incident_memo()` raise only `ValueError` on a
127
+ trace the model refuses** (#93). On a trace changed in place after it was built they raised a
128
+ raw `TypeError` or `AttributeError` (`seq = "zero"`, `steps = None`, `trace = None`, a step
129
+ `kind = "bogus"`, an integer `error_msg`), mostly from analysis code past their structural
130
+ check, and the memo rendered a step `status = "maybe"` without complaint. Each now analyzes the
131
+ copy the model validates, the same one `artifact.dumps()` and the writers use, so a value the
132
+ model coerces (`"0"` for a `seq`) is analyzed as it would be read and the caller's trace is
133
+ left unchanged. `analyze()` and the memo still accept structurally sound but non-canonical
134
+ traces; `check.evaluate()` still requires the canonical form. The validated copy does not change
135
+ the report for a valid trace; cost totals change separately in this release (#100, #111). That
136
+ copy is now made from a Python-mode dump: the JSON-mode dump
137
+ turned a NaN in `trace.run_id` or `trace.links_preserved` into `null`, which validated, so
138
+ `dumps()` and both writers erased the value instead of refusing it. It no longer depends on
139
+ the warnings filter either: under `-W error` a coercible value used to be refused through
140
+ pydantic's serializer warning. Cost on a 6000-step chain, best of 7, measured in PR #95 against
141
+ its parent `2eaa403` (which already had #82): `analyze()` 0.25–0.27 s
142
+ → 0.30–0.32 s, with a baseline 0.57–0.58 s → 0.69–0.70 s, `check.evaluate()` 0.65–0.67 s →
143
+ 0.78–0.79 s, `build_incident_memo()` 0.016 s → 0.044 s.
144
+
145
+ - **`artifact.dumps()` raises only `ValueError`** (#82). A trace changed in place after it was
146
+ built (pydantic does not revalidate on attribute assignment) could make it raise a raw
147
+ `TypeError` (`seq = "zero"`, `steps = None`, `edges = None`) or write a field the readers
148
+ reject (`status = "maybe"`, an integer `step_id`). `dumps()` now serializes a copy the model
149
+ has validated, as the writers already did, and raises `ValueError` for anything the model
150
+ refuses or cannot serialize: pydantic's `ValidationError` for a mistyped field, a
151
+ serialization error (also a `ValueError`) for a value it cannot dump at all, such as
152
+ `trace = None`. A value the model coerces (`"0"` for a `seq`) is written as it would be read.
153
+ A lazy sequence that fails while being serialized, through `dumps()` or either writer, is now
154
+ reported as a `ValueError` too, instead of whatever it raised. `dumps()` still checks fields,
155
+ not structure: a dangling edge or a stale trace status is written. Checking structure there
156
+ would refuse the structurally sound but non-canonical traces that `analyze()` digests, such as
157
+ one with a stale trace status (`analyze()` itself refuses a dangling edge). Unknown-field flags
158
+ without a capture record now reach a `dumps()`
159
+ caller as `ValidationError`, as they already did for the writers. A valid trace is
160
+ serialized byte for byte as before. Cost on a 6000-step chain, measured in PR #94 (its helper
161
+ was changed again by #93): `dumps()` 0.15 s → 0.20 s;
162
+ `analyze()` digests the current trace through it, a baseline comparison and `check` digest
163
+ both.
164
+
165
+ - **An error already in the parent graph is no longer recorded again around a subgraph** (#85).
166
+ A LangGraph checkpoint is an error where the error channel holds one and none of the
167
+ checkpoints it was built from did, but the check read only a checkpoint's causal parents, and
168
+ three shapes hid the earlier error from it. A subgraph's `__start__` was compared only with
169
+ the subgraph's input checkpoint, which holds its values as pending writes. A persistent
170
+ subgraph (`checkpointer=True`) entered again was compared with its previous invocation, not
171
+ with the outer checkpoint that launched it. And the parent-graph checkpoint after a subgraph
172
+ was compared only with the subgraph's exit, so a subgraph whose state has no error channel, or
173
+ that cleared its own copy, made the outer error look new. One failure was recorded two or more
174
+ times: three with nested subgraphs, or when the subgraph cleared its copy. The check now also
175
+ reads each checkpoint's declared parents, and for a `__start__`, its input checkpoint's
176
+ declared parent and its launch parent (`metadata.parents`). A former duplicate written by a
177
+ node that rewrote the still-set channel is disclosed as `langgraph.rewritten_error_channel`
178
+ instead, with its stderr line, as for any such write since #47; `check` reads that gap as
179
+ before.
180
+
181
+ - **A fork copy is no longer a second failure.** `update_state(..., as_node="__copy__")`
182
+ copies a checkpoint and runs no node, but it is linked to the copied checkpoint's parent, so
183
+ copying the checkpoint where an error first appeared (in the root graph, or a subgraph's
184
+ `__start__`) recorded that error again. A fork now never records an error.
185
+
186
+ For both: steps, edges, names and kinds are unchanged; what changes is which steps are errors
187
+ (with their `error_msg`) and the disclosure above. An error first written inside a subgraph, a
188
+ `Send` payload error below a clean state, and an error a caller's `update_state` writes are
189
+ recorded as before. Artifacts change only for histories that held such a duplicate. The
190
+ schema stays v7, and the committed fixtures are byte-identical. One shape still records the
191
+ error twice: a fork copy of a subgraph's *input* checkpoint, then resumed (#90).
192
+
193
+ ### Known limitations
194
+
195
+ - Resuming after a fork copy of a subgraph's input checkpoint
196
+ (`update_state(..., as_node="__copy__")` on it) records an error again at the subgraph's next
197
+ start: one the parent graph held (#90) or one a `Send` payload carried (#96). Four ways of
198
+ recognizing such a copy were tried. Three classified the fork by its content: an unread
199
+ `__start__` value, a `__start__` version absent from `versions_seen`, and pending writes from
200
+ LangGraph's `__start__` task. The fourth matched it to an input checkpoint with identical
201
+ `channel_values`, `channel_versions` and `versions_seen`, and compared its run with the
202
+ original's. Each hid a genuine failure on a plain `Pregel` graph, so none shipped. A
203
+ `Pregel` input's first step can run a user node, and a no-op `update_state(...,
204
+ as_node="__input__")` can write an input identical to a loop checkpoint.
205
+
9
206
  ## 0.5.0 - 2026-10-18
10
207
 
11
208
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph.
5
5
  Project-URL: Homepage, https://github.com/memtomem/tracegraph
6
6
  Project-URL: Repository, https://github.com/memtomem/tracegraph
@@ -110,6 +110,7 @@ tracegraph presets # list cross-trace query patterns
110
110
  tracegraph query tool-failure A.json B.json # find a causal pattern across many traces
111
111
  tracegraph query tool-retry-failure *.json # explicit retry marker, then same tool failing
112
112
  tracegraph query tool-failure --backend ladybug A.json B.json # use the optional Cypher accelerator
113
+ tracegraph summarize tool-retry-failure traces/ -o motifs.json # group matches by observed shape
113
114
 
114
115
  # export versioned, body-free governance evidence (an empty match set is a valid report)
115
116
  tracegraph export-review-candidates tool-retry-failure *.json -o candidates.json
@@ -130,7 +131,7 @@ Frequently-used options, beyond `--out` / `--json-out`:
130
131
  | `--explain` | `query` | Print each match's raw causal chain back to its root cause. |
131
132
  | `--structure` | `diff` | Compare topology only, ignoring labels. |
132
133
  | `--backend` | `query` | Use the optional LadybugDB Cypher accelerator instead of the pure-Python matcher. |
133
- | `--strict` | `query`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
134
+ | `--strict` | `query`, `summarize`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
134
135
  | `--rule` | `check` | Rule to evaluate, repeatable; there is no default rule set. |
135
136
 
136
137
  ### Regression check
@@ -304,6 +305,37 @@ Every shipped preset has a stable `pattern_id` and positive integer `pattern_ver
304
305
  as `pattern-id@vN` by `presets` and `query`. Increment the version when match semantics or
305
306
  review-export eligibility changes; wording-only description edits keep the current version.
306
307
 
308
+ `summarize` groups one preset's matches across the same file/directory batches. It is meant for
309
+ deciding which recurring failure shape to look at first, without tallying `query` output by hand.
310
+ A group is one observed shape. Two matches share a group when, for every matched step, the kind,
311
+ status, source and safe-v1 name agree, and for every edge between them the `CAUSED_BY` origin
312
+ agrees, under the same source kind, causal fidelity and link setting, and the same answer to
313
+ whether the trace discloses causal edges chosen heuristically at ingest (`motif-signature/v1`).
314
+ That last answer is per trace: an OTLP export records a parent picked by timing with the same
315
+ origin as an unambiguous one, and says only that some edge was picked that way, not which. The
316
+ answer is `disclosed` whenever a recognized heuristic-edge gap is present, else `not_disclosed`.
317
+ It is `unknown` for a trace with no capture record, or with a gap code this build does not know
318
+ and no recognized heuristic-edge gap. It is `not_applicable` for a single-step preset, which has
319
+ no edge.
320
+ Trace and step ids, error text and artifact file paths are never part of it. Names pass the same
321
+ safe-v1 rule as `analyze` reports. An identifier-shaped name is kept even when it looks like a path or an
322
+ address, and anything else becomes `redacted:<sha256>`. An operation whose name changes from run
323
+ to run gets one group per name. Each group counts its matches and, separately, the traces they
324
+ come from, and lists up to three representatives, each an artifact digest plus the matched step
325
+ ids. Only *confirmed* matches are grouped, by the same rule as `check`. A match that relies on a
326
+ substituted value (a defaulted kind, an unrecorded status) is counted as unconfirmed instead. A
327
+ field that is not known is written as `null` and listed under the step's `unknown`, never by the
328
+ value that stands in for it. The coverage section is always printed. It shows how many artifacts
329
+ were discovered, valid and skipped; how many traces may hide a match (`check`'s per-preset rule);
330
+ how many disclose any capture gap; how many carry no capture record; and the unconfirmed matches.
331
+ None of it is a rate, and an empty result is not an all-clear. A group does not show a shared root
332
+ cause. Version 1 accepts only strictly adjacent presets: between gap endpoints the matched path
333
+ is not recorded, so `repeated-agent-failure`, `tool-repeat-failure-heuristic` and
334
+ `tool-retry-failure-near` exit 2. `--out` writes the `tracegraph.motif-summary` schema-v1 JSON.
335
+ It must not name an input, is excluded from directory discovery, and is left untouched when the
336
+ run is refused. Exit 0 with at least one group, 1 with none (the report is still written), 2 on
337
+ an input error.
338
+
307
339
  `export-review-candidates` accepts the same artifact-file/directory batches as `query` and
308
340
  writes a deterministic schema-v1 envelope. Only presets ending in a failing `TOOL` predicate
309
341
  are eligible. The matched endpoint name must already be a qualified `server::tool` key —
@@ -343,7 +375,7 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
343
375
  | Area | What exists |
344
376
  |---|---|
345
377
  | Sources | LangGraph checkpoint savers (including subgraph namespaces and native task failures), OTLP/OpenInference span exports, Phoenix CLI exports |
346
- | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
378
+ | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `summarize`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
347
379
  | Patterns | Versioned presets matched over the raw causal graph; the governance-eligible `tool-retry-failure@v2` requires an explicit producer retry marker, and repetition-only heuristics are query-only |
348
380
  | Backends | Pure-Python in-memory store (default); optional LadybugDB/Cypher accelerator (`[cypher]`), whose results must equal the pure-Python matcher's |
349
381
  | Ecosystem | SyncMill run correlation (`syncmill.run_id`), Toolgraph preflight evidence as non-causal metadata, a weekly Phoenix + SyncMill end-to-end workflow (Phoenix CLI and server pinned; SyncMill tracks its default branch) |
@@ -432,6 +464,19 @@ change (`ok` to `unset`, or the reverse).
432
464
 
433
465
  LangGraph ingestion reads both the configured error state channel and native task failures
434
466
  from checkpoint pending writes. A checkpoint is an error where the channel first holds an error.
467
+ An error the parent graph already held is not recorded again where a subgraph receives it, nor
468
+ where the parent graph continues after a subgraph that did not carry it, and a fork copy
469
+ (`update_state(..., as_node="__copy__")`), which runs no node, never records one.
470
+ One time-travel shape still records an error twice: copying a subgraph's *input* checkpoint as a
471
+ fork and resuming. The subgraph's next start records the error again, whether the parent graph
472
+ held it (#90) or a `Send` payload carried it (#96). The copy holds the same channels as the input
473
+ it copies but no link back to it. Each marker tried (an unread `__start__` value, a pending
474
+ `__start__` task) also appears on a fork of a plain `Pregel` graph's loop checkpoint, where
475
+ treating it as an input hid a genuine failure. Matching the copy to an input checkpoint with
476
+ identical channels and versions fails too. A `Pregel` input's first step can run a user node, so
477
+ the original run is no baseline for the copy's run. And a no-op
478
+ `update_state(..., as_node="__input__")` can write an input identical to a loop checkpoint. So
479
+ the duplicate is left in place.
435
480
  A node that writes the channel again while it still holds one may have failed again, or only
436
481
  have returned the whole state (and a reducer can change the value either way); the checkpoint
437
482
  cannot tell which, so such a write is not recorded as a failure but disclosed as the capture
@@ -497,9 +542,15 @@ unavailable, disclosing each on stderr; the producer's value is never kept. File
497
542
  and Phoenix `px` output share one size limit (`TRACEGRAPH_MAX_ARTIFACT_BYTES`). `query` reads
498
543
  artifacts one after another and keeps only the matches it prints (plus the traces `--explain`
499
544
  renders). With `--limit`, its peak memory therefore grows with the number of artifacts only
500
- by a small per-file record (path, trace id); without it, by the matches it prints. It still
501
- enumerates every match of the trace it is reading, to report the exact total: one trace with
502
- very many matches is not bounded by `--limit`.
545
+ by a small per-file record (path, trace id); without it, by the matches it prints. Inside one
546
+ trace, too, it keeps only the matches that still fit under `--limit`, but it still enumerates
547
+ and counts every match to report the exact total, so time grows with the match count: one
548
+ 2500-step trace with 3.1M matches takes about 3 s at `--limit 1`, in 70 MiB. The search's own
549
+ working state (steps reached at each distance of a gap) does not depend on `--limit`. With
550
+ `--backend ladybug` on a pattern that compiles to Cypher, LadybugDB itself buffers the
551
+ query's full result set: a 3001-step trace with 2.25M `tool-retry-failure` matches peaked
552
+ at 757 MiB, where the store alone takes about 300 MiB. A plain read, `count(*)` and
553
+ `ORDER BY … LIMIT` all buffered it in our measurements.
503
554
 
504
555
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
505
556
  Ladybug's existing insertion semantics are unchanged. Use `is_isomorphic` or `diff` for
@@ -79,6 +79,7 @@ tracegraph presets # list cross-trace query patterns
79
79
  tracegraph query tool-failure A.json B.json # find a causal pattern across many traces
80
80
  tracegraph query tool-retry-failure *.json # explicit retry marker, then same tool failing
81
81
  tracegraph query tool-failure --backend ladybug A.json B.json # use the optional Cypher accelerator
82
+ tracegraph summarize tool-retry-failure traces/ -o motifs.json # group matches by observed shape
82
83
 
83
84
  # export versioned, body-free governance evidence (an empty match set is a valid report)
84
85
  tracegraph export-review-candidates tool-retry-failure *.json -o candidates.json
@@ -99,7 +100,7 @@ Frequently-used options, beyond `--out` / `--json-out`:
99
100
  | `--explain` | `query` | Print each match's raw causal chain back to its root cause. |
100
101
  | `--structure` | `diff` | Compare topology only, ignoring labels. |
101
102
  | `--backend` | `query` | Use the optional LadybugDB Cypher accelerator instead of the pure-Python matcher. |
102
- | `--strict` | `query`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
103
+ | `--strict` | `query`, `summarize`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
103
104
  | `--rule` | `check` | Rule to evaluate, repeatable; there is no default rule set. |
104
105
 
105
106
  ### Regression check
@@ -273,6 +274,37 @@ Every shipped preset has a stable `pattern_id` and positive integer `pattern_ver
273
274
  as `pattern-id@vN` by `presets` and `query`. Increment the version when match semantics or
274
275
  review-export eligibility changes; wording-only description edits keep the current version.
275
276
 
277
+ `summarize` groups one preset's matches across the same file/directory batches. It is meant for
278
+ deciding which recurring failure shape to look at first, without tallying `query` output by hand.
279
+ A group is one observed shape. Two matches share a group when, for every matched step, the kind,
280
+ status, source and safe-v1 name agree, and for every edge between them the `CAUSED_BY` origin
281
+ agrees, under the same source kind, causal fidelity and link setting, and the same answer to
282
+ whether the trace discloses causal edges chosen heuristically at ingest (`motif-signature/v1`).
283
+ That last answer is per trace: an OTLP export records a parent picked by timing with the same
284
+ origin as an unambiguous one, and says only that some edge was picked that way, not which. The
285
+ answer is `disclosed` whenever a recognized heuristic-edge gap is present, else `not_disclosed`.
286
+ It is `unknown` for a trace with no capture record, or with a gap code this build does not know
287
+ and no recognized heuristic-edge gap. It is `not_applicable` for a single-step preset, which has
288
+ no edge.
289
+ Trace and step ids, error text and artifact file paths are never part of it. Names pass the same
290
+ safe-v1 rule as `analyze` reports. An identifier-shaped name is kept even when it looks like a path or an
291
+ address, and anything else becomes `redacted:<sha256>`. An operation whose name changes from run
292
+ to run gets one group per name. Each group counts its matches and, separately, the traces they
293
+ come from, and lists up to three representatives, each an artifact digest plus the matched step
294
+ ids. Only *confirmed* matches are grouped, by the same rule as `check`. A match that relies on a
295
+ substituted value (a defaulted kind, an unrecorded status) is counted as unconfirmed instead. A
296
+ field that is not known is written as `null` and listed under the step's `unknown`, never by the
297
+ value that stands in for it. The coverage section is always printed. It shows how many artifacts
298
+ were discovered, valid and skipped; how many traces may hide a match (`check`'s per-preset rule);
299
+ how many disclose any capture gap; how many carry no capture record; and the unconfirmed matches.
300
+ None of it is a rate, and an empty result is not an all-clear. A group does not show a shared root
301
+ cause. Version 1 accepts only strictly adjacent presets: between gap endpoints the matched path
302
+ is not recorded, so `repeated-agent-failure`, `tool-repeat-failure-heuristic` and
303
+ `tool-retry-failure-near` exit 2. `--out` writes the `tracegraph.motif-summary` schema-v1 JSON.
304
+ It must not name an input, is excluded from directory discovery, and is left untouched when the
305
+ run is refused. Exit 0 with at least one group, 1 with none (the report is still written), 2 on
306
+ an input error.
307
+
276
308
  `export-review-candidates` accepts the same artifact-file/directory batches as `query` and
277
309
  writes a deterministic schema-v1 envelope. Only presets ending in a failing `TOOL` predicate
278
310
  are eligible. The matched endpoint name must already be a qualified `server::tool` key —
@@ -312,7 +344,7 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
312
344
  | Area | What exists |
313
345
  |---|---|
314
346
  | Sources | LangGraph checkpoint savers (including subgraph namespaces and native task failures), OTLP/OpenInference span exports, Phoenix CLI exports |
315
- | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
347
+ | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `summarize`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
316
348
  | Patterns | Versioned presets matched over the raw causal graph; the governance-eligible `tool-retry-failure@v2` requires an explicit producer retry marker, and repetition-only heuristics are query-only |
317
349
  | Backends | Pure-Python in-memory store (default); optional LadybugDB/Cypher accelerator (`[cypher]`), whose results must equal the pure-Python matcher's |
318
350
  | Ecosystem | SyncMill run correlation (`syncmill.run_id`), Toolgraph preflight evidence as non-causal metadata, a weekly Phoenix + SyncMill end-to-end workflow (Phoenix CLI and server pinned; SyncMill tracks its default branch) |
@@ -401,6 +433,19 @@ change (`ok` to `unset`, or the reverse).
401
433
 
402
434
  LangGraph ingestion reads both the configured error state channel and native task failures
403
435
  from checkpoint pending writes. A checkpoint is an error where the channel first holds an error.
436
+ An error the parent graph already held is not recorded again where a subgraph receives it, nor
437
+ where the parent graph continues after a subgraph that did not carry it, and a fork copy
438
+ (`update_state(..., as_node="__copy__")`), which runs no node, never records one.
439
+ One time-travel shape still records an error twice: copying a subgraph's *input* checkpoint as a
440
+ fork and resuming. The subgraph's next start records the error again, whether the parent graph
441
+ held it (#90) or a `Send` payload carried it (#96). The copy holds the same channels as the input
442
+ it copies but no link back to it. Each marker tried (an unread `__start__` value, a pending
443
+ `__start__` task) also appears on a fork of a plain `Pregel` graph's loop checkpoint, where
444
+ treating it as an input hid a genuine failure. Matching the copy to an input checkpoint with
445
+ identical channels and versions fails too. A `Pregel` input's first step can run a user node, so
446
+ the original run is no baseline for the copy's run. And a no-op
447
+ `update_state(..., as_node="__input__")` can write an input identical to a loop checkpoint. So
448
+ the duplicate is left in place.
404
449
  A node that writes the channel again while it still holds one may have failed again, or only
405
450
  have returned the whole state (and a reducer can change the value either way); the checkpoint
406
451
  cannot tell which, so such a write is not recorded as a failure but disclosed as the capture
@@ -466,9 +511,15 @@ unavailable, disclosing each on stderr; the producer's value is never kept. File
466
511
  and Phoenix `px` output share one size limit (`TRACEGRAPH_MAX_ARTIFACT_BYTES`). `query` reads
467
512
  artifacts one after another and keeps only the matches it prints (plus the traces `--explain`
468
513
  renders). With `--limit`, its peak memory therefore grows with the number of artifacts only
469
- by a small per-file record (path, trace id); without it, by the matches it prints. It still
470
- enumerates every match of the trace it is reading, to report the exact total: one trace with
471
- very many matches is not bounded by `--limit`.
514
+ by a small per-file record (path, trace id); without it, by the matches it prints. Inside one
515
+ trace, too, it keeps only the matches that still fit under `--limit`, but it still enumerates
516
+ and counts every match to report the exact total, so time grows with the match count: one
517
+ 2500-step trace with 3.1M matches takes about 3 s at `--limit 1`, in 70 MiB. The search's own
518
+ working state (steps reached at each distance of a gap) does not depend on `--limit`. With
519
+ `--backend ladybug` on a pattern that compiles to Cypher, LadybugDB itself buffers the
520
+ query's full result set: a 3001-step trace with 2.25M `tool-retry-failure` matches peaked
521
+ at 757 MiB, where the store alone takes about 300 MiB. A plain read, `count(*)` and
522
+ `ORDER BY … LIMIT` all buffered it in our measurements.
472
523
 
473
524
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
474
525
  Ladybug's existing insertion semantics are unchanged. Use `is_isomorphic` or `diff` for
@@ -4,7 +4,7 @@
4
4
  # the schema `kind` constants and the TRACEGRAPH_* environment variables — those are package
5
5
  # identity, not distribution identity, and renaming them would break published contracts.
6
6
  name = "agent-tracegraph"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,35 @@
1
+ """Describe an exception raised by code tracegraph does not control, without running more of it.
2
+
3
+ A boundary that promises ``ValueError`` often names the exception it caught. Doing that runs the
4
+ caller's code again: ``str(exc)`` calls its ``__str__``, and a metaclass can make the type's
5
+ ``__name__`` a property. Either can raise, or return a ``str`` subclass whose own methods raise
6
+ while the message is built, and that error would escape in place of the ``ValueError``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ #: Stands in for a type name that cannot be read safely.
12
+ _UNNAMED = "exception"
13
+
14
+
15
+ def type_name(exc: BaseException) -> str:
16
+ """The exception's type name, or a fixed text when reading it fails or is not a plain str."""
17
+ try:
18
+ name = type(exc).__name__
19
+ except KeyboardInterrupt:
20
+ raise
21
+ except BaseException:
22
+ return _UNNAMED
23
+ return name if type(name) is str else _UNNAMED
24
+
25
+
26
+ def describe(exc: BaseException) -> str:
27
+ """``"Type: message"``, or the type alone when the message is empty or cannot be read safely."""
28
+ name = type_name(exc)
29
+ try:
30
+ text = str(exc)
31
+ return f"{name}: {text}" if type(text) is str and text else name
32
+ except KeyboardInterrupt:
33
+ raise
34
+ except BaseException: # the composition too: building the text can fail (MemoryError)
35
+ return name
@@ -96,6 +96,7 @@ from typing import Any, Iterable, Iterator, get_type_hints
96
96
  from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata
97
97
  from pydantic import TypeAdapter, ValidationError
98
98
 
99
+ from tracegraph._errors import describe, type_name
99
100
  from tracegraph.adapters.base import Disclosures
100
101
  from tracegraph.input_validation import object_field
101
102
  from tracegraph.model import (
@@ -260,6 +261,7 @@ class LangGraphCheckpointAdapter:
260
261
  original_seq: dict[str, int] = {}
261
262
  steps: dict[str, Step] = {}
262
263
  writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]] = {}
264
+ launch_keys: dict[str, list[_CheckpointKey]] = {}
263
265
  malformed_writes = 0
264
266
  for t in tuples:
265
267
  cfg, cp, md, parent_cfg = _checked(t)
@@ -278,6 +280,7 @@ class LangGraphCheckpointAdapter:
278
280
  checkpoints[step_id] = cp
279
281
  original_seq[step_id] = md.get("step", 0)
280
282
  parent_keys[step_id] = self._declared_parent_keys(parent_cfg, md, ns)
283
+ launch_keys[step_id] = self._launch_parent_keys(md, ns)
281
284
  writes_by_step[step_id], malformed = self._writes_by_task(
282
285
  getattr(t, "pending_writes", None)
283
286
  )
@@ -356,9 +359,23 @@ class LangGraphCheckpointAdapter:
356
359
  # both stay display labels and are recorded as unknown, named or not. Task,
357
360
  # interrupt and scheduled steps are named by LangGraph's own task ids instead.
358
361
  step.unknown_fields = ["kind", "name"]
359
- causal_parent_ids = parents_by_step.get(step_id, [])
360
- error_parent_cps = [checkpoints[parent_id] for parent_id in causal_parent_ids]
361
- err = self._error_introduced(checkpoints[step_id], error_parent_cps)
362
+ # An error is new here only if none of the states this checkpoint was built from held
363
+ # one, and the causal parents alone can miss them: exit-edge expansion replaces a
364
+ # same-namespace predecessor with a subgraph's exit, and the input checkpoint a
365
+ # ``__start__`` applies holds its values as pending writes, so that input's own
366
+ # parents (the namespace's previous invocation, and the checkpoint that launched the
367
+ # subgraph) stand in. A fork (``update_state(..., as_node="__copy__")``) copies an
368
+ # existing checkpoint and runs no node, so whatever error it holds is not new; it is
369
+ # linked to the copied checkpoint's parent, which need not hold that error.
370
+ upstream_keys = list(parent_keys.get(step_id, []))
371
+ if no_producer and step.source is StepSource.LOOP and display_parent_id is not None:
372
+ upstream_keys += [*parent_keys.get(display_parent_id, []),
373
+ *launch_keys.get(display_parent_id, [])]
374
+ upstream_ids = [*parents_by_step.get(step_id, []),
375
+ *(pid for key in upstream_keys if (pid := step_id_by_key.get(key)))]
376
+ error_parent_cps = [checkpoints[pid] for pid in dict.fromkeys(upstream_ids)]
377
+ err = (None if step.source is StepSource.FORK
378
+ else self._error_introduced(checkpoints[step_id], error_parent_cps))
362
379
  # Only a checkpoint a node wrote can disclose a rewrite: an input, an update and a
363
380
  # fork (``update_state(..., as_node="__copy__")`` copies state, linked to the copied
364
381
  # checkpoint's parent) carry what the caller supplied. The channel's own history is
@@ -669,9 +686,9 @@ class LangGraphCheckpointAdapter:
669
686
  except KeyboardInterrupt:
670
687
  raise
671
688
  except BaseException as exc:
672
- text = str(exc)
673
- reason = f"{type(exc).__name__}: {text}" if text else type(exc).__name__
674
- raise ValueError(f"the supplied graph could not read checkpoint {cid}: {reason}") from exc
689
+ raise ValueError(
690
+ f"the supplied graph could not read checkpoint {cid}: {describe(exc)}"
691
+ ) from exc
675
692
  read = (snapshot.config or {}).get("configurable", {}).get("checkpoint_id")
676
693
  if read != cid:
677
694
  raise ValueError(
@@ -794,7 +811,12 @@ class LangGraphCheckpointAdapter:
794
811
  seed = unhexlify(cid.replace("-", ""))
795
812
  except (BinasciiError, AttributeError, ValueError):
796
813
  return None
797
- joined = b"".join(part.encode() for part in parts)
814
+ try:
815
+ joined = b"".join(part.encode() for part in parts)
816
+ except UnicodeEncodeError:
817
+ # A lone surrogate has no UTF-8 encoding, so LangGraph could not have hashed this
818
+ # candidate into any recorded task id. It names nothing; it must not end the ingest.
819
+ return None
798
820
  if legacy:
799
821
  digest = sha1(seed, usedforsecurity=False)
800
822
  digest.update(joined)
@@ -943,6 +965,13 @@ class LangGraphCheckpointAdapter:
943
965
  parent_ns = parent_cfg.get("checkpoint_ns", ns) or _ROOT_NS
944
966
  return [(parent_ns, parent_id)]
945
967
 
968
+ return LangGraphCheckpointAdapter._launch_parent_keys(md, ns)
969
+
970
+ @staticmethod
971
+ def _launch_parent_keys(md: dict, ns: str) -> list[_CheckpointKey]:
972
+ """The closest enclosing-graph checkpoint named in ``metadata.parents``: for a subgraph,
973
+ the checkpoint it was launched from, recorded even when ``parent_config`` points at
974
+ the namespace's previous invocation instead."""
946
975
  # `_checked` has already established `parents` as a str -> str mapping.
947
976
  parents = md.get("parents") or {}
948
977
  closest = LangGraphCheckpointAdapter._closest_parent_ns(ns, parents)
@@ -1222,7 +1251,7 @@ def _decoded(listing: Iterable[Any]) -> Iterator[Any]:
1222
1251
  return
1223
1252
  except Exception as exc:
1224
1253
  raise ValueError(
1225
- f"checkpoint history cannot be decoded ({type(exc).__name__})"
1254
+ f"checkpoint history cannot be decoded ({type_name(exc)})"
1226
1255
  ) from exc
1227
1256
  yield item
1228
1257
 
@@ -73,6 +73,7 @@ from tracegraph.model import (
73
73
  StepSource,
74
74
  StepStatus,
75
75
  Trace,
76
+ cost_is_bounded,
76
77
  UnknownField,
77
78
  )
78
79
 
@@ -255,7 +256,9 @@ def _as_decimal(value: Any) -> str | None:
255
256
  parsed = Decimal(str(value))
256
257
  except InvalidOperation:
257
258
  return None
258
- return format(parsed, "f") if parsed.is_finite() and parsed >= 0 else None
259
+ if not (parsed.is_finite() and parsed >= 0 and cost_is_bounded(parsed)):
260
+ return None
261
+ return format(parsed, "f")
259
262
 
260
263
 
261
264
  def _as_currency(value: Any) -> str | None:
@@ -37,6 +37,7 @@ from tracegraph.model import (
37
37
  NormalizedTrace,
38
38
  Step,
39
39
  StepStatus,
40
+ _validated_copy,
40
41
  )
41
42
  from tracegraph.normalize import ProjectionRule, validate_normalized
42
43
 
@@ -540,6 +541,7 @@ def _topology_rule(
540
541
 
541
542
  def evaluate(current: NormalizedTrace, baseline: NormalizedTrace, rules: list[RuleSpec]) -> CheckReport:
542
543
  """Evaluate ``rules`` over the pair. Raises ``ValueError`` for a malformed trace."""
544
+ current, baseline = _validated_copy(current), _validated_copy(baseline)
543
545
  contexts = (_context(current), _context(baseline))
544
546
  common = _common_reasons(current, baseline)
545
547
  langgraph = "langgraph" in (current.trace.source_kind, baseline.trace.source_kind)