agent-tracegraph 0.3.0__tar.gz → 0.4.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 (35) hide show
  1. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/CHANGELOG.md +80 -0
  2. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/PKG-INFO +20 -8
  3. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/README.md +19 -7
  4. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/pyproject.toml +1 -1
  5. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/langgraph_checkpoint.py +242 -36
  6. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/otlp_spans.py +91 -35
  7. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/phoenix_export.py +28 -11
  8. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/artifact.py +9 -3
  9. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/model.py +7 -1
  10. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/.gitignore +0 -0
  11. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/LICENSE +0 -0
  12. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/SECURITY.md +0 -0
  13. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/contracts/analysis-report.schema.json +0 -0
  14. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/contracts/phoenix-runtime.json +0 -0
  15. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/contracts/review-candidates.schema.json +0 -0
  16. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/__init__.py +0 -0
  17. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/_atomic_write.py +0 -0
  18. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/__init__.py +0 -0
  19. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/base.py +0 -0
  20. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/__init__.py +0 -0
  21. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/ahu.py +0 -0
  22. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/diagnose.py +0 -0
  23. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/explain.py +0 -0
  24. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/memo.py +0 -0
  25. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/patterns.py +0 -0
  26. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/cli.py +0 -0
  27. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/input_validation.py +0 -0
  28. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/normalize.py +0 -0
  29. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/py.typed +0 -0
  30. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/review_candidates.py +0 -0
  31. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/sqlite_snapshot.py +0 -0
  32. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/__init__.py +0 -0
  33. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/base.py +0 -0
  34. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/in_memory.py +0 -0
  35. {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/ladybug.py +0 -0
@@ -6,6 +6,86 @@ 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.4.0 - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - **LangGraph tasks paused by `interrupt()` are reported.** A task whose only write on the
14
+ thread's newest root checkpoint is `__interrupt__` — it paused and the history holds no
15
+ resume and no result — becomes an `unset` step with the new source value `interrupt`,
16
+ linked by `CAUSED_BY` to that checkpoint and named by the same task-id reconstruction as
17
+ failed tasks. The step describes the recorded history, not whether LangGraph would resume
18
+ the task now. An interrupt recorded beside a resume or an error and no result is ambiguous
19
+ in the checkpoint (under LangGraph 1.2.2 a resumed task that wrote nothing looks exactly
20
+ like one paused twice; 1.2.15 marks it finished), so it is disclosed at ingest instead. Trace `status` is unchanged: a pause is not an error.
21
+ - **Artifact schema version 5**, stamped by content: only an artifact carrying an
22
+ `interrupt` step declares it, so every other artifact serializes byte for byte as before.
23
+ 0.3.1 and earlier refuse a version 5 artifact by name; this build reads versions 1 to 5,
24
+ and still rejects an envelope that declares less than its content needs.
25
+
26
+ ### Known limitations
27
+
28
+ - Tasks scheduled but never started — the recursion limit, a killed process, an
29
+ `interrupt_before`/`interrupt_after` breakpoint — leave no write and are still not
30
+ reported. Telling them apart from finished work needs the compiled graph.
31
+ - An interrupt inside a subgraph is reported as the subgraph's node at the root, not as the
32
+ node inside the subgraph that paused.
33
+
34
+ ## 0.3.1 - 2026-10-13
35
+
36
+ ### Fixed
37
+
38
+ - **An unreadable cost no longer understates the run's total.** The importers dropped a
39
+ `llm.cost.total` that was not a finite non-negative decimal (`"abc"`, `-1`, a boolean), so
40
+ `analyze` summed the remaining costs and printed a total that was knowably too low, with no
41
+ warning. The documented rule that a single unparseable cost withholds the whole total only
42
+ applied to artifacts built by hand. An unreadable total cost is now stored as the fixed
43
+ marker `"unreadable"`, never the producer's value, and the report withholds `total_cost`
44
+ and its delta with the existing note.
45
+ - Token counts, prompt and completion costs, cost currencies and Phoenix evaluation scores
46
+ that cannot be read were dropped without a trace. They are still reported as unavailable,
47
+ and `ingest-otlp`, `ingest-phoenix`, `analyze` and `phoenix diagnose` now say so on stderr,
48
+ naming the kind of value and how many spans (for scores, evaluations) carried it, never
49
+ the value itself. A boolean score still reads as `1.0` or `0.0`, as before.
50
+ - An OTLP `intValue` sent as a fractional JSON number was truncated before validation:
51
+ `1.5` became `1` and `-0.5` became `0`, so an invalid token count looked valid. It is now
52
+ validated as sent.
53
+ - **A checkpoint history LangGraph could not have written is a usage error, not a
54
+ traceback or a different trace.** About half of 64 one-field mutations of a real history
55
+ (a non-object `metadata` or `channel_values`, a numeric `checkpoint_ns`, a boolean `v`, an
56
+ unhashable parent checkpoint id, ...) crashed `ingest` with a traceback, and several were
57
+ read silently as something else: a malformed parent config or `metadata.parents` dropped
58
+ the declared parent and made the checkpoint a root, and `metadata.step` of `true` or `1.5`
59
+ was read as step 1. Each checkpoint is now checked against the types LangGraph declares
60
+ for the fields tracegraph reads, strictly, and rejected with a message naming the field.
61
+ A config `checkpoint_id` that disagrees with the checkpoint's own id is rejected too.
62
+ - A checkpoint row the saver cannot decode (an unknown serialization type, metadata nested
63
+ past the recursion limit, a non-string metadata column) crashed `ingest` and `discover`
64
+ with a traceback, except that `discover` reported an unknown serialization type as "does
65
+ not support listing all threads". Both now report the history as undecodable.
66
+ - Pending writes that are not a `(task id, channel, value)` triple were skipped without a
67
+ word, and any 3-character string was unpacked as one (`"abc"` became task `a`). They are
68
+ still ignored, but `ingest` now says how many on stderr, since one could have carried a
69
+ task failure.
70
+
71
+ ### Compatibility notes for imports
72
+
73
+ - An artifact imported from a span with an unreadable total cost now carries
74
+ `"total_cost": "unreadable"` instead of no cost, and its reports withhold the run's
75
+ total cost. No schema change: 0.3.0 reads such an artifact and withholds the same way.
76
+ - Because `intValue` is no longer truncated, `llm.cost.total = {"intValue": 1.5}` is stored as
77
+ `"1.5"` instead of `"1"`, and a `toolgraph.graph_generation` of `{"intValue": 1.5}` is
78
+ rejected like any other invalid generation instead of being read as `1`.
79
+ - The committed Phoenix fixture carries an unreadable `llm.cost.prompt`; reading it now prints
80
+ one stderr disclosure. Every committed fixture's artifact, report, memo, candidate file and
81
+ CLI stdout is unchanged.
82
+ - LangGraph histories that `ingest` used to accept with a malformed field listed above are
83
+ now refused. Histories written by LangGraph's own savers are unaffected: the checks cover
84
+ only fields tracegraph reads (`counters_since_delta_snapshot`, which savers return as
85
+ lists although it is declared as tuples, is not checked), a missing `updated_channels`
86
+ and a legacy `pending_sends` are accepted, and an unknown `metadata.source` string still
87
+ reads as a loop. The committed LangGraph fixture is byte-identical.
88
+
9
89
  ## 0.3.0 - 2026-10-13
10
90
 
11
91
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -289,9 +289,11 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
289
289
 
290
290
  Known limits:
291
291
 
292
- - A LangGraph task that was scheduled but left no result and no recorded error (a run
293
- paused by `interrupt()`, stopped by the recursion limit, or killed) is not reported, so
294
- such a run reads `ok` unless some other step recorded an error.
292
+ - A LangGraph task that was scheduled but never started (a run stopped by the recursion
293
+ limit, killed, or held at an `interrupt_before`/`interrupt_after` breakpoint) leaves no
294
+ write and is not reported, so such a run reads `ok` unless some other step recorded an
295
+ error. A task paused by `interrupt()` *is* reported (below), but only at the root graph:
296
+ the subgraph node is named, not the node inside it that paused.
295
297
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
296
298
  causes may be missing.
297
299
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
@@ -376,9 +378,16 @@ cancels its siblings and records that through the same channel; a cancelled task
376
378
  as an `unset` step with no message, because it was torn down rather than at fault. Only the
377
379
  latest persisted error survives per task, so repeated attempts are not reconstructed, and
378
380
  `Send` packets are nameable only where `langgraph.types` is importable — an ordinary install
379
- discloses them instead. An artifact whose derived tree relies on EdgeOrigin precedence declares
380
- artifact `schema_version` 4; otherwise, an artifact carrying derived task steps declares
381
- `schema_version` 3, and artifacts without them still serialize as version 2, byte for byte.
381
+ discloses them instead. A task that called `interrupt()` and whose only write on the thread's
382
+ newest root checkpoint is that interrupt becomes an `unset` step with source `interrupt` under
383
+ that checkpoint: the recorded history holds no resume and no result for it. That describes the
384
+ history, not whether LangGraph would resume the task today. An interrupt recorded together with
385
+ a resume or an error and no result is ambiguous in the checkpoint itself — under LangGraph 1.2.2
386
+ a task resumed with nothing to write looks exactly like one paused a second time — so it is
387
+ disclosed at ingest, not reported. An artifact carrying interrupt steps declares artifact `schema_version` 5; one
388
+ whose derived tree relies on EdgeOrigin precedence declares 4; otherwise, an artifact carrying
389
+ derived task steps declares `schema_version` 3, and artifacts without them still serialize as
390
+ version 2, byte for byte.
382
391
  Missing observed errors still do not
383
392
  prove success: an artifact ingested by an older build, or one using an unrecognized checkpoint
384
393
  layout, carries no derived step and looks exactly like a clean run, which is why the capture
@@ -387,7 +396,10 @@ means valid in-trace links only. Structural diff compares the derived tree and d
387
396
  lossy step counts. Metrics sum available span observations: partial coverage and producer
388
397
  aggregation can undercount or double count. Coverage gaps are disclosed rather than absorbed
389
398
  — a wall duration derived from a subset of steps says so, one whose end precedes its start is
390
- withheld, and a single unparseable cost withholds the whole total instead of understating it. File input has a size limit, but stdin/Phoenix
399
+ withheld, and a single unparseable cost withholds the whole total instead of understating it.
400
+ An OTLP or Phoenix import records an unreadable total cost as `unreadable` for that reason, and
401
+ reports token counts, other costs, currencies and evaluation scores it cannot read as
402
+ unavailable, disclosing each on stderr; the producer's value is never kept. File input has a size limit, but stdin/Phoenix
391
403
  subprocess output and query materialization are not streaming memory guarantees.
392
404
 
393
405
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
@@ -258,9 +258,11 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
258
258
 
259
259
  Known limits:
260
260
 
261
- - A LangGraph task that was scheduled but left no result and no recorded error (a run
262
- paused by `interrupt()`, stopped by the recursion limit, or killed) is not reported, so
263
- such a run reads `ok` unless some other step recorded an error.
261
+ - A LangGraph task that was scheduled but never started (a run stopped by the recursion
262
+ limit, killed, or held at an `interrupt_before`/`interrupt_after` breakpoint) leaves no
263
+ write and is not reported, so such a run reads `ok` unless some other step recorded an
264
+ error. A task paused by `interrupt()` *is* reported (below), but only at the root graph:
265
+ the subgraph node is named, not the node inside it that paused.
264
266
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
265
267
  causes may be missing.
266
268
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
@@ -345,9 +347,16 @@ cancels its siblings and records that through the same channel; a cancelled task
345
347
  as an `unset` step with no message, because it was torn down rather than at fault. Only the
346
348
  latest persisted error survives per task, so repeated attempts are not reconstructed, and
347
349
  `Send` packets are nameable only where `langgraph.types` is importable — an ordinary install
348
- discloses them instead. An artifact whose derived tree relies on EdgeOrigin precedence declares
349
- artifact `schema_version` 4; otherwise, an artifact carrying derived task steps declares
350
- `schema_version` 3, and artifacts without them still serialize as version 2, byte for byte.
350
+ discloses them instead. A task that called `interrupt()` and whose only write on the thread's
351
+ newest root checkpoint is that interrupt becomes an `unset` step with source `interrupt` under
352
+ that checkpoint: the recorded history holds no resume and no result for it. That describes the
353
+ history, not whether LangGraph would resume the task today. An interrupt recorded together with
354
+ a resume or an error and no result is ambiguous in the checkpoint itself — under LangGraph 1.2.2
355
+ a task resumed with nothing to write looks exactly like one paused a second time — so it is
356
+ disclosed at ingest, not reported. An artifact carrying interrupt steps declares artifact `schema_version` 5; one
357
+ whose derived tree relies on EdgeOrigin precedence declares 4; otherwise, an artifact carrying
358
+ derived task steps declares `schema_version` 3, and artifacts without them still serialize as
359
+ version 2, byte for byte.
351
360
  Missing observed errors still do not
352
361
  prove success: an artifact ingested by an older build, or one using an unrecognized checkpoint
353
362
  layout, carries no derived step and looks exactly like a clean run, which is why the capture
@@ -356,7 +365,10 @@ means valid in-trace links only. Structural diff compares the derived tree and d
356
365
  lossy step counts. Metrics sum available span observations: partial coverage and producer
357
366
  aggregation can undercount or double count. Coverage gaps are disclosed rather than absorbed
358
367
  — a wall duration derived from a subset of steps says so, one whose end precedes its start is
359
- withheld, and a single unparseable cost withholds the whole total instead of understating it. File input has a size limit, but stdin/Phoenix
368
+ withheld, and a single unparseable cost withholds the whole total instead of understating it.
369
+ An OTLP or Phoenix import records an unreadable total cost as `unreadable` for that reason, and
370
+ reports token counts, other costs, currencies and evaluation scores it cannot read as
371
+ unavailable, disclosing each on stderr; the producer's value is never kept. File input has a size limit, but stdin/Phoenix
360
372
  subprocess output and query materialization are not streaming memory guarantees.
361
373
 
362
374
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
@@ -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.3.0"
7
+ version = "0.4.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"
@@ -54,6 +54,17 @@ steps carrying no message, and only the task that actually raised is an ``ERROR`
54
54
  Only the *latest* persisted error survives per task id — a fail/fail/succeed sequence leaves
55
55
  one record — so attempts are never reconstructed. Errors are recorded wherever they appear;
56
56
  this module does not infer anything from their *absence*.
57
+
58
+ Interrupted tasks
59
+ -----------------
60
+ A node that calls ``interrupt()`` leaves an ``__interrupt__`` pending write on the checkpoint
61
+ that scheduled it, and LangGraph resumes from the thread's newest root checkpoint. A task whose
62
+ *only* write on that checkpoint is ``__interrupt__`` therefore never received a resume or
63
+ produced a result, and becomes an ``UNSET`` step with ``source=INTERRUPT``. That is a statement
64
+ about the recorded history, not a prediction that LangGraph would resume it now. Tasks that were
65
+ scheduled but never started (the recursion limit, a killed process, a static breakpoint) leave no
66
+ write at all and are not reported: telling them apart from finished work needs the compiled
67
+ graph, which this adapter does not have.
57
68
  """
58
69
 
59
70
  from __future__ import annotations
@@ -62,8 +73,12 @@ from binascii import Error as BinasciiError, unhexlify
62
73
  from hashlib import sha1
63
74
  import heapq
64
75
  import re
65
- from typing import Any
76
+ from typing import Any, Iterable, Iterator, get_type_hints
77
+
78
+ from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata
79
+ from pydantic import TypeAdapter, ValidationError
66
80
 
81
+ from tracegraph.input_validation import object_field
67
82
  from tracegraph.model import (
68
83
  CausalFidelity,
69
84
  Edge,
@@ -82,12 +97,33 @@ _BRANCH_PREFIX = "branch:to:"
82
97
  _JOIN_PREFIX = "join:"
83
98
  _TOOL_TOKENS = {"tool", "tools"}
84
99
 
100
+ # The shape of every field this adapter reads, taken from the types LangGraph itself declares
101
+ # rather than restated here, so a LangGraph type change surfaces (in the upstream drift
102
+ # workflow) instead of hiding behind a stale copy. Only fields the adapter reads are checked:
103
+ # LangGraph declares ``counters_since_delta_snapshot`` as tuples, but both savers hand it back
104
+ # as lists, so checking everything would reject histories LangGraph itself wrote. ``source``
105
+ # is any string, not LangGraph's Literal, because an unknown source still reads as a loop.
106
+ _CHECKPOINT_FIELDS = {
107
+ name: TypeAdapter(get_type_hints(Checkpoint)[name])
108
+ for name in ("id", "ts", "v", "channel_values", "channel_versions", "versions_seen")
109
+ }
110
+ _METADATA_FIELDS = {
111
+ **{name: TypeAdapter(get_type_hints(CheckpointMetadata)[name]) for name in ("step", "parents")},
112
+ "source": TypeAdapter(str),
113
+ }
114
+ _CONFIG_FIELDS = {name: TypeAdapter(str) for name in ("thread_id", "checkpoint_ns", "checkpoint_id")}
115
+
85
116
  # LangGraph's reserved pending-write channels and task-path markers, spelled out rather than
86
117
  # imported: they live under ``langgraph._internal``, which is private and not a dependency of
87
118
  # this package. They are stable on-disk names — a checkpoint written by any 4.x saver uses
88
119
  # them — so copying them is safer than importing a private module that may move.
89
120
  _ERROR = "__error__"
90
121
  _ERROR_SOURCE_NODE = "__error_source_node__"
122
+ _INTERRUPT = "__interrupt__"
123
+ _RESUME = "__resume__"
124
+ #: Writes that can sit beside an ``__interrupt__`` without recording whether the task is still
125
+ #: paused. Any other write is a result, and the task is no longer waiting.
126
+ _UNDECIDED_WITH_INTERRUPT = frozenset({_INTERRUPT, _RESUME, _ERROR, _ERROR_SOURCE_NODE})
91
127
  _START = "__start__"
92
128
  _TASKS = "__pregel_tasks"
93
129
  _PULL = "__pregel_pull"
@@ -165,18 +201,18 @@ class LangGraphCheckpointAdapter:
165
201
  "this checkpointer does not support listing all threads"
166
202
  )
167
203
  try:
168
- tuples = list(list_fn(None))
204
+ listing = list_fn(None)
169
205
  except NotImplementedError as exc:
170
- # Only the documented capability signal becomes "unsupported"; any other
171
- # failure (sqlite corruption, I/O error, a saver bug) must propagate, not
172
- # masquerade as a missing capability.
206
+ # Only the documented capability signal becomes "unsupported", and only when the
207
+ # call itself raises it. The same exception raised while iterating is the
208
+ # serializer failing to decode a row, which `_decoded` reports as corrupt input.
173
209
  raise NotImplementedError(
174
210
  "this checkpointer does not support listing all threads"
175
211
  ) from exc
176
212
  seen: set[str] = set()
177
213
  ordered: list[str] = []
178
- for t in tuples:
179
- tid = (t.config.get("configurable") or {}).get("thread_id")
214
+ for t in _decoded(listing):
215
+ tid = _checked(t)[0]["thread_id"]
180
216
  if tid is not None and tid not in seen:
181
217
  seen.add(tid)
182
218
  ordered.append(tid)
@@ -186,8 +222,8 @@ class LangGraphCheckpointAdapter:
186
222
  config = {"configurable": {"thread_id": trace_id}}
187
223
  tuples = [
188
224
  t
189
- for t in self._saver.list(config)
190
- if (t.config.get("configurable") or {}).get("thread_id") == trace_id
225
+ for t in _decoded(self._saver.list(config))
226
+ if _checked(t)[0]["thread_id"] == trace_id
191
227
  ]
192
228
  if not tuples:
193
229
  raise KeyError(f"no checkpoints for thread_id {trace_id!r}")
@@ -199,12 +235,11 @@ class LangGraphCheckpointAdapter:
199
235
  original_seq: dict[str, int] = {}
200
236
  steps: dict[str, Step] = {}
201
237
  writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]] = {}
238
+ malformed_writes = 0
202
239
  for t in tuples:
203
- cfg = t.config.get("configurable") or {}
204
- cp = t.checkpoint
205
- md = t.metadata or {}
240
+ cfg, cp, md, parent_cfg = _checked(t)
206
241
  ns = cfg.get("checkpoint_ns", _ROOT_NS) or _ROOT_NS
207
- cid = cfg.get("checkpoint_id") or cp["id"]
242
+ cid = cp["id"]
208
243
  key = (ns, cid)
209
244
  if key in step_id_by_key:
210
245
  raise ValueError(f"duplicate checkpoint {cid!r} in namespace {ns!r}")
@@ -216,15 +251,12 @@ class LangGraphCheckpointAdapter:
216
251
  step_id_by_key[key] = step_id
217
252
  key_by_step_id[step_id] = key
218
253
  checkpoints[step_id] = cp
219
- try:
220
- original_seq[step_id] = int(md.get("step", 0))
221
- except (TypeError, ValueError) as exc:
222
- raise ValueError(
223
- f"checkpoint {cid!r} in thread {trace_id!r} has a non-integer "
224
- f"metadata step: {md.get('step')!r}"
225
- ) from exc
226
- parent_keys[step_id] = self._declared_parent_keys(t, ns)
227
- writes_by_step[step_id] = self._writes_by_task(getattr(t, "pending_writes", None))
254
+ original_seq[step_id] = md.get("step", 0)
255
+ parent_keys[step_id] = self._declared_parent_keys(parent_cfg, md, ns)
256
+ writes_by_step[step_id], malformed = self._writes_by_task(
257
+ getattr(t, "pending_writes", None)
258
+ )
259
+ malformed_writes += malformed
228
260
  steps[step_id] = Step(
229
261
  step_id=step_id,
230
262
  trace_id=trace_id,
@@ -304,6 +336,16 @@ class LangGraphCheckpointAdapter:
304
336
  ingest_warnings = self._derive_task_steps(
305
337
  trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
306
338
  )
339
+ ingest_warnings.extend(
340
+ self._derive_interrupt_steps(
341
+ trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
342
+ )
343
+ )
344
+ if malformed_writes:
345
+ ingest_warnings.append(
346
+ f"{malformed_writes} pending write(s) are not a (task id, channel, value) "
347
+ "triple and were ignored; a task failure recorded in one would not be reported."
348
+ )
307
349
 
308
350
  any_error = any(s.status is StepStatus.ERROR for s in steps.values())
309
351
  trace = Trace(
@@ -418,24 +460,115 @@ class LangGraphCheckpointAdapter:
418
460
  )
419
461
  return warnings
420
462
 
463
+ @classmethod
464
+ def _derive_interrupt_steps(
465
+ cls,
466
+ trace_id: str,
467
+ steps: dict[str, Step],
468
+ checkpoints: dict[str, dict],
469
+ key_by_step_id: dict[str, _CheckpointKey],
470
+ original_seq: dict[str, int],
471
+ writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]],
472
+ edges: list[Edge],
473
+ ) -> list[str]:
474
+ """Add one UNSET step per task the head checkpoint records as interrupted, never resumed.
475
+
476
+ Only the thread's head — the newest root checkpoint, which is what LangGraph itself
477
+ resumes from — is read. An ``__interrupt__`` write on any earlier checkpoint was resumed,
478
+ forked past, or abandoned, and is history rather than an open pause.
479
+
480
+ A task is reported only when ``__interrupt__`` is its *only* write. Anything else that
481
+ accompanies it leaves the task's state unrecorded, and it is disclosed rather than
482
+ guessed: under LangGraph 1.2.2 a resumed task that returned nothing leaves
483
+ ``{__interrupt__, __resume__}``, exactly like one that reached a second ``interrupt()``
484
+ (LangGraph overwrites the interrupt in place; 1.2.15 adds ``__no_writes__`` to the
485
+ first, which then reads as finished); a failure followed by a retry that interrupts leaves the same
486
+ ``{__error__, __interrupt__}`` as the reverse order, because savers keep no order.
487
+ """
488
+ root_ids = [sid for sid, (ns, _) in key_by_step_id.items() if ns == _ROOT_NS]
489
+ if not root_ids:
490
+ return []
491
+ head_id = max(root_ids, key=lambda sid: checkpoints[sid]["id"])
492
+ head = steps[head_id]
493
+ cp = checkpoints[head_id]
494
+ _, cid = key_by_step_id[head_id]
495
+ warnings: list[str] = []
496
+ undecided = 0
497
+ for task_id, task_writes in (writes_by_step.get(head_id) or {}).items():
498
+ if task_id == _NULL_TASK_ID:
499
+ continue
500
+ channels = {channel for channel, _ in task_writes}
501
+ if _INTERRUPT not in channels:
502
+ continue
503
+ if channels != {_INTERRUPT}:
504
+ if channels <= _UNDECIDED_WITH_INTERRUPT:
505
+ undecided += 1
506
+ continue
507
+ name = cls._resolve_task_name(cp, _ROOT_NS, original_seq[head_id], task_id, task_writes)
508
+ if name is None:
509
+ warnings.append(
510
+ f"checkpoint {cid}: interrupted task {task_id} could not be attributed to a "
511
+ "node; no candidate reproduced its task id, which is also what a change in "
512
+ "LangGraph's task-id algorithm would look like."
513
+ )
514
+ derived_id = f"{head_id}#interrupt:{task_id}"
515
+ if derived_id in steps:
516
+ raise ValueError(
517
+ f"derived interrupt step id {derived_id!r} collides with a checkpoint id "
518
+ "or another derived step"
519
+ )
520
+ steps[derived_id] = Step(
521
+ step_id=derived_id,
522
+ trace_id=trace_id,
523
+ seq=head.seq + 1,
524
+ ts=head.ts,
525
+ kind=StepKind.TOOL if name and _looks_like_tool_node(name) else StepKind.CHAIN,
526
+ source=StepSource.INTERRUPT,
527
+ name=name,
528
+ # The task has no outcome yet. The interrupt's value is whatever the node asked
529
+ # its caller, which is user content, so it is not carried as a message.
530
+ status=StepStatus.UNSET,
531
+ )
532
+ edges.append(
533
+ Edge(
534
+ type=EdgeType.CAUSED_BY,
535
+ src=derived_id,
536
+ dst=head_id,
537
+ # LangGraph keys the write to the head checkpoint itself.
538
+ origin=EdgeOrigin.CHECKPOINT_PARENT,
539
+ )
540
+ )
541
+ if undecided:
542
+ warnings.append(
543
+ f"checkpoint {cid}: {undecided} task(s) recorded an interrupt together with a "
544
+ "resume or an error and no result; the checkpoint does not record whether each "
545
+ "is still paused, so none is reported as interrupted."
546
+ )
547
+ return warnings
548
+
421
549
  @staticmethod
422
- def _writes_by_task(pending_writes: Any) -> dict[str, list[tuple[str, Any]]]:
550
+ def _writes_by_task(pending_writes: Any) -> tuple[dict[str, list[tuple[str, Any]]], int]:
423
551
  """Group a checkpoint's pending writes by task id, in first-seen order.
424
552
 
425
553
  Savers are free to order writes as they like — the SQLite saver sorts by task id and
426
554
  index, the in-memory one keeps insertion order — so nothing here may depend on
427
555
  position. ``pending_writes`` is also optional on ``CheckpointTuple``; a third-party
428
- saver that omits it reads as "no writes", never as an error.
556
+ saver that omits it reads as "no writes", never as an error. Returns the grouping and
557
+ how many entries were not a ``(str, str, value)`` triple: those are not ours to
558
+ interpret, but the caller discloses them because one could have carried a failure.
429
559
  """
430
560
  grouped: dict[str, list[tuple[str, Any]]] = {}
561
+ malformed = 0
431
562
  for write in pending_writes or ():
432
- try:
433
- task_id, channel, value = write
434
- except (TypeError, ValueError):
435
- continue # not a (task_id, channel, value) triple; not ours to interpret
563
+ if not isinstance(write, (tuple, list)) or len(write) != 3:
564
+ malformed += 1
565
+ continue
566
+ task_id, channel, value = write
436
567
  if isinstance(task_id, str) and isinstance(channel, str):
437
568
  grouped.setdefault(task_id, []).append((channel, value))
438
- return grouped
569
+ else:
570
+ malformed += 1
571
+ return grouped, malformed
439
572
 
440
573
  @staticmethod
441
574
  def _hash_task_id(cid: str, legacy: bool, *parts: str) -> str | None:
@@ -593,22 +726,18 @@ class LangGraphCheckpointAdapter:
593
726
  return str(value)
594
727
 
595
728
  @staticmethod
596
- def _declared_parent_keys(t: Any, ns: str) -> list[_CheckpointKey]:
597
- parent_cfg = (t.parent_config or {}).get("configurable") or {}
729
+ def _declared_parent_keys(parent_cfg: dict, md: dict, ns: str) -> list[_CheckpointKey]:
598
730
  parent_id = parent_cfg.get("checkpoint_id")
599
731
  if parent_id is not None:
600
732
  parent_ns = parent_cfg.get("checkpoint_ns", ns) or _ROOT_NS
601
733
  return [(parent_ns, parent_id)]
602
734
 
603
- md = t.metadata or {}
735
+ # `_checked` has already established `parents` as a str -> str mapping.
604
736
  parents = md.get("parents") or {}
605
- if not isinstance(parents, dict):
606
- return []
607
737
  closest = LangGraphCheckpointAdapter._closest_parent_ns(ns, parents)
608
738
  if closest is None:
609
739
  return []
610
- checkpoint_id = parents[closest]
611
- return [] if checkpoint_id is None else [(closest or _ROOT_NS, checkpoint_id)]
740
+ return [(closest or _ROOT_NS, parents[closest])]
612
741
 
613
742
  @staticmethod
614
743
  def _closest_parent_ns(ns: str, parents: dict[str, str]) -> str | None:
@@ -809,3 +938,80 @@ class LangGraphCheckpointAdapter:
809
938
  if previous: # error was already present upstream -> not introduced here
810
939
  return None
811
940
  return str(current)
941
+
942
+
943
+ def _decoded(listing: Iterable[Any]) -> Iterator[Any]:
944
+ """Iterate a saver's listing, turning a failure to decode a row into a usage error.
945
+
946
+ Only saver code runs while the next tuple is produced: the serializer reviving a blob,
947
+ ``json.loads`` on the metadata column, the database driver. Whatever it raises there
948
+ (an unknown serialization type, JSON nested past the recursion limit, a non-string column)
949
+ means the history cannot be read, which is a property of the input, not a crash.
950
+ """
951
+ iterator = iter(listing)
952
+ while True:
953
+ try:
954
+ item = next(iterator)
955
+ except StopIteration:
956
+ return
957
+ except Exception as exc:
958
+ raise ValueError(
959
+ f"checkpoint history cannot be decoded ({type(exc).__name__})"
960
+ ) from exc
961
+ yield item
962
+
963
+
964
+ def _checked(t: Any) -> tuple[dict, dict, dict, dict]:
965
+ """The tuple's ``configurable``, checkpoint and metadata, each checked against the shape
966
+ LangGraph declares for the fields this adapter reads (strictly: ``True`` is not ``1``).
967
+
968
+ A field is checked only when present; ``checkpoint.id`` is required, and when the config
969
+ also names the checkpoint the two must agree, because step identity comes from one and
970
+ chronology and task hashing from the other.
971
+ """
972
+ # A config without a thread id is not "empty": `ingest` filters on the thread id, so it
973
+ # would silently drop the checkpoint, and with it any failure it recorded.
974
+ config = getattr(t, "config", None)
975
+ if not isinstance(config, dict) or not isinstance(config.get("configurable"), dict):
976
+ raise ValueError("checkpoint config must be an object with a configurable object")
977
+ cfg = config["configurable"]
978
+ if "thread_id" not in cfg:
979
+ raise ValueError("checkpoint config has no thread_id")
980
+ cp = getattr(t, "checkpoint", None)
981
+ if not isinstance(cp, dict):
982
+ raise ValueError("checkpoint must be an object")
983
+ cid = cp.get("id")
984
+ label = f"checkpoint {cid!r}" if isinstance(cid, str) else "a checkpoint"
985
+ if "id" not in cp:
986
+ raise ValueError(f"{label} has no id")
987
+ md = object_field(getattr(t, "metadata", None), f"{label} metadata")
988
+ # No parent config is legitimate (a thread's first checkpoint). One that is present but
989
+ # names no checkpoint is not: read as "no parent", it would make this checkpoint a root.
990
+ parent = getattr(t, "parent_config", None)
991
+ parent_cfg: dict = {}
992
+ if parent is not None:
993
+ if not isinstance(parent, dict) or not isinstance(parent.get("configurable"), dict):
994
+ raise ValueError(f"{label} parent config must be an object with a configurable object")
995
+ parent_cfg = parent["configurable"]
996
+ if "checkpoint_id" not in parent_cfg:
997
+ raise ValueError(f"{label} parent config has no checkpoint_id")
998
+ for where, fields, values in (
999
+ ("checkpoint", _CHECKPOINT_FIELDS, cp),
1000
+ ("metadata", _METADATA_FIELDS, md),
1001
+ ("config", _CONFIG_FIELDS, cfg),
1002
+ ("parent config", _CONFIG_FIELDS, parent_cfg),
1003
+ ):
1004
+ for name, adapter in fields.items():
1005
+ if name not in values:
1006
+ continue
1007
+ try:
1008
+ adapter.validate_python(values[name], strict=True)
1009
+ except ValidationError as exc:
1010
+ reason = exc.errors()[0]["msg"]
1011
+ raise ValueError(f"{label} has a malformed {where} {name}: {reason}") from None
1012
+ if "checkpoint_id" in cfg and cfg["checkpoint_id"] != cid:
1013
+ raise ValueError(f"{label} disagrees with its config checkpoint_id")
1014
+ writes = getattr(t, "pending_writes", None)
1015
+ if writes is not None and not isinstance(writes, (list, tuple)):
1016
+ raise ValueError(f"{label} has pending writes that are not a list")
1017
+ return cfg, cp, md, parent_cfg
@@ -118,6 +118,8 @@ def _attr_value(v: Any) -> Any:
118
118
  raw = v[key]
119
119
  if isinstance(raw, bool):
120
120
  return raw # `int(True) == 1` would hide that the value was a boolean
121
+ if isinstance(raw, float) and not raw.is_integer():
122
+ return raw # `int(1.5) == 1` would hand validation a plausible count
121
123
  try:
122
124
  return int(raw)
123
125
  except (TypeError, ValueError, OverflowError):
@@ -229,43 +231,83 @@ def _iso_ts(start_nano: Any) -> str | None:
229
231
  return datetime.fromtimestamp(nanos / 1e9, tz=timezone.utc).isoformat()
230
232
 
231
233
 
232
- def _int_attr(attrs: dict[str, Any], *names: str) -> int | None:
233
- for name in names:
234
- value = attrs.get(name)
235
- if value is None or isinstance(value, bool):
236
- continue
237
- try:
238
- parsed = int(value)
239
- except (TypeError, ValueError, OverflowError):
240
- continue
241
- if parsed < 0 or (isinstance(value, float) and not value.is_integer()):
242
- continue
243
- return parsed
244
- return None
234
+ def _as_count(value: Any) -> int | None:
235
+ if value is None or isinstance(value, bool):
236
+ return None
237
+ try:
238
+ parsed = int(value)
239
+ except (TypeError, ValueError, OverflowError):
240
+ return None
241
+ if parsed < 0 or (isinstance(value, float) and not value.is_integer()):
242
+ return None
243
+ return parsed
245
244
 
246
245
 
247
- def _decimal_attr(attrs: dict[str, Any], *names: str) -> str | None:
248
- for name in names:
249
- value = attrs.get(name)
250
- if value is None or not isinstance(value, (int, float, str)):
251
- continue
252
- try:
253
- parsed = Decimal(str(value))
254
- except InvalidOperation:
255
- continue
256
- if parsed.is_finite() and parsed >= 0:
257
- return format(parsed, "f")
258
- return None
246
+ def _as_decimal(value: Any) -> str | None:
247
+ if value is None or isinstance(value, bool) or not isinstance(value, (int, float, str)):
248
+ return None
249
+ try:
250
+ parsed = Decimal(str(value))
251
+ except InvalidOperation:
252
+ return None
253
+ return format(parsed, "f") if parsed.is_finite() and parsed >= 0 else None
259
254
 
260
255
 
261
- def _currency_attr(attrs: dict[str, Any]) -> str | None:
262
- value = attrs.get("llm.cost.currency")
256
+ def _as_currency(value: Any) -> str | None:
263
257
  if not isinstance(value, str):
264
258
  return None
265
259
  normalized = value.upper()
266
260
  return normalized if re.fullmatch(r"[A-Z]{3,8}", normalized) else None
267
261
 
268
262
 
263
+ def _first_readable(attrs: dict[str, Any], read: Any, *names: str) -> Any:
264
+ for name in names:
265
+ parsed = read(attrs.get(name))
266
+ if parsed is not None:
267
+ return parsed
268
+ return None
269
+
270
+
271
+ def _unreadable(attrs: dict[str, Any], read: Any, *names: str) -> bool:
272
+ """The producer sent a value under one of ``names`` and none of them can be read."""
273
+ return _first_readable(attrs, read, *names) is None and any(
274
+ attrs.get(name) is not None for name in names
275
+ )
276
+
277
+
278
+ _TOKEN_ATTRS = (
279
+ ("llm.token_count.prompt", "llm.token_count.input"),
280
+ ("llm.token_count.completion", "llm.token_count.output"),
281
+ ("llm.token_count.total",),
282
+ )
283
+ _COST_ATTRS = (
284
+ ("llm.cost.prompt", "llm.cost.input"),
285
+ ("llm.cost.completion", "llm.cost.output"),
286
+ ("llm.cost.total",),
287
+ )
288
+ _CURRENCY_ATTR = "llm.cost.currency"
289
+ #: Stored in ``total_cost`` when the producer sent a total cost that cannot be read. It is not a
290
+ #: decimal, so analysis withholds the run's total instead of summing the rest into a figure that
291
+ #: is knowably too low. Fixed text: the producer's own value never reaches the artifact.
292
+ UNREADABLE_COST = "unreadable"
293
+
294
+
295
+ def _unreadable_telemetry(attrs: dict[str, Any]) -> dict[str, set[str]]:
296
+ """Which kinds of telemetry this span sent but tracegraph cannot read, and under which of
297
+ our fixed attribute names (never the producer's value)."""
298
+ groups = (
299
+ [("token count", _as_count, names) for names in _TOKEN_ATTRS]
300
+ + [("prompt or completion cost", _as_decimal, names) for names in _COST_ATTRS[:2]]
301
+ + [("total cost", _as_decimal, _COST_ATTRS[2])]
302
+ + [("cost currency", _as_currency, (_CURRENCY_ATTR,))]
303
+ )
304
+ found: dict[str, set[str]] = {}
305
+ for kind, read, names in groups:
306
+ if _unreadable(attrs, read, *names):
307
+ found.setdefault(kind, set()).update(n for n in names if attrs.get(n) is not None)
308
+ return found
309
+
310
+
269
311
  def _digest_attr(attrs: dict[str, Any], name: str) -> str | None:
270
312
  value = attrs.get(name)
271
313
  if value is None:
@@ -294,15 +336,16 @@ def _evidence(
294
336
  evidence = StepEvidence(
295
337
  end_ts=_iso_ts(end),
296
338
  duration_ms=duration_ms,
297
- prompt_tokens=_int_attr(attrs, "llm.token_count.prompt", "llm.token_count.input"),
298
- completion_tokens=_int_attr(
299
- attrs, "llm.token_count.completion", "llm.token_count.output"
339
+ prompt_tokens=_first_readable(attrs, _as_count, *_TOKEN_ATTRS[0]),
340
+ completion_tokens=_first_readable(attrs, _as_count, *_TOKEN_ATTRS[1]),
341
+ total_tokens=_first_readable(attrs, _as_count, *_TOKEN_ATTRS[2]),
342
+ prompt_cost=_first_readable(attrs, _as_decimal, *_COST_ATTRS[0]),
343
+ completion_cost=_first_readable(attrs, _as_decimal, *_COST_ATTRS[1]),
344
+ total_cost=(
345
+ _first_readable(attrs, _as_decimal, *_COST_ATTRS[2])
346
+ or (UNREADABLE_COST if _unreadable(attrs, _as_decimal, *_COST_ATTRS[2]) else None)
300
347
  ),
301
- total_tokens=_int_attr(attrs, "llm.token_count.total"),
302
- prompt_cost=_decimal_attr(attrs, "llm.cost.prompt", "llm.cost.input"),
303
- completion_cost=_decimal_attr(attrs, "llm.cost.completion", "llm.cost.output"),
304
- total_cost=_decimal_attr(attrs, "llm.cost.total"),
305
- cost_currency=_currency_attr(attrs),
348
+ cost_currency=_as_currency(attrs.get(_CURRENCY_ATTR)),
306
349
  artifact_digest=_digest_attr(attrs, "syncmill.artifact_digest"),
307
350
  evaluations=evaluations,
308
351
  )
@@ -471,6 +514,19 @@ class OTLPSpanAdapter:
471
514
  if unreadable
472
515
  else []
473
516
  )
517
+ dropped = [_unreadable_telemetry(attrs[sid]) for sid in by_id]
518
+ for kind, consequence in (
519
+ ("token count", "that count is reported as unavailable"),
520
+ ("prompt or completion cost", "that cost is reported as unavailable"),
521
+ ("total cost", "the run's total cost is withheld rather than understated"),
522
+ ("cost currency", "that cost is reported without a currency"),
523
+ ):
524
+ count = sum(1 for found in dropped if kind in found)
525
+ if count:
526
+ names = ", ".join(sorted(set().union(*(found.get(kind, set()) for found in dropped))))
527
+ warnings.append(
528
+ f"{count} span(s) carry a {kind} that cannot be read ({names}); {consequence}."
529
+ )
474
530
  return RawTrace(trace=trace, steps=steps, causal_edges=edges, ingest_warnings=warnings)
475
531
 
476
532
  # --- helpers ---
@@ -73,7 +73,19 @@ def _attrs(span: dict[str, Any]) -> dict[str, Any]:
73
73
  return safe
74
74
 
75
75
 
76
+ def _as_score(value: Any) -> float | None:
77
+ if value is None:
78
+ return None
79
+ try:
80
+ score = float(value)
81
+ except (TypeError, ValueError, OverflowError):
82
+ return None
83
+ return score if math.isfinite(score) else None
84
+
85
+
76
86
  def _evaluations(span: dict[str, Any]) -> list[dict[str, Any]]:
87
+ """The span's evaluations, each with ``unreadable_score`` set when a score was sent but
88
+ cannot be represented; the caller strips that key and discloses the count."""
77
89
  out: list[dict[str, Any]] = []
78
90
  annotations = span.get("annotations")
79
91
  if annotations is None:
@@ -93,16 +105,12 @@ def _evaluations(span: dict[str, Any]) -> list[dict[str, Any]]:
93
105
  if name is None:
94
106
  continue
95
107
  label = _first(result, "label") or annotation.get("label")
96
- score = _first(result, "score")
97
- if score is None:
98
- score = annotation.get("score")
99
- try:
100
- score = float(score) if score is not None else None
101
- except (TypeError, ValueError, OverflowError):
102
- score = None
103
- if score is not None and not math.isfinite(score):
104
- score = None
105
- out.append({"name": name, "label": _safe_operation_name(label), "score": score})
108
+ sent = _first(result, "score")
109
+ if sent is None:
110
+ sent = annotation.get("score")
111
+ score = _as_score(sent)
112
+ out.append({"name": name, "label": _safe_operation_name(label), "score": score,
113
+ "unreadable_score": sent is not None and score is None})
106
114
  return out
107
115
 
108
116
 
@@ -153,8 +161,11 @@ class PhoenixExportAdapter:
153
161
  except KeyError as exc:
154
162
  raise KeyError(f"no Phoenix trace {trace_id!r}") from exc
155
163
  spans = [self._span(trace_id, span) for span in trace["spans"]]
164
+ unreadable_scores = sum(
165
+ item.pop("unreadable_score") for span in spans for item in span["_tracegraph_evaluations"]
166
+ )
156
167
  document = {"resourceSpans": [{"scopeSpans": [{"spans": spans}]}]}
157
- return OTLPSpanAdapter(
168
+ raw = OTLPSpanAdapter(
158
169
  document,
159
170
  source_kind="phoenix_cli",
160
171
  links_as_causes=False,
@@ -162,6 +173,12 @@ class PhoenixExportAdapter:
162
173
  preserve_unset_status=True,
163
174
  evaluation_channel="_tracegraph_evaluations",
164
175
  ).ingest(trace_id)
176
+ if unreadable_scores:
177
+ raw.ingest_warnings.append(
178
+ f"{unreadable_scores} evaluation score(s) cannot be read as a finite number; "
179
+ "each is reported as null."
180
+ )
181
+ return raw
165
182
 
166
183
  @staticmethod
167
184
  def _trace_id(trace: dict[str, Any]) -> str:
@@ -19,13 +19,13 @@ from tracegraph.input_validation import loads as json_loads
19
19
  from tracegraph.model import NormalizedTrace, StepSource
20
20
 
21
21
  #: Bump when the on-disk shape changes incompatibly.
22
- ARTIFACT_SCHEMA_VERSION = 4
22
+ ARTIFACT_SCHEMA_VERSION = 5
23
23
 
24
24
  #: Versions this build can read. 1 needs a migration (see ``_migrate_v1``); 2 -> 3 is the
25
25
  #: identity, because 3 adds only a new ``Step.source`` value and changes nothing existing.
26
26
  #: 4 signals that TREE_PARENT was derived under EdgeOrigin precedence rather than
27
- #: legacy temporal-only selection.
28
- _READABLE_VERSIONS = (1, 2, 3, ARTIFACT_SCHEMA_VERSION)
27
+ #: legacy temporal-only selection. 5 adds only the ``interrupt`` ``Step.source`` value.
28
+ _READABLE_VERSIONS = (1, 2, 3, 4, ARTIFACT_SCHEMA_VERSION)
29
29
 
30
30
 
31
31
  def _required_version(nt: NormalizedTrace) -> int:
@@ -38,6 +38,9 @@ def _required_version(nt: NormalizedTrace) -> int:
38
38
  bump would invalidate digests already exported in review-candidate reports for artifacts
39
39
  whose content did not change at all.
40
40
 
41
+ * An artifact that carries an interrupt step is stamped 5, whatever its projection: a
42
+ v5 reader also reads the v4 projection, and an older reader would otherwise fail on
43
+ the unknown enum value instead of refusing the version by name.
41
44
  * An artifact whose derived TREE_PARENT relies on EdgeOrigin priority is stamped 4,
42
45
  so older readers (which used temporal-only parent selection) refuse it with an
43
46
  explicit "unsupported artifact schema_version 4" rather than failing canonical
@@ -47,6 +50,9 @@ def _required_version(nt: NormalizedTrace) -> int:
47
50
  """
48
51
  from tracegraph.normalize import requires_origin_priority_projection
49
52
 
53
+ interrupt = StepSource.INTERRUPT
54
+ if any(step.source is interrupt for step in nt.steps):
55
+ return 5
50
56
  if requires_origin_priority_projection(nt):
51
57
  return 4
52
58
  task = StepSource.TASK
@@ -54,10 +54,15 @@ class StepKind(str, Enum):
54
54
  class StepSource(str, Enum):
55
55
  """How the step entered the trace (mirrors LangGraph ``metadata.source``).
56
56
 
57
- ``TASK`` is the one value that does *not* come from ``metadata.source``: it marks a step
57
+ ``TASK`` and ``INTERRUPT`` do *not* come from ``metadata.source``. ``TASK`` marks a step
58
58
  derived from a checkpoint's **pending writes** (one LangGraph task) rather than from a
59
59
  checkpoint itself. It is explicit provenance, not a naming convention — ``step_id`` is an
60
60
  unrestricted string, so nothing about an id may be read as evidence of how a step was made.
61
+
62
+ ``INTERRUPT`` is likewise derived from pending writes: a LangGraph task that called ``interrupt()`` and
63
+ for which the recorded history holds no resume and no result. It states what the
64
+ checkpoints record, not that LangGraph would resume the task today — a thread re-run under
65
+ a revised graph can finish without writing a checkpoint, leaving the record unchanged.
61
66
  """
62
67
 
63
68
  INPUT = "input"
@@ -65,6 +70,7 @@ class StepSource(str, Enum):
65
70
  UPDATE = "update"
66
71
  FORK = "fork"
67
72
  TASK = "task"
73
+ INTERRUPT = "interrupt"
68
74
 
69
75
 
70
76
  class StepStatus(str, Enum):