sebbi 1.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.
sebbi/__init__.py ADDED
@@ -0,0 +1,454 @@
1
+ """
2
+ sebbi - a receipt for every AI agent action.
3
+
4
+ pip install sebbi
5
+
6
+ import sebbi
7
+
8
+ @sebbi.receipt("refund-decision")
9
+ def decide_refund(order):
10
+ return {"refund": True}
11
+
12
+ decide_refund({"id": 7})
13
+ print(sebbi.last().url) # https://sebbi.pro/r/...
14
+
15
+ Every call is fingerprinted on your machine (SHA-256 of canonical JSON).
16
+ Only the fingerprints, a short label and a timestamp are sent. The data
17
+ itself never leaves your process. Sealing happens on a background thread
18
+ in batches, so your code never waits for it and never fails because of it.
19
+
20
+ Works with no account (free lane). Set SEBBI_API_KEY for full volume and
21
+ receipts tied to your sebbi.pro account: https://sebbi.pro/connect
22
+
23
+ Docs: https://sebbi.pro/receipts
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import atexit
29
+ import functools
30
+ import hashlib
31
+ import hmac
32
+ import inspect
33
+ import json
34
+ import os
35
+ import queue
36
+ import threading
37
+ import time
38
+ import urllib.error
39
+ import urllib.request
40
+ import uuid
41
+ from typing import Any, Callable, Dict, List, Optional
42
+
43
+ __version__ = "1.1.0"
44
+ __all__ = ["receipt", "seal", "reverse", "last", "flush", "configure", "stats", "fingerprint", "Receipt"]
45
+
46
+ SITE = "https://sebbi.pro"
47
+ _UA = "sebbi-python/" + __version__
48
+
49
+
50
+ # ------------------------------------------------------------------ config
51
+ class _Config:
52
+ def __init__(self) -> None:
53
+ e = os.environ.get
54
+ self.api_key: str = e("SEBBI_API_KEY", "")
55
+ self.endpoint: str = e("SEBBI_ENDPOINT", SITE + "/v1/receipts")
56
+ self.enabled: bool = e("SEBBI_DISABLED", "").strip().lower() not in ("1", "true", "yes", "on")
57
+ self.timeout: float = float(e("SEBBI_TIMEOUT", "10"))
58
+ self.client: str = _UA
59
+
60
+
61
+ _cfg = _Config()
62
+
63
+
64
+ # ------------------------------------------------------------- ownership
65
+ # Each receipt carries a commitment to a secret only this installation holds,
66
+ # so only you can reverse your receipts. The secret per receipt is derived
67
+ # from one master secret, so revealing it to reverse one receipt reveals
68
+ # nothing about any other.
69
+ def _master() -> bytes:
70
+ env = os.environ.get("SEBBI_OWNER_SECRET", "")
71
+ if env:
72
+ return hashlib.sha256(env.encode()).digest()
73
+ path = os.path.join(os.path.expanduser("~"), ".sebbi", "owner_secret")
74
+ try:
75
+ with open(path, "rb") as f:
76
+ v = f.read().strip()
77
+ if len(v) >= 32:
78
+ return v
79
+ except Exception:
80
+ pass
81
+ v = uuid.uuid4().hex.encode() + uuid.uuid4().hex.encode()
82
+ try:
83
+ os.makedirs(os.path.dirname(path), exist_ok=True)
84
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
85
+ with os.fdopen(fd, "wb") as f:
86
+ f.write(v)
87
+ except Exception:
88
+ pass # read-only filesystem: this process can still reverse what it sealed
89
+ return v
90
+
91
+
92
+ _MASTER = [b""]
93
+
94
+
95
+ def _secret_for(rid: str) -> str:
96
+ if not _MASTER[0]:
97
+ _MASTER[0] = _master()
98
+ return hmac.new(_MASTER[0], ("sebbi-owner|" + rid).encode(), hashlib.sha256).hexdigest()
99
+
100
+
101
+ def configure(api_key: Optional[str] = None, endpoint: Optional[str] = None,
102
+ enabled: Optional[bool] = None, timeout: Optional[float] = None,
103
+ client: Optional[str] = None) -> None:
104
+ """Override settings in code. Environment: SEBBI_API_KEY, SEBBI_ENDPOINT, SEBBI_DISABLED."""
105
+ if api_key is not None:
106
+ _cfg.api_key = api_key
107
+ if endpoint is not None:
108
+ _cfg.endpoint = endpoint
109
+ if enabled is not None:
110
+ _cfg.enabled = bool(enabled)
111
+ if timeout is not None:
112
+ _cfg.timeout = float(timeout)
113
+ if client is not None:
114
+ _cfg.client = client
115
+
116
+
117
+ # ------------------------------------------------------------ fingerprints
118
+ def _canonical(o: Any, depth: int = 0) -> Any:
119
+ if depth > 32:
120
+ return "__sebbi_deep__"
121
+ if o is None or isinstance(o, (bool, int, str)):
122
+ return o
123
+ if isinstance(o, float):
124
+ return o if o == o and o not in (float("inf"), float("-inf")) else str(o)
125
+ if isinstance(o, dict):
126
+ return {str(k): _canonical(v, depth + 1) for k, v in o.items()}
127
+ if isinstance(o, (list, tuple)):
128
+ return [_canonical(v, depth + 1) for v in o]
129
+ if isinstance(o, (set, frozenset)):
130
+ return sorted((_canonical(v, depth + 1) for v in o), key=lambda x: json.dumps(x, sort_keys=True))
131
+ if isinstance(o, bytes):
132
+ return {"__bytes_sha256__": hashlib.sha256(o).hexdigest()}
133
+ for attr in ("model_dump", "dict", "to_dict"):
134
+ f = getattr(o, attr, None)
135
+ if callable(f):
136
+ try:
137
+ return _canonical(f(), depth + 1)
138
+ except Exception:
139
+ pass
140
+ if hasattr(o, "__dict__"):
141
+ try:
142
+ return {"__type__": type(o).__name__, **_canonical(vars(o), depth + 1)}
143
+ except Exception:
144
+ pass
145
+ return {"__type__": type(o).__name__}
146
+
147
+
148
+ def fingerprint(value: Any) -> str:
149
+ """SHA-256 hex of the canonical JSON of value (keys sorted, compact, ASCII)."""
150
+ s = json.dumps(_canonical(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
151
+ return hashlib.sha256(s.encode("ascii")).hexdigest()
152
+
153
+
154
+ # ------------------------------------------------------------------ receipt
155
+ class Receipt:
156
+ """One sealed action. `url` is usable straight away; `wait()` blocks until sealed."""
157
+
158
+ __slots__ = ("id", "label", "input_hash", "output_hash", "hash", "ts", "outcome", "sealed", "error",
159
+ "block_index", "merkle_root", "lane", "reverses", "reason", "_event")
160
+
161
+ def __init__(self, label: str, input_hash: str, output_hash: str, outcome: str = "ok") -> None:
162
+ self.id = "r" + uuid.uuid4().hex[:23]
163
+ self.label = (label or "")[:80]
164
+ self.input_hash = input_hash
165
+ self.output_hash = output_hash
166
+ self.hash = hashlib.sha256((input_hash + ":" + output_hash).encode("ascii")).hexdigest()
167
+ self.ts = time.time()
168
+ self.outcome = outcome
169
+ self.sealed = False
170
+ self.error: Optional[str] = None
171
+ self.block_index: Optional[int] = None
172
+ self.merkle_root: Optional[str] = None
173
+ self.lane: Optional[str] = None
174
+ self.reverses: Optional[str] = None
175
+ self.reason: str = ""
176
+ self._event = threading.Event()
177
+
178
+ @property
179
+ def url(self) -> str:
180
+ return SITE + "/r/" + self.id
181
+
182
+ def wait(self, timeout: float = 15.0) -> "Receipt":
183
+ _sender.kick()
184
+ self._event.wait(timeout)
185
+ return self
186
+
187
+ def wire(self) -> Dict[str, Any]:
188
+ d = {"local_id": self.id, "label": self.label, "input_hash": self.input_hash,
189
+ "output_hash": self.output_hash, "hash": self.hash, "ts": self.ts, "outcome": self.outcome,
190
+ "owner": hashlib.sha256(_secret_for(self.id).encode()).hexdigest()}
191
+ if self.reverses:
192
+ d.update(reverses=self.reverses, reason=self.reason, owner_secret=_secret_for(self.reverses))
193
+ return d
194
+
195
+ def to_dict(self) -> Dict[str, Any]:
196
+ d = self.wire()
197
+ d.update(id=self.id, url=self.url, sealed=self.sealed, error=self.error, block_index=self.block_index,
198
+ merkle_root=self.merkle_root, lane=self.lane)
199
+ for k in ("local_id", "owner", "owner_secret"):
200
+ d.pop(k, None)
201
+ return d
202
+
203
+ def __repr__(self) -> str:
204
+ return "<Receipt %s %s %s>" % (self.id, self.label or "-", "sealed" if self.sealed else (self.error or "pending"))
205
+
206
+
207
+ # ------------------------------------------------------------------ sender
208
+ class _Sender:
209
+ BATCH = 100
210
+ INTERVAL = 1.0
211
+
212
+ def __init__(self) -> None:
213
+ self.q: "queue.Queue[Receipt]" = queue.Queue(maxsize=50000)
214
+ self.lock = threading.Lock()
215
+ self.thread: Optional[threading.Thread] = None
216
+ self.wake = threading.Event()
217
+ self.counts = {"queued": 0, "sealed": 0, "failed": 0, "dropped": 0}
218
+ self.notice: Optional[str] = None
219
+ self.inflight = 0
220
+
221
+ def start(self) -> None:
222
+ with self.lock:
223
+ if self.thread and self.thread.is_alive():
224
+ return
225
+ self.thread = threading.Thread(target=self._run, name="sebbi-sender", daemon=True)
226
+ self.thread.start()
227
+
228
+ def put(self, r: Receipt) -> None:
229
+ self.start()
230
+ try:
231
+ self.q.put_nowait(r)
232
+ self.counts["queued"] += 1
233
+ except queue.Full:
234
+ r.error = "queue_full"
235
+ self.counts["dropped"] += 1
236
+ r._event.set()
237
+
238
+ def kick(self) -> None:
239
+ self.wake.set()
240
+
241
+ def _run(self) -> None:
242
+ while True:
243
+ self.wake.wait(self.INTERVAL)
244
+ self.wake.clear()
245
+ self._drain()
246
+
247
+ def _drain(self) -> None:
248
+ while True:
249
+ batch: List[Receipt] = []
250
+ try:
251
+ while len(batch) < self.BATCH:
252
+ batch.append(self.q.get_nowait())
253
+ except queue.Empty:
254
+ pass
255
+ if not batch:
256
+ return
257
+ self.inflight += len(batch)
258
+ try:
259
+ self._send(batch)
260
+ finally:
261
+ self.inflight -= len(batch)
262
+
263
+ def _send(self, batch: List[Receipt]) -> None:
264
+ body = json.dumps({"records": [r.wire() for r in batch], "sdk": _cfg.client}).encode("utf-8")
265
+ headers = {"Content-Type": "application/json", "Accept": "application/json", "User-Agent": _cfg.client}
266
+ if _cfg.api_key:
267
+ headers["Authorization"] = "Bearer " + _cfg.api_key
268
+ err = None
269
+ for attempt in range(4):
270
+ try:
271
+ req = urllib.request.Request(_cfg.endpoint, data=body, headers=headers, method="POST")
272
+ with urllib.request.urlopen(req, timeout=_cfg.timeout) as resp:
273
+ doc = json.loads(resp.read().decode("utf-8", "replace") or "{}")
274
+ self._apply(batch, doc)
275
+ return
276
+ except urllib.error.HTTPError as e:
277
+ err = "http_%d" % e.code
278
+ try:
279
+ doc = json.loads(e.read().decode("utf-8", "replace") or "{}")
280
+ if doc.get("error"):
281
+ err = "%s:%s" % (err, doc["error"])
282
+ if doc.get("message"):
283
+ self.notice = doc["message"]
284
+ except Exception:
285
+ pass
286
+ if e.code < 500 and e.code != 429:
287
+ break
288
+ except Exception as e: # network, timeout
289
+ err = type(e).__name__
290
+ time.sleep(min(8.0, 0.5 * (2 ** attempt)))
291
+ for r in batch:
292
+ r.error = err
293
+ r._event.set()
294
+ self.counts["failed"] += len(batch)
295
+
296
+ def _apply(self, batch: List[Receipt], doc: Dict[str, Any]) -> None:
297
+ if doc.get("message"):
298
+ self.notice = doc["message"]
299
+ by = {str(x.get("local_id")): x for x in (doc.get("records") or []) if isinstance(x, dict)}
300
+ for r in batch:
301
+ x = by.get(r.id)
302
+ if x:
303
+ r.id = x.get("id") or r.id
304
+ r.sealed = True
305
+ r.block_index = x.get("block_index")
306
+ r.merkle_root = x.get("merkle_root")
307
+ r.lane = doc.get("lane")
308
+ self.counts["sealed"] += 1
309
+ else:
310
+ r.error = "not_in_response"
311
+ self.counts["failed"] += 1
312
+ r._event.set()
313
+
314
+
315
+ _sender = _Sender()
316
+ _last: List[Optional[Receipt]] = [None]
317
+
318
+
319
+ def flush(timeout: float = 5.0) -> bool:
320
+ """Send everything queued now. Returns True if the queue emptied in time."""
321
+ if _sender.thread is None:
322
+ return True
323
+ end = time.time() + timeout
324
+ _sender.kick()
325
+ while time.time() < end:
326
+ if _sender.q.empty() and _sender.inflight == 0:
327
+ return True
328
+ time.sleep(0.05)
329
+ return False
330
+
331
+
332
+ atexit.register(lambda: flush(3.0))
333
+
334
+
335
+ def stats() -> Dict[str, Any]:
336
+ d = dict(_sender.counts)
337
+ d.update(pending=_sender.q.qsize(), endpoint=_cfg.endpoint, keyed=bool(_cfg.api_key), notice=_sender.notice)
338
+ return d
339
+
340
+
341
+ def last() -> Optional[Receipt]:
342
+ """The most recent receipt created in this process."""
343
+ return _last[0]
344
+
345
+
346
+ # ------------------------------------------------------------------ API
347
+ def seal(input: Any = None, output: Any = None, label: str = "", outcome: str = "ok") -> Receipt:
348
+ """Seal one action. Returns its Receipt immediately; sealing finishes in the background."""
349
+ r = Receipt(label, fingerprint(input), fingerprint(output), outcome)
350
+ _last[0] = r
351
+ if _cfg.enabled:
352
+ _sender.put(r)
353
+ else:
354
+ r.error = "disabled"
355
+ r._event.set()
356
+ return r
357
+
358
+
359
+ def reverse(original: Any, reason: str, output: Any = None, label: str = "") -> Receipt:
360
+ """
361
+ Reverse an action you sealed earlier (a refund, a cancelled payment, a
362
+ withdrawn decision). Nothing is deleted: the original receipt stays sealed
363
+ and is shown as REVERSED, linked to this new reversal receipt. The reason
364
+ is public on both. Only the installation (or account key) that sealed the
365
+ original can reverse it.
366
+ """
367
+ oid = original.id if isinstance(original, Receipt) else str(original)
368
+ if not reason:
369
+ raise ValueError("a reversal needs a reason; it is shown on both receipts")
370
+ r = Receipt(label or ("reversal of " + oid), fingerprint({"reverses": oid}), fingerprint(output), "reversal")
371
+ r.reverses = oid
372
+ r.reason = str(reason)[:300]
373
+ _last[0] = r
374
+ if _cfg.enabled:
375
+ _sender.put(r)
376
+ else:
377
+ r.error = "disabled"
378
+ r._event.set()
379
+ return r
380
+
381
+
382
+ def _inputs(fn: Callable, args: tuple, kwargs: dict) -> Any:
383
+ try:
384
+ b = inspect.signature(fn).bind_partial(*args, **kwargs)
385
+ d = dict(b.arguments)
386
+ d.pop("self", None)
387
+ d.pop("cls", None)
388
+ return d
389
+ except Exception:
390
+ return {"args": list(args), "kwargs": kwargs}
391
+
392
+
393
+ def receipt(label: Any = None) -> Any:
394
+ """
395
+ Decorator. @sebbi.receipt, @sebbi.receipt() or @sebbi.receipt("label").
396
+ Works on plain and async functions. Exceptions are sealed as outcome
397
+ "error" and re-raised unchanged.
398
+ """
399
+ def deco(fn: Callable) -> Callable:
400
+ name = label if isinstance(label, str) and label else getattr(fn, "__name__", "action")
401
+
402
+ if inspect.iscoroutinefunction(fn):
403
+ @functools.wraps(fn)
404
+ async def aw(*a: Any, **k: Any) -> Any:
405
+ inp = _inputs(fn, a, k)
406
+ try:
407
+ out = await fn(*a, **k)
408
+ except Exception as e:
409
+ seal(inp, {"error_type": type(e).__name__}, name, "error")
410
+ raise
411
+ seal(inp, out, name)
412
+ return out
413
+ return aw
414
+
415
+ @functools.wraps(fn)
416
+ def w(*a: Any, **k: Any) -> Any:
417
+ inp = _inputs(fn, a, k)
418
+ try:
419
+ out = fn(*a, **k)
420
+ except Exception as e:
421
+ seal(inp, {"error_type": type(e).__name__}, name, "error")
422
+ raise
423
+ seal(inp, out, name)
424
+ return out
425
+ return w
426
+
427
+ if callable(label) and not isinstance(label, str):
428
+ fn, label = label, None
429
+ return deco(fn)
430
+ return deco
431
+
432
+
433
+ def _cli() -> int:
434
+ import sys
435
+ a = sys.argv[1:]
436
+ if not a or a[0] in ("-h", "--help"):
437
+ print("sebbi %s - a receipt for every AI agent action\n\n sebbi seal <text> [label] seal a fingerprint of <text>, print the receipt link\n"
438
+ " sebbi check <id> check a receipt\n\nDocs: %s/receipts" % (__version__, SITE))
439
+ return 0
440
+ if a[0] == "seal" and len(a) > 1:
441
+ r = seal(a[1], None, a[2] if len(a) > 2 else "cli").wait()
442
+ print(r.url if r.sealed else "not sealed: %s %s" % (r.error, _sender.notice or ""))
443
+ return 0 if r.sealed else 1
444
+ if a[0] == "check" and len(a) > 1:
445
+ try:
446
+ with urllib.request.urlopen(SITE + "/v1/receipts/" + a[1], timeout=15) as resp:
447
+ d = json.loads(resp.read().decode())
448
+ print(("VALID" if d.get("valid") else "INVALID") + " " + json.dumps(d.get("checks")))
449
+ return 0 if d.get("valid") else 1
450
+ except urllib.error.HTTPError as e:
451
+ print("not found (%d)" % e.code)
452
+ return 1
453
+ print("unknown command; sebbi --help")
454
+ return 2
sebbi/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from sebbi import _cli
4
+
5
+ sys.exit(_cli())
@@ -0,0 +1 @@
1
+ """Ready-made hooks: langchain, crewai, mcp, openai_agents. Each imports its framework lazily."""
@@ -0,0 +1,20 @@
1
+ """
2
+ CrewAI: a receipt for every agent step and every finished task.
3
+
4
+ from sebbi.integrations.crewai import step_callback, task_callback
5
+ crew = Crew(agents=[...], tasks=[...], step_callback=step_callback, task_callback=task_callback)
6
+ """
7
+ from typing import Any
8
+
9
+ import sebbi
10
+
11
+
12
+ def step_callback(step: Any) -> None:
13
+ tool = getattr(step, "tool", None)
14
+ label = ("crewai:" + str(tool)) if tool else "crewai-step"
15
+ sebbi.seal(getattr(step, "tool_input", None) or getattr(step, "thought", None), step, label[:80])
16
+
17
+
18
+ def task_callback(output: Any) -> None:
19
+ desc = str(getattr(output, "description", "") or "task")
20
+ sebbi.seal(desc, getattr(output, "raw", output), ("crewai-task:" + desc)[:80])
@@ -0,0 +1,54 @@
1
+ """
2
+ LangChain / LangGraph: a receipt for every tool call (and, optionally, every model answer).
3
+
4
+ from sebbi.integrations.langchain import SebbiCallbackHandler
5
+ agent.invoke({"input": "..."}, config={"callbacks": [SebbiCallbackHandler()]})
6
+ """
7
+ from typing import Any, Dict, Optional
8
+ from uuid import UUID
9
+
10
+ import sebbi
11
+
12
+ try: # langchain-core is optional; the handler is duck-typed if it is absent
13
+ from langchain_core.callbacks import BaseCallbackHandler as _Base # type: ignore
14
+ except Exception: # pragma: no cover
15
+ _Base = object # type: ignore
16
+
17
+
18
+ class SebbiCallbackHandler(_Base): # type: ignore[misc]
19
+ """Seals tool calls. Pass llm=True to also seal every model answer."""
20
+
21
+ raise_error = False
22
+ run_inline = True
23
+
24
+ def __init__(self, llm: bool = False, prefix: str = "") -> None:
25
+ if _Base is not object:
26
+ super().__init__()
27
+ self.llm = llm
28
+ self.prefix = prefix
29
+ self._open: Dict[str, Any] = {}
30
+
31
+ def on_tool_start(self, serialized: Optional[Dict[str, Any]], input_str: str, *, run_id: UUID, **kw: Any) -> None:
32
+ name = (serialized or {}).get("name") or kw.get("name") or "tool"
33
+ self._open[str(run_id)] = (name, kw.get("inputs") or input_str)
34
+
35
+ def on_tool_end(self, output: Any, *, run_id: UUID, **kw: Any) -> None:
36
+ name, inp = self._open.pop(str(run_id), ("tool", None))
37
+ sebbi.seal(inp, getattr(output, "content", output), self.prefix + name)
38
+
39
+ def on_tool_error(self, error: BaseException, *, run_id: UUID, **kw: Any) -> None:
40
+ name, inp = self._open.pop(str(run_id), ("tool", None))
41
+ sebbi.seal(inp, {"error_type": type(error).__name__}, self.prefix + name, "error")
42
+
43
+ def on_llm_start(self, serialized: Optional[Dict[str, Any]], prompts: Any, *, run_id: UUID, **kw: Any) -> None:
44
+ if self.llm:
45
+ self._open[str(run_id)] = ("llm", prompts)
46
+
47
+ def on_chat_model_start(self, serialized: Optional[Dict[str, Any]], messages: Any, *, run_id: UUID, **kw: Any) -> None:
48
+ if self.llm:
49
+ self._open[str(run_id)] = ("llm", messages)
50
+
51
+ def on_llm_end(self, response: Any, *, run_id: UUID, **kw: Any) -> None:
52
+ if self.llm and str(run_id) in self._open:
53
+ name, inp = self._open.pop(str(run_id))
54
+ sebbi.seal(inp, response, self.prefix + name)
@@ -0,0 +1,15 @@
1
+ """
2
+ MCP servers (FastMCP / the official Python SDK): a receipt for every tool call.
3
+
4
+ from sebbi.integrations.mcp import sealed
5
+
6
+ @mcp.tool()
7
+ @sealed("lookup_customer")
8
+ def lookup_customer(customer_id: str) -> dict:
9
+ ...
10
+
11
+ The function's signature is preserved, so the MCP schema is unchanged.
12
+ """
13
+ import sebbi
14
+
15
+ sealed = sebbi.receipt
@@ -0,0 +1,23 @@
1
+ """
2
+ OpenAI Agents SDK: a receipt for every tool call and every final answer.
3
+
4
+ from sebbi.integrations.openai_agents import SebbiRunHooks
5
+ result = await Runner.run(agent, "...", hooks=SebbiRunHooks())
6
+ """
7
+ from typing import Any
8
+
9
+ import sebbi
10
+
11
+ try:
12
+ from agents import RunHooks as _Base # type: ignore
13
+ except Exception: # pragma: no cover
14
+ _Base = object # type: ignore
15
+
16
+
17
+ class SebbiRunHooks(_Base): # type: ignore[misc]
18
+ async def on_tool_end(self, context: Any, agent: Any, tool: Any, result: Any) -> None:
19
+ sebbi.seal({"agent": getattr(agent, "name", None), "tool": getattr(tool, "name", None)}, result,
20
+ "tool:" + str(getattr(tool, "name", "tool")))
21
+
22
+ async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
23
+ sebbi.seal({"agent": getattr(agent, "name", None)}, output, "agent:" + str(getattr(agent, "name", "agent")))
sebbi/py.typed ADDED
File without changes
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: sebbi
3
+ Version: 1.1.0
4
+ Summary: A receipt for every AI agent action. One line: every tool call gets a sealed, publicly checkable receipt.
5
+ Project-URL: Homepage, https://sebbi.pro/receipts
6
+ Project-URL: Documentation, https://sebbi.pro/receipts
7
+ Project-URL: Check a receipt, https://sebbi.pro/receipts
8
+ Project-URL: MCP server, https://sebbi.pro/mcp
9
+ Author-email: Monop Content <justrightdecorators@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,ai,audit,compliance,crewai,eu-ai-act,governance,langchain,mcp,merkle,openai-agents,provenance,receipts
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sebbi: a receipt for every AI agent action
24
+
25
+ Your agent refunds a customer, sends an email, moves money or answers a patient.
26
+ Add one line and every one of those actions gets a **sealed receipt with a public link anyone can check**:
27
+ your customer, your auditor, your insurer, a regulator.
28
+
29
+ ```bash
30
+ pip install sebbi
31
+ ```
32
+
33
+ ```python
34
+ import sebbi
35
+
36
+ @sebbi.receipt("refund-decision")
37
+ def decide_refund(order):
38
+ return {"refund": True, "amount": 42}
39
+
40
+ decide_refund({"id": 7})
41
+ print(sebbi.last().url) # https://sebbi.pro/r/... anyone can check it
42
+ ```
43
+
44
+ No account needed to start. Zero dependencies. Python 3.8+.
45
+
46
+ ## Why teams add it
47
+
48
+ - **Proof, not logs.** Each receipt sits in a Merkle tree sealed into the sebbi.pro chain, witnessed by independent chains, timestamped into Bitcoin and signed post-quantum. Nobody can quietly edit it afterwards.
49
+ - **Your data stays yours.** Inputs and outputs are fingerprinted (SHA-256) on your machine. Only fingerprints, a short label and a timestamp are sent.
50
+ - **Never slows your agent.** Sealing runs on a background thread in batches. If the network is down, your code carries on.
51
+ - **A link anyone can check.** `receipt.url` works straight away. Put it in the email, the ticket or the audit file.
52
+ - **Ready for the EU AI Act and UK GDPR.** Record-keeping, logging and the right to an explanation all start with being able to show what your AI actually did.
53
+
54
+ ## Your framework, one line
55
+
56
+ **LangChain / LangGraph**
57
+ ```python
58
+ from sebbi.integrations.langchain import SebbiCallbackHandler
59
+ agent.invoke({"input": "..."}, config={"callbacks": [SebbiCallbackHandler()]})
60
+ ```
61
+
62
+ **CrewAI**
63
+ ```python
64
+ from sebbi.integrations.crewai import step_callback, task_callback
65
+ crew = Crew(agents=[...], tasks=[...], step_callback=step_callback, task_callback=task_callback)
66
+ ```
67
+
68
+ **OpenAI Agents SDK**
69
+ ```python
70
+ from sebbi.integrations.openai_agents import SebbiRunHooks
71
+ result = await Runner.run(agent, "...", hooks=SebbiRunHooks())
72
+ ```
73
+
74
+ **MCP servers**
75
+ ```python
76
+ from sebbi.integrations.mcp import sealed
77
+
78
+ @mcp.tool()
79
+ @sealed("lookup_customer")
80
+ def lookup_customer(customer_id: str) -> dict: ...
81
+ ```
82
+
83
+ **Anything else**
84
+ ```python
85
+ r = sebbi.seal(input=request, output=result, label="send-invoice")
86
+ r.wait() # optional: block until sealed
87
+ print(r.url, r.sealed, r.block_index)
88
+ ```
89
+
90
+ Async functions work with the same decorator. Exceptions are sealed with outcome `error` and re-raised unchanged.
91
+
92
+ ## Reversing an action
93
+
94
+ ```python
95
+ r = sebbi.seal(order, refund, "refund")
96
+ ...
97
+ sebbi.reverse(r, "Customer cancelled; refund issued")
98
+ ```
99
+
100
+ Refunds, cancellations and withdrawn decisions happen. A reversal never deletes anything: the original receipt stays sealed and valid, and its page shows **Reversed**, the reason and a link to the sealed reversal receipt. Only whoever sealed the original (this installation, or the same account key) can reverse it, and a reversal cannot itself be reversed.
101
+
102
+ ## Settings
103
+
104
+ | Environment | Meaning |
105
+ |---|---|
106
+ | `SEBBI_API_KEY` | Your key from https://sebbi.pro/connect. Optional. |
107
+ | `SEBBI_DISABLED=1` | Turn every receipt into a no-op (tests, local dev). |
108
+ | `SEBBI_ENDPOINT` | Override the API address. |
109
+ | `SEBBI_OWNER_SECRET` | Fix the owner secret (serverless, several machines). |
110
+
111
+ Or in code: `sebbi.configure(api_key="...")`.
112
+
113
+ ## Pricing
114
+
115
+ - **Free lane:** 1,000 receipts a day, no account, no key.
116
+ - **With a sebbi.pro account:** up to 100,000 a day, receipts tied to your account and your evidence packs. 7 days free, then 50p per device per month.
117
+ - **Checking any receipt is always free.**
118
+
119
+ ## Command line
120
+
121
+ ```bash
122
+ sebbi seal "hello world" my-label # prints the receipt link
123
+ sebbi check r3f9... # VALID / INVALID
124
+ ```
125
+
126
+ ## Check a receipt yourself
127
+
128
+ `GET https://sebbi.pro/v1/receipts/<id>` returns the leaf, the RFC 6962 inclusion path, the Merkle root and the chain block. The receipt page re-runs every check and lets anyone paste the original data to confirm it matches, fingerprinted in their own browser.
129
+
130
+ Docs: https://sebbi.pro/receipts · MCP server: https://sebbi.pro/mcp · Made by Monop Content.
@@ -0,0 +1,13 @@
1
+ sebbi/__init__.py,sha256=SV03uxdI8SeZvkzmsfHgtICg4D20K5z56zoQFJSldes,16330
2
+ sebbi/__main__.py,sha256=27geQcs_n-Z7dkTHN9rEP5xf3LP7VehgPkTTWudpaoU,53
3
+ sebbi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sebbi/integrations/__init__.py,sha256=RAvbq72glR3FXjZquyNGlQoBsX50lexsjlKYAVt_SXA,98
5
+ sebbi/integrations/crewai.py,sha256=iP3IKWhs32kVblscUwH8md2pWIQUN3M-Uog4q6UeZtU,712
6
+ sebbi/integrations/langchain.py,sha256=LSKLe5c_FTV90kBF2DsagqIo16ysyr54g7VFVABluH4,2333
7
+ sebbi/integrations/mcp.py,sha256=tnK5Aswz7aogkoXbsrM7m-qtMJB59cW70QPk_uCRV-M,355
8
+ sebbi/integrations/openai_agents.py,sha256=NyRM79yNsGGR4RgHFFAmqZ0hbmdU3OlVjBK_laJsGaA,897
9
+ sebbi-1.1.0.dist-info/METADATA,sha256=Zt3PL214fs-efetX7KTWy--MO4UuEitAkmNQCL7Nl8g,5104
10
+ sebbi-1.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ sebbi-1.1.0.dist-info/entry_points.txt,sha256=BIl-EnIho4smEkVyroMYWZxqGXFgasRiD0WHIVtMnhQ,37
12
+ sebbi-1.1.0.dist-info/licenses/LICENSE,sha256=h5kPfqWRJG4wKEFfK4mpPDWSVVEBIrumAlb9PjxSZdI,1070
13
+ sebbi-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sebbi = sebbi:_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Monop Content
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.