jep-langgraph-adapter 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jep_langgraph_adapter-0.1.1/PKG-INFO +88 -0
- jep_langgraph_adapter-0.1.1/README.md +74 -0
- jep_langgraph_adapter-0.1.1/pyproject.toml +27 -0
- jep_langgraph_adapter-0.1.1/setup.cfg +4 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/__init__.py +20 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/adapter.py +64 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/canonicalization.py +45 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/cli.py +34 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/events.py +85 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/exporter.py +77 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/middleware.py +290 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter/tracer.py +184 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter.egg-info/PKG-INFO +88 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter.egg-info/SOURCES.txt +18 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter.egg-info/dependency_links.txt +1 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter.egg-info/entry_points.txt +2 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter.egg-info/requires.txt +6 -0
- jep_langgraph_adapter-0.1.1/src/jep_langgraph_adapter.egg-info/top_level.txt +1 -0
- jep_langgraph_adapter-0.1.1/tests/test_adapter.py +58 -0
- jep_langgraph_adapter-0.1.1/tests/test_execution_failures.py +58 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jep-langgraph-adapter
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: JEP runtime adapter for LangGraph: replayable delegation and verifiable AI accountability semantics.
|
|
5
|
+
Author: JEP
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: jep,langgraph,accountability,replay,agent
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Provides-Extra: langgraph
|
|
13
|
+
Requires-Dist: langgraph>=0.2; extra == "langgraph"
|
|
14
|
+
|
|
15
|
+
# jep-langgraph-adapter
|
|
16
|
+
|
|
17
|
+
JEP runtime adapter for LangGraph: replayable delegation and verifiable AI accountability semantics.
|
|
18
|
+
|
|
19
|
+
This package instruments LangGraph-style node callables without modifying LangGraph core. Wrapped node execution naturally emits a deterministic, hash-linked JEP accountability chain containing:
|
|
20
|
+
|
|
21
|
+
- Judgment Event
|
|
22
|
+
- Delegation Event
|
|
23
|
+
- Termination Event
|
|
24
|
+
- Verification Event
|
|
25
|
+
|
|
26
|
+
Each event records `node_name`, `agent_id`, `tool_name`, `state_transition`, `authority_scope`, and `previous_event_hash`.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
LangGraph is optional for tests and examples because the adapter works at the callable-node boundary:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e '.[langgraph]'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from jep_langgraph_adapter import LangGraphEventAdapter
|
|
44
|
+
|
|
45
|
+
adapter = LangGraphEventAdapter(
|
|
46
|
+
session_id="demo-session",
|
|
47
|
+
agent_id="planner-agent",
|
|
48
|
+
authority_scope={"tools": ["search"], "max_steps": 3},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def plan(state):
|
|
52
|
+
return {**state, "plan": "call search"}
|
|
53
|
+
|
|
54
|
+
wrapped_plan = adapter.wrap_node(plan, node_name="plan")
|
|
55
|
+
next_state = wrapped_plan({"question": "What changed?"})
|
|
56
|
+
adapter.exporter().export_jsonl("session.jsonl")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For a `StateGraph`, add wrapped nodes through `instrument_state_graph`:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
adapter.instrument_state_graph(graph, {"plan": plan, "answer": answer})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Replay CLI
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
jep-langgraph replay session.jsonl
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The replay command validates deterministic event hashes, sequence numbers, and `previous_event_hash` links.
|
|
72
|
+
|
|
73
|
+
## Components
|
|
74
|
+
|
|
75
|
+
- `JEPNodeMiddleware`: wraps sync or async LangGraph node callables.
|
|
76
|
+
- `JEPExecutionTracer`: records judgment, delegation, termination, and verification events.
|
|
77
|
+
- `JEPReplayExporter`: exports JSON/JSONL and validates replay chains.
|
|
78
|
+
- `LangGraphEventAdapter`: high-level facade for node and graph instrumentation.
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
- `examples/multi_step_graph.py`: multi-step graph execution.
|
|
83
|
+
- `examples/sub_agent_delegation.py`: sub-agent delegation chain.
|
|
84
|
+
- `examples/tool_invocation_replay.py`: tool invocation verification and replay.
|
|
85
|
+
|
|
86
|
+
## Runtime and verification notes
|
|
87
|
+
|
|
88
|
+
See [HARDENING.md](HARDENING.md) for supported behavior, regression checks, and compatibility boundaries.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# jep-langgraph-adapter
|
|
2
|
+
|
|
3
|
+
JEP runtime adapter for LangGraph: replayable delegation and verifiable AI accountability semantics.
|
|
4
|
+
|
|
5
|
+
This package instruments LangGraph-style node callables without modifying LangGraph core. Wrapped node execution naturally emits a deterministic, hash-linked JEP accountability chain containing:
|
|
6
|
+
|
|
7
|
+
- Judgment Event
|
|
8
|
+
- Delegation Event
|
|
9
|
+
- Termination Event
|
|
10
|
+
- Verification Event
|
|
11
|
+
|
|
12
|
+
Each event records `node_name`, `agent_id`, `tool_name`, `state_transition`, `authority_scope`, and `previous_event_hash`.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
LangGraph is optional for tests and examples because the adapter works at the callable-node boundary:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e '.[langgraph]'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from jep_langgraph_adapter import LangGraphEventAdapter
|
|
30
|
+
|
|
31
|
+
adapter = LangGraphEventAdapter(
|
|
32
|
+
session_id="demo-session",
|
|
33
|
+
agent_id="planner-agent",
|
|
34
|
+
authority_scope={"tools": ["search"], "max_steps": 3},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def plan(state):
|
|
38
|
+
return {**state, "plan": "call search"}
|
|
39
|
+
|
|
40
|
+
wrapped_plan = adapter.wrap_node(plan, node_name="plan")
|
|
41
|
+
next_state = wrapped_plan({"question": "What changed?"})
|
|
42
|
+
adapter.exporter().export_jsonl("session.jsonl")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For a `StateGraph`, add wrapped nodes through `instrument_state_graph`:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
adapter.instrument_state_graph(graph, {"plan": plan, "answer": answer})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Replay CLI
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
jep-langgraph replay session.jsonl
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The replay command validates deterministic event hashes, sequence numbers, and `previous_event_hash` links.
|
|
58
|
+
|
|
59
|
+
## Components
|
|
60
|
+
|
|
61
|
+
- `JEPNodeMiddleware`: wraps sync or async LangGraph node callables.
|
|
62
|
+
- `JEPExecutionTracer`: records judgment, delegation, termination, and verification events.
|
|
63
|
+
- `JEPReplayExporter`: exports JSON/JSONL and validates replay chains.
|
|
64
|
+
- `LangGraphEventAdapter`: high-level facade for node and graph instrumentation.
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
- `examples/multi_step_graph.py`: multi-step graph execution.
|
|
69
|
+
- `examples/sub_agent_delegation.py`: sub-agent delegation chain.
|
|
70
|
+
- `examples/tool_invocation_replay.py`: tool invocation verification and replay.
|
|
71
|
+
|
|
72
|
+
## Runtime and verification notes
|
|
73
|
+
|
|
74
|
+
See [HARDENING.md](HARDENING.md) for supported behavior, regression checks, and compatibility boundaries.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jep-langgraph-adapter"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "JEP runtime adapter for LangGraph: replayable delegation and verifiable AI accountability semantics."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [{ name = "JEP" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = ["jep", "langgraph", "accountability", "replay", "agent"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=7.0"]
|
|
18
|
+
langgraph = ["langgraph>=0.2"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
jep-langgraph = "jep_langgraph_adapter.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""LangGraph adapter for JEP accountability event chains."""
|
|
2
|
+
|
|
3
|
+
from .adapter import LangGraphEventAdapter
|
|
4
|
+
from .canonicalization import canonicalize, canonical_hash
|
|
5
|
+
from .events import JEPEvent, JEPEventType
|
|
6
|
+
from .exporter import JEPReplayExporter, ReplayResult
|
|
7
|
+
from .middleware import JEPNodeMiddleware
|
|
8
|
+
from .tracer import JEPExecutionTracer
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"JEPEvent",
|
|
12
|
+
"JEPEventType",
|
|
13
|
+
"JEPExecutionTracer",
|
|
14
|
+
"JEPNodeMiddleware",
|
|
15
|
+
"JEPReplayExporter",
|
|
16
|
+
"LangGraphEventAdapter",
|
|
17
|
+
"ReplayResult",
|
|
18
|
+
"canonical_hash",
|
|
19
|
+
"canonicalize",
|
|
20
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Public LangGraph adapter facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Callable, Dict, Mapping, MutableMapping, Optional
|
|
7
|
+
|
|
8
|
+
from .exporter import JEPReplayExporter
|
|
9
|
+
from .middleware import JEPNodeMiddleware
|
|
10
|
+
from .tracer import JEPExecutionTracer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LangGraphEventAdapter:
|
|
14
|
+
"""Facade for instrumenting LangGraph nodes without patching LangGraph core."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
session_id: Optional[str] = None,
|
|
20
|
+
agent_id: str = "langgraph-agent",
|
|
21
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
22
|
+
verifier: Optional[Callable[[Any], bool]] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.tracer = JEPExecutionTracer(
|
|
25
|
+
session_id=session_id or str(uuid.uuid4()),
|
|
26
|
+
default_agent_id=agent_id,
|
|
27
|
+
default_authority_scope=dict(authority_scope or {}),
|
|
28
|
+
)
|
|
29
|
+
self.middleware = JEPNodeMiddleware(
|
|
30
|
+
self.tracer,
|
|
31
|
+
agent_id=agent_id,
|
|
32
|
+
authority_scope=authority_scope,
|
|
33
|
+
verifier=verifier,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def events(self):
|
|
38
|
+
return self.tracer.events
|
|
39
|
+
|
|
40
|
+
def wrap_node(self, node: Callable[..., Any], **kwargs: Any) -> Callable[..., Any]:
|
|
41
|
+
return self.middleware.wrap_node(node, **kwargs)
|
|
42
|
+
|
|
43
|
+
def wrap_nodes(self, nodes: Mapping[str, Callable[..., Any]], **defaults: Any) -> Dict[str, Callable[..., Any]]:
|
|
44
|
+
return {
|
|
45
|
+
name: self.wrap_node(node, node_name=name, **defaults)
|
|
46
|
+
for name, node in nodes.items()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def instrument_state_graph(self, graph: Any, nodes: Mapping[str, Callable[..., Any]], **defaults: Any) -> Any:
|
|
50
|
+
"""Add wrapped nodes to a LangGraph StateGraph-like object and return the graph."""
|
|
51
|
+
|
|
52
|
+
for name, node in nodes.items():
|
|
53
|
+
graph.add_node(name, self.wrap_node(node, node_name=name, **defaults))
|
|
54
|
+
return graph
|
|
55
|
+
|
|
56
|
+
def instrument_node_mapping(self, mapping: MutableMapping[str, Callable[..., Any]], **defaults: Any) -> MutableMapping[str, Callable[..., Any]]:
|
|
57
|
+
"""Replace callables in a mutable node mapping with wrapped equivalents."""
|
|
58
|
+
|
|
59
|
+
for name, node in list(mapping.items()):
|
|
60
|
+
mapping[name] = self.wrap_node(node, node_name=name, **defaults)
|
|
61
|
+
return mapping
|
|
62
|
+
|
|
63
|
+
def exporter(self) -> JEPReplayExporter:
|
|
64
|
+
return JEPReplayExporter(self.events)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Deterministic canonicalization utilities for replayable JEP events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize(value: Any) -> Any:
|
|
13
|
+
"""Convert Python values into deterministic JSON-compatible structures."""
|
|
14
|
+
|
|
15
|
+
if dataclasses.is_dataclass(value):
|
|
16
|
+
value = dataclasses.asdict(value)
|
|
17
|
+
|
|
18
|
+
if isinstance(value, Mapping):
|
|
19
|
+
return {str(key): _normalize(value[key]) for key in sorted(value, key=lambda item: str(item))}
|
|
20
|
+
|
|
21
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
25
|
+
return [_normalize(item) for item in value]
|
|
26
|
+
|
|
27
|
+
if isinstance(value, (set, frozenset)):
|
|
28
|
+
return sorted((_normalize(item) for item in value), key=lambda item: canonicalize(item))
|
|
29
|
+
|
|
30
|
+
if isinstance(value, (bytes, bytearray)):
|
|
31
|
+
return value.hex()
|
|
32
|
+
|
|
33
|
+
return repr(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def canonicalize(value: Any) -> str:
|
|
37
|
+
"""Return a deterministic JSON representation for hashing and export."""
|
|
38
|
+
|
|
39
|
+
return json.dumps(_normalize(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def canonical_hash(value: Any) -> str:
|
|
43
|
+
"""Return the SHA-256 hash of a canonicalized value."""
|
|
44
|
+
|
|
45
|
+
return hashlib.sha256(canonicalize(value).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Command line interface for the JEP LangGraph adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .exporter import JEPReplayExporter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="jep-langgraph")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
replay = subparsers.add_parser("replay", help="verify and replay a JSONL JEP event chain")
|
|
15
|
+
replay.add_argument("session", help="path to session.jsonl")
|
|
16
|
+
return parser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
args = build_parser().parse_args(argv)
|
|
21
|
+
if args.command == "replay":
|
|
22
|
+
result = JEPReplayExporter.replay_file(args.session)
|
|
23
|
+
if result.valid:
|
|
24
|
+
print(f"Replay valid: {len(result.events)} events")
|
|
25
|
+
return 0
|
|
26
|
+
print("Replay invalid:", file=sys.stderr)
|
|
27
|
+
for error in result.errors:
|
|
28
|
+
print(f"- {error}", file=sys.stderr)
|
|
29
|
+
return 1
|
|
30
|
+
return 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""JEP event model used by the LangGraph adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, Mapping, Optional
|
|
8
|
+
|
|
9
|
+
from .canonicalization import canonical_hash, canonicalize
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JEPEventType(str, Enum):
|
|
13
|
+
"""JEP accountability event types emitted around LangGraph execution."""
|
|
14
|
+
|
|
15
|
+
JUDGMENT = "Judgment Event"
|
|
16
|
+
DELEGATION = "Delegation Event"
|
|
17
|
+
TERMINATION = "Termination Event"
|
|
18
|
+
VERIFICATION = "Verification Event"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class JEPEvent:
|
|
23
|
+
"""A replayable, hash-linked JEP event."""
|
|
24
|
+
|
|
25
|
+
event_type: JEPEventType
|
|
26
|
+
event_id: str
|
|
27
|
+
session_id: str
|
|
28
|
+
sequence: int
|
|
29
|
+
node_name: str
|
|
30
|
+
agent_id: str
|
|
31
|
+
tool_name: Optional[str]
|
|
32
|
+
state_transition: Mapping[str, Any]
|
|
33
|
+
authority_scope: Mapping[str, Any]
|
|
34
|
+
previous_event_hash: Optional[str]
|
|
35
|
+
delegation_parent_hash: Optional[str] = None
|
|
36
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
37
|
+
event_hash: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
def payload(self, include_hash: bool = False) -> Dict[str, Any]:
|
|
40
|
+
data: Dict[str, Any] = {
|
|
41
|
+
"event_type": self.event_type.value,
|
|
42
|
+
"event_id": self.event_id,
|
|
43
|
+
"session_id": self.session_id,
|
|
44
|
+
"sequence": self.sequence,
|
|
45
|
+
"node_name": self.node_name,
|
|
46
|
+
"agent_id": self.agent_id,
|
|
47
|
+
"tool_name": self.tool_name,
|
|
48
|
+
"state_transition": dict(self.state_transition),
|
|
49
|
+
"authority_scope": dict(self.authority_scope),
|
|
50
|
+
"previous_event_hash": self.previous_event_hash,
|
|
51
|
+
"delegation_parent_hash": self.delegation_parent_hash,
|
|
52
|
+
"metadata": dict(self.metadata),
|
|
53
|
+
}
|
|
54
|
+
if include_hash:
|
|
55
|
+
data["event_hash"] = self.hash()
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
def hash(self) -> str:
|
|
59
|
+
return self.event_hash or canonical_hash(self.payload(include_hash=False))
|
|
60
|
+
|
|
61
|
+
def to_json(self) -> str:
|
|
62
|
+
return canonicalize(self.payload(include_hash=True))
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> "JEPEvent":
|
|
66
|
+
event_hash = data.get("event_hash")
|
|
67
|
+
event = cls(
|
|
68
|
+
event_type=JEPEventType(data["event_type"]),
|
|
69
|
+
event_id=str(data["event_id"]),
|
|
70
|
+
session_id=str(data["session_id"]),
|
|
71
|
+
sequence=int(data["sequence"]),
|
|
72
|
+
node_name=str(data["node_name"]),
|
|
73
|
+
agent_id=str(data["agent_id"]),
|
|
74
|
+
tool_name=data.get("tool_name"),
|
|
75
|
+
state_transition=data.get("state_transition", {}),
|
|
76
|
+
authority_scope=data.get("authority_scope", {}),
|
|
77
|
+
previous_event_hash=data.get("previous_event_hash"),
|
|
78
|
+
delegation_parent_hash=data.get("delegation_parent_hash"),
|
|
79
|
+
metadata=data.get("metadata", {}),
|
|
80
|
+
event_hash=event_hash,
|
|
81
|
+
)
|
|
82
|
+
expected = event.hash() if event_hash is None else canonical_hash(event.payload(include_hash=False))
|
|
83
|
+
if event_hash is not None and event_hash != expected:
|
|
84
|
+
raise ValueError(f"event_hash mismatch for {event.event_id}: expected {expected}, got {event_hash}")
|
|
85
|
+
return event
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Replay and export support for JEP event chains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from .events import JEPEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ReplayResult:
|
|
15
|
+
valid: bool
|
|
16
|
+
events: Sequence[JEPEvent]
|
|
17
|
+
errors: Sequence[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JEPReplayExporter:
|
|
21
|
+
"""Export and verify replayable JEP event chains."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, events: Optional[Iterable[JEPEvent]] = None) -> None:
|
|
24
|
+
self.events = list(events or [])
|
|
25
|
+
|
|
26
|
+
def export_jsonl(self, path: str | Path, events: Optional[Iterable[JEPEvent]] = None) -> Path:
|
|
27
|
+
destination = Path(path)
|
|
28
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
selected_events = list(events or self.events)
|
|
30
|
+
with destination.open("w", encoding="utf-8") as handle:
|
|
31
|
+
for event in selected_events:
|
|
32
|
+
handle.write(event.to_json())
|
|
33
|
+
handle.write("\n")
|
|
34
|
+
return destination
|
|
35
|
+
|
|
36
|
+
def export_json(self, path: str | Path, events: Optional[Iterable[JEPEvent]] = None) -> Path:
|
|
37
|
+
destination = Path(path)
|
|
38
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
selected_events = [event.payload(include_hash=True) for event in list(events or self.events)]
|
|
40
|
+
with destination.open("w", encoding="utf-8") as handle:
|
|
41
|
+
json.dump(selected_events, handle, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
42
|
+
return destination
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def load_jsonl(path: str | Path) -> List[JEPEvent]:
|
|
46
|
+
events: List[JEPEvent] = []
|
|
47
|
+
with Path(path).open("r", encoding="utf-8") as handle:
|
|
48
|
+
for line_number, line in enumerate(handle, start=1):
|
|
49
|
+
if not line.strip():
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
events.append(JEPEvent.from_mapping(json.loads(line)))
|
|
53
|
+
except Exception as exc: # noqa: BLE001 - replay should report malformed event lines.
|
|
54
|
+
raise ValueError(f"invalid event on line {line_number}: {exc}") from exc
|
|
55
|
+
return events
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def replay(events: Iterable[JEPEvent]) -> ReplayResult:
|
|
59
|
+
parsed = list(events)
|
|
60
|
+
errors: List[str] = []
|
|
61
|
+
previous_hash = None
|
|
62
|
+
for expected_sequence, event in enumerate(parsed):
|
|
63
|
+
if event.sequence != expected_sequence:
|
|
64
|
+
errors.append(f"sequence mismatch at {event.event_id}: expected {expected_sequence}, got {event.sequence}")
|
|
65
|
+
if event.previous_event_hash != previous_hash:
|
|
66
|
+
errors.append(
|
|
67
|
+
f"previous_event_hash mismatch at {event.event_id}: "
|
|
68
|
+
f"expected {previous_hash}, got {event.previous_event_hash}"
|
|
69
|
+
)
|
|
70
|
+
if event.hash() != event.payload(include_hash=True)["event_hash"]:
|
|
71
|
+
errors.append(f"event_hash mismatch at {event.event_id}")
|
|
72
|
+
previous_hash = event.hash()
|
|
73
|
+
return ReplayResult(valid=not errors, events=parsed, errors=errors)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def replay_file(cls, path: str | Path) -> ReplayResult:
|
|
77
|
+
return cls.replay(cls.load_jsonl(path))
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""LangGraph node middleware that wraps node callables without modifying LangGraph core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import asyncio
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Any, Callable, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from .tracer import JEPExecutionTracer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JEPNodeMiddleware:
|
|
15
|
+
"""Wrap LangGraph node execution and emit JEP events automatically."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
tracer: Optional[JEPExecutionTracer] = None,
|
|
20
|
+
*,
|
|
21
|
+
agent_id: Optional[str] = None,
|
|
22
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
23
|
+
verifier: Optional[Callable[[Any], bool]] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.tracer = tracer or JEPExecutionTracer()
|
|
26
|
+
self.agent_id = agent_id
|
|
27
|
+
self.authority_scope = dict(authority_scope or {})
|
|
28
|
+
self.verifier = verifier
|
|
29
|
+
|
|
30
|
+
def wrap_node(
|
|
31
|
+
self,
|
|
32
|
+
node: Callable[..., Any],
|
|
33
|
+
*,
|
|
34
|
+
node_name: Optional[str] = None,
|
|
35
|
+
agent_id: Optional[str] = None,
|
|
36
|
+
tool_name: Optional[str] = None,
|
|
37
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
38
|
+
is_delegation: bool = False,
|
|
39
|
+
delegated_to: Optional[str] = None,
|
|
40
|
+
) -> Callable[..., Any]:
|
|
41
|
+
"""Return a wrapped node callable that records judgment/termination/verification events."""
|
|
42
|
+
|
|
43
|
+
resolved_node_name = node_name or getattr(
|
|
44
|
+
node, "__name__", node.__class__.__name__
|
|
45
|
+
)
|
|
46
|
+
resolved_agent_id = agent_id or self.agent_id
|
|
47
|
+
resolved_scope = dict(
|
|
48
|
+
authority_scope if authority_scope is not None else self.authority_scope
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if inspect.iscoroutinefunction(node):
|
|
52
|
+
|
|
53
|
+
@wraps(node)
|
|
54
|
+
async def async_wrapper(state: Any, *args: Any, **kwargs: Any) -> Any:
|
|
55
|
+
input_snapshot = deepcopy(state)
|
|
56
|
+
delegation_event = self._maybe_record_delegation(
|
|
57
|
+
is_delegation=is_delegation,
|
|
58
|
+
delegated_to=delegated_to,
|
|
59
|
+
node_name=resolved_node_name,
|
|
60
|
+
agent_id=resolved_agent_id,
|
|
61
|
+
tool_name=tool_name,
|
|
62
|
+
state=state,
|
|
63
|
+
authority_scope=resolved_scope,
|
|
64
|
+
)
|
|
65
|
+
context = (
|
|
66
|
+
self.tracer.delegation_context(delegation_event)
|
|
67
|
+
if delegation_event
|
|
68
|
+
else _NullContext()
|
|
69
|
+
)
|
|
70
|
+
with context:
|
|
71
|
+
self.tracer.record_judgment(
|
|
72
|
+
node_name=resolved_node_name,
|
|
73
|
+
agent_id=resolved_agent_id,
|
|
74
|
+
input_state=input_snapshot,
|
|
75
|
+
authority_scope=resolved_scope,
|
|
76
|
+
metadata={"callable": repr(node)},
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
result = await node(state, *args, **kwargs)
|
|
80
|
+
except BaseException as exc:
|
|
81
|
+
self._record_failure(
|
|
82
|
+
resolved_node_name,
|
|
83
|
+
resolved_agent_id,
|
|
84
|
+
input_snapshot,
|
|
85
|
+
resolved_scope,
|
|
86
|
+
exc,
|
|
87
|
+
)
|
|
88
|
+
raise
|
|
89
|
+
self._record_completion(
|
|
90
|
+
node_name=resolved_node_name,
|
|
91
|
+
agent_id=resolved_agent_id,
|
|
92
|
+
tool_name=tool_name,
|
|
93
|
+
input_state=input_snapshot,
|
|
94
|
+
output_state=result,
|
|
95
|
+
authority_scope=resolved_scope,
|
|
96
|
+
)
|
|
97
|
+
if self.verifier is not None:
|
|
98
|
+
try:
|
|
99
|
+
verified = self.verifier(result)
|
|
100
|
+
if inspect.isawaitable(verified):
|
|
101
|
+
verified = await verified
|
|
102
|
+
self._record_verdict(
|
|
103
|
+
resolved_node_name,
|
|
104
|
+
resolved_agent_id,
|
|
105
|
+
tool_name,
|
|
106
|
+
result,
|
|
107
|
+
resolved_scope,
|
|
108
|
+
verified,
|
|
109
|
+
)
|
|
110
|
+
except BaseException as exc:
|
|
111
|
+
self._record_verdict(
|
|
112
|
+
resolved_node_name,
|
|
113
|
+
resolved_agent_id,
|
|
114
|
+
tool_name,
|
|
115
|
+
result,
|
|
116
|
+
resolved_scope,
|
|
117
|
+
False,
|
|
118
|
+
exc,
|
|
119
|
+
)
|
|
120
|
+
raise
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
return async_wrapper
|
|
124
|
+
|
|
125
|
+
@wraps(node)
|
|
126
|
+
def wrapper(state: Any, *args: Any, **kwargs: Any) -> Any:
|
|
127
|
+
input_snapshot = deepcopy(state)
|
|
128
|
+
delegation_event = self._maybe_record_delegation(
|
|
129
|
+
is_delegation=is_delegation,
|
|
130
|
+
delegated_to=delegated_to,
|
|
131
|
+
node_name=resolved_node_name,
|
|
132
|
+
agent_id=resolved_agent_id,
|
|
133
|
+
tool_name=tool_name,
|
|
134
|
+
state=state,
|
|
135
|
+
authority_scope=resolved_scope,
|
|
136
|
+
)
|
|
137
|
+
context = (
|
|
138
|
+
self.tracer.delegation_context(delegation_event)
|
|
139
|
+
if delegation_event
|
|
140
|
+
else _NullContext()
|
|
141
|
+
)
|
|
142
|
+
with context:
|
|
143
|
+
self.tracer.record_judgment(
|
|
144
|
+
node_name=resolved_node_name,
|
|
145
|
+
agent_id=resolved_agent_id,
|
|
146
|
+
input_state=input_snapshot,
|
|
147
|
+
authority_scope=resolved_scope,
|
|
148
|
+
metadata={"callable": repr(node)},
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
result = node(state, *args, **kwargs)
|
|
152
|
+
except BaseException as exc:
|
|
153
|
+
self._record_failure(
|
|
154
|
+
resolved_node_name,
|
|
155
|
+
resolved_agent_id,
|
|
156
|
+
input_snapshot,
|
|
157
|
+
resolved_scope,
|
|
158
|
+
exc,
|
|
159
|
+
)
|
|
160
|
+
raise
|
|
161
|
+
self._record_completion(
|
|
162
|
+
node_name=resolved_node_name,
|
|
163
|
+
agent_id=resolved_agent_id,
|
|
164
|
+
tool_name=tool_name,
|
|
165
|
+
input_state=input_snapshot,
|
|
166
|
+
output_state=result,
|
|
167
|
+
authority_scope=resolved_scope,
|
|
168
|
+
)
|
|
169
|
+
if self.verifier is not None:
|
|
170
|
+
try:
|
|
171
|
+
verified = self.verifier(result)
|
|
172
|
+
if inspect.isawaitable(verified):
|
|
173
|
+
if inspect.iscoroutine(verified):
|
|
174
|
+
verified.close()
|
|
175
|
+
raise TypeError("async verifiers require an async node")
|
|
176
|
+
self._record_verdict(
|
|
177
|
+
resolved_node_name,
|
|
178
|
+
resolved_agent_id,
|
|
179
|
+
tool_name,
|
|
180
|
+
result,
|
|
181
|
+
resolved_scope,
|
|
182
|
+
verified,
|
|
183
|
+
)
|
|
184
|
+
except BaseException as exc:
|
|
185
|
+
self._record_verdict(
|
|
186
|
+
resolved_node_name,
|
|
187
|
+
resolved_agent_id,
|
|
188
|
+
tool_name,
|
|
189
|
+
result,
|
|
190
|
+
resolved_scope,
|
|
191
|
+
False,
|
|
192
|
+
exc,
|
|
193
|
+
)
|
|
194
|
+
raise
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
return wrapper
|
|
198
|
+
|
|
199
|
+
def record_delegation(self, **kwargs: Any):
|
|
200
|
+
"""Record an explicit sub-agent delegation event from user graph code."""
|
|
201
|
+
|
|
202
|
+
return self.tracer.record_delegation(**kwargs)
|
|
203
|
+
|
|
204
|
+
def _maybe_record_delegation(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
is_delegation: bool,
|
|
208
|
+
delegated_to: Optional[str],
|
|
209
|
+
node_name: str,
|
|
210
|
+
agent_id: Optional[str],
|
|
211
|
+
tool_name: Optional[str],
|
|
212
|
+
state: Any,
|
|
213
|
+
authority_scope: Mapping[str, Any],
|
|
214
|
+
):
|
|
215
|
+
if not is_delegation:
|
|
216
|
+
return None
|
|
217
|
+
return self.tracer.record_delegation(
|
|
218
|
+
node_name=node_name,
|
|
219
|
+
agent_id=agent_id,
|
|
220
|
+
delegated_to=delegated_to or node_name,
|
|
221
|
+
tool_name=tool_name,
|
|
222
|
+
state=state,
|
|
223
|
+
authority_scope=authority_scope,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _record_completion(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
node_name: str,
|
|
230
|
+
agent_id: Optional[str],
|
|
231
|
+
tool_name: Optional[str],
|
|
232
|
+
input_state: Any,
|
|
233
|
+
output_state: Any,
|
|
234
|
+
authority_scope: Mapping[str, Any],
|
|
235
|
+
) -> None:
|
|
236
|
+
self.tracer.record_termination(
|
|
237
|
+
node_name=node_name,
|
|
238
|
+
agent_id=agent_id,
|
|
239
|
+
input_state=input_state,
|
|
240
|
+
output_state=output_state,
|
|
241
|
+
authority_scope=authority_scope,
|
|
242
|
+
metadata={
|
|
243
|
+
"status": "succeeded",
|
|
244
|
+
"verification_status": (
|
|
245
|
+
"pending" if self.verifier is not None else "unchecked"
|
|
246
|
+
),
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _record_failure(self, node_name, agent_id, input_state, scope, exc):
|
|
251
|
+
self.tracer.record_termination(
|
|
252
|
+
node_name=node_name,
|
|
253
|
+
agent_id=agent_id,
|
|
254
|
+
input_state=input_state,
|
|
255
|
+
output_state=None,
|
|
256
|
+
authority_scope=scope,
|
|
257
|
+
metadata={
|
|
258
|
+
"status": (
|
|
259
|
+
"cancelled" if isinstance(exc, asyncio.CancelledError) else "failed"
|
|
260
|
+
),
|
|
261
|
+
"error_type": type(exc).__name__,
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _record_verdict(
|
|
266
|
+
self, node_name, agent_id, tool_name, state, scope, verified, error=None
|
|
267
|
+
):
|
|
268
|
+
if type(verified) is not bool:
|
|
269
|
+
raise TypeError("verifier must return a boolean")
|
|
270
|
+
self.tracer.record_verification(
|
|
271
|
+
node_name=node_name,
|
|
272
|
+
agent_id=agent_id,
|
|
273
|
+
tool_name=tool_name,
|
|
274
|
+
state=state,
|
|
275
|
+
verified=verified,
|
|
276
|
+
authority_scope=scope,
|
|
277
|
+
metadata=(
|
|
278
|
+
{"status": "error", "error_type": type(error).__name__}
|
|
279
|
+
if error
|
|
280
|
+
else {"status": "checked"}
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class _NullContext:
|
|
286
|
+
def __enter__(self):
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
def __exit__(self, exc_type, exc, tb):
|
|
290
|
+
return False
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Execution tracer that emits JEP accountability events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import uuid
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from threading import RLock
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
from .canonicalization import canonical_hash
|
|
13
|
+
from .events import JEPEvent, JEPEventType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class JEPExecutionTracer:
|
|
18
|
+
"""Collects a deterministic chain of JEP events for a LangGraph run."""
|
|
19
|
+
|
|
20
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
21
|
+
default_agent_id: str = "langgraph-agent"
|
|
22
|
+
default_authority_scope: Mapping[str, Any] = field(default_factory=dict)
|
|
23
|
+
events: List[JEPEvent] = field(default_factory=list)
|
|
24
|
+
_lock: Any = field(default_factory=RLock, repr=False)
|
|
25
|
+
_delegation_hash: Any = field(
|
|
26
|
+
default_factory=lambda: contextvars.ContextVar(
|
|
27
|
+
"jep_delegation_hash", default=None
|
|
28
|
+
),
|
|
29
|
+
repr=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def previous_event_hash(self) -> Optional[str]:
|
|
34
|
+
return self.events[-1].hash() if self.events else None
|
|
35
|
+
|
|
36
|
+
def record_judgment(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
node_name: str,
|
|
40
|
+
agent_id: Optional[str],
|
|
41
|
+
input_state: Any,
|
|
42
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
43
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
44
|
+
) -> JEPEvent:
|
|
45
|
+
return self._append(
|
|
46
|
+
JEPEventType.JUDGMENT,
|
|
47
|
+
node_name=node_name,
|
|
48
|
+
agent_id=agent_id,
|
|
49
|
+
tool_name=None,
|
|
50
|
+
state_transition={
|
|
51
|
+
"phase": "before_node",
|
|
52
|
+
"input_state_hash": canonical_hash(input_state),
|
|
53
|
+
"output_state_hash": None,
|
|
54
|
+
},
|
|
55
|
+
authority_scope=authority_scope,
|
|
56
|
+
metadata=metadata,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def record_delegation(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
node_name: str,
|
|
63
|
+
agent_id: Optional[str],
|
|
64
|
+
delegated_to: str,
|
|
65
|
+
tool_name: Optional[str] = None,
|
|
66
|
+
state: Any = None,
|
|
67
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
68
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
69
|
+
) -> JEPEvent:
|
|
70
|
+
parent_hash = self._delegation_hash.get() or self.previous_event_hash
|
|
71
|
+
return self._append(
|
|
72
|
+
JEPEventType.DELEGATION,
|
|
73
|
+
node_name=node_name,
|
|
74
|
+
agent_id=agent_id,
|
|
75
|
+
tool_name=tool_name,
|
|
76
|
+
state_transition={
|
|
77
|
+
"phase": "delegation",
|
|
78
|
+
"state_hash": canonical_hash(state),
|
|
79
|
+
"delegated_to": delegated_to,
|
|
80
|
+
},
|
|
81
|
+
authority_scope=authority_scope,
|
|
82
|
+
delegation_parent_hash=parent_hash,
|
|
83
|
+
metadata=metadata,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def record_termination(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
node_name: str,
|
|
90
|
+
agent_id: Optional[str],
|
|
91
|
+
input_state: Any,
|
|
92
|
+
output_state: Any,
|
|
93
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
94
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
95
|
+
) -> JEPEvent:
|
|
96
|
+
return self._append(
|
|
97
|
+
JEPEventType.TERMINATION,
|
|
98
|
+
node_name=node_name,
|
|
99
|
+
agent_id=agent_id,
|
|
100
|
+
tool_name=None,
|
|
101
|
+
state_transition={
|
|
102
|
+
"phase": "after_node",
|
|
103
|
+
"input_state_hash": canonical_hash(input_state),
|
|
104
|
+
"output_state_hash": canonical_hash(output_state),
|
|
105
|
+
},
|
|
106
|
+
authority_scope=authority_scope,
|
|
107
|
+
metadata=metadata,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def record_verification(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
node_name: str,
|
|
114
|
+
agent_id: Optional[str],
|
|
115
|
+
tool_name: Optional[str],
|
|
116
|
+
state: Any,
|
|
117
|
+
verified: bool,
|
|
118
|
+
authority_scope: Optional[Mapping[str, Any]] = None,
|
|
119
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
120
|
+
) -> JEPEvent:
|
|
121
|
+
return self._append(
|
|
122
|
+
JEPEventType.VERIFICATION,
|
|
123
|
+
node_name=node_name,
|
|
124
|
+
agent_id=agent_id,
|
|
125
|
+
tool_name=tool_name,
|
|
126
|
+
state_transition={
|
|
127
|
+
"phase": "verification",
|
|
128
|
+
"state_hash": canonical_hash(state),
|
|
129
|
+
"verified": verified,
|
|
130
|
+
},
|
|
131
|
+
authority_scope=authority_scope,
|
|
132
|
+
metadata=metadata,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def delegation_context(self, delegation_event: JEPEvent):
|
|
136
|
+
"""Return a context manager that records nested events under a delegation hash."""
|
|
137
|
+
|
|
138
|
+
class _DelegationContext:
|
|
139
|
+
def __enter__(self_inner):
|
|
140
|
+
self_inner.token = self._delegation_hash.set(delegation_event.hash())
|
|
141
|
+
return delegation_event
|
|
142
|
+
|
|
143
|
+
def __exit__(self_inner, exc_type, exc, tb):
|
|
144
|
+
self._delegation_hash.reset(self_inner.token)
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
return _DelegationContext()
|
|
148
|
+
|
|
149
|
+
def _append(
|
|
150
|
+
self,
|
|
151
|
+
event_type: JEPEventType,
|
|
152
|
+
*,
|
|
153
|
+
node_name: str,
|
|
154
|
+
agent_id: Optional[str],
|
|
155
|
+
tool_name: Optional[str],
|
|
156
|
+
state_transition: Mapping[str, Any],
|
|
157
|
+
authority_scope: Optional[Mapping[str, Any]],
|
|
158
|
+
delegation_parent_hash: Optional[str] = None,
|
|
159
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
160
|
+
) -> JEPEvent:
|
|
161
|
+
with self._lock:
|
|
162
|
+
event = JEPEvent(
|
|
163
|
+
event_type=event_type,
|
|
164
|
+
event_id=f"{self.session_id}:{len(self.events)}",
|
|
165
|
+
session_id=self.session_id,
|
|
166
|
+
sequence=len(self.events),
|
|
167
|
+
node_name=node_name,
|
|
168
|
+
agent_id=agent_id or self.default_agent_id,
|
|
169
|
+
tool_name=tool_name,
|
|
170
|
+
state_transition=deepcopy(dict(state_transition)),
|
|
171
|
+
authority_scope=deepcopy(
|
|
172
|
+
dict(
|
|
173
|
+
authority_scope
|
|
174
|
+
if authority_scope is not None
|
|
175
|
+
else self.default_authority_scope
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
previous_event_hash=self.previous_event_hash,
|
|
179
|
+
delegation_parent_hash=delegation_parent_hash,
|
|
180
|
+
metadata=deepcopy(dict(metadata or {})),
|
|
181
|
+
)
|
|
182
|
+
event = JEPEvent.from_mapping(event.payload(include_hash=True))
|
|
183
|
+
self.events.append(event)
|
|
184
|
+
return event
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jep-langgraph-adapter
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: JEP runtime adapter for LangGraph: replayable delegation and verifiable AI accountability semantics.
|
|
5
|
+
Author: JEP
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: jep,langgraph,accountability,replay,agent
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Provides-Extra: langgraph
|
|
13
|
+
Requires-Dist: langgraph>=0.2; extra == "langgraph"
|
|
14
|
+
|
|
15
|
+
# jep-langgraph-adapter
|
|
16
|
+
|
|
17
|
+
JEP runtime adapter for LangGraph: replayable delegation and verifiable AI accountability semantics.
|
|
18
|
+
|
|
19
|
+
This package instruments LangGraph-style node callables without modifying LangGraph core. Wrapped node execution naturally emits a deterministic, hash-linked JEP accountability chain containing:
|
|
20
|
+
|
|
21
|
+
- Judgment Event
|
|
22
|
+
- Delegation Event
|
|
23
|
+
- Termination Event
|
|
24
|
+
- Verification Event
|
|
25
|
+
|
|
26
|
+
Each event records `node_name`, `agent_id`, `tool_name`, `state_transition`, `authority_scope`, and `previous_event_hash`.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
LangGraph is optional for tests and examples because the adapter works at the callable-node boundary:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e '.[langgraph]'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from jep_langgraph_adapter import LangGraphEventAdapter
|
|
44
|
+
|
|
45
|
+
adapter = LangGraphEventAdapter(
|
|
46
|
+
session_id="demo-session",
|
|
47
|
+
agent_id="planner-agent",
|
|
48
|
+
authority_scope={"tools": ["search"], "max_steps": 3},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def plan(state):
|
|
52
|
+
return {**state, "plan": "call search"}
|
|
53
|
+
|
|
54
|
+
wrapped_plan = adapter.wrap_node(plan, node_name="plan")
|
|
55
|
+
next_state = wrapped_plan({"question": "What changed?"})
|
|
56
|
+
adapter.exporter().export_jsonl("session.jsonl")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For a `StateGraph`, add wrapped nodes through `instrument_state_graph`:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
adapter.instrument_state_graph(graph, {"plan": plan, "answer": answer})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Replay CLI
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
jep-langgraph replay session.jsonl
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The replay command validates deterministic event hashes, sequence numbers, and `previous_event_hash` links.
|
|
72
|
+
|
|
73
|
+
## Components
|
|
74
|
+
|
|
75
|
+
- `JEPNodeMiddleware`: wraps sync or async LangGraph node callables.
|
|
76
|
+
- `JEPExecutionTracer`: records judgment, delegation, termination, and verification events.
|
|
77
|
+
- `JEPReplayExporter`: exports JSON/JSONL and validates replay chains.
|
|
78
|
+
- `LangGraphEventAdapter`: high-level facade for node and graph instrumentation.
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
- `examples/multi_step_graph.py`: multi-step graph execution.
|
|
83
|
+
- `examples/sub_agent_delegation.py`: sub-agent delegation chain.
|
|
84
|
+
- `examples/tool_invocation_replay.py`: tool invocation verification and replay.
|
|
85
|
+
|
|
86
|
+
## Runtime and verification notes
|
|
87
|
+
|
|
88
|
+
See [HARDENING.md](HARDENING.md) for supported behavior, regression checks, and compatibility boundaries.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/jep_langgraph_adapter/__init__.py
|
|
4
|
+
src/jep_langgraph_adapter/adapter.py
|
|
5
|
+
src/jep_langgraph_adapter/canonicalization.py
|
|
6
|
+
src/jep_langgraph_adapter/cli.py
|
|
7
|
+
src/jep_langgraph_adapter/events.py
|
|
8
|
+
src/jep_langgraph_adapter/exporter.py
|
|
9
|
+
src/jep_langgraph_adapter/middleware.py
|
|
10
|
+
src/jep_langgraph_adapter/tracer.py
|
|
11
|
+
src/jep_langgraph_adapter.egg-info/PKG-INFO
|
|
12
|
+
src/jep_langgraph_adapter.egg-info/SOURCES.txt
|
|
13
|
+
src/jep_langgraph_adapter.egg-info/dependency_links.txt
|
|
14
|
+
src/jep_langgraph_adapter.egg-info/entry_points.txt
|
|
15
|
+
src/jep_langgraph_adapter.egg-info/requires.txt
|
|
16
|
+
src/jep_langgraph_adapter.egg-info/top_level.txt
|
|
17
|
+
tests/test_adapter.py
|
|
18
|
+
tests/test_execution_failures.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jep_langgraph_adapter
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from jep_langgraph_adapter import JEPEventType, JEPReplayExporter, LangGraphEventAdapter, canonicalize
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_wrap_node_emits_hash_chain(tmp_path: Path):
|
|
7
|
+
adapter = LangGraphEventAdapter(session_id="test", agent_id="agent", authority_scope={"scope": "local"})
|
|
8
|
+
|
|
9
|
+
def node(state):
|
|
10
|
+
return {**state, "done": True}
|
|
11
|
+
|
|
12
|
+
wrapped = adapter.wrap_node(node, node_name="node_a")
|
|
13
|
+
assert wrapped({"done": False}) == {"done": True}
|
|
14
|
+
|
|
15
|
+
assert [event.event_type for event in adapter.events] == [JEPEventType.JUDGMENT, JEPEventType.TERMINATION]
|
|
16
|
+
assert adapter.events[0].previous_event_hash is None
|
|
17
|
+
assert adapter.events[1].previous_event_hash == adapter.events[0].hash()
|
|
18
|
+
assert adapter.events[0].node_name == "node_a"
|
|
19
|
+
assert adapter.events[0].agent_id == "agent"
|
|
20
|
+
assert adapter.events[0].authority_scope == {"scope": "local"}
|
|
21
|
+
|
|
22
|
+
path = adapter.exporter().export_jsonl(tmp_path / "session.jsonl")
|
|
23
|
+
result = JEPReplayExporter.replay_file(path)
|
|
24
|
+
assert result.valid, result.errors
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_delegation_and_tool_verification_events():
|
|
28
|
+
adapter = LangGraphEventAdapter(session_id="test", verifier=lambda state: state["value"] == 42)
|
|
29
|
+
|
|
30
|
+
def tool(state):
|
|
31
|
+
return {"value": state["value"] * 2}
|
|
32
|
+
|
|
33
|
+
wrapped = adapter.wrap_node(
|
|
34
|
+
tool,
|
|
35
|
+
node_name="tool_node",
|
|
36
|
+
tool_name="double",
|
|
37
|
+
is_delegation=True,
|
|
38
|
+
delegated_to="tool-agent",
|
|
39
|
+
)
|
|
40
|
+
wrapped({"value": 21})
|
|
41
|
+
|
|
42
|
+
assert [event.event_type for event in adapter.events] == [
|
|
43
|
+
JEPEventType.DELEGATION,
|
|
44
|
+
JEPEventType.JUDGMENT,
|
|
45
|
+
JEPEventType.TERMINATION,
|
|
46
|
+
JEPEventType.VERIFICATION,
|
|
47
|
+
]
|
|
48
|
+
assert adapter.events[0].state_transition["delegated_to"] == "tool-agent"
|
|
49
|
+
assert adapter.events[1].delegation_parent_hash is None
|
|
50
|
+
assert adapter.events[3].tool_name == "double"
|
|
51
|
+
assert adapter.events[3].state_transition["verified"] is True
|
|
52
|
+
assert JEPReplayExporter.replay(adapter.events).valid
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_canonicalization_is_deterministic():
|
|
56
|
+
left = {"b": [2, 1], "a": {"z": True}}
|
|
57
|
+
right = {"a": {"z": True}, "b": [2, 1]}
|
|
58
|
+
assert canonicalize(left) == canonicalize(right)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
import pytest
|
|
4
|
+
from jep_langgraph_adapter import LangGraphEventAdapter, JEPEventType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_tool_without_verifier_is_unchecked():
|
|
8
|
+
adapter = LangGraphEventAdapter()
|
|
9
|
+
adapter.wrap_node(lambda s: s, tool_name="echo")({})
|
|
10
|
+
assert [e.event_type for e in adapter.events] == [JEPEventType.JUDGMENT, JEPEventType.TERMINATION]
|
|
11
|
+
assert adapter.events[-1].metadata["verification_status"] == "unchecked"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_failure_closes_event_and_preserves_input_snapshot():
|
|
15
|
+
adapter = LangGraphEventAdapter()
|
|
16
|
+
def fail(state):
|
|
17
|
+
state["value"] = 2
|
|
18
|
+
raise RuntimeError("failed")
|
|
19
|
+
with pytest.raises(RuntimeError):
|
|
20
|
+
adapter.wrap_node(fail)({"value": 1})
|
|
21
|
+
assert adapter.events[-1].metadata["status"] == "failed"
|
|
22
|
+
assert adapter.events[0].state_transition["input_state_hash"] == adapter.events[-1].state_transition["input_state_hash"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_async_cancellation_and_async_verifier():
|
|
26
|
+
async def run():
|
|
27
|
+
cancelled = LangGraphEventAdapter()
|
|
28
|
+
async def cancel(state):
|
|
29
|
+
raise asyncio.CancelledError()
|
|
30
|
+
with pytest.raises(asyncio.CancelledError):
|
|
31
|
+
await cancelled.wrap_node(cancel)({})
|
|
32
|
+
assert cancelled.events[-1].metadata["status"] == "cancelled"
|
|
33
|
+
async def verify(state):
|
|
34
|
+
await asyncio.sleep(0)
|
|
35
|
+
return False
|
|
36
|
+
adapter = LangGraphEventAdapter(verifier=verify)
|
|
37
|
+
async def node(state):
|
|
38
|
+
return state
|
|
39
|
+
await adapter.wrap_node(node)({})
|
|
40
|
+
assert adapter.events[-1].state_transition["verified"] is False
|
|
41
|
+
asyncio.run(run())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_verifier_must_return_boolean_and_errors_are_recorded():
|
|
45
|
+
adapter = LangGraphEventAdapter(verifier=lambda s: "yes")
|
|
46
|
+
with pytest.raises(TypeError):
|
|
47
|
+
adapter.wrap_node(lambda s: s)({})
|
|
48
|
+
assert adapter.events[-1].state_transition["verified"] is False
|
|
49
|
+
assert adapter.events[-1].metadata["status"] == "error"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_parallel_nodes_keep_unique_sequence_and_hash_chain():
|
|
53
|
+
adapter = LangGraphEventAdapter()
|
|
54
|
+
node = adapter.wrap_node(lambda s: s)
|
|
55
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
56
|
+
list(pool.map(node, range(40)))
|
|
57
|
+
assert [e.sequence for e in adapter.events] == list(range(80))
|
|
58
|
+
assert all(e.previous_event_hash == p.hash() for p, e in zip(adapter.events, adapter.events[1:]))
|