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.
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/CHANGELOG.md +197 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/PKG-INFO +57 -6
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/README.md +56 -5
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/pyproject.toml +1 -1
- agent_tracegraph-0.6.0/src/tracegraph/_errors.py +35 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/langgraph_checkpoint.py +37 -8
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/otlp_spans.py +4 -1
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/check.py +2 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/diagnose.py +61 -5
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/memo.py +4 -1
- agent_tracegraph-0.6.0/src/tracegraph/analysis/motifs.py +272 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/patterns.py +95 -48
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/artifact.py +40 -30
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/cli.py +153 -8
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/graph_ref.py +18 -8
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/model.py +49 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/ladybug.py +31 -6
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/.gitignore +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/LICENSE +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/SECURITY.md +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/analysis-report.schema.json +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/check-report.schema.json +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/phoenix-runtime.json +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/contracts/review-candidates.schema.json +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/__init__.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/_atomic_write.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/__init__.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/base.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/phoenix_export.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/__init__.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/ahu.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/analysis/explain.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/input_validation.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/normalize.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/py.typed +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/review_candidates.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/sqlite_snapshot.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/__init__.py +0 -0
- {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/store/base.py +0 -0
- {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.
|
|
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.
|
|
501
|
-
|
|
502
|
-
|
|
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.
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
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
|
{agent_tracegraph-0.5.0 → agent_tracegraph-0.6.0}/src/tracegraph/adapters/langgraph_checkpoint.py
RENAMED
|
@@ -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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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 ({
|
|
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
|
-
|
|
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)
|