agent-tracegraph 0.3.0__tar.gz → 0.3.1__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.3.1}/CHANGELOG.md +55 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/PKG-INFO +5 -2
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/README.md +4 -1
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/pyproject.toml +1 -1
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/langgraph_checkpoint.py +135 -36
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/otlp_spans.py +91 -35
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/phoenix_export.py +28 -11
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/.gitignore +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/LICENSE +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/SECURITY.md +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/contracts/analysis-report.schema.json +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/contracts/phoenix-runtime.json +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/contracts/review-candidates.schema.json +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/_atomic_write.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/base.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/ahu.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/diagnose.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/explain.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/memo.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/patterns.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/artifact.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/cli.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/input_validation.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/model.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/normalize.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/py.typed +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/review_candidates.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/sqlite_snapshot.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/__init__.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/base.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/in_memory.py +0 -0
- {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/ladybug.py +0 -0
|
@@ -6,6 +6,61 @@ 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.3.1 - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **An unreadable cost no longer understates the run's total.** The importers dropped a
|
|
14
|
+
`llm.cost.total` that was not a finite non-negative decimal (`"abc"`, `-1`, a boolean), so
|
|
15
|
+
`analyze` summed the remaining costs and printed a total that was knowably too low, with no
|
|
16
|
+
warning. The documented rule that a single unparseable cost withholds the whole total only
|
|
17
|
+
applied to artifacts built by hand. An unreadable total cost is now stored as the fixed
|
|
18
|
+
marker `"unreadable"`, never the producer's value, and the report withholds `total_cost`
|
|
19
|
+
and its delta with the existing note.
|
|
20
|
+
- Token counts, prompt and completion costs, cost currencies and Phoenix evaluation scores
|
|
21
|
+
that cannot be read were dropped without a trace. They are still reported as unavailable,
|
|
22
|
+
and `ingest-otlp`, `ingest-phoenix`, `analyze` and `phoenix diagnose` now say so on stderr,
|
|
23
|
+
naming the kind of value and how many spans (for scores, evaluations) carried it, never
|
|
24
|
+
the value itself. A boolean score still reads as `1.0` or `0.0`, as before.
|
|
25
|
+
- An OTLP `intValue` sent as a fractional JSON number was truncated before validation:
|
|
26
|
+
`1.5` became `1` and `-0.5` became `0`, so an invalid token count looked valid. It is now
|
|
27
|
+
validated as sent.
|
|
28
|
+
- **A checkpoint history LangGraph could not have written is a usage error, not a
|
|
29
|
+
traceback or a different trace.** About half of 64 one-field mutations of a real history
|
|
30
|
+
(a non-object `metadata` or `channel_values`, a numeric `checkpoint_ns`, a boolean `v`, an
|
|
31
|
+
unhashable parent checkpoint id, ...) crashed `ingest` with a traceback, and several were
|
|
32
|
+
read silently as something else: a malformed parent config or `metadata.parents` dropped
|
|
33
|
+
the declared parent and made the checkpoint a root, and `metadata.step` of `true` or `1.5`
|
|
34
|
+
was read as step 1. Each checkpoint is now checked against the types LangGraph declares
|
|
35
|
+
for the fields tracegraph reads, strictly, and rejected with a message naming the field.
|
|
36
|
+
A config `checkpoint_id` that disagrees with the checkpoint's own id is rejected too.
|
|
37
|
+
- A checkpoint row the saver cannot decode (an unknown serialization type, metadata nested
|
|
38
|
+
past the recursion limit, a non-string metadata column) crashed `ingest` and `discover`
|
|
39
|
+
with a traceback, except that `discover` reported an unknown serialization type as "does
|
|
40
|
+
not support listing all threads". Both now report the history as undecodable.
|
|
41
|
+
- Pending writes that are not a `(task id, channel, value)` triple were skipped without a
|
|
42
|
+
word, and any 3-character string was unpacked as one (`"abc"` became task `a`). They are
|
|
43
|
+
still ignored, but `ingest` now says how many on stderr, since one could have carried a
|
|
44
|
+
task failure.
|
|
45
|
+
|
|
46
|
+
### Compatibility notes for imports
|
|
47
|
+
|
|
48
|
+
- An artifact imported from a span with an unreadable total cost now carries
|
|
49
|
+
`"total_cost": "unreadable"` instead of no cost, and its reports withhold the run's
|
|
50
|
+
total cost. No schema change: 0.3.0 reads such an artifact and withholds the same way.
|
|
51
|
+
- Because `intValue` is no longer truncated, `llm.cost.total = {"intValue": 1.5}` is stored as
|
|
52
|
+
`"1.5"` instead of `"1"`, and a `toolgraph.graph_generation` of `{"intValue": 1.5}` is
|
|
53
|
+
rejected like any other invalid generation instead of being read as `1`.
|
|
54
|
+
- The committed Phoenix fixture carries an unreadable `llm.cost.prompt`; reading it now prints
|
|
55
|
+
one stderr disclosure. Every committed fixture's artifact, report, memo, candidate file and
|
|
56
|
+
CLI stdout is unchanged.
|
|
57
|
+
- LangGraph histories that `ingest` used to accept with a malformed field listed above are
|
|
58
|
+
now refused. Histories written by LangGraph's own savers are unaffected: the checks cover
|
|
59
|
+
only fields tracegraph reads (`counters_since_delta_snapshot`, which savers return as
|
|
60
|
+
lists although it is declared as tuples, is not checked), a missing `updated_channels`
|
|
61
|
+
and a legacy `pending_sends` are accepted, and an unknown `metadata.source` string still
|
|
62
|
+
reads as a loop. The committed LangGraph fixture is byte-identical.
|
|
63
|
+
|
|
9
64
|
## 0.3.0 - 2026-10-13
|
|
10
65
|
|
|
11
66
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-tracegraph
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -387,7 +387,10 @@ means valid in-trace links only. Structural diff compares the derived tree and d
|
|
|
387
387
|
lossy step counts. Metrics sum available span observations: partial coverage and producer
|
|
388
388
|
aggregation can undercount or double count. Coverage gaps are disclosed rather than absorbed
|
|
389
389
|
— 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.
|
|
390
|
+
withheld, and a single unparseable cost withholds the whole total instead of understating it.
|
|
391
|
+
An OTLP or Phoenix import records an unreadable total cost as `unreadable` for that reason, and
|
|
392
|
+
reports token counts, other costs, currencies and evaluation scores it cannot read as
|
|
393
|
+
unavailable, disclosing each on stderr; the producer's value is never kept. File input has a size limit, but stdin/Phoenix
|
|
391
394
|
subprocess output and query materialization are not streaming memory guarantees.
|
|
392
395
|
|
|
393
396
|
Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
|
|
@@ -356,7 +356,10 @@ means valid in-trace links only. Structural diff compares the derived tree and d
|
|
|
356
356
|
lossy step counts. Metrics sum available span observations: partial coverage and producer
|
|
357
357
|
aggregation can undercount or double count. Coverage gaps are disclosed rather than absorbed
|
|
358
358
|
— 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.
|
|
359
|
+
withheld, and a single unparseable cost withholds the whole total instead of understating it.
|
|
360
|
+
An OTLP or Phoenix import records an unreadable total cost as `unreadable` for that reason, and
|
|
361
|
+
reports token counts, other costs, currencies and evaluation scores it cannot read as
|
|
362
|
+
unavailable, disclosing each on stderr; the producer's value is never kept. File input has a size limit, but stdin/Phoenix
|
|
360
363
|
subprocess output and query materialization are not streaming memory guarantees.
|
|
361
364
|
|
|
362
365
|
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.
|
|
7
|
+
version = "0.3.1"
|
|
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.3.1}/src/tracegraph/adapters/langgraph_checkpoint.py
RENAMED
|
@@ -62,8 +62,12 @@ from binascii import Error as BinasciiError, unhexlify
|
|
|
62
62
|
from hashlib import sha1
|
|
63
63
|
import heapq
|
|
64
64
|
import re
|
|
65
|
-
from typing import Any
|
|
65
|
+
from typing import Any, Iterable, Iterator, get_type_hints
|
|
66
66
|
|
|
67
|
+
from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata
|
|
68
|
+
from pydantic import TypeAdapter, ValidationError
|
|
69
|
+
|
|
70
|
+
from tracegraph.input_validation import object_field
|
|
67
71
|
from tracegraph.model import (
|
|
68
72
|
CausalFidelity,
|
|
69
73
|
Edge,
|
|
@@ -82,6 +86,22 @@ _BRANCH_PREFIX = "branch:to:"
|
|
|
82
86
|
_JOIN_PREFIX = "join:"
|
|
83
87
|
_TOOL_TOKENS = {"tool", "tools"}
|
|
84
88
|
|
|
89
|
+
# The shape of every field this adapter reads, taken from the types LangGraph itself declares
|
|
90
|
+
# rather than restated here, so a LangGraph type change surfaces (in the upstream drift
|
|
91
|
+
# workflow) instead of hiding behind a stale copy. Only fields the adapter reads are checked:
|
|
92
|
+
# LangGraph declares ``counters_since_delta_snapshot`` as tuples, but both savers hand it back
|
|
93
|
+
# as lists, so checking everything would reject histories LangGraph itself wrote. ``source``
|
|
94
|
+
# is any string, not LangGraph's Literal, because an unknown source still reads as a loop.
|
|
95
|
+
_CHECKPOINT_FIELDS = {
|
|
96
|
+
name: TypeAdapter(get_type_hints(Checkpoint)[name])
|
|
97
|
+
for name in ("id", "ts", "v", "channel_values", "channel_versions", "versions_seen")
|
|
98
|
+
}
|
|
99
|
+
_METADATA_FIELDS = {
|
|
100
|
+
**{name: TypeAdapter(get_type_hints(CheckpointMetadata)[name]) for name in ("step", "parents")},
|
|
101
|
+
"source": TypeAdapter(str),
|
|
102
|
+
}
|
|
103
|
+
_CONFIG_FIELDS = {name: TypeAdapter(str) for name in ("thread_id", "checkpoint_ns", "checkpoint_id")}
|
|
104
|
+
|
|
85
105
|
# LangGraph's reserved pending-write channels and task-path markers, spelled out rather than
|
|
86
106
|
# imported: they live under ``langgraph._internal``, which is private and not a dependency of
|
|
87
107
|
# this package. They are stable on-disk names — a checkpoint written by any 4.x saver uses
|
|
@@ -165,18 +185,18 @@ class LangGraphCheckpointAdapter:
|
|
|
165
185
|
"this checkpointer does not support listing all threads"
|
|
166
186
|
)
|
|
167
187
|
try:
|
|
168
|
-
|
|
188
|
+
listing = list_fn(None)
|
|
169
189
|
except NotImplementedError as exc:
|
|
170
|
-
# Only the documented capability signal becomes "unsupported"
|
|
171
|
-
#
|
|
172
|
-
#
|
|
190
|
+
# Only the documented capability signal becomes "unsupported", and only when the
|
|
191
|
+
# call itself raises it. The same exception raised while iterating is the
|
|
192
|
+
# serializer failing to decode a row, which `_decoded` reports as corrupt input.
|
|
173
193
|
raise NotImplementedError(
|
|
174
194
|
"this checkpointer does not support listing all threads"
|
|
175
195
|
) from exc
|
|
176
196
|
seen: set[str] = set()
|
|
177
197
|
ordered: list[str] = []
|
|
178
|
-
for t in
|
|
179
|
-
tid = (t
|
|
198
|
+
for t in _decoded(listing):
|
|
199
|
+
tid = _checked(t)[0]["thread_id"]
|
|
180
200
|
if tid is not None and tid not in seen:
|
|
181
201
|
seen.add(tid)
|
|
182
202
|
ordered.append(tid)
|
|
@@ -186,8 +206,8 @@ class LangGraphCheckpointAdapter:
|
|
|
186
206
|
config = {"configurable": {"thread_id": trace_id}}
|
|
187
207
|
tuples = [
|
|
188
208
|
t
|
|
189
|
-
for t in self._saver.list(config)
|
|
190
|
-
if (t
|
|
209
|
+
for t in _decoded(self._saver.list(config))
|
|
210
|
+
if _checked(t)[0]["thread_id"] == trace_id
|
|
191
211
|
]
|
|
192
212
|
if not tuples:
|
|
193
213
|
raise KeyError(f"no checkpoints for thread_id {trace_id!r}")
|
|
@@ -199,12 +219,11 @@ class LangGraphCheckpointAdapter:
|
|
|
199
219
|
original_seq: dict[str, int] = {}
|
|
200
220
|
steps: dict[str, Step] = {}
|
|
201
221
|
writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]] = {}
|
|
222
|
+
malformed_writes = 0
|
|
202
223
|
for t in tuples:
|
|
203
|
-
cfg = t
|
|
204
|
-
cp = t.checkpoint
|
|
205
|
-
md = t.metadata or {}
|
|
224
|
+
cfg, cp, md, parent_cfg = _checked(t)
|
|
206
225
|
ns = cfg.get("checkpoint_ns", _ROOT_NS) or _ROOT_NS
|
|
207
|
-
cid =
|
|
226
|
+
cid = cp["id"]
|
|
208
227
|
key = (ns, cid)
|
|
209
228
|
if key in step_id_by_key:
|
|
210
229
|
raise ValueError(f"duplicate checkpoint {cid!r} in namespace {ns!r}")
|
|
@@ -216,15 +235,12 @@ class LangGraphCheckpointAdapter:
|
|
|
216
235
|
step_id_by_key[key] = step_id
|
|
217
236
|
key_by_step_id[step_id] = key
|
|
218
237
|
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))
|
|
238
|
+
original_seq[step_id] = md.get("step", 0)
|
|
239
|
+
parent_keys[step_id] = self._declared_parent_keys(parent_cfg, md, ns)
|
|
240
|
+
writes_by_step[step_id], malformed = self._writes_by_task(
|
|
241
|
+
getattr(t, "pending_writes", None)
|
|
242
|
+
)
|
|
243
|
+
malformed_writes += malformed
|
|
228
244
|
steps[step_id] = Step(
|
|
229
245
|
step_id=step_id,
|
|
230
246
|
trace_id=trace_id,
|
|
@@ -304,6 +320,11 @@ class LangGraphCheckpointAdapter:
|
|
|
304
320
|
ingest_warnings = self._derive_task_steps(
|
|
305
321
|
trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
|
|
306
322
|
)
|
|
323
|
+
if malformed_writes:
|
|
324
|
+
ingest_warnings.append(
|
|
325
|
+
f"{malformed_writes} pending write(s) are not a (task id, channel, value) "
|
|
326
|
+
"triple and were ignored; a task failure recorded in one would not be reported."
|
|
327
|
+
)
|
|
307
328
|
|
|
308
329
|
any_error = any(s.status is StepStatus.ERROR for s in steps.values())
|
|
309
330
|
trace = Trace(
|
|
@@ -419,23 +440,28 @@ class LangGraphCheckpointAdapter:
|
|
|
419
440
|
return warnings
|
|
420
441
|
|
|
421
442
|
@staticmethod
|
|
422
|
-
def _writes_by_task(pending_writes: Any) -> dict[str, list[tuple[str, Any]]]:
|
|
443
|
+
def _writes_by_task(pending_writes: Any) -> tuple[dict[str, list[tuple[str, Any]]], int]:
|
|
423
444
|
"""Group a checkpoint's pending writes by task id, in first-seen order.
|
|
424
445
|
|
|
425
446
|
Savers are free to order writes as they like — the SQLite saver sorts by task id and
|
|
426
447
|
index, the in-memory one keeps insertion order — so nothing here may depend on
|
|
427
448
|
position. ``pending_writes`` is also optional on ``CheckpointTuple``; a third-party
|
|
428
|
-
saver that omits it reads as "no writes", never as an error.
|
|
449
|
+
saver that omits it reads as "no writes", never as an error. Returns the grouping and
|
|
450
|
+
how many entries were not a ``(str, str, value)`` triple: those are not ours to
|
|
451
|
+
interpret, but the caller discloses them because one could have carried a failure.
|
|
429
452
|
"""
|
|
430
453
|
grouped: dict[str, list[tuple[str, Any]]] = {}
|
|
454
|
+
malformed = 0
|
|
431
455
|
for write in pending_writes or ():
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
456
|
+
if not isinstance(write, (tuple, list)) or len(write) != 3:
|
|
457
|
+
malformed += 1
|
|
458
|
+
continue
|
|
459
|
+
task_id, channel, value = write
|
|
436
460
|
if isinstance(task_id, str) and isinstance(channel, str):
|
|
437
461
|
grouped.setdefault(task_id, []).append((channel, value))
|
|
438
|
-
|
|
462
|
+
else:
|
|
463
|
+
malformed += 1
|
|
464
|
+
return grouped, malformed
|
|
439
465
|
|
|
440
466
|
@staticmethod
|
|
441
467
|
def _hash_task_id(cid: str, legacy: bool, *parts: str) -> str | None:
|
|
@@ -593,22 +619,18 @@ class LangGraphCheckpointAdapter:
|
|
|
593
619
|
return str(value)
|
|
594
620
|
|
|
595
621
|
@staticmethod
|
|
596
|
-
def _declared_parent_keys(
|
|
597
|
-
parent_cfg = (t.parent_config or {}).get("configurable") or {}
|
|
622
|
+
def _declared_parent_keys(parent_cfg: dict, md: dict, ns: str) -> list[_CheckpointKey]:
|
|
598
623
|
parent_id = parent_cfg.get("checkpoint_id")
|
|
599
624
|
if parent_id is not None:
|
|
600
625
|
parent_ns = parent_cfg.get("checkpoint_ns", ns) or _ROOT_NS
|
|
601
626
|
return [(parent_ns, parent_id)]
|
|
602
627
|
|
|
603
|
-
|
|
628
|
+
# `_checked` has already established `parents` as a str -> str mapping.
|
|
604
629
|
parents = md.get("parents") or {}
|
|
605
|
-
if not isinstance(parents, dict):
|
|
606
|
-
return []
|
|
607
630
|
closest = LangGraphCheckpointAdapter._closest_parent_ns(ns, parents)
|
|
608
631
|
if closest is None:
|
|
609
632
|
return []
|
|
610
|
-
|
|
611
|
-
return [] if checkpoint_id is None else [(closest or _ROOT_NS, checkpoint_id)]
|
|
633
|
+
return [(closest or _ROOT_NS, parents[closest])]
|
|
612
634
|
|
|
613
635
|
@staticmethod
|
|
614
636
|
def _closest_parent_ns(ns: str, parents: dict[str, str]) -> str | None:
|
|
@@ -809,3 +831,80 @@ class LangGraphCheckpointAdapter:
|
|
|
809
831
|
if previous: # error was already present upstream -> not introduced here
|
|
810
832
|
return None
|
|
811
833
|
return str(current)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _decoded(listing: Iterable[Any]) -> Iterator[Any]:
|
|
837
|
+
"""Iterate a saver's listing, turning a failure to decode a row into a usage error.
|
|
838
|
+
|
|
839
|
+
Only saver code runs while the next tuple is produced: the serializer reviving a blob,
|
|
840
|
+
``json.loads`` on the metadata column, the database driver. Whatever it raises there
|
|
841
|
+
(an unknown serialization type, JSON nested past the recursion limit, a non-string column)
|
|
842
|
+
means the history cannot be read, which is a property of the input, not a crash.
|
|
843
|
+
"""
|
|
844
|
+
iterator = iter(listing)
|
|
845
|
+
while True:
|
|
846
|
+
try:
|
|
847
|
+
item = next(iterator)
|
|
848
|
+
except StopIteration:
|
|
849
|
+
return
|
|
850
|
+
except Exception as exc:
|
|
851
|
+
raise ValueError(
|
|
852
|
+
f"checkpoint history cannot be decoded ({type(exc).__name__})"
|
|
853
|
+
) from exc
|
|
854
|
+
yield item
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _checked(t: Any) -> tuple[dict, dict, dict, dict]:
|
|
858
|
+
"""The tuple's ``configurable``, checkpoint and metadata, each checked against the shape
|
|
859
|
+
LangGraph declares for the fields this adapter reads (strictly: ``True`` is not ``1``).
|
|
860
|
+
|
|
861
|
+
A field is checked only when present; ``checkpoint.id`` is required, and when the config
|
|
862
|
+
also names the checkpoint the two must agree, because step identity comes from one and
|
|
863
|
+
chronology and task hashing from the other.
|
|
864
|
+
"""
|
|
865
|
+
# A config without a thread id is not "empty": `ingest` filters on the thread id, so it
|
|
866
|
+
# would silently drop the checkpoint, and with it any failure it recorded.
|
|
867
|
+
config = getattr(t, "config", None)
|
|
868
|
+
if not isinstance(config, dict) or not isinstance(config.get("configurable"), dict):
|
|
869
|
+
raise ValueError("checkpoint config must be an object with a configurable object")
|
|
870
|
+
cfg = config["configurable"]
|
|
871
|
+
if "thread_id" not in cfg:
|
|
872
|
+
raise ValueError("checkpoint config has no thread_id")
|
|
873
|
+
cp = getattr(t, "checkpoint", None)
|
|
874
|
+
if not isinstance(cp, dict):
|
|
875
|
+
raise ValueError("checkpoint must be an object")
|
|
876
|
+
cid = cp.get("id")
|
|
877
|
+
label = f"checkpoint {cid!r}" if isinstance(cid, str) else "a checkpoint"
|
|
878
|
+
if "id" not in cp:
|
|
879
|
+
raise ValueError(f"{label} has no id")
|
|
880
|
+
md = object_field(getattr(t, "metadata", None), f"{label} metadata")
|
|
881
|
+
# No parent config is legitimate (a thread's first checkpoint). One that is present but
|
|
882
|
+
# names no checkpoint is not: read as "no parent", it would make this checkpoint a root.
|
|
883
|
+
parent = getattr(t, "parent_config", None)
|
|
884
|
+
parent_cfg: dict = {}
|
|
885
|
+
if parent is not None:
|
|
886
|
+
if not isinstance(parent, dict) or not isinstance(parent.get("configurable"), dict):
|
|
887
|
+
raise ValueError(f"{label} parent config must be an object with a configurable object")
|
|
888
|
+
parent_cfg = parent["configurable"]
|
|
889
|
+
if "checkpoint_id" not in parent_cfg:
|
|
890
|
+
raise ValueError(f"{label} parent config has no checkpoint_id")
|
|
891
|
+
for where, fields, values in (
|
|
892
|
+
("checkpoint", _CHECKPOINT_FIELDS, cp),
|
|
893
|
+
("metadata", _METADATA_FIELDS, md),
|
|
894
|
+
("config", _CONFIG_FIELDS, cfg),
|
|
895
|
+
("parent config", _CONFIG_FIELDS, parent_cfg),
|
|
896
|
+
):
|
|
897
|
+
for name, adapter in fields.items():
|
|
898
|
+
if name not in values:
|
|
899
|
+
continue
|
|
900
|
+
try:
|
|
901
|
+
adapter.validate_python(values[name], strict=True)
|
|
902
|
+
except ValidationError as exc:
|
|
903
|
+
reason = exc.errors()[0]["msg"]
|
|
904
|
+
raise ValueError(f"{label} has a malformed {where} {name}: {reason}") from None
|
|
905
|
+
if "checkpoint_id" in cfg and cfg["checkpoint_id"] != cid:
|
|
906
|
+
raise ValueError(f"{label} disagrees with its config checkpoint_id")
|
|
907
|
+
writes = getattr(t, "pending_writes", None)
|
|
908
|
+
if writes is not None and not isinstance(writes, (list, tuple)):
|
|
909
|
+
raise ValueError(f"{label} has pending writes that are not a list")
|
|
910
|
+
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:
|
|
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
|
|
File without changes
|
|
File without changes
|