vitnify 0.1.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.
vitnify/__init__.py ADDED
File without changes
File without changes
File without changes
@@ -0,0 +1,181 @@
1
+ """Content-addressed store + Merkle ledger — the provenance backbone of PCK.
2
+
3
+ Every source chunk is addressed by its cryptographic hash. A Merkle tree over those
4
+ hashes yields a single ``root`` that attests the whole corpus. Any single chunk can
5
+ then carry an *inclusion proof*: a short path of sibling hashes that recomputes the
6
+ root. The guarantees this buys, and why they matter for AI answers:
7
+
8
+ * **Tamper-evidence** — change one byte of one chunk and the root changes.
9
+ * **Independent verifiability** — anyone holding the root can check a chunk's proof
10
+ without trusting the party that produced it (no model, no server, no secret key).
11
+ * **Fabrication is uncredentialed** — a made-up "source" has no leaf in the tree, so
12
+ it cannot produce a valid proof. This is the cryptographic half of catching a
13
+ hallucination: a grounded claim points at a chunk with a valid proof; an invented
14
+ one cannot.
15
+
16
+ This mirrors the BLAKE3 + Merkle design of a native runtime's ``cas_store`` (whose real
17
+ Rust code is exercised in ``research/pck_provenance/``); here we keep a dependency-light
18
+ pure-Python implementation that uses ``blake3`` when available and falls back to
19
+ ``hashlib.blake2b`` otherwise. The wire format of a proof is identical either way.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ from dataclasses import dataclass, field
25
+ from typing import Optional
26
+
27
+ try: # BLAKE3 if the user has it (matches the substrate); else a stdlib fallback.
28
+ import blake3 as _blake3 # type: ignore
29
+
30
+ _HASH_NAME = "blake3"
31
+
32
+ def _digest(b: bytes) -> str:
33
+ return _blake3.blake3(b).hexdigest()
34
+ except Exception: # pragma: no cover - depends on environment
35
+ _HASH_NAME = "blake2b-256"
36
+
37
+ def _digest(b: bytes) -> str:
38
+ return hashlib.blake2b(b, digest_size=32).hexdigest()
39
+
40
+
41
+ HASH_NAME = _HASH_NAME
42
+
43
+
44
+ def hash_bytes(b: bytes) -> str:
45
+ """Content address of raw bytes (hex)."""
46
+ return _digest(b)
47
+
48
+
49
+ def hash_text(s: str) -> str:
50
+ """Content address of a text chunk (UTF-8, hex)."""
51
+ return _digest(s.encode("utf-8"))
52
+
53
+
54
+ def _hash_pair(left: str, right: str) -> str:
55
+ """Internal Merkle node = hash of the concatenated child hex digests."""
56
+ return _digest((left + right).encode("utf-8"))
57
+
58
+
59
+ # --- inclusion proof -------------------------------------------------------------
60
+
61
+ @dataclass(frozen=True)
62
+ class ProofStep:
63
+ """One level of a Merkle inclusion path."""
64
+
65
+ sibling: str # hex digest of the sibling node
66
+ on_right: bool # True if the *current* node is the right child at this level
67
+
68
+ def to_json(self) -> dict:
69
+ return {"sibling": self.sibling, "on_right": self.on_right}
70
+
71
+ @staticmethod
72
+ def from_json(d: dict) -> "ProofStep":
73
+ return ProofStep(sibling=d["sibling"], on_right=bool(d["on_right"]))
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class InclusionProof:
78
+ """A self-contained proof that ``leaf`` is committed under ``root``.
79
+
80
+ Carries everything a third party needs to re-verify against a *published* root:
81
+ the leaf hash, the sibling path, and the index. No tree, server, or key required.
82
+ """
83
+
84
+ leaf: str
85
+ index: int
86
+ path: list[ProofStep]
87
+ hash_name: str = HASH_NAME
88
+
89
+ def verify(self, root: str) -> bool:
90
+ return verify_proof(self.leaf, self.path, root)
91
+
92
+ def to_json(self) -> dict:
93
+ return {
94
+ "leaf": self.leaf,
95
+ "index": self.index,
96
+ "hash": self.hash_name,
97
+ "path": [s.to_json() for s in self.path],
98
+ }
99
+
100
+ @staticmethod
101
+ def from_json(d: dict) -> "InclusionProof":
102
+ return InclusionProof(
103
+ leaf=d["leaf"],
104
+ index=int(d["index"]),
105
+ path=[ProofStep.from_json(s) for s in d["path"]],
106
+ hash_name=d.get("hash", HASH_NAME),
107
+ )
108
+
109
+
110
+ def verify_proof(leaf: str, path: list[ProofStep], root: str) -> bool:
111
+ """Recompute the root from ``leaf`` and ``path``; True iff it equals ``root``."""
112
+ h = leaf
113
+ for step in path:
114
+ h = _hash_pair(step.sibling, h) if step.on_right else _hash_pair(h, step.sibling)
115
+ return h == root
116
+
117
+
118
+ # --- the tree / store ------------------------------------------------------------
119
+
120
+ @dataclass
121
+ class MerkleCAS:
122
+ """Content-addressed store with a Merkle commitment over its chunks.
123
+
124
+ Odd layers duplicate the final node (Bitcoin-style), so a proof always exists for
125
+ every leaf. Construction is deterministic in the chunk order.
126
+ """
127
+
128
+ chunks: list[str]
129
+ leaves: list[str] = field(default_factory=list)
130
+ layers: list[list[str]] = field(default_factory=list)
131
+ _index_of_hash: dict[str, int] = field(default_factory=dict)
132
+
133
+ def __post_init__(self) -> None:
134
+ if not self.chunks:
135
+ raise ValueError("MerkleCAS requires at least one chunk")
136
+ self.leaves = [hash_text(c) for c in self.chunks]
137
+ # first occurrence wins if a chunk repeats
138
+ for i, h in enumerate(self.leaves):
139
+ self._index_of_hash.setdefault(h, i)
140
+ self.layers = self._build(self.leaves)
141
+
142
+ @staticmethod
143
+ def _build(leaves: list[str]) -> list[list[str]]:
144
+ layers = [leaves[:]]
145
+ while len(layers[-1]) > 1:
146
+ cur = layers[-1]
147
+ nxt = [
148
+ _hash_pair(cur[i], cur[i + 1] if i + 1 < len(cur) else cur[i])
149
+ for i in range(0, len(cur), 2)
150
+ ]
151
+ layers.append(nxt)
152
+ return layers
153
+
154
+ @property
155
+ def root(self) -> str:
156
+ return self.layers[-1][0]
157
+
158
+ def index_of(self, chunk_or_hash: str) -> Optional[int]:
159
+ """Index of a chunk by its raw text or by its hash; None if absent."""
160
+ if chunk_or_hash in self._index_of_hash:
161
+ return self._index_of_hash[chunk_or_hash]
162
+ return self._index_of_hash.get(hash_text(chunk_or_hash))
163
+
164
+ def contains(self, chunk_or_hash: str) -> bool:
165
+ return self.index_of(chunk_or_hash) is not None
166
+
167
+ def prove_index(self, idx: int) -> InclusionProof:
168
+ if not 0 <= idx < len(self.leaves):
169
+ raise IndexError(idx)
170
+ path: list[ProofStep] = []
171
+ i = idx
172
+ for layer in self.layers[:-1]:
173
+ sib = i ^ 1 if (i ^ 1) < len(layer) else i # duplicated node is its own sibling
174
+ path.append(ProofStep(sibling=layer[sib], on_right=bool(i & 1)))
175
+ i //= 2
176
+ return InclusionProof(leaf=self.leaves[idx], index=idx, path=path)
177
+
178
+ def prove(self, chunk_or_hash: str) -> Optional[InclusionProof]:
179
+ """Inclusion proof for a chunk (by text or hash); None if not in the corpus."""
180
+ idx = self.index_of(chunk_or_hash)
181
+ return None if idx is None else self.prove_index(idx)
vitnify/capability.py ADDED
@@ -0,0 +1,38 @@
1
+ """Userspace, kernel-free capability broker (adapted from the PoC).
2
+ Containment by construction: every side-effecting tool call routes through here,
3
+ and a tool the run wasn't granted is simply unreachable -- a jailbreak that reaches
4
+ an ungranted tool hits a wall, not a classifier. Each call is recorded to the event
5
+ log (with its result, so replay can re-inject it). No ML, no score, no bypass.
6
+ """
7
+ from __future__ import annotations
8
+ import hashlib
9
+ from .events import EventLog, Kind
10
+
11
+ def _rh(x) -> str:
12
+ return hashlib.blake2b(repr(x).encode(), digest_size=16).hexdigest()
13
+
14
+ class Broker:
15
+ def __init__(self, capabilities, tools: dict, log: EventLog, replay=None):
16
+ self.caps = set(capabilities)
17
+ self.tools = tools
18
+ self.log = log
19
+ self.replay = replay # if set: list of recorded tool results to re-inject
20
+
21
+ def call(self, tool: str, *args):
22
+ allowed = tool in self.caps
23
+ if not allowed:
24
+ self.log.append(Kind.TOOL_CALL, {"tool": tool, "args": list(args), "decision": "DENY"})
25
+ return False, None
26
+ if self.replay is not None:
27
+ # external effect: DO NOT re-run the live tool; serve the recorded result
28
+ result = self.replay.pop(0)
29
+ else:
30
+ result = self.tools[tool](*args)
31
+ self.log.append(Kind.TOOL_CALL, {"tool": tool, "args": list(args),
32
+ "decision": "ALLOW", "result": result, "result_hash": _rh(result)})
33
+ return True, result
34
+
35
+ def recorded_tool_results(log: EventLog):
36
+ """Extract the ordered list of ALLOW tool results, to feed a replay Broker."""
37
+ return [e.payload["result"] for e in log.events
38
+ if e.kind == Kind.TOOL_CALL.value and e.payload.get("decision") == "ALLOW"]
vitnify/certificate.py ADDED
@@ -0,0 +1,139 @@
1
+ """vitnify-receipt v1 -- the signed, self-verifying execution receipt.
2
+
3
+ One receipt binds an agent run into a single object:
4
+
5
+ program hash | granted capabilities | hash-chained event log (Merkle root + head)
6
+ | the engine's per-step model-computation digests
7
+
8
+ so an action can never be detached from the computation and context that produced
9
+ it. The receipt digest is BLAKE3 over a canonical body (matching the vitni-tensor
10
+ engine and the event log); the receipt is signed with ed25519 and embeds its own
11
+ public key, so ANYONE can verify it offline with no model, no network, and no secret.
12
+
13
+ Two verification levels:
14
+ * level 1 (integrity): recompute the Merkle root, chain head, and event count from
15
+ the raw events, and check the ed25519 signature. No model required.
16
+ * level 2 (recomputation): re-run each bound model step through vitni-tensor and
17
+ confirm every model_digest reproduces bit-for-bit.
18
+
19
+ verify_certificate() implements level 1 and is fully independent. HMAC signing is
20
+ retained only as a fallback for environments without an asymmetric key.
21
+ """
22
+ from __future__ import annotations
23
+ import os, sys, json, hmac
24
+ from dataclasses import dataclass, field, asdict
25
+ from .events import EventLog
26
+
27
+ from ._vendor.pck.cas import MerkleCAS # noqa: E402
28
+
29
+ FORMAT = "vitnify-receipt v1"
30
+
31
+ try:
32
+ import blake3 as _blake3
33
+ def _digest32(b: bytes) -> str:
34
+ return _blake3.blake3(b).hexdigest()
35
+ except ImportError:
36
+ import hashlib
37
+ def _digest32(b: bytes) -> str:
38
+ return hashlib.blake2b(b, digest_size=32).hexdigest()
39
+
40
+ # cryptography is imported lazily inside the ed25519 helpers so the fallback path
41
+ # still runs on a stock Python without the dependency.
42
+
43
+
44
+ def _canon(o) -> str:
45
+ return json.dumps(o, sort_keys=True, separators=(",", ":"))
46
+
47
+
48
+ def gen_ed25519():
49
+ """Return (private_key, public_key_hex). Persist the private key to sign future runs."""
50
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
51
+ priv = Ed25519PrivateKey.generate()
52
+ return priv, priv.public_key().public_bytes_raw().hex()
53
+
54
+
55
+ @dataclass
56
+ class ExecutionCertificate:
57
+ program_hash: str
58
+ capabilities: list
59
+ event_root: str
60
+ n_events: int
61
+ head_hash: str
62
+ model_digests: list = field(default_factory=list) # engine per-step digests, bound by the log
63
+ v: str = FORMAT
64
+ sig: str | None = None
65
+ sig_alg: str = "none"
66
+ pubkey: str | None = None # ed25519 public key (hex) -- verifier needs no secret
67
+
68
+ def body(self) -> dict:
69
+ return {"v": self.v, "program_hash": self.program_hash,
70
+ "capabilities": sorted(self.capabilities), "event_root": self.event_root,
71
+ "n_events": self.n_events, "head_hash": self.head_hash,
72
+ "model_digests": list(self.model_digests)}
73
+
74
+ def digest(self) -> str:
75
+ return _digest32(_canon(self.body()).encode())
76
+
77
+ def sign_ed25519(self, priv) -> "ExecutionCertificate":
78
+ self.sig = priv.sign(bytes.fromhex(self.digest())).hex()
79
+ self.pubkey = priv.public_key().public_bytes_raw().hex()
80
+ self.sig_alg = "ed25519"
81
+ return self
82
+
83
+ def sign_hmac(self, key: bytes) -> "ExecutionCertificate":
84
+ import hashlib
85
+ self.sig = hmac.new(key, self.digest().encode(), hashlib.blake2b).hexdigest()
86
+ self.sig_alg = "hmac-blake2b"
87
+ return self
88
+
89
+ def to_json(self) -> str:
90
+ return _canon(asdict(self))
91
+
92
+
93
+ def issue_certificate(program_hash, capabilities, log: EventLog, priv=None, key: bytes | None = None):
94
+ """Build and sign a vitnify-receipt for a completed run.
95
+
96
+ The model-computation digests are read from the log's llm_call events, so the
97
+ receipt binds exactly the model steps that were recorded. ed25519 (self-verifying)
98
+ is the canonical signing path; pass `priv`.
99
+ """
100
+ cas = MerkleCAS(log.chunks())
101
+ cert = ExecutionCertificate(
102
+ program_hash, sorted(set(capabilities)), cas.root, len(log), log.head(),
103
+ model_digests=log.model_digests(),
104
+ )
105
+ if priv is not None:
106
+ cert.sign_ed25519(priv)
107
+ elif key is not None:
108
+ cert.sign_hmac(key)
109
+ return cert, cas
110
+
111
+
112
+ def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | None = None) -> dict:
113
+ """Level-1 verification: recompute everything from the raw events; trust nothing.
114
+
115
+ ed25519 receipts verify against their OWN embedded public key -- no secret required.
116
+ (Level 2 -- recomputing each model_digest through vitni-tensor -- is a separate step
117
+ that additionally needs the model weights.)
118
+ """
119
+ cas = MerkleCAS(log.chunks())
120
+ checks = {
121
+ "format": cert.v == FORMAT,
122
+ "root_matches": cas.root == cert.event_root,
123
+ "head_matches": log.head() == cert.head_hash,
124
+ "count_matches": len(log) == cert.n_events,
125
+ "model_digests_match": log.model_digests() == list(cert.model_digests),
126
+ }
127
+ if cert.sig_alg == "ed25519":
128
+ try:
129
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
130
+ Ed25519PublicKey.from_public_bytes(bytes.fromhex(cert.pubkey)).verify(
131
+ bytes.fromhex(cert.sig), bytes.fromhex(cert.digest()))
132
+ checks["sig_valid"] = True
133
+ except Exception:
134
+ checks["sig_valid"] = False
135
+ elif cert.sig_alg == "hmac-blake2b" and key is not None:
136
+ import hashlib
137
+ checks["sig_valid"] = cert.sig == hmac.new(key, cert.digest().encode(), hashlib.blake2b).hexdigest()
138
+ checks["ok"] = all(checks.values())
139
+ return checks
@@ -0,0 +1,64 @@
1
+ """Deterministic local inference -- the piece that makes a replay CERTIFIABLE.
2
+ Greedy decode over TinyLlama, with an optional batch-invariant linear (your bi_linear
3
+ work). The certificate commits to a hash of the LOGITS at each step, not just the token:
4
+ tokens rarely flip under batch load, but logits always shift -- so a bit-identical
5
+ certificate can only be reproduced when inference is batch-invariant.
6
+ """
7
+ from __future__ import annotations
8
+ import hashlib
9
+ from contextlib import nullcontext
10
+ import torch, torch.nn.functional as F
11
+ from transformers import AutoModelForCausalLM, AutoTokenizer
12
+
13
+ _ORIG_LINEAR = F.linear
14
+
15
+ def _bi_linear(x, w, b=None):
16
+ """Fixed-chunk fp32 reduction -> output independent of batch size (bit-identical)."""
17
+ d, CH = x.shape[-1], 128
18
+ xf, wf, acc = x.float(), w.float(), None
19
+ for c in range(0, d, CH):
20
+ p = xf[..., c:c+CH] @ wf[:, c:c+CH].t()
21
+ acc = p if acc is None else acc + p
22
+ out = (acc + b.float()) if b is not None else acc
23
+ return out.to(x.dtype)
24
+
25
+ class batch_invariant:
26
+ def __enter__(self): F.linear = _bi_linear
27
+ def __exit__(self, *a): F.linear = _ORIG_LINEAR
28
+
29
+ def hash_tensor(t: torch.Tensor) -> str:
30
+ return hashlib.blake2b(t.detach().to("cpu").contiguous().numpy().tobytes(), digest_size=16).hexdigest()
31
+
32
+ class DeterministicLM:
33
+ def __init__(self, name="TinyLlama/TinyLlama-1.1B-Chat-v1.0", device=None):
34
+ self.dev = device or ("mps" if torch.backends.mps.is_available() else "cpu")
35
+ self.tok = AutoTokenizer.from_pretrained(name, local_files_only=True)
36
+ self.model = AutoModelForCausalLM.from_pretrained(
37
+ name, torch_dtype=torch.float16, local_files_only=True).eval().to(self.dev)
38
+ self.V = self.model.config.vocab_size
39
+
40
+ def chat_prompt(self, messages) -> str:
41
+ """Render messages with the model's own chat template (Qwen, Llama, etc.)."""
42
+ return self.tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
43
+
44
+ @torch.no_grad()
45
+ def generate(self, prompt: str, n_new=8, batch_load=0, invariant=False):
46
+ """Greedy decode. batch_load simulates production co-batching (padded neighbors).
47
+ Returns (tokens, per_step_logit_hashes). Our request is always row 0."""
48
+ ids = self.tok(prompt, return_tensors="pt").input_ids.to(self.dev)
49
+ ctx = batch_invariant() if invariant else nullcontext()
50
+ toks, hashes = [], []
51
+ with ctx:
52
+ cur = ids
53
+ for _ in range(n_new):
54
+ if batch_load:
55
+ pad = torch.randint(0, self.V, (batch_load, cur.shape[1]), device=self.dev)
56
+ batch = torch.cat([cur, pad], 0)
57
+ else:
58
+ batch = cur
59
+ logits = self.model(batch).logits[0, -1]
60
+ hashes.append(hash_tensor(logits))
61
+ nxt = int(logits.argmax())
62
+ toks.append(nxt)
63
+ cur = torch.cat([cur, torch.tensor([[nxt]], device=self.dev)], 1)
64
+ return toks, hashes
vitnify/engine.py ADDED
@@ -0,0 +1,46 @@
1
+ """Bridge to the vitni-tensor engine.
2
+
3
+ Runs a model step through the deterministic engine and returns its
4
+ `vitnify-receipt v1` model-computation digest, so the SDK can bind what the model
5
+ actually computed into the signed execution receipt (`EventLog.append_llm_call`).
6
+
7
+ The engine is the `vitni-receipt` binary (built from the vitni-tensor crate). Point
8
+ `VITNI_RECEIPT_BIN` at it, or put it on PATH.
9
+ """
10
+ from __future__ import annotations
11
+ import os, json, subprocess
12
+
13
+ try:
14
+ import blake3 as _blake3
15
+ def _h32(b: bytes) -> str:
16
+ return _blake3.blake3(b).hexdigest()
17
+ except ImportError:
18
+ import hashlib
19
+ def _h32(b: bytes) -> str:
20
+ return hashlib.blake2b(b, digest_size=32).hexdigest()
21
+
22
+ DEFAULT_BIN = os.environ.get("VITNI_RECEIPT_BIN", "vitni-receipt")
23
+
24
+
25
+ def prompt_hash(tokens: list[int]) -> str:
26
+ return _h32(json.dumps(list(tokens), separators=(",", ":")).encode())
27
+
28
+
29
+ class Engine:
30
+ """Deterministic model backend. Every `run` is reproducible bit-for-bit and yields
31
+ a cross-vendor model-computation digest to bind into the receipt."""
32
+
33
+ def __init__(self, gguf: str, model_id: str, binary: str = DEFAULT_BIN):
34
+ self.gguf = gguf
35
+ self.model_id = model_id
36
+ self.binary = binary
37
+
38
+ def run(self, prompt_tokens: list[int], n_new: int = 16) -> dict:
39
+ """Return {"tokens": [...], "model_digest": hex, "weights_hash": hex}."""
40
+ proc = subprocess.run(
41
+ [self.binary, "--gguf", self.gguf,
42
+ "--prompt", ",".join(str(t) for t in prompt_tokens),
43
+ "--n", str(n_new), "--model-id", self.model_id],
44
+ capture_output=True, text=True, check=True,
45
+ )
46
+ return json.loads(proc.stdout)
vitnify/events.py ADDED
@@ -0,0 +1,95 @@
1
+ """Canonical, hash-chained agent event log.
2
+
3
+ Every nondeterministic input/output of an agent run becomes one canonical,
4
+ hash-linked Event. The chain gives append-only tamper-evidence during recording;
5
+ certificate.py Merkle-commits the same chunks for inclusion proofs and binds the
6
+ root into the signed vitnify-receipt.
7
+
8
+ Hashing is BLAKE3 throughout (matching the vitni-tensor engine and the
9
+ `vitnify-receipt v1` format), so the whole receipt is one hash family end to end.
10
+ """
11
+ from __future__ import annotations
12
+ import json
13
+ from dataclasses import dataclass, asdict
14
+ from enum import Enum
15
+
16
+ try:
17
+ import blake3 as _blake3
18
+ def _h32(b: bytes) -> str:
19
+ return _blake3.blake3(b).hexdigest()
20
+ except ImportError: # stdlib fallback; wire format is identical either way
21
+ import hashlib
22
+ def _h32(b: bytes) -> str:
23
+ return hashlib.blake2b(b, digest_size=32).hexdigest()
24
+
25
+
26
+ def canon(obj) -> str:
27
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
28
+
29
+
30
+ def h(s: str) -> str:
31
+ return _h32(s.encode())
32
+
33
+
34
+ class Kind(str, Enum):
35
+ LLM_CALL = "llm_call" # (prompt_hash, tokens, seed, model_digest) -- the model step
36
+ TOOL_CALL = "tool_call" # (tool, args, decision, result) -- external, re-injected on replay
37
+ ENTROPY = "entropy" # (source, value) -- RNG/clock draw, from-log on replay
38
+ AGENT_STEP = "agent_step" # (state) -- orchestration checkpoint
39
+
40
+
41
+ @dataclass
42
+ class Event:
43
+ i: int
44
+ kind: str
45
+ payload: dict
46
+ prev: str
47
+ def canonical(self) -> str:
48
+ return canon({"i": self.i, "kind": self.kind, "payload": self.payload, "prev": self.prev})
49
+ @property
50
+ def hash(self) -> str:
51
+ return h(self.canonical())
52
+
53
+
54
+ class EventLog:
55
+ def __init__(self):
56
+ self.events: list[Event] = []
57
+
58
+ def append(self, kind, payload: dict) -> Event:
59
+ prev = self.events[-1].hash if self.events else "genesis"
60
+ ev = Event(len(self.events), kind.value if isinstance(kind, Kind) else str(kind), payload, prev)
61
+ self.events.append(ev)
62
+ return ev
63
+
64
+ def append_llm_call(self, prompt_hash: str, tokens: list, seed, model_digest: str) -> Event:
65
+ """Record a model step and bind its vitni-tensor model-computation digest.
66
+
67
+ `model_digest` is the engine's `vitnify-receipt v1` digest for this forward
68
+ pass. Committing it here (and thus in the receipt's Merkle root) is what binds
69
+ the deterministic model recomputation to the agent run.
70
+ """
71
+ return self.append(Kind.LLM_CALL, {
72
+ "prompt_hash": prompt_hash, "tokens": list(tokens),
73
+ "seed": seed, "model_digest": model_digest,
74
+ })
75
+
76
+ def model_digests(self) -> list[str]:
77
+ """Ordered engine model-computation digests bound by this log (for L2 recompute)."""
78
+ return [e.payload["model_digest"] for e in self.events
79
+ if e.kind == Kind.LLM_CALL.value and "model_digest" in e.payload]
80
+
81
+ def chunks(self) -> list[str]:
82
+ return [e.canonical() for e in self.events]
83
+
84
+ def head(self) -> str:
85
+ return self.events[-1].hash if self.events else "genesis"
86
+
87
+ def to_json(self) -> str:
88
+ return canon([asdict(e) for e in self.events])
89
+
90
+ @classmethod
91
+ def from_events(cls, events: list[Event]) -> "EventLog":
92
+ log = cls(); log.events = events; return log
93
+
94
+ def __len__(self):
95
+ return len(self.events)
@@ -0,0 +1,101 @@
1
+ """LangGraph adapter — make a real LangGraph agent recordable, replayable, certifiable,
2
+ and contained, with no change to the agent's logic.
3
+
4
+ Two integration points:
5
+ 1. build_react_graph(session, ...) -> a compiled LangGraph StateGraph whose *agent*
6
+ node calls session.llm() and whose *tool* node calls session.tool() (the capability
7
+ wall). Because everything flows through the VitniReplay Session, the whole graph is
8
+ recorded, deterministically replayable, and certified — for free.
9
+ 2. VitniReplayCallback(session) -> a LangChain BaseCallbackHandler that records LLM/tool
10
+ events from ANY LangChain/LangGraph agent (observability + certificate). This path is
11
+ record-only: callbacks observe but cannot re-inject, so it doesn't drive replay.
12
+ """
13
+ from __future__ import annotations
14
+ from typing import TypedDict, Optional
15
+ from .react import parse_action, REACT_SYS
16
+ from .events import Kind
17
+
18
+ try: # optional dependency: only needed to actually build/run the graph
19
+ from langgraph.graph import StateGraph, START, END
20
+ from langchain_core.callbacks import BaseCallbackHandler
21
+ _IMPORT_ERROR: Exception | None = None
22
+ except ImportError as exc: # keep the module importable without the extra installed
23
+ StateGraph = START = END = None # type: ignore[assignment]
24
+ BaseCallbackHandler = object # type: ignore[assignment,misc]
25
+ _IMPORT_ERROR = exc
26
+
27
+
28
+ def _require_langgraph() -> None:
29
+ """Raise a clear, actionable error if the LangGraph extra is not installed."""
30
+ if _IMPORT_ERROR is not None:
31
+ raise ImportError(
32
+ "vitnify's LangGraph adapter requires 'langgraph' and 'langchain-core'. "
33
+ 'Install them with: pip install "vitnify[langgraph]"'
34
+ ) from _IMPORT_ERROR
35
+
36
+
37
+ class VRState(TypedDict, total=False):
38
+ prompt: str
39
+ steps: int
40
+ last_text: str
41
+ last_act: Optional[dict]
42
+
43
+
44
+ def initial_prompt(task: str, tools_desc: str) -> str:
45
+ return (f"<|system|>\n{REACT_SYS.format(tools=tools_desc)}</s>\n"
46
+ f"<|user|>\n{task}</s>\n<|assistant|>\n")
47
+
48
+
49
+ def build_react_graph(session, tools_desc: str, tool_names, max_steps: int = 3, n_new: int = 40):
50
+ """Compile a LangGraph StateGraph wired to a VitniReplay Session."""
51
+ _require_langgraph()
52
+
53
+ def agent_node(state: VRState) -> VRState:
54
+ text = session.llm(state["prompt"], n_new=n_new) # recorded + recomputable
55
+ act = parse_action(text, tool_names)
56
+ return {"last_text": text, "last_act": act, "steps": state.get("steps", 0) + 1}
57
+
58
+ def route(state: VRState) -> str:
59
+ act = state.get("last_act")
60
+ if act is None or "final" in act or state.get("steps", 0) >= max_steps:
61
+ return "end"
62
+ return "tools"
63
+
64
+ def tool_node(state: VRState) -> VRState:
65
+ act = state["last_act"]
66
+ ok, obs = session.tool(act["tool"], *act["args"]) # capability wall
67
+ observation = obs if ok else f"DENIED: agent has no capability '{act['tool']}'"
68
+ return {"prompt": state["prompt"] + state["last_text"] + f"\nObservation: {observation}\n"}
69
+
70
+ g = StateGraph(VRState)
71
+ g.add_node("agent", agent_node)
72
+ g.add_node("tools", tool_node)
73
+ g.add_edge(START, "agent")
74
+ g.add_conditional_edges("agent", route, {"tools": "tools", "end": END})
75
+ g.add_edge("tools", "agent")
76
+ return g.compile()
77
+
78
+
79
+ class VitniReplayCallback(BaseCallbackHandler):
80
+ """Observability adapter for ANY LangChain/LangGraph agent: records LLM + tool events
81
+ into the session's event log. Record-only (callbacks can't re-inject for replay)."""
82
+ def __init__(self, session):
83
+ _require_langgraph()
84
+ self.session = session
85
+ self._pending = ("?", "")
86
+
87
+ def on_llm_end(self, response, **kwargs):
88
+ try:
89
+ text = response.generations[0][0].text
90
+ except Exception:
91
+ text = str(response)[:200]
92
+ self.session.log.append(Kind.LLM_CALL, {"text": text, "via": "callback"})
93
+
94
+ def on_tool_start(self, serialized, input_str, **kwargs):
95
+ name = serialized.get("name", "?") if isinstance(serialized, dict) else "?"
96
+ self._pending = (name, str(input_str))
97
+
98
+ def on_tool_end(self, output, **kwargs):
99
+ name, inp = self._pending
100
+ self.session.log.append(Kind.TOOL_CALL,
101
+ {"tool": name, "args": [inp], "decision": "OBSERVED", "result": str(output)[:200]})