langgraph-ledger 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.
- langgraph_ledger/__init__.py +39 -0
- langgraph_ledger/__main__.py +6 -0
- langgraph_ledger/analysis.py +113 -0
- langgraph_ledger/callbacks.py +221 -0
- langgraph_ledger/checkpointer.py +213 -0
- langgraph_ledger/cli.py +71 -0
- langgraph_ledger/dag.py +155 -0
- langgraph_ledger/events.py +161 -0
- langgraph_ledger/hashing.py +76 -0
- langgraph_ledger/py.typed +0 -0
- langgraph_ledger/recorder.py +240 -0
- langgraph_ledger/repair.py +70 -0
- langgraph_ledger/replay.py +83 -0
- langgraph_ledger/rollback.py +124 -0
- langgraph_ledger/verify.py +148 -0
- langgraph_ledger-0.2.0.dist-info/METADATA +162 -0
- langgraph_ledger-0.2.0.dist-info/RECORD +21 -0
- langgraph_ledger-0.2.0.dist-info/WHEEL +5 -0
- langgraph_ledger-0.2.0.dist-info/entry_points.txt +2 -0
- langgraph_ledger-0.2.0.dist-info/licenses/LICENSE +21 -0
- langgraph_ledger-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Public API of langgraph-ledger."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .analysis import analyze_log
|
|
6
|
+
from .callbacks import DshTraceCallbackHandler
|
|
7
|
+
from .checkpointer import TracingCheckpointSaver
|
|
8
|
+
from .dag import RunDAG, build_dag, build_dag_from_file
|
|
9
|
+
from .events import EVENT_KINDS, FORMAT_VERSION
|
|
10
|
+
from .hashing import (canonical_json, checkpoint_label, event_id, sha256_hex,
|
|
11
|
+
tool_call_label)
|
|
12
|
+
from .recorder import (TracePayloadError, TraceRecorder, active_recorder,
|
|
13
|
+
bind_recorder, current_recorder, emit_event, read_log,
|
|
14
|
+
recorder_for)
|
|
15
|
+
from .repair import close_orphaned_run, find_orphaned_runs, repair_all
|
|
16
|
+
from .replay import ReplayError, replay_messages
|
|
17
|
+
from .rollback import find_checkpoint_by_label, fork_thread, time_travel_config
|
|
18
|
+
from .verify import VerifyReport, verify_log, verify_thread
|
|
19
|
+
|
|
20
|
+
__version__ = "0.2.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
# recording
|
|
25
|
+
"TraceRecorder", "TracePayloadError", "bind_recorder", "current_recorder",
|
|
26
|
+
"active_recorder", "emit_event", "read_log", "recorder_for",
|
|
27
|
+
# integration
|
|
28
|
+
"TracingCheckpointSaver", "DshTraceCallbackHandler",
|
|
29
|
+
# labels / hashing
|
|
30
|
+
"canonical_json", "sha256_hex", "event_id", "tool_call_label",
|
|
31
|
+
"checkpoint_label", "EVENT_KINDS", "FORMAT_VERSION",
|
|
32
|
+
# dag / rollback / audit
|
|
33
|
+
"RunDAG", "build_dag", "build_dag_from_file",
|
|
34
|
+
"fork_thread", "time_travel_config", "find_checkpoint_by_label",
|
|
35
|
+
"verify_log", "verify_thread", "VerifyReport", "analyze_log",
|
|
36
|
+
# crash recovery / replay
|
|
37
|
+
"find_orphaned_runs", "close_orphaned_run", "repair_all",
|
|
38
|
+
"replay_messages", "ReplayError",
|
|
39
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Failure analysis over a recorded log.
|
|
3
|
+
|
|
4
|
+
Because every tool call carries a content-addressed label, exact-repeat loops
|
|
5
|
+
(the agent calling the same tool with the same input over and over) fall out
|
|
6
|
+
for free. Combined with error events and node timings this gives a compact
|
|
7
|
+
first-stop failure report:
|
|
8
|
+
|
|
9
|
+
python -m langgraph_ledger analyze <log.jsonl>
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import events as ev
|
|
17
|
+
from .recorder import read_log
|
|
18
|
+
|
|
19
|
+
__all__ = ["analyze_log"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def analyze_log(path: str | Path) -> dict[str, Any]:
|
|
23
|
+
"""Summarize one thread log: counts, errors, loops, timeline, checkpoints."""
|
|
24
|
+
by_kind: dict[str, int] = {}
|
|
25
|
+
errors: list[dict[str, Any]] = []
|
|
26
|
+
tool_labels: dict[str, dict[str, Any]] = {}
|
|
27
|
+
checkpoints = 0
|
|
28
|
+
interrupted_runs = 0
|
|
29
|
+
models: dict[str, int] = {}
|
|
30
|
+
node_ms: dict[str, int] = {}
|
|
31
|
+
first_ts: str | None = None
|
|
32
|
+
last_ts: str | None = None
|
|
33
|
+
unknown: dict[str, int] = {}
|
|
34
|
+
|
|
35
|
+
prev_call_label: str | None = None
|
|
36
|
+
streaks: list[dict[str, Any]] = []
|
|
37
|
+
streak: dict[str, Any] | None = None
|
|
38
|
+
|
|
39
|
+
for e in read_log(path):
|
|
40
|
+
kind = str(e.get("kind") or "")
|
|
41
|
+
payload = e.get("payload") or {}
|
|
42
|
+
seq = e.get("seq")
|
|
43
|
+
first_ts = first_ts or e.get("ts")
|
|
44
|
+
last_ts = e.get("ts") or last_ts
|
|
45
|
+
if kind in ev.EVENT_KINDS:
|
|
46
|
+
by_kind[kind] = by_kind.get(kind, 0) + 1
|
|
47
|
+
else:
|
|
48
|
+
unknown[kind] = unknown.get(kind, 0) + 1
|
|
49
|
+
|
|
50
|
+
if kind == ev.KIND_TOOL_CALL:
|
|
51
|
+
label = str(payload.get("label") or "")
|
|
52
|
+
name = str(payload.get("name") or "")
|
|
53
|
+
slot = tool_labels.setdefault(label, {"name": name, "count": 0, "seqs": []})
|
|
54
|
+
slot["count"] += 1
|
|
55
|
+
slot["seqs"].append(seq)
|
|
56
|
+
if label == prev_call_label:
|
|
57
|
+
if streak is None:
|
|
58
|
+
streak = {"label": label, "name": name, "start_seq": seq, "length": 2}
|
|
59
|
+
else:
|
|
60
|
+
streak["length"] += 1
|
|
61
|
+
else:
|
|
62
|
+
if streak is not None:
|
|
63
|
+
streaks.append(streak)
|
|
64
|
+
streak = None
|
|
65
|
+
prev_call_label = label
|
|
66
|
+
elif kind == ev.KIND_TOOL_RESULT:
|
|
67
|
+
if not payload.get("ok"):
|
|
68
|
+
errors.append({"seq": seq, "kind": kind,
|
|
69
|
+
"name": payload.get("name"),
|
|
70
|
+
"error": payload.get("error")})
|
|
71
|
+
elif kind == ev.KIND_LLM_CALL and payload.get("error"):
|
|
72
|
+
errors.append({"seq": seq, "kind": kind, "node": payload.get("node"),
|
|
73
|
+
"error": payload.get("error")})
|
|
74
|
+
if kind == ev.KIND_LLM_CALL and payload.get("model"):
|
|
75
|
+
m = str(payload["model"])
|
|
76
|
+
models[m] = models.get(m, 0) + 1
|
|
77
|
+
elif kind == ev.KIND_NODE_END and not payload.get("ok", True):
|
|
78
|
+
errors.append({"seq": seq, "kind": kind, "run_id": payload.get("run_id"),
|
|
79
|
+
"error": payload.get("error")})
|
|
80
|
+
if kind == ev.KIND_NODE_END and payload.get("node"):
|
|
81
|
+
node = str(payload["node"])
|
|
82
|
+
node_ms[node] = node_ms.get(node, 0) + int(payload.get("ms") or 0)
|
|
83
|
+
elif kind == ev.KIND_RUN_END and payload.get("status") == "interrupted":
|
|
84
|
+
interrupted_runs += 1
|
|
85
|
+
elif kind == ev.KIND_ERROR:
|
|
86
|
+
errors.append({"seq": seq, "kind": kind, "where": payload.get("where"),
|
|
87
|
+
"error": payload.get("error")})
|
|
88
|
+
elif kind == ev.KIND_STATE_SNAPSHOT:
|
|
89
|
+
checkpoints += 1
|
|
90
|
+
prev_call_label = None
|
|
91
|
+
|
|
92
|
+
if streak is not None:
|
|
93
|
+
streaks.append(streak)
|
|
94
|
+
|
|
95
|
+
loops = [{"label": k, "name": v["name"], "count": v["count"], "seqs": v["seqs"]}
|
|
96
|
+
for k, v in tool_labels.items() if v["count"] > 1]
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"log": str(path),
|
|
100
|
+
"events": sum(by_kind.values()) + sum(unknown.values()),
|
|
101
|
+
"by_kind": by_kind,
|
|
102
|
+
"unknown_kinds": unknown,
|
|
103
|
+
"checkpoints": checkpoints,
|
|
104
|
+
"interrupted_runs": interrupted_runs,
|
|
105
|
+
"models": models,
|
|
106
|
+
"node_time_ms": node_ms,
|
|
107
|
+
"errors": errors,
|
|
108
|
+
"error_count": len(errors),
|
|
109
|
+
"repeated_tool_calls": loops,
|
|
110
|
+
"consecutive_repeat_streaks": streaks,
|
|
111
|
+
"first_ts": first_ts,
|
|
112
|
+
"last_ts": last_ts,
|
|
113
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""LangChain/LangGraph callback handler that feeds the trace recorder.
|
|
3
|
+
|
|
4
|
+
Attach to any LangGraph run:
|
|
5
|
+
|
|
6
|
+
graph.invoke(input, config={**config, "callbacks": [DshTraceCallbackHandler()]})
|
|
7
|
+
|
|
8
|
+
LangChain propagates callbacks into every node, LLM call and tool call, so a
|
|
9
|
+
single handler sees the whole run. Node boundaries are recognized through
|
|
10
|
+
LangGraph's ``langgraph_node`` metadata; LLM/tool activity is paired by run id
|
|
11
|
+
— the tool *result* carries the same content label as its call, not just a
|
|
12
|
+
run-id echo.
|
|
13
|
+
|
|
14
|
+
``record_full=True`` opts into persisting full prompt/response/input/output
|
|
15
|
+
text alongside the digests (required for replay). Default off: digests only.
|
|
16
|
+
|
|
17
|
+
All emit calls are fail-soft unless the recorder is strict: tracing never
|
|
18
|
+
breaks the run.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
from uuid import UUID
|
|
25
|
+
|
|
26
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
27
|
+
|
|
28
|
+
from . import events as ev
|
|
29
|
+
from .hashing import tool_call_label
|
|
30
|
+
from .recorder import current_recorder
|
|
31
|
+
|
|
32
|
+
__all__ = ["DshTraceCallbackHandler"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DshTraceCallbackHandler(BaseCallbackHandler):
|
|
36
|
+
"""Emit dsh-style trace events for node / LLM / tool activity."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, recorder: Any = None, *, record_full: bool = False) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
#: Optional explicit recorder; falls back to the context-bound one.
|
|
41
|
+
self.recorder = recorder
|
|
42
|
+
#: Opt-in: persist full text next to digests (replay prerequisite).
|
|
43
|
+
self.record_full = bool(record_full)
|
|
44
|
+
self._starts: dict[str, float] = {}
|
|
45
|
+
self._llm_open: dict[str, dict[str, Any]] = {}
|
|
46
|
+
#: run_id -> {label, name, node} so the result can echo the call's label
|
|
47
|
+
self._tool_open: dict[str, dict[str, Any]] = {}
|
|
48
|
+
#: run_id -> node name, so node/end keeps its identity
|
|
49
|
+
self._node_open: dict[str, str] = {}
|
|
50
|
+
#: run_id -> {"steps": int} for the top-level graph run (run brackets —
|
|
51
|
+
#: the crash-recovery anchor: an unclosed run/start means "died here")
|
|
52
|
+
self._run_open: dict[str, dict[str, Any]] = {}
|
|
53
|
+
|
|
54
|
+
# -- plumbing --------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _rec(self) -> Any:
|
|
57
|
+
return self.recorder if self.recorder is not None else current_recorder()
|
|
58
|
+
|
|
59
|
+
def _emit(self, kind: str, payload: Any) -> bool:
|
|
60
|
+
rec = self._rec()
|
|
61
|
+
return rec.emit(kind, payload) if rec is not None else False
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _node(metadata: dict | None) -> str:
|
|
65
|
+
return str((metadata or {}).get("langgraph_node") or "")
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _model_of(serialized: dict | None, metadata: dict | None,
|
|
69
|
+
llm_output: dict | None = None) -> str:
|
|
70
|
+
"""Best-effort model identity: langsmith metadata → serialized → llm_output."""
|
|
71
|
+
for source in ((metadata or {}).get("ls_model_name"),
|
|
72
|
+
(serialized or {}).get("name"),
|
|
73
|
+
(serialized or {}).get("id", [None])[-1]
|
|
74
|
+
if isinstance((serialized or {}).get("id"), list) else None,
|
|
75
|
+
(llm_output or {}).get("model_name")):
|
|
76
|
+
if source:
|
|
77
|
+
return str(source)
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
def _mark(self, run_id: UUID) -> None:
|
|
81
|
+
self._starts[str(run_id)] = time.monotonic()
|
|
82
|
+
|
|
83
|
+
def _ms_since(self, run_id: UUID) -> int:
|
|
84
|
+
start = self._starts.pop(str(run_id), None)
|
|
85
|
+
return int((time.monotonic() - start) * 1000) if start is not None else 0
|
|
86
|
+
|
|
87
|
+
# -- node boundaries (LangGraph chain runs carrying langgraph_node) ---------
|
|
88
|
+
|
|
89
|
+
def on_chain_start(self, serialized: dict | None, inputs: Any, *,
|
|
90
|
+
run_id: UUID, parent_run_id: UUID | None = None,
|
|
91
|
+
metadata: dict | None = None, **kwargs: Any) -> None:
|
|
92
|
+
node = self._node(metadata)
|
|
93
|
+
if not node:
|
|
94
|
+
if parent_run_id is None:
|
|
95
|
+
# the top-level graph invocation — open a run bracket
|
|
96
|
+
self._mark(run_id)
|
|
97
|
+
self._run_open[str(run_id)] = {"steps": 0}
|
|
98
|
+
self._emit(ev.KIND_RUN_START, ev.run_start_payload(
|
|
99
|
+
graph=str(kwargs.get("name") or "")))
|
|
100
|
+
return
|
|
101
|
+
self._mark(run_id)
|
|
102
|
+
self._node_open[str(run_id)] = node
|
|
103
|
+
self._emit(ev.KIND_NODE_START, ev.node_boundary_payload(node=node, run_id=str(run_id)))
|
|
104
|
+
|
|
105
|
+
def _close_run(self, run_id: UUID, *, status: str, error: str = "") -> None:
|
|
106
|
+
opened = self._run_open.pop(str(run_id), None)
|
|
107
|
+
if opened is None:
|
|
108
|
+
return
|
|
109
|
+
ms = self._ms_since(run_id)
|
|
110
|
+
self._emit(ev.KIND_RUN_END, ev.run_end_payload(
|
|
111
|
+
status=status, error=error, steps=opened["steps"], ms=ms))
|
|
112
|
+
|
|
113
|
+
def _close_node(self, run_id: UUID, *, ok: bool, error: str = "") -> None:
|
|
114
|
+
node = self._node_open.pop(str(run_id), None)
|
|
115
|
+
if node is None:
|
|
116
|
+
return
|
|
117
|
+
ms = self._ms_since(run_id)
|
|
118
|
+
payload = {**ev.node_boundary_payload(node=node, run_id=str(run_id)),
|
|
119
|
+
"ms": ms, "ok": ok}
|
|
120
|
+
if error:
|
|
121
|
+
payload["error"] = error
|
|
122
|
+
self._emit(ev.KIND_NODE_END, payload)
|
|
123
|
+
for opened in self._run_open.values():
|
|
124
|
+
opened["steps"] += 1
|
|
125
|
+
|
|
126
|
+
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
127
|
+
if str(run_id) in self._run_open:
|
|
128
|
+
self._close_run(run_id, status="completed")
|
|
129
|
+
return
|
|
130
|
+
self._close_node(run_id, ok=True)
|
|
131
|
+
|
|
132
|
+
def on_chain_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
133
|
+
if str(run_id) in self._run_open:
|
|
134
|
+
self._close_run(run_id, status="error",
|
|
135
|
+
error=f"{type(error).__name__}: {error}")
|
|
136
|
+
return
|
|
137
|
+
self._close_node(run_id, ok=False, error=f"{type(error).__name__}: {error}")
|
|
138
|
+
|
|
139
|
+
# -- LLM calls ----------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def on_llm_start(self, serialized: dict | None, prompts: list[str], *,
|
|
142
|
+
run_id: UUID, metadata: dict | None = None, **kwargs: Any) -> None:
|
|
143
|
+
self._mark(run_id)
|
|
144
|
+
self._llm_open[str(run_id)] = {
|
|
145
|
+
"text": "\n".join(prompts),
|
|
146
|
+
"model": self._model_of(serialized, metadata),
|
|
147
|
+
"node": self._node(metadata),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def on_chat_model_start(self, serialized: dict | None, messages: Any, *,
|
|
151
|
+
run_id: UUID, metadata: dict | None = None, **kwargs: Any) -> None:
|
|
152
|
+
self._mark(run_id)
|
|
153
|
+
flat = "\n".join(f"{getattr(m, 'type', '?')}: {getattr(m, 'content', m)}"
|
|
154
|
+
for batch in (messages or []) for m in (batch or []))
|
|
155
|
+
self._llm_open[str(run_id)] = {
|
|
156
|
+
"text": flat,
|
|
157
|
+
"model": self._model_of(serialized, metadata),
|
|
158
|
+
"node": self._node(metadata),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
162
|
+
ms = self._ms_since(run_id)
|
|
163
|
+
info = self._llm_open.pop(str(run_id), {})
|
|
164
|
+
text = ""
|
|
165
|
+
usage = None
|
|
166
|
+
try:
|
|
167
|
+
text = "\n".join(getattr(g, "text", "") or str(getattr(g, "message", ""))
|
|
168
|
+
for gen in (response.generations or [])
|
|
169
|
+
for g in (gen or []))
|
|
170
|
+
usage = (response.llm_output or {}).get("token_usage")
|
|
171
|
+
if not info.get("model"):
|
|
172
|
+
info["model"] = self._model_of(None, None, response.llm_output)
|
|
173
|
+
except AttributeError:
|
|
174
|
+
pass
|
|
175
|
+
self._emit(ev.KIND_LLM_CALL, ev.llm_call_payload(
|
|
176
|
+
node=info.get("node", ""), model=info.get("model", ""),
|
|
177
|
+
prompt=info.get("text", ""), response=text, ms=ms, usage=usage,
|
|
178
|
+
record_full=self.record_full))
|
|
179
|
+
|
|
180
|
+
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
181
|
+
ms = self._ms_since(run_id)
|
|
182
|
+
info = self._llm_open.pop(str(run_id), {})
|
|
183
|
+
self._emit(ev.KIND_LLM_CALL, ev.llm_call_payload(
|
|
184
|
+
node=info.get("node", ""), model=info.get("model", ""),
|
|
185
|
+
prompt=info.get("text", ""), response="", ms=ms,
|
|
186
|
+
error=f"{type(error).__name__}: {error}",
|
|
187
|
+
record_full=self.record_full))
|
|
188
|
+
|
|
189
|
+
# -- tool calls (hash-labeled; result echoes the call's label) -----------------
|
|
190
|
+
|
|
191
|
+
def on_tool_start(self, serialized: dict | None, input_str: str, *,
|
|
192
|
+
run_id: UUID, parent_run_id: UUID | None = None,
|
|
193
|
+
metadata: dict | None = None, **kwargs: Any) -> None:
|
|
194
|
+
self._mark(run_id)
|
|
195
|
+
name = str((serialized or {}).get("name") or "")
|
|
196
|
+
tool_input = kwargs.get("inputs", input_str)
|
|
197
|
+
canonical_input = tool_input if isinstance(tool_input, (dict, list)) else input_str
|
|
198
|
+
label = tool_call_label(name, canonical_input)
|
|
199
|
+
self._tool_open[str(run_id)] = {"label": label, "name": name,
|
|
200
|
+
"node": self._node(metadata)}
|
|
201
|
+
self._emit(ev.KIND_TOOL_CALL, ev.tool_call_payload(
|
|
202
|
+
label=label, node=self._node(metadata), name=name,
|
|
203
|
+
tool_input=canonical_input, run_id=str(run_id),
|
|
204
|
+
parent_run_id=str(parent_run_id) if parent_run_id else "",
|
|
205
|
+
record_full=self.record_full))
|
|
206
|
+
|
|
207
|
+
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
208
|
+
ms = self._ms_since(run_id)
|
|
209
|
+
opened = self._tool_open.pop(str(run_id), {})
|
|
210
|
+
self._emit(ev.KIND_TOOL_RESULT, ev.tool_result_payload(
|
|
211
|
+
label=opened.get("label", ""), name=opened.get("name", ""),
|
|
212
|
+
ok=True, ms=ms, output=output, run_id=str(run_id),
|
|
213
|
+
record_full=self.record_full))
|
|
214
|
+
|
|
215
|
+
def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
216
|
+
ms = self._ms_since(run_id)
|
|
217
|
+
opened = self._tool_open.pop(str(run_id), {})
|
|
218
|
+
self._emit(ev.KIND_TOOL_RESULT, ev.tool_result_payload(
|
|
219
|
+
label=opened.get("label", ""), name=opened.get("name", ""),
|
|
220
|
+
ok=False, ms=ms, error=f"{type(error).__name__}: {error}",
|
|
221
|
+
run_id=str(run_id), record_full=self.record_full))
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""A drop-in wrapper that adds dsh-grade traceability to ANY LangGraph checkpointer.
|
|
3
|
+
|
|
4
|
+
Wraps an existing ``BaseCheckpointSaver`` (in-memory, SQLite, Postgres, Redis,
|
|
5
|
+
…) and, while passing every call through unchanged:
|
|
6
|
+
|
|
7
|
+
- hashes every checkpoint on write (content-addressed ``cp_*`` label + full
|
|
8
|
+
sha256) and records its parent — this is the checkpoint DAG edge;
|
|
9
|
+
- records pending writes per task with digested values;
|
|
10
|
+
- emits all of it into the hash-chained append-only event log.
|
|
11
|
+
|
|
12
|
+
Behavioral contract of the wrapped saver is preserved exactly: return values,
|
|
13
|
+
exceptions and ordering all pass through. Trace failures are fail-soft and
|
|
14
|
+
never reach the caller.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, AsyncIterator, Iterator, Sequence
|
|
20
|
+
|
|
21
|
+
from langgraph.checkpoint.base import (
|
|
22
|
+
BaseCheckpointSaver,
|
|
23
|
+
ChannelProtocol,
|
|
24
|
+
Checkpoint,
|
|
25
|
+
CheckpointMetadata,
|
|
26
|
+
CheckpointTuple,
|
|
27
|
+
RunnableConfig,
|
|
28
|
+
SerializerProtocol,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from . import events as ev
|
|
32
|
+
from .hashing import checkpoint_label, sha256_hex
|
|
33
|
+
from .recorder import TraceRecorder, current_recorder, recorder_for
|
|
34
|
+
|
|
35
|
+
__all__ = ["TracingCheckpointSaver", "normalized_checkpoint_digest"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalized_checkpoint_digest(serde: SerializerProtocol,
|
|
39
|
+
checkpoint: Checkpoint) -> tuple[str, str]:
|
|
40
|
+
"""(full sha256 hex, cp_* label) over the *roundtrip-normalized* checkpoint.
|
|
41
|
+
|
|
42
|
+
The object handed to ``put`` may contain fields that do not survive
|
|
43
|
+
serialization. Hashing ``dumps(loads(dumps(cp)))`` makes the write-time
|
|
44
|
+
digest equal to the read-time digest — the property ``verify_thread``
|
|
45
|
+
relies on to prove no post-hoc drift.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
tag1, blob1 = serde.dumps_typed(checkpoint)
|
|
49
|
+
restored = serde.loads_typed((tag1, blob1))
|
|
50
|
+
tag2, blob2 = serde.dumps_typed(restored)
|
|
51
|
+
if isinstance(blob2, str):
|
|
52
|
+
blob2 = blob2.encode("utf-8")
|
|
53
|
+
return sha256_hex(tag2.encode() + b"\x00" + blob2), checkpoint_label(blob2)
|
|
54
|
+
except Exception: # noqa: BLE001 — digest must never break a write
|
|
55
|
+
return "", ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cfg_ids(config: RunnableConfig) -> tuple[str, str, str | None]:
|
|
59
|
+
conf = (config or {}).get("configurable") or {}
|
|
60
|
+
return (str(conf.get("thread_id") or "anonymous"),
|
|
61
|
+
str(conf.get("checkpoint_ns") or ""),
|
|
62
|
+
conf.get("checkpoint_id"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TracingCheckpointSaver(BaseCheckpointSaver):
|
|
66
|
+
"""Wrap any BaseCheckpointSaver with hash-labeled, hash-chained tracing."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, inner: BaseCheckpointSaver, *,
|
|
69
|
+
trace_root: str | Path,
|
|
70
|
+
recorder: TraceRecorder | None = None,
|
|
71
|
+
enabled: bool = True) -> None:
|
|
72
|
+
super().__init__(serde=inner.serde)
|
|
73
|
+
self.inner = inner
|
|
74
|
+
self.trace_root = Path(trace_root)
|
|
75
|
+
self._default_recorder = recorder
|
|
76
|
+
self.enabled = bool(enabled)
|
|
77
|
+
|
|
78
|
+
# -- recorder resolution ------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def recorder_for(self, thread_id: str) -> TraceRecorder:
|
|
81
|
+
return recorder_for(self.trace_root, thread_id, enabled=self.enabled)
|
|
82
|
+
|
|
83
|
+
def _rec_for(self, thread_id: str) -> TraceRecorder | None:
|
|
84
|
+
bound = self._default_recorder or current_recorder()
|
|
85
|
+
if bound is not None:
|
|
86
|
+
return bound if bound.enabled else None
|
|
87
|
+
if not self.enabled:
|
|
88
|
+
return None
|
|
89
|
+
return self.recorder_for(thread_id)
|
|
90
|
+
|
|
91
|
+
# -- checkpoint hashing ---------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _checkpoint_digest(self, checkpoint: Checkpoint) -> tuple[str, str]:
|
|
94
|
+
"""(full sha256 hex, short cp_* label) — roundtrip-normalized."""
|
|
95
|
+
return normalized_checkpoint_digest(self.serde, checkpoint)
|
|
96
|
+
|
|
97
|
+
def _emit_snapshot(self, out_config: RunnableConfig,
|
|
98
|
+
metadata: CheckpointMetadata) -> None:
|
|
99
|
+
"""Hash the *stored* form: re-read the just-written checkpoint and digest
|
|
100
|
+
its reconstruction — the same object verify_thread will see later."""
|
|
101
|
+
thread_id, _ns, _parent = _cfg_ids(out_config)
|
|
102
|
+
rec = self._rec_for(thread_id)
|
|
103
|
+
if rec is None:
|
|
104
|
+
return
|
|
105
|
+
tup = self.inner.get_tuple(out_config)
|
|
106
|
+
if tup is None:
|
|
107
|
+
return
|
|
108
|
+
full, label = self._checkpoint_digest(tup.checkpoint)
|
|
109
|
+
md = dict(metadata or {})
|
|
110
|
+
parent_id = (tup.parent_config or {}).get("configurable", {}).get("checkpoint_id")
|
|
111
|
+
rec.emit(ev.KIND_STATE_SNAPSHOT, ev.state_snapshot_payload(
|
|
112
|
+
checkpoint_id=str(tup.checkpoint.get("id") or ""),
|
|
113
|
+
checkpoint_sha256=full, label=label,
|
|
114
|
+
parent_checkpoint_id=str(parent_id) if parent_id else None,
|
|
115
|
+
step=md.get("step"), source=str(md.get("source") or "")))
|
|
116
|
+
|
|
117
|
+
# -- pass-through with tracing ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def put(self, config: RunnableConfig, checkpoint: Checkpoint,
|
|
120
|
+
metadata: CheckpointMetadata, new_versions: Any) -> RunnableConfig:
|
|
121
|
+
out = self.inner.put(config, checkpoint, metadata, new_versions)
|
|
122
|
+
try:
|
|
123
|
+
self._emit_snapshot(out, metadata)
|
|
124
|
+
except Exception: # noqa: BLE001 — observation never breaks the run
|
|
125
|
+
pass
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def _trace_writes(self, config: RunnableConfig, writes: Sequence[tuple[str, Any]],
|
|
129
|
+
task_id: str) -> None:
|
|
130
|
+
try:
|
|
131
|
+
thread_id, _ns, checkpoint_id = _cfg_ids(config)
|
|
132
|
+
rec = self._rec_for(thread_id)
|
|
133
|
+
if rec is not None:
|
|
134
|
+
digested = []
|
|
135
|
+
for channel, value in writes:
|
|
136
|
+
try:
|
|
137
|
+
_t, blob = self.serde.dumps_typed(value)
|
|
138
|
+
digest = sha256_hex(blob if isinstance(blob, bytes)
|
|
139
|
+
else str(blob).encode("utf-8"))
|
|
140
|
+
size = len(blob)
|
|
141
|
+
except Exception: # noqa: BLE001
|
|
142
|
+
digest, size = "", 0
|
|
143
|
+
digested.append({"channel": str(channel), "sha256": digest,
|
|
144
|
+
"size": int(size)})
|
|
145
|
+
rec.emit(ev.KIND_CHECKPOINT_WRITES, ev.checkpoint_writes_payload(
|
|
146
|
+
checkpoint_id=str(checkpoint_id) if checkpoint_id else None,
|
|
147
|
+
task_id=task_id, writes=digested))
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def put_writes(self, config: RunnableConfig, writes: Sequence[tuple[str, Any]],
|
|
152
|
+
task_id: str, task_path: str = "") -> None:
|
|
153
|
+
self.inner.put_writes(config, writes, task_id, task_path)
|
|
154
|
+
self._trace_writes(config, writes, task_id)
|
|
155
|
+
|
|
156
|
+
# -- pure delegation ---------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
159
|
+
return self.inner.get_tuple(config)
|
|
160
|
+
|
|
161
|
+
def list(self, config: RunnableConfig | None, *,
|
|
162
|
+
filter: dict | None = None, before: RunnableConfig | None = None,
|
|
163
|
+
limit: int | None = None) -> Iterator[CheckpointTuple]:
|
|
164
|
+
yield from self.inner.list(config, filter=filter, before=before, limit=limit)
|
|
165
|
+
|
|
166
|
+
def delete_thread(self, thread_id: str) -> None:
|
|
167
|
+
self.inner.delete_thread(thread_id)
|
|
168
|
+
|
|
169
|
+
# -- async: delegate to the inner saver's async path --------------------------------
|
|
170
|
+
|
|
171
|
+
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
172
|
+
return await self.inner.aget_tuple(config)
|
|
173
|
+
|
|
174
|
+
async def alist(self, config: RunnableConfig | None, *,
|
|
175
|
+
filter: dict | None = None, before: RunnableConfig | None = None,
|
|
176
|
+
limit: int | None = None) -> AsyncIterator[CheckpointTuple]:
|
|
177
|
+
async for tup in self.inner.alist(config, filter=filter, before=before, limit=limit):
|
|
178
|
+
yield tup
|
|
179
|
+
|
|
180
|
+
async def aput(self, config: RunnableConfig, checkpoint: Checkpoint,
|
|
181
|
+
metadata: CheckpointMetadata, new_versions: Any) -> RunnableConfig:
|
|
182
|
+
out = await self.inner.aput(config, checkpoint, metadata, new_versions)
|
|
183
|
+
try:
|
|
184
|
+
tup = await self.inner.aget_tuple(out)
|
|
185
|
+
if tup is not None:
|
|
186
|
+
thread_id, _ns, _p = _cfg_ids(out)
|
|
187
|
+
rec = self._rec_for(thread_id)
|
|
188
|
+
if rec is not None:
|
|
189
|
+
full, label = self._checkpoint_digest(tup.checkpoint)
|
|
190
|
+
md = dict(metadata or {})
|
|
191
|
+
parent_id = (tup.parent_config or {}).get(
|
|
192
|
+
"configurable", {}).get("checkpoint_id")
|
|
193
|
+
rec.emit(ev.KIND_STATE_SNAPSHOT, ev.state_snapshot_payload(
|
|
194
|
+
checkpoint_id=str(tup.checkpoint.get("id") or ""),
|
|
195
|
+
checkpoint_sha256=full, label=label,
|
|
196
|
+
parent_checkpoint_id=str(parent_id) if parent_id else None,
|
|
197
|
+
step=md.get("step"), source=str(md.get("source") or "")))
|
|
198
|
+
except Exception: # noqa: BLE001
|
|
199
|
+
pass
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
async def aput_writes(self, config: RunnableConfig, writes: Sequence[tuple[str, Any]],
|
|
203
|
+
task_id: str, task_path: str = "") -> None:
|
|
204
|
+
await self.inner.aput_writes(config, writes, task_id, task_path)
|
|
205
|
+
self._trace_writes(config, writes, task_id)
|
|
206
|
+
|
|
207
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
208
|
+
await self.inner.adelete_thread(thread_id)
|
|
209
|
+
|
|
210
|
+
# -- convenience -----------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def register_channel(self, channel: ChannelProtocol, key: str) -> None:
|
|
213
|
+
self.inner.register_channel(channel, key)
|
langgraph_ledger/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""CLI: verify / analyze / dag / repair / replay a recorded trace log.
|
|
3
|
+
|
|
4
|
+
python -m langgraph_ledger verify <thread.jsonl>
|
|
5
|
+
python -m langgraph_ledger analyze <thread.jsonl>
|
|
6
|
+
python -m langgraph_ledger dag <thread.jsonl> [--mermaid]
|
|
7
|
+
python -m langgraph_ledger repair <thread.jsonl | trace-root/>
|
|
8
|
+
python -m langgraph_ledger replay <thread.jsonl>
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .analysis import analyze_log
|
|
18
|
+
from .dag import build_dag_from_file
|
|
19
|
+
from .repair import close_orphaned_run, repair_all
|
|
20
|
+
from .replay import replay_messages
|
|
21
|
+
from .verify import verify_log
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="langgraph_ledger",
|
|
26
|
+
description=__doc__)
|
|
27
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
for name in ("verify", "analyze", "dag", "replay"):
|
|
29
|
+
p = sub.add_parser(name)
|
|
30
|
+
p.add_argument("log", help="path to a thread .jsonl trace log")
|
|
31
|
+
if name == "dag":
|
|
32
|
+
p.add_argument("--mermaid", action="store_true",
|
|
33
|
+
help="print a Mermaid flowchart instead of JSON")
|
|
34
|
+
p = sub.add_parser("repair", help="close crash-orphaned runs "
|
|
35
|
+
"(a single log or a whole trace-root directory)")
|
|
36
|
+
p.add_argument("target", help="a thread .jsonl log or a trace-root directory")
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
|
|
39
|
+
if args.command == "repair":
|
|
40
|
+
target = Path(args.target)
|
|
41
|
+
if target.is_dir():
|
|
42
|
+
summary = repair_all(target)
|
|
43
|
+
elif target.is_file():
|
|
44
|
+
summary = close_orphaned_run(target)
|
|
45
|
+
else:
|
|
46
|
+
print(f"target not found: {target}", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
if not Path(args.log).is_file():
|
|
52
|
+
print(f"log not found: {args.log}", file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
if args.command == "verify":
|
|
56
|
+
report = verify_log(args.log)
|
|
57
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
58
|
+
return 0 if report else 1
|
|
59
|
+
if args.command == "analyze":
|
|
60
|
+
print(json.dumps(analyze_log(args.log), ensure_ascii=False, indent=2))
|
|
61
|
+
return 0
|
|
62
|
+
if args.command == "replay":
|
|
63
|
+
print(json.dumps(replay_messages(args.log), ensure_ascii=False, indent=2))
|
|
64
|
+
return 0
|
|
65
|
+
dag = build_dag_from_file(args.log)
|
|
66
|
+
print(dag.to_mermaid() if args.mermaid else json.dumps(dag.to_dict(), ensure_ascii=False, indent=2))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main())
|