agent-tracegraph 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,804 @@
1
+ """Ingest LangGraph checkpoint history into a :class:`RawTrace`.
2
+
3
+ Reads any ``BaseCheckpointSaver`` via ``.list()`` and reconstructs the causal chain from
4
+ each checkpoint's declared parentage. One checkpoint (super-step) becomes one ``Step``;
5
+ the parent-checkpoint links become ``CAUSED_BY`` edges (effect → cause).
6
+
7
+ All checkpoint namespaces for the requested thread are ingested. Root-namespace
8
+ checkpoints keep their checkpoint id as ``step_id``; non-root checkpoints use
9
+ ``"<checkpoint_ns>:<checkpoint_id>"`` so subgraph checkpoint ids cannot collide with root
10
+ ids. Causality comes from ``parent_config`` first; subgraph namespace roots also use the
11
+ closest LangGraph-declared ``metadata.parents`` entry when there is no direct
12
+ ``parent_config``. When a parent namespace continues after a subgraph, LangGraph records
13
+ the parent namespace checkpoint as its ``parent_config``; this adapter expands that
14
+ namespace-level shortcut through the terminal checkpoint inside the subgraph namespace.
15
+
16
+ A few facts about the checkpoint stream this relies on (verified against
17
+ langgraph-checkpoint 4.x):
18
+
19
+ * ``checkpoint["id"]`` is a monotonic UUIDv6. Root-namespace ``metadata["step"]`` values
20
+ increase with the parent chain; subgraph namespaces can reuse local step numbers, so the
21
+ adapter re-sequences globally when declared cross-namespace parentage needs it.
22
+ * ``metadata["writes"]`` is **not** reliably populated by ``list()``; instead we read the
23
+ serialized ``channel_values``. A step "introduced" an error when a designated error
24
+ channel (default ``"error"``) is truthy at that checkpoint but falsy at its parent.
25
+ * the producing node's name is recovered best-effort from the parent checkpoint's
26
+ internal ``branch:to:<node>`` channel; absent that, ``name`` is left unset.
27
+
28
+ Native task failures
29
+ --------------------
30
+ A node that *raises* never reaches ``channel_values``: LangGraph records the exception as a
31
+ ``(task_id, "__error__", repr(exc))`` entry in the checkpoint's **pending writes**, and the
32
+ Pregel loop explicitly skips those control channels when applying writes. Such a run also
33
+ writes no further checkpoint, so reading channels alone reports a crashed run as a clean
34
+ success. This adapter therefore derives one extra ``Step`` per errored task, as a
35
+ ``CAUSED_BY`` child of the checkpoint that scheduled it — never by marking that checkpoint,
36
+ whose own name belongs to the node that *produced* it, not the one that failed.
37
+
38
+ Naming a derived step requires evidence, never a guess:
39
+
40
+ * a ``__error_source_node__`` write for the same task id names it outright (LangGraph emits
41
+ this only for nodes with an ``on_error`` handler), otherwise
42
+ * the task id is **recomputed** for each candidate node and compared. LangGraph derives it as
43
+ an xxh3-128 digest over the checkpoint id, task namespace, next step number, node name,
44
+ ``__pregel_pull``, and the node's *configured* triggers (``langgraph.pregel._algo``); a
45
+ ``Send`` task uses ``__pregel_push`` and the packet index instead. Candidates come from the
46
+ checkpoint's own channels, so a node LangGraph never mentions can never be named.
47
+
48
+ When neither works the step keeps ``name=None`` rather than borrowing a neighbour's name.
49
+
50
+ Not every ``__error__`` write is a failure. When one task raises, LangGraph cancels the
51
+ siblings still running in that super-step and records a ``CancelledError`` for each through
52
+ the same channel. Those tasks did not fail — they were torn down — so they become ``UNSET``
53
+ steps carrying no message, and only the task that actually raised is an ``ERROR``.
54
+ Only the *latest* persisted error survives per task id — a fail/fail/succeed sequence leaves
55
+ one record — so attempts are never reconstructed. Errors are recorded wherever they appear;
56
+ this module does not infer anything from their *absence*.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ from binascii import Error as BinasciiError, unhexlify
62
+ from hashlib import sha1
63
+ import heapq
64
+ import re
65
+ from typing import Any
66
+
67
+ from tracegraph.model import (
68
+ CausalFidelity,
69
+ Edge,
70
+ EdgeOrigin,
71
+ EdgeType,
72
+ RawTrace,
73
+ Step,
74
+ StepKind,
75
+ StepSource,
76
+ StepStatus,
77
+ Trace,
78
+ )
79
+
80
+ _ROOT_NS = ""
81
+ _BRANCH_PREFIX = "branch:to:"
82
+ _JOIN_PREFIX = "join:"
83
+ _TOOL_TOKENS = {"tool", "tools"}
84
+
85
+ # LangGraph's reserved pending-write channels and task-path markers, spelled out rather than
86
+ # imported: they live under ``langgraph._internal``, which is private and not a dependency of
87
+ # this package. They are stable on-disk names — a checkpoint written by any 4.x saver uses
88
+ # them — so copying them is safer than importing a private module that may move.
89
+ _ERROR = "__error__"
90
+ _ERROR_SOURCE_NODE = "__error_source_node__"
91
+ _START = "__start__"
92
+ _TASKS = "__pregel_tasks"
93
+ _PULL = "__pregel_pull"
94
+ _PUSH = "__pregel_push"
95
+ #: Writes LangGraph makes under this sentinel id belong to the graph, not to any node task.
96
+ _NULL_TASK_ID = "00000000-0000-0000-0000-000000000000"
97
+ #: Checkpoints at or below this version hash task ids with SHA-1; later ones use xxh3-128.
98
+ _MAX_LEGACY_CHECKPOINT_V = 1
99
+ #: When one task raises, LangGraph cancels its siblings and records a ``CancelledError`` for
100
+ #: each under the *same* ``__error__`` channel as a genuine failure (``pregel/_runner.commit``
101
+ #: handles cancellation in its own branch, before real exceptions). Reading them alike blames
102
+ #: healthy nodes for a neighbour's failure — the exact misattribution this module exists to
103
+ #: avoid — so cancellation is matched by exception class and reported as an outcome that never
104
+ #: happened, not as a failure. Both ``asyncio`` and ``concurrent.futures`` spellings repr this
105
+ #: way; a user exception of the same name would be under-reported, never over-reported.
106
+ _CANCELLED_PREFIX = "CancelledError("
107
+ #: Distinguishes "this task has no __error__ write" from "__error__ was persisted as None",
108
+ #: which a legacy serializer really does produce for a revived exception class.
109
+ _MISSING = object()
110
+
111
+
112
+ def _looks_like_tool_node(name: str) -> bool:
113
+ """True iff ``name`` names a tool node — has ``tool``/``tools`` as a *whole token*.
114
+
115
+ The old test ``"tool" in name.lower()`` matched any name *containing* the substring, so
116
+ ``retool`` / ``toolbar`` / ``stool`` wrongly became tools and leaked ``kind=TOOL`` into
117
+ pattern queries. We tokenize instead — split on non-alphanumeric separators, then on
118
+ camelCase / acronym boundaries — and match ``tool`` / ``tools`` as a *whole token*:
119
+ ``tool``, ``tools``, ``call_tool``, ``run_tools``, ``tool_node``, ``ToolNode`` and the
120
+ all-caps ``TOOL`` / ``CALL_TOOL`` all classify; ``retool`` does not.
121
+
122
+ This is deliberately stricter than the old substring test, so it does *not* match every
123
+ name the old check did: a name where ``tool`` is glued into a larger all-lowercase word
124
+ (``toolnode``, ``runtool``) or an all-caps run fused to a CamelWord (``TOOLs``) is not a
125
+ tool here. That's an accepted limitation — real LangGraph tool nodes use separated or
126
+ camelCase names (``tools``, ``call_tool``, ``ToolNode``). ASCII letters only.
127
+ """
128
+ tokens: list[str] = []
129
+ for part in re.split(r"[^A-Za-z0-9]+", name):
130
+ # Split into tokens, handling: an acronym run before a CamelWord ("HTTPTool" ->
131
+ # ["HTTP", "Tool"]); a Capitalized/lowercase word ("Tool", "tool"); an all-caps run
132
+ # with no trailing lowercase ("TOOL", "CALL"); and digit runs.
133
+ tokens.extend(re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", part))
134
+ return any(t.lower() in _TOOL_TOKENS for t in tokens)
135
+
136
+
137
+ _SOURCE_MAP = {
138
+ "input": StepSource.INPUT,
139
+ "loop": StepSource.LOOP,
140
+ "update": StepSource.UPDATE,
141
+ "fork": StepSource.FORK,
142
+ }
143
+ _CheckpointKey = tuple[str, str]
144
+
145
+
146
+ class LangGraphCheckpointAdapter:
147
+ def __init__(
148
+ self,
149
+ saver: Any,
150
+ *,
151
+ error_channel: str = "error",
152
+ source_kind: str = "langgraph",
153
+ ) -> None:
154
+ self._saver = saver
155
+ self._error_channel = error_channel
156
+ self._source_kind = source_kind
157
+
158
+ # --- TraceAdapter ---
159
+
160
+ def discover(self) -> list[str]:
161
+ """Distinct thread ids known to the saver (best effort; not all savers support it)."""
162
+ list_fn = getattr(self._saver, "list", None)
163
+ if list_fn is None:
164
+ raise NotImplementedError(
165
+ "this checkpointer does not support listing all threads"
166
+ )
167
+ try:
168
+ tuples = list(list_fn(None))
169
+ 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.
173
+ raise NotImplementedError(
174
+ "this checkpointer does not support listing all threads"
175
+ ) from exc
176
+ seen: set[str] = set()
177
+ ordered: list[str] = []
178
+ for t in tuples:
179
+ tid = (t.config.get("configurable") or {}).get("thread_id")
180
+ if tid is not None and tid not in seen:
181
+ seen.add(tid)
182
+ ordered.append(tid)
183
+ return ordered
184
+
185
+ def ingest(self, trace_id: str) -> RawTrace:
186
+ config = {"configurable": {"thread_id": trace_id}}
187
+ tuples = [
188
+ t
189
+ for t in self._saver.list(config)
190
+ if (t.config.get("configurable") or {}).get("thread_id") == trace_id
191
+ ]
192
+ if not tuples:
193
+ raise KeyError(f"no checkpoints for thread_id {trace_id!r}")
194
+
195
+ checkpoints: dict[str, dict] = {}
196
+ step_id_by_key: dict[_CheckpointKey, str] = {}
197
+ key_by_step_id: dict[str, _CheckpointKey] = {}
198
+ parent_keys: dict[str, list[_CheckpointKey]] = {}
199
+ original_seq: dict[str, int] = {}
200
+ steps: dict[str, Step] = {}
201
+ writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]] = {}
202
+ for t in tuples:
203
+ cfg = t.config.get("configurable") or {}
204
+ cp = t.checkpoint
205
+ md = t.metadata or {}
206
+ ns = cfg.get("checkpoint_ns", _ROOT_NS) or _ROOT_NS
207
+ cid = cfg.get("checkpoint_id") or cp["id"]
208
+ key = (ns, cid)
209
+ if key in step_id_by_key:
210
+ raise ValueError(f"duplicate checkpoint {cid!r} in namespace {ns!r}")
211
+ step_id = self._step_id(ns, cid)
212
+ if step_id in steps:
213
+ raise ValueError(
214
+ f"checkpoint namespace/id collision produced duplicate step_id {step_id!r}"
215
+ )
216
+ step_id_by_key[key] = step_id
217
+ key_by_step_id[step_id] = key
218
+ 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))
228
+ steps[step_id] = Step(
229
+ step_id=step_id,
230
+ trace_id=trace_id,
231
+ seq=original_seq[step_id],
232
+ ts=cp.get("ts"),
233
+ source=_SOURCE_MAP.get(md.get("source", "loop"), StepSource.LOOP),
234
+ kind=StepKind.CHAIN,
235
+ status=StepStatus.OK,
236
+ )
237
+
238
+ chrono_rank = self._chrono_rank(checkpoints)
239
+ causal_parent_keys = self._with_subgraph_exit_edges(
240
+ parent_keys,
241
+ key_by_step_id,
242
+ step_id_by_key,
243
+ chrono_rank,
244
+ )
245
+
246
+ parents_by_step: dict[str, list[str]] = {}
247
+ display_parent_by_step: dict[str, str] = {}
248
+ for step_id, refs in causal_parent_keys.items():
249
+ for parent_key in refs:
250
+ parent_id = step_id_by_key.get(parent_key)
251
+ if parent_id is None:
252
+ ns, cid = parent_key
253
+ child_ns, child_cid = key_by_step_id[step_id]
254
+ raise ValueError(
255
+ f"checkpoint {child_cid!r} in namespace {child_ns!r} declares "
256
+ f"missing parent checkpoint {cid!r} in namespace {ns!r}; the "
257
+ "checkpoint history is partial/corrupt"
258
+ )
259
+ parents_by_step.setdefault(step_id, []).append(parent_id)
260
+
261
+ for step_id, refs in parent_keys.items():
262
+ if refs:
263
+ display_parent_id = step_id_by_key.get(refs[0])
264
+ if display_parent_id is not None:
265
+ display_parent_by_step[step_id] = display_parent_id
266
+
267
+ if not self._metadata_seq_is_valid(original_seq, parents_by_step):
268
+ global_seq = self._topo_seq(steps, parents_by_step, original_seq)
269
+ for step_id, seq in global_seq.items():
270
+ steps[step_id].seq = seq
271
+
272
+ edges = [
273
+ Edge(
274
+ type=EdgeType.CAUSED_BY,
275
+ src=step_id,
276
+ dst=parent_id,
277
+ origin=EdgeOrigin.CHECKPOINT_PARENT,
278
+ )
279
+ for step_id, parents in parents_by_step.items()
280
+ for parent_id in parents
281
+ ]
282
+
283
+ for step_id, step in steps.items():
284
+ display_parent_id = display_parent_by_step.get(step_id)
285
+ parent_cp = checkpoints.get(display_parent_id) if display_parent_id else None
286
+ parent_key = key_by_step_id.get(display_parent_id) if display_parent_id else None
287
+ branch_target = self._branch_target(parent_key, key_by_step_id[step_id])
288
+ name = self._producing_node(parent_cp, branch_target)
289
+ if name:
290
+ step.name = name
291
+ if _looks_like_tool_node(name):
292
+ step.kind = StepKind.TOOL
293
+ causal_parent_ids = parents_by_step.get(step_id, [])
294
+ error_parent_cps = [checkpoints[parent_id] for parent_id in causal_parent_ids]
295
+ err = self._error_introduced(checkpoints[step_id], error_parent_cps)
296
+ if err is not None:
297
+ step.status = StepStatus.ERROR
298
+ step.error_msg = err
299
+
300
+ # Derived task steps come last: everything above indexes `steps` by checkpoint id
301
+ # (`checkpoints[step_id]`, `key_by_step_id[step_id]`), and a derived id has no entry
302
+ # in either. Building them here also means they inherit each carrying step's *final*
303
+ # seq, after `_topo_seq` may have re-sequenced the whole thread.
304
+ ingest_warnings = self._derive_task_steps(
305
+ trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
306
+ )
307
+
308
+ any_error = any(s.status is StepStatus.ERROR for s in steps.values())
309
+ trace = Trace(
310
+ trace_id=trace_id,
311
+ source_kind=self._source_kind,
312
+ thread_id=trace_id,
313
+ status=StepStatus.ERROR if any_error else StepStatus.OK,
314
+ causal_fidelity=CausalFidelity.DECLARED_DAG,
315
+ )
316
+ return RawTrace(
317
+ trace=trace,
318
+ ingest_warnings=ingest_warnings,
319
+ steps=sorted(steps.values(), key=lambda s: (s.seq, s.step_id)),
320
+ causal_edges=edges,
321
+ )
322
+
323
+ # --- helpers ---
324
+
325
+ @staticmethod
326
+ def _step_id(ns: str, cid: str) -> str:
327
+ return cid if ns == _ROOT_NS else f"{ns}:{cid}"
328
+
329
+ # --- native task failures (pending writes) ---
330
+
331
+ @classmethod
332
+ def _derive_task_steps(
333
+ cls,
334
+ trace_id: str,
335
+ steps: dict[str, Step],
336
+ checkpoints: dict[str, dict],
337
+ key_by_step_id: dict[str, _CheckpointKey],
338
+ original_seq: dict[str, int],
339
+ writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]],
340
+ edges: list[Edge],
341
+ ) -> list[str]:
342
+ """Add one ERROR step per failed task and return any ingestion disclosures.
343
+
344
+ The failing task becomes a child of the checkpoint that scheduled it. Marking that
345
+ checkpoint instead would blame the wrong node: its name is the node that *produced*
346
+ it, so a ``call_tool`` exception would be reported against ``plan``.
347
+ """
348
+ warnings: list[str] = []
349
+ derived: list[tuple[str, Step]] = []
350
+ for step_id in sorted(steps, key=lambda sid: (steps[sid].seq, sid)):
351
+ writes = writes_by_step.get(step_id) or {}
352
+ cp = checkpoints[step_id]
353
+ ns, cid = key_by_step_id[step_id]
354
+ undecodable = cls._undecodable_sends(cp)
355
+ if undecodable:
356
+ warnings.append(
357
+ f"checkpoint {cid}: {undecodable} Send packet(s) could not be decoded "
358
+ "(langgraph is not installed), so a failure in one cannot be named."
359
+ )
360
+ for task_id, task_writes in writes.items():
361
+ if task_id == _NULL_TASK_ID:
362
+ continue # a graph-level write (e.g. a top-level resume), not a node task
363
+ error = next(
364
+ (value for channel, value in task_writes if channel == _ERROR), _MISSING
365
+ )
366
+ if error is _MISSING:
367
+ continue
368
+ name = cls._resolve_task_name(
369
+ cp, ns, original_seq[step_id], task_id, task_writes
370
+ )
371
+ message = cls._error_message(error)
372
+ cancelled = message.startswith(_CANCELLED_PREFIX)
373
+ derived_id = f"{step_id}#task:{task_id}"
374
+ if derived_id in steps:
375
+ # Checkpoint ids are unrestricted strings, so this is conceivable. The
376
+ # duplicate guard above only covers real checkpoints, and silently
377
+ # overwriting one would delete a genuine step.
378
+ raise ValueError(
379
+ f"derived task step id {derived_id!r} collides with a checkpoint id"
380
+ )
381
+ derived.append((
382
+ step_id,
383
+ Step(
384
+ step_id=derived_id,
385
+ trace_id=trace_id,
386
+ seq=steps[step_id].seq + 1,
387
+ ts=steps[step_id].ts,
388
+ kind=StepKind.TOOL if name and _looks_like_tool_node(name) else StepKind.CHAIN,
389
+ source=StepSource.TASK,
390
+ name=name,
391
+ # A cancelled task produced no outcome, good or bad. UNSET says that
392
+ # without claiming it failed, and leaves the trace status to the task
393
+ # that actually raised.
394
+ status=StepStatus.UNSET if cancelled else StepStatus.ERROR,
395
+ error_msg=None if cancelled else message,
396
+ ),
397
+ ))
398
+ for parent_step_id, step in derived:
399
+ steps[step.step_id] = step
400
+ edges.append(
401
+ Edge(
402
+ type=EdgeType.CAUSED_BY,
403
+ src=step.step_id,
404
+ dst=parent_step_id,
405
+ # The write is keyed to that checkpoint's id by LangGraph itself, so the
406
+ # link is declared, not inferred from timing or namespace membership.
407
+ origin=EdgeOrigin.CHECKPOINT_PARENT,
408
+ )
409
+ )
410
+ return warnings
411
+
412
+ @staticmethod
413
+ def _writes_by_task(pending_writes: Any) -> dict[str, list[tuple[str, Any]]]:
414
+ """Group a checkpoint's pending writes by task id, in first-seen order.
415
+
416
+ Savers are free to order writes as they like — the SQLite saver sorts by task id and
417
+ index, the in-memory one keeps insertion order — so nothing here may depend on
418
+ position. ``pending_writes`` is also optional on ``CheckpointTuple``; a third-party
419
+ saver that omits it reads as "no writes", never as an error.
420
+ """
421
+ grouped: dict[str, list[tuple[str, Any]]] = {}
422
+ for write in pending_writes or ():
423
+ try:
424
+ task_id, channel, value = write
425
+ except (TypeError, ValueError):
426
+ continue # not a (task_id, channel, value) triple; not ours to interpret
427
+ if isinstance(task_id, str) and isinstance(channel, str):
428
+ grouped.setdefault(task_id, []).append((channel, value))
429
+ return grouped
430
+
431
+ @staticmethod
432
+ def _hash_task_id(cid: str, legacy: bool, *parts: str) -> str | None:
433
+ """Recompute a LangGraph task id, or ``None`` if this checkpoint id cannot carry one.
434
+
435
+ Mirrors ``_xxhash_str`` / ``_uuid5_str`` in ``langgraph.pregel._algo``. A checkpoint id
436
+ that is not hex (a hand-built test stub, a third-party saver) simply yields ``None``:
437
+ the caller then leaves the step unnamed instead of comparing against a value LangGraph
438
+ could not have produced.
439
+ """
440
+ try:
441
+ seed = unhexlify(cid.replace("-", ""))
442
+ except (BinasciiError, AttributeError, ValueError):
443
+ return None
444
+ joined = b"".join(part.encode() for part in parts)
445
+ if legacy:
446
+ digest = sha1(seed, usedforsecurity=False)
447
+ digest.update(joined)
448
+ hexed = digest.hexdigest()
449
+ else:
450
+ try:
451
+ from xxhash import xxh3_128_hexdigest
452
+ except ImportError: # pragma: no cover - xxhash is a declared dependency
453
+ return None
454
+ hexed = xxh3_128_hexdigest(seed + joined)
455
+ return f"{hexed[:8]}-{hexed[8:12]}-{hexed[12:16]}-{hexed[16:20]}-{hexed[20:32]}"
456
+
457
+ @staticmethod
458
+ def _trigger_targets(cp: dict) -> dict[str, set[str]]:
459
+ """Node name -> every trigger channel in this checkpoint that targets it.
460
+
461
+ Both ``channel_values`` and ``channel_versions`` are read: a consumed ``branch:to:``
462
+ channel disappears from the values but keeps its version, and a barrier node's
463
+ implicit branch channel may be absent from both (it is added by the caller).
464
+ """
465
+ targets: dict[str, set[str]] = {}
466
+ for channel in (*cp.get("channel_values", {}), *cp.get("channel_versions", {})):
467
+ if not isinstance(channel, str):
468
+ continue
469
+ if channel.startswith(_BRANCH_PREFIX):
470
+ name = channel[len(_BRANCH_PREFIX):]
471
+ elif channel.startswith(_JOIN_PREFIX):
472
+ # ``join:<src1>+<src2>:<target>`` — the target is the last segment.
473
+ name = channel.rpartition(":")[2]
474
+ else:
475
+ continue
476
+ if name:
477
+ targets.setdefault(name, set()).add(channel)
478
+ return targets
479
+
480
+ @classmethod
481
+ def _candidate_names(cls, cp: dict) -> list[str]:
482
+ """Node names this checkpoint mentions, as naming candidates.
483
+
484
+ Only names LangGraph itself recorded are considered. A name that never appears cannot
485
+ be proposed, and a name that appears but does not hash to the recorded task id is
486
+ rejected — so the candidate list only ever affects *coverage*, never correctness.
487
+ """
488
+ names = set(cls._trigger_targets(cp))
489
+ names.update(
490
+ name for name in cp.get("versions_seen", {}) if isinstance(name, str)
491
+ )
492
+ names.add(_START)
493
+ return sorted(names)
494
+
495
+ @classmethod
496
+ def _pull_task_ids(cls, cp: dict, ns: str, md_step: int, name: str) -> list[str]:
497
+ """Every task id node ``name`` could have at this checkpoint, by trigger set.
498
+
499
+ LangGraph hashes a node's *configured* triggers, which are not always visible here: a
500
+ fan-in node is triggered by both its barrier channel and an implicit ``branch:to:``
501
+ channel that the checkpoint may never mention. Rather than reconstruct the compiled
502
+ graph, this tries the plausible sets. Trying several is safe because a match is the
503
+ evidence — a trigger set that is not the node's real one simply does not match.
504
+ """
505
+ legacy = cp.get("v", 0) <= _MAX_LEGACY_CHECKPOINT_V
506
+ task_ns = f"{ns}|{name}" if ns else name
507
+ step = str(md_step + 1)
508
+ if name == _START:
509
+ trigger_sets: list[tuple[str, ...]] = [(_START,)]
510
+ else:
511
+ branch = f"{_BRANCH_PREFIX}{name}"
512
+ observed = cls._trigger_targets(cp).get(name, set())
513
+ configured = tuple(sorted(observed | {branch}))
514
+ trigger_sets = [configured]
515
+ if observed and configured != tuple(sorted(observed)):
516
+ trigger_sets.append(tuple(sorted(observed)))
517
+ if (branch,) not in trigger_sets:
518
+ trigger_sets.append((branch,))
519
+ ids = []
520
+ for triggers in trigger_sets:
521
+ task_id = cls._hash_task_id(cp["id"], legacy, task_ns, step, name, _PULL, *triggers)
522
+ if task_id is not None:
523
+ ids.append(task_id)
524
+ return ids
525
+
526
+ @classmethod
527
+ def _push_task_ids(cls, cp: dict, ns: str, md_step: int) -> dict[str, str]:
528
+ """Task id -> node name for this checkpoint's ``Send`` packets.
529
+
530
+ The packet index is part of the identity, so two ``Send``s to one node are two tasks.
531
+ Packets only decode when ``langgraph.types`` is importable: the checkpoint serializer
532
+ revives them through it and yields ``None`` otherwise. An ordinary install has only
533
+ the ``langgraph.checkpoint`` half of that namespace, so seeing ``None`` here is the
534
+ normal case, not a corrupt checkpoint — it is disclosed by the caller, never guessed
535
+ around, because a ``Send`` whose node is unknown cannot name anything.
536
+ """
537
+ packets = cp.get("channel_values", {}).get(_TASKS)
538
+ if not isinstance(packets, (list, tuple)):
539
+ return {}
540
+ legacy = cp.get("v", 0) <= _MAX_LEGACY_CHECKPOINT_V
541
+ out: dict[str, str] = {}
542
+ for idx, packet in enumerate(packets):
543
+ node = getattr(packet, "node", None)
544
+ if not isinstance(node, str):
545
+ continue # undecodable packet; _undecodable_sends reports it
546
+ task_ns = f"{ns}|{node}" if ns else node
547
+ task_id = cls._hash_task_id(
548
+ cp["id"], legacy, task_ns, str(md_step + 1), node, _PUSH, str(idx)
549
+ )
550
+ if task_id is not None:
551
+ out[task_id] = node
552
+ return out
553
+
554
+ @staticmethod
555
+ def _undecodable_sends(cp: dict) -> int:
556
+ """How many ``Send`` packets this checkpoint holds that did not deserialize."""
557
+ packets = cp.get("channel_values", {}).get(_TASKS)
558
+ if not isinstance(packets, (list, tuple)):
559
+ return 0
560
+ return sum(1 for packet in packets if not isinstance(getattr(packet, "node", None), str))
561
+
562
+ @classmethod
563
+ def _resolve_task_name(
564
+ cls, cp: dict, ns: str, md_step: int, task_id: str, writes: list[tuple[str, Any]]
565
+ ) -> str | None:
566
+ """Name the node that ran as ``task_id``, or ``None`` when nothing proves which it was."""
567
+ for channel, value in writes:
568
+ if channel == _ERROR_SOURCE_NODE and isinstance(value, str) and value:
569
+ return value
570
+ for name in cls._candidate_names(cp):
571
+ if task_id in cls._pull_task_ids(cp, ns, md_step, name):
572
+ return name
573
+ return cls._push_task_ids(cp, ns, md_step).get(task_id)
574
+
575
+ @staticmethod
576
+ def _error_message(value: Any) -> str:
577
+ """The exception text LangGraph persisted, as a string.
578
+
579
+ The serializer stores ``repr(exc)`` for an exception; a legacy ``lc:2`` envelope
580
+ instead revives the class as ``None``. Neither is a body — it is the exception text
581
+ the run itself would have printed — but "None" would read as a message, so an absent
582
+ value is labelled as absent.
583
+ """
584
+ if value is None:
585
+ return "<exception not serialized by the checkpointer>"
586
+ return str(value)
587
+
588
+ @staticmethod
589
+ def _declared_parent_keys(t: Any, ns: str) -> list[_CheckpointKey]:
590
+ parent_cfg = (t.parent_config or {}).get("configurable") or {}
591
+ parent_id = parent_cfg.get("checkpoint_id")
592
+ if parent_id is not None:
593
+ parent_ns = parent_cfg.get("checkpoint_ns", ns) or _ROOT_NS
594
+ return [(parent_ns, parent_id)]
595
+
596
+ md = t.metadata or {}
597
+ parents = md.get("parents") or {}
598
+ if not isinstance(parents, dict):
599
+ return []
600
+ closest = LangGraphCheckpointAdapter._closest_parent_ns(ns, parents)
601
+ if closest is None:
602
+ return []
603
+ checkpoint_id = parents[closest]
604
+ return [] if checkpoint_id is None else [(closest or _ROOT_NS, checkpoint_id)]
605
+
606
+ @staticmethod
607
+ def _closest_parent_ns(ns: str, parents: dict[str, str]) -> str | None:
608
+ candidates = [
609
+ parent_ns or _ROOT_NS
610
+ for parent_ns in parents
611
+ if parent_ns == _ROOT_NS or ns.startswith(f"{parent_ns}|")
612
+ ]
613
+ if not candidates:
614
+ return None
615
+ return max(candidates, key=lambda parent_ns: (parent_ns.count("|"), len(parent_ns)))
616
+
617
+ @staticmethod
618
+ def _chrono_rank(checkpoints: dict[str, dict]) -> dict[str, int]:
619
+ return {
620
+ step_id: i
621
+ for i, step_id in enumerate(
622
+ sorted(
623
+ checkpoints,
624
+ key=lambda sid: (
625
+ checkpoints[sid].get("id") or sid,
626
+ checkpoints[sid].get("ts") or "",
627
+ sid,
628
+ ),
629
+ )
630
+ )
631
+ }
632
+
633
+ @staticmethod
634
+ def _with_subgraph_exit_edges(
635
+ parent_keys: dict[str, list[_CheckpointKey]],
636
+ key_by_step_id: dict[str, _CheckpointKey],
637
+ step_id_by_key: dict[_CheckpointKey, str],
638
+ chrono_rank: dict[str, int],
639
+ ) -> dict[str, list[_CheckpointKey]]:
640
+ # Group each namespace's checkpoints into *invocations*, in chronological order.
641
+ #
642
+ # A namespace can be entered more than once (a loop re-running the same subgraph
643
+ # node). Pairing one entry with one terminal per namespace is then wrong in both
644
+ # directions: keeping the last entry (which is what raw saver.list() order happened
645
+ # to give, since it pages newest-first) or the first entry both leave the terminal
646
+ # belonging to a *different* invocation, and the parent graph's continuation edge for
647
+ # every other invocation is dropped. So an entry and the terminal it resumes from
648
+ # must come from the same invocation.
649
+ #
650
+ # A step whose parent lies outside the namespace *is* an entry, and so begins a new
651
+ # invocation; the invocation's terminal is its chronologically last member.
652
+ members_by_ns: dict[str, list[str]] = {}
653
+ for step_id, key in sorted(
654
+ key_by_step_id.items(), key=lambda item: (chrono_rank[item[0]], item[0])
655
+ ):
656
+ ns, _ = key
657
+ if ns != _ROOT_NS:
658
+ members_by_ns.setdefault(ns, []).append(step_id)
659
+
660
+ exits_by_entry_parent: dict[_CheckpointKey, list[_CheckpointKey]] = {}
661
+ for ns, member_ids in members_by_ns.items():
662
+ invocations: list[tuple[_CheckpointKey, list[str]]] = []
663
+ pending: list[str] = []
664
+ for step_id in member_ids:
665
+ # Only a parent *outside and above* this namespace is an entry. A parent in a
666
+ # descendant namespace is the opposite: the subgraph this namespace launched
667
+ # handing control back, which continues the invocation already in progress.
668
+ # Treating that return as a new entry would close the invocation early, and
669
+ # the parent graph would then resume from a checkpoint before the nested work
670
+ # instead of after it, dropping that whole execution from the ancestry.
671
+ entry_parent = next(
672
+ (
673
+ key
674
+ for key in parent_keys.get(step_id, [])
675
+ if key[0] != ns and not key[0].startswith(f"{ns}|")
676
+ ),
677
+ None,
678
+ )
679
+ if entry_parent is None:
680
+ # Not an entry: it continues the invocation in progress. Anything seen
681
+ # before the first entry is held over and joins it.
682
+ (invocations[-1][1] if invocations else pending).append(step_id)
683
+ else:
684
+ invocations.append((entry_parent, [*pending, step_id]))
685
+ pending = []
686
+ for entry_parent, members in invocations:
687
+ terminal = max(members, key=lambda sid: (chrono_rank[sid], sid))
688
+ exits_by_entry_parent.setdefault(entry_parent, []).append(
689
+ key_by_step_id[terminal]
690
+ )
691
+
692
+ out: dict[str, list[_CheckpointKey]] = {}
693
+ for step_id, refs in parent_keys.items():
694
+ step_ns, _ = key_by_step_id[step_id]
695
+ expanded: list[_CheckpointKey] = []
696
+ seen: set[_CheckpointKey] = set()
697
+ for ref in refs:
698
+ exit_refs = [
699
+ exit_ref
700
+ for exit_ref in exits_by_entry_parent.get(ref, [])
701
+ if ref[0] == step_ns
702
+ and chrono_rank[step_id_by_key[exit_ref]] < chrono_rank[step_id]
703
+ ]
704
+ replacements = sorted(
705
+ exit_refs,
706
+ key=lambda exit_ref: chrono_rank[step_id_by_key[exit_ref]],
707
+ ) or [ref]
708
+ for replacement in replacements:
709
+ if replacement not in seen:
710
+ seen.add(replacement)
711
+ expanded.append(replacement)
712
+ out[step_id] = expanded
713
+ return out
714
+
715
+ @staticmethod
716
+ def _metadata_seq_is_valid(
717
+ seq: dict[str, int],
718
+ parents_by_step: dict[str, list[str]],
719
+ ) -> bool:
720
+ return all(
721
+ seq[parent_id] < seq[step_id]
722
+ for step_id, parents in parents_by_step.items()
723
+ for parent_id in parents
724
+ )
725
+
726
+ @staticmethod
727
+ def _topo_seq(
728
+ steps: dict[str, Step],
729
+ parents_by_step: dict[str, list[str]],
730
+ original_seq: dict[str, int],
731
+ ) -> dict[str, int]:
732
+ effects_of: dict[str, list[str]] = {sid: [] for sid in steps}
733
+ indegree: dict[str, int] = dict.fromkeys(steps, 0)
734
+ for effect, parents in parents_by_step.items():
735
+ for cause in parents:
736
+ effects_of[cause].append(effect)
737
+ indegree[effect] += 1
738
+
739
+ rank = {
740
+ sid: (original_seq[sid], steps[sid].ts or "", sid)
741
+ for sid in steps
742
+ }
743
+ ready = [(rank[sid], sid) for sid in steps if indegree[sid] == 0]
744
+ heapq.heapify(ready)
745
+ order: list[str] = []
746
+ while ready:
747
+ _, sid = heapq.heappop(ready)
748
+ order.append(sid)
749
+ for effect in sorted(effects_of[sid], key=lambda item: rank[item]):
750
+ indegree[effect] -= 1
751
+ if indegree[effect] == 0:
752
+ heapq.heappush(ready, (rank[effect], effect))
753
+
754
+ if len(order) != len(steps):
755
+ stuck = sorted(s for s in steps if indegree[s] > 0)
756
+ raise ValueError(
757
+ f"cycle in LangGraph checkpoint parentage involving {stuck[:5]}; "
758
+ "checkpoint causality must be acyclic"
759
+ )
760
+ return {sid: i for i, sid in enumerate(order)}
761
+
762
+ @staticmethod
763
+ def _branch_target(parent_key: _CheckpointKey | None, child_key: _CheckpointKey) -> str | None:
764
+ if parent_key is None:
765
+ return None
766
+ parent_ns, _ = parent_key
767
+ child_ns, _ = child_key
768
+ if parent_ns == child_ns:
769
+ return None
770
+ if parent_ns == _ROOT_NS:
771
+ relative_ns = child_ns
772
+ elif child_ns.startswith(f"{parent_ns}|"):
773
+ relative_ns = child_ns[len(parent_ns) + 1:]
774
+ else:
775
+ return None
776
+ next_ns = relative_ns.split("|", 1)[0]
777
+ return next_ns.split(":", 1)[0] or None
778
+
779
+ @staticmethod
780
+ def _producing_node(parent_cp: dict | None, branch_target: str | None = None) -> str | None:
781
+ if not parent_cp:
782
+ return None
783
+ if branch_target is not None:
784
+ channel = f"{_BRANCH_PREFIX}{branch_target}"
785
+ if channel in parent_cp.get("channel_values", {}):
786
+ return branch_target
787
+ return None
788
+ for channel in parent_cp.get("channel_values", {}):
789
+ if channel.startswith(_BRANCH_PREFIX):
790
+ return channel[len(_BRANCH_PREFIX):]
791
+ return None
792
+
793
+ def _error_introduced(self, cp: dict, parent_cps: list[dict]) -> str | None:
794
+ """Return the error message iff this checkpoint is where the error first appears."""
795
+ current = cp.get("channel_values", {}).get(self._error_channel)
796
+ if not current:
797
+ return None
798
+ previous = any(
799
+ parent_cp.get("channel_values", {}).get(self._error_channel)
800
+ for parent_cp in parent_cps
801
+ )
802
+ if previous: # error was already present upstream -> not introduced here
803
+ return None
804
+ return str(current)