sebbi 1.1.0__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.
- sebbi-1.1.0/.gitignore +2 -0
- sebbi-1.1.0/LICENSE +21 -0
- sebbi-1.1.0/PKG-INFO +130 -0
- sebbi-1.1.0/README.md +108 -0
- sebbi-1.1.0/pyproject.toml +36 -0
- sebbi-1.1.0/src/sebbi/__init__.py +454 -0
- sebbi-1.1.0/src/sebbi/__main__.py +5 -0
- sebbi-1.1.0/src/sebbi/integrations/__init__.py +1 -0
- sebbi-1.1.0/src/sebbi/integrations/crewai.py +20 -0
- sebbi-1.1.0/src/sebbi/integrations/langchain.py +54 -0
- sebbi-1.1.0/src/sebbi/integrations/mcp.py +15 -0
- sebbi-1.1.0/src/sebbi/integrations/openai_agents.py +23 -0
- sebbi-1.1.0/src/sebbi/py.typed +0 -0
sebbi-1.1.0/.gitignore
ADDED
sebbi-1.1.0/LICENSE
ADDED
|
@@ -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.
|
sebbi-1.1.0/PKG-INFO
ADDED
|
@@ -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.
|
sebbi-1.1.0/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# sebbi: a receipt for every AI agent action
|
|
2
|
+
|
|
3
|
+
Your agent refunds a customer, sends an email, moves money or answers a patient.
|
|
4
|
+
Add one line and every one of those actions gets a **sealed receipt with a public link anyone can check**:
|
|
5
|
+
your customer, your auditor, your insurer, a regulator.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install sebbi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import sebbi
|
|
13
|
+
|
|
14
|
+
@sebbi.receipt("refund-decision")
|
|
15
|
+
def decide_refund(order):
|
|
16
|
+
return {"refund": True, "amount": 42}
|
|
17
|
+
|
|
18
|
+
decide_refund({"id": 7})
|
|
19
|
+
print(sebbi.last().url) # https://sebbi.pro/r/... anyone can check it
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No account needed to start. Zero dependencies. Python 3.8+.
|
|
23
|
+
|
|
24
|
+
## Why teams add it
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
- **Your data stays yours.** Inputs and outputs are fingerprinted (SHA-256) on your machine. Only fingerprints, a short label and a timestamp are sent.
|
|
28
|
+
- **Never slows your agent.** Sealing runs on a background thread in batches. If the network is down, your code carries on.
|
|
29
|
+
- **A link anyone can check.** `receipt.url` works straight away. Put it in the email, the ticket or the audit file.
|
|
30
|
+
- **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.
|
|
31
|
+
|
|
32
|
+
## Your framework, one line
|
|
33
|
+
|
|
34
|
+
**LangChain / LangGraph**
|
|
35
|
+
```python
|
|
36
|
+
from sebbi.integrations.langchain import SebbiCallbackHandler
|
|
37
|
+
agent.invoke({"input": "..."}, config={"callbacks": [SebbiCallbackHandler()]})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**CrewAI**
|
|
41
|
+
```python
|
|
42
|
+
from sebbi.integrations.crewai import step_callback, task_callback
|
|
43
|
+
crew = Crew(agents=[...], tasks=[...], step_callback=step_callback, task_callback=task_callback)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**OpenAI Agents SDK**
|
|
47
|
+
```python
|
|
48
|
+
from sebbi.integrations.openai_agents import SebbiRunHooks
|
|
49
|
+
result = await Runner.run(agent, "...", hooks=SebbiRunHooks())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**MCP servers**
|
|
53
|
+
```python
|
|
54
|
+
from sebbi.integrations.mcp import sealed
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
@sealed("lookup_customer")
|
|
58
|
+
def lookup_customer(customer_id: str) -> dict: ...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Anything else**
|
|
62
|
+
```python
|
|
63
|
+
r = sebbi.seal(input=request, output=result, label="send-invoice")
|
|
64
|
+
r.wait() # optional: block until sealed
|
|
65
|
+
print(r.url, r.sealed, r.block_index)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Async functions work with the same decorator. Exceptions are sealed with outcome `error` and re-raised unchanged.
|
|
69
|
+
|
|
70
|
+
## Reversing an action
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
r = sebbi.seal(order, refund, "refund")
|
|
74
|
+
...
|
|
75
|
+
sebbi.reverse(r, "Customer cancelled; refund issued")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
## Settings
|
|
81
|
+
|
|
82
|
+
| Environment | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `SEBBI_API_KEY` | Your key from https://sebbi.pro/connect. Optional. |
|
|
85
|
+
| `SEBBI_DISABLED=1` | Turn every receipt into a no-op (tests, local dev). |
|
|
86
|
+
| `SEBBI_ENDPOINT` | Override the API address. |
|
|
87
|
+
| `SEBBI_OWNER_SECRET` | Fix the owner secret (serverless, several machines). |
|
|
88
|
+
|
|
89
|
+
Or in code: `sebbi.configure(api_key="...")`.
|
|
90
|
+
|
|
91
|
+
## Pricing
|
|
92
|
+
|
|
93
|
+
- **Free lane:** 1,000 receipts a day, no account, no key.
|
|
94
|
+
- **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.
|
|
95
|
+
- **Checking any receipt is always free.**
|
|
96
|
+
|
|
97
|
+
## Command line
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
sebbi seal "hello world" my-label # prints the receipt link
|
|
101
|
+
sebbi check r3f9... # VALID / INVALID
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Check a receipt yourself
|
|
105
|
+
|
|
106
|
+
`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.
|
|
107
|
+
|
|
108
|
+
Docs: https://sebbi.pro/receipts · MCP server: https://sebbi.pro/mcp · Made by Monop Content.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sebbi"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "A receipt for every AI agent action. One line: every tool call gets a sealed, publicly checkable receipt."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
authors = [{ name = "Monop Content", email = "justrightdecorators@gmail.com" }]
|
|
14
|
+
keywords = ["ai", "agents", "audit", "receipts", "langchain", "crewai", "mcp", "openai-agents", "governance", "eu-ai-act", "compliance", "provenance", "merkle"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Security :: Cryptography",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://sebbi.pro/receipts"
|
|
28
|
+
Documentation = "https://sebbi.pro/receipts"
|
|
29
|
+
"Check a receipt" = "https://sebbi.pro/receipts"
|
|
30
|
+
"MCP server" = "https://sebbi.pro/mcp"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
sebbi = "sebbi:_cli"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/sebbi"]
|
|
@@ -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
|
|
@@ -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")))
|
|
File without changes
|