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.
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/CHANGELOG.md +80 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/PKG-INFO +20 -8
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/README.md +19 -7
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/pyproject.toml +1 -1
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/langgraph_checkpoint.py +242 -36
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/otlp_spans.py +91 -35
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/phoenix_export.py +28 -11
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/artifact.py +9 -3
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/model.py +7 -1
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/.gitignore +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/LICENSE +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/SECURITY.md +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/contracts/analysis-report.schema.json +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/contracts/phoenix-runtime.json +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/contracts/review-candidates.schema.json +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/_atomic_write.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/base.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/ahu.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/diagnose.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/explain.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/memo.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/patterns.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/cli.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/input_validation.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/normalize.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/py.typed +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/review_candidates.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/sqlite_snapshot.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/base.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/store/in_memory.py +0 -0
- {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
|
+
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
|
|
293
|
-
|
|
294
|
-
such a run reads `ok` unless some other step recorded an
|
|
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.
|
|
380
|
-
|
|
381
|
-
|
|
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.
|
|
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
|
|
262
|
-
|
|
263
|
-
such a run reads `ok` unless some other step recorded an
|
|
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.
|
|
349
|
-
|
|
350
|
-
|
|
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.
|
|
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.
|
|
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"
|
{agent_tracegraph-0.3.0 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/langgraph_checkpoint.py
RENAMED
|
@@ -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
|
-
|
|
204
|
+
listing = list_fn(None)
|
|
169
205
|
except NotImplementedError as exc:
|
|
170
|
-
# Only the documented capability signal becomes "unsupported"
|
|
171
|
-
#
|
|
172
|
-
#
|
|
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
|
|
179
|
-
tid = (t
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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=
|
|
298
|
-
completion_tokens=
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 =
|
|
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``
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|