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.
- agent_tracegraph-0.2.0.dist-info/METADATA +380 -0
- agent_tracegraph-0.2.0.dist-info/RECORD +28 -0
- agent_tracegraph-0.2.0.dist-info/WHEEL +4 -0
- agent_tracegraph-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tracegraph-0.2.0.dist-info/licenses/LICENSE +191 -0
- tracegraph/__init__.py +38 -0
- tracegraph/adapters/__init__.py +13 -0
- tracegraph/adapters/base.py +22 -0
- tracegraph/adapters/langgraph_checkpoint.py +804 -0
- tracegraph/adapters/otlp_spans.py +703 -0
- tracegraph/adapters/phoenix_export.py +218 -0
- tracegraph/analysis/__init__.py +55 -0
- tracegraph/analysis/ahu.py +329 -0
- tracegraph/analysis/diagnose.py +676 -0
- tracegraph/analysis/explain.py +46 -0
- tracegraph/analysis/patterns.py +628 -0
- tracegraph/artifact.py +192 -0
- tracegraph/cli.py +1242 -0
- tracegraph/input_validation.py +19 -0
- tracegraph/model.py +225 -0
- tracegraph/normalize.py +248 -0
- tracegraph/py.typed +0 -0
- tracegraph/review_candidates.py +136 -0
- tracegraph/sqlite_snapshot.py +65 -0
- tracegraph/store/__init__.py +22 -0
- tracegraph/store/base.py +43 -0
- tracegraph/store/in_memory.py +94 -0
- tracegraph/store/ladybug.py +505 -0
|
@@ -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)
|