aer1kit 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.
aer1kit/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """aer1kit: verifiable execution receipts for hackathon agents."""
2
+
3
+ from .emitter import (
4
+ ReceiptEmitter,
5
+ merkle_root,
6
+ verify_receipt,
7
+ ZAMBO_MCP_URL,
8
+ ZAMBO_VERIFY_URL,
9
+ )
10
+
11
+ __all__ = [
12
+ "ReceiptEmitter",
13
+ "merkle_root",
14
+ "verify_receipt",
15
+ "ZAMBO_MCP_URL",
16
+ "ZAMBO_VERIFY_URL",
17
+ ]
18
+
19
+ __version__ = "0.1.0"
aer1kit/emitter.py ADDED
@@ -0,0 +1,397 @@
1
+ """AER-1 receipt emitter for hackathon builders.
2
+
3
+ Three lines and your agent emits verifiable execution receipts:
4
+
5
+ from aer1kit import ReceiptEmitter
6
+ emitter = ReceiptEmitter(goal="answer the user's question")
7
+ emitter.record("web_search", {"query": "AER-1", "results": 3})
8
+ url = emitter.mint() # live verifiable receipt on zambo.dev
9
+
10
+ No API keys. No signup. The basic flow is fully offline; mint() submits
11
+ the receipt to the free zambo.dev verifier and returns a shareable URL.
12
+
13
+ AER-1 is an IETF draft (draft-zambo-aer1). This emitter implements the
14
+ Section 8 workflow receipt with the Section 8.1 Merkle construction.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import re
22
+ import time
23
+ import uuid
24
+ from datetime import datetime, timezone
25
+
26
+ WORKFLOW_TYPE = "verifiable-workflow-receipt"
27
+ WORKFLOW_VERSION = "1"
28
+
29
+ ZAMBO_MCP_URL = "https://zambo.dev/mcp"
30
+ ZAMBO_VERIFY_URL = "https://zambo.dev/verify"
31
+
32
+ _UUID_RE = re.compile(
33
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z"
34
+ )
35
+ _RFC3339_RE = re.compile(
36
+ r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
37
+ r"(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})\Z"
38
+ )
39
+ _HEX64_RE = re.compile(r"^[0-9a-f]{64}\Z")
40
+
41
+
42
+ def _new_uuid() -> str:
43
+ return str(uuid.uuid4())
44
+
45
+
46
+ def _rfc3339(ts: float) -> str:
47
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
48
+
49
+
50
+ def _json_safe(value):
51
+ if value is None or isinstance(value, (bool, int, float, str)):
52
+ return value
53
+ if isinstance(value, dict):
54
+ return {str(k): _json_safe(v) for k, v in value.items()}
55
+ if isinstance(value, (list, tuple)):
56
+ return [_json_safe(v) for v in value]
57
+ if isinstance(value, set):
58
+ return sorted((_json_safe(v) for v in value), key=repr)
59
+ return str(value)
60
+
61
+
62
+ def _canonical(obj) -> bytes:
63
+ return json.dumps(
64
+ _json_safe(obj),
65
+ sort_keys=True,
66
+ separators=(",", ":"),
67
+ ensure_ascii=False,
68
+ ).encode("utf-8")
69
+
70
+
71
+ def _sha256_hex(data: bytes) -> str:
72
+ return hashlib.sha256(data).hexdigest()
73
+
74
+
75
+ def merkle_root(receipt_ids: list) -> str:
76
+ """AER-1 Section 8.1 normative Merkle construction.
77
+
78
+ leaf = SHA-256 over the UTF-8 bytes of receipt_id; internal node =
79
+ SHA-256 over raw 32-byte left || raw 32-byte right; an odd trailing
80
+ node is duplicated.
81
+ """
82
+ if not receipt_ids:
83
+ return hashlib.sha256(b"").hexdigest()
84
+ level = [hashlib.sha256(rid.encode("utf-8")).digest() for rid in receipt_ids]
85
+ while len(level) > 1:
86
+ if len(level) % 2:
87
+ level.append(level[-1])
88
+ level = [
89
+ hashlib.sha256(level[i] + level[i + 1]).digest()
90
+ for i in range(0, len(level), 2)
91
+ ]
92
+ return level[0].hex()
93
+
94
+
95
+ def verify_receipt(workflow) -> list:
96
+ """Offline-verify an AER-1 workflow receipt.
97
+
98
+ Returns a list of failure reasons; [] means the receipt is valid.
99
+ """
100
+ failures = []
101
+ if not isinstance(workflow, dict):
102
+ return ["workflow is not a JSON object"]
103
+ if workflow.get("type") != WORKFLOW_TYPE:
104
+ failures.append('workflow type is not "verifiable-workflow-receipt"')
105
+ version = workflow.get("version")
106
+ if not isinstance(version, str) or not version:
107
+ failures.append("workflow version is not a non-empty string")
108
+ for field in ("workflow_id", "receipt_id"):
109
+ if not isinstance(workflow.get(field), str) or not _UUID_RE.match(
110
+ workflow.get(field)
111
+ ):
112
+ failures.append(f"workflow {field} is not a lowercase UUID")
113
+ if not isinstance(workflow.get("session_id"), str) or not workflow.get("session_id"):
114
+ failures.append("workflow session_id is not a non-empty string")
115
+ if not isinstance(workflow.get("goal"), str) or not workflow.get("goal"):
116
+ failures.append("workflow goal is not a non-empty string")
117
+ if not isinstance(workflow.get("status"), str) or not workflow.get("status"):
118
+ failures.append("workflow status is not a non-empty string")
119
+ oh = workflow.get("output_hash")
120
+ if not isinstance(oh, str) or not _HEX64_RE.match(oh):
121
+ failures.append("workflow output_hash is not a 64-char lowercase hex digest")
122
+ vu = workflow.get("verify_url")
123
+ if not isinstance(vu, str) or not (
124
+ vu.startswith("http://") or vu.startswith("https://")
125
+ ):
126
+ failures.append("workflow verify_url is not an http(s) URL string")
127
+ steps = workflow.get("steps")
128
+ if not isinstance(steps, list) or not steps:
129
+ failures.append("workflow steps is not a non-empty list")
130
+ return failures
131
+ for i, step in enumerate(steps):
132
+ if not isinstance(step, dict):
133
+ failures.append(f"step {i + 1} is not an object")
134
+ continue
135
+ seq = step.get("seq")
136
+ if isinstance(seq, bool) or not (
137
+ isinstance(seq, int) or (isinstance(seq, float) and seq.is_integer())
138
+ ):
139
+ failures.append(f"step {i + 1} seq is not an integer")
140
+ if not isinstance(step.get("receipt_id"), str):
141
+ failures.append(f"step {i + 1} receipt_id is not a string")
142
+ if not isinstance(step.get("tool"), str) or not step.get("tool"):
143
+ failures.append(f"step {i + 1} tool is not a non-empty string")
144
+ rh = step.get("receipt_hash")
145
+ if not isinstance(rh, str) or not _HEX64_RE.match(rh):
146
+ failures.append(
147
+ f"step {i + 1} receipt_hash is not a 64-char lowercase hex digest"
148
+ )
149
+ for ts_field in ("started_at", "ended_at"):
150
+ if not isinstance(step.get(ts_field), str) or not _RFC3339_RE.match(
151
+ step.get(ts_field)
152
+ ):
153
+ failures.append(
154
+ f"step {i + 1} {ts_field} is not a valid RFC 3339 timestamp"
155
+ )
156
+ if not isinstance(step.get("status"), str) or not step.get("status"):
157
+ failures.append(f"step {i + 1} status is not a non-empty string")
158
+ if failures:
159
+ return failures
160
+ n = len(steps)
161
+ for i, step in enumerate(steps):
162
+ if step["seq"] != i + 1:
163
+ failures.append(
164
+ f"step seq values are not 1..{n} in order "
165
+ f"(index {i} carries seq {step['seq']})"
166
+ )
167
+ break
168
+ seen = set()
169
+ for step in steps:
170
+ rid = step["receipt_id"]
171
+ if rid in seen:
172
+ failures.append(f"duplicate receipt_id: {rid}")
173
+ break
174
+ seen.add(rid)
175
+ root = merkle_root([step["receipt_id"] for step in steps])
176
+ mr = workflow.get("merkle_root")
177
+ if not isinstance(mr, str) or mr != root:
178
+ failures.append("merkle_root does not match the recomputed Section 8.1 root")
179
+ return failures
180
+
181
+
182
+ def _mint_audit_url(workflow, timeout=60) -> str:
183
+ """Submit a finalized AER-1 receipt to zambo.dev and return the live URL.
184
+
185
+ Free tier, no auth, no API keys. Every submission mints a verifiable
186
+ receipt at https://zambo.dev/run/<id>.
187
+ """
188
+ import urllib.error
189
+ import urllib.request
190
+
191
+ body = json.dumps(
192
+ {
193
+ "jsonrpc": "2.0",
194
+ "id": 1,
195
+ "method": "tools/call",
196
+ "params": {
197
+ "name": "zambo_check",
198
+ "arguments": {
199
+ "check": (
200
+ "Verify this AER-1 verifiable workflow receipt: "
201
+ "confirm the step sequence, receipt hashes, and "
202
+ "Merkle root are well-formed."
203
+ ),
204
+ "context": json.dumps(
205
+ _json_safe(workflow), ensure_ascii=False
206
+ ),
207
+ "type": "execution",
208
+ },
209
+ },
210
+ }
211
+ ).encode("utf-8")
212
+ last_error = None
213
+ for attempt in range(3):
214
+ try:
215
+ req = urllib.request.Request(
216
+ ZAMBO_MCP_URL,
217
+ data=body,
218
+ headers={
219
+ "Content-Type": "application/json",
220
+ "Accept": "application/json, text/event-stream",
221
+ },
222
+ method="POST",
223
+ )
224
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
225
+ raw = resp.read().decode("utf-8")
226
+ break
227
+ except Exception as exc:
228
+ last_error = exc
229
+ time.sleep(0.6 * (attempt + 1))
230
+ else:
231
+ raise RuntimeError(
232
+ f"zambo.dev mint failed after 3 attempts: {last_error}"
233
+ )
234
+
235
+ payload = None
236
+ text = raw.strip()
237
+ if text.startswith("{"):
238
+ payload = json.loads(text)
239
+ else:
240
+ for line in text.splitlines():
241
+ if line.startswith("data:"):
242
+ try:
243
+ payload = json.loads(line[5:].strip())
244
+ except ValueError:
245
+ continue
246
+ if payload:
247
+ break
248
+ if not payload:
249
+ raise RuntimeError("zambo.dev mint returned an unreadable response")
250
+ if "error" in payload and payload["error"]:
251
+ raise RuntimeError(f"zambo.dev mint error: {payload['error']}")
252
+ result = payload.get("result") or {}
253
+ receipt = result.get("_receipt") or {}
254
+ audit = receipt.get("audit") or (
255
+ f"https://zambo.dev/run/{receipt['id']}" if receipt.get("id") else None
256
+ )
257
+ if not audit:
258
+ raise RuntimeError("zambo.dev mint returned no receipt id")
259
+ return audit
260
+
261
+
262
+ class ReceiptEmitter:
263
+ """Emit AER-1 verifiable execution receipts for any agent.
264
+
265
+ Framework-independent: works with LangChain, CrewAI, raw API calls,
266
+ or a hand-rolled agent loop. Record one step per action your agent
267
+ takes; finalize when the run ends.
268
+
269
+ emitter = ReceiptEmitter(goal="book a flight to Austin")
270
+ emitter.record("search_flights", {"origin": "JFK", "dest": "AUS"})
271
+ emitter.record("book_flight", {"flight": "AA123", "price_usd": 249})
272
+ url = emitter.mint() # live verifiable receipt
273
+ """
274
+
275
+ def __init__(self, goal=None, session_id=None, verify_url=None):
276
+ self._goal = goal
277
+ self._session_id = session_id or _new_uuid()
278
+ self._verify_url = verify_url or ZAMBO_VERIFY_URL
279
+ self._steps = []
280
+ self._seq = 0
281
+
282
+ def record(self, tool, evidence, status="ok", started=None, ended=None, **extra):
283
+ """Record one receipt step for an action the agent took.
284
+
285
+ tool: short label for the action (e.g. "web_search", "send_email").
286
+ evidence: dict of what the action observed or produced; the
287
+ receipt_hash commits to its canonical JSON, so anyone can
288
+ check later that the evidence has not changed.
289
+ status: "ok", "error", or "skipped".
290
+ extra: informational members, e.g. agent="planner", model="gpt-4o".
291
+ None values are dropped.
292
+ """
293
+ self._seq += 1
294
+ now = time.time()
295
+ if started is None:
296
+ started = now
297
+ if ended is None or ended < started:
298
+ ended = started
299
+ step = {
300
+ "seq": self._seq,
301
+ "receipt_id": _new_uuid(),
302
+ "tool": str(tool),
303
+ "receipt_hash": _sha256_hex(_canonical(evidence)),
304
+ "started_at": _rfc3339(started),
305
+ "ended_at": _rfc3339(ended),
306
+ "status": status,
307
+ }
308
+ for key, value in extra.items():
309
+ if value is not None:
310
+ step[key] = _json_safe(value)
311
+ self._steps.append(step)
312
+ return step
313
+
314
+ def finalize(self, final_answer=None) -> dict:
315
+ """Build the AER-1 verifiable workflow receipt for the run."""
316
+ status = "error" if any(s["status"] == "error" for s in self._steps) else "ok"
317
+ answer_text = "" if final_answer is None else str(final_answer)
318
+ return {
319
+ "type": WORKFLOW_TYPE,
320
+ "version": WORKFLOW_VERSION,
321
+ "workflow_id": _new_uuid(),
322
+ "receipt_id": _new_uuid(),
323
+ "session_id": self._session_id,
324
+ "goal": self._goal or "agent run",
325
+ "status": status,
326
+ "steps": list(self._steps),
327
+ "merkle_root": merkle_root([s["receipt_id"] for s in self._steps]),
328
+ "output_hash": _sha256_hex(answer_text.encode("utf-8")),
329
+ "verify_url": self._verify_url,
330
+ }
331
+
332
+ def verify(self, workflow=None) -> list:
333
+ """Offline-verify a receipt. [] means valid."""
334
+ if workflow is None:
335
+ workflow = self.finalize()
336
+ return verify_receipt(workflow)
337
+
338
+ def mint(self, final_answer=None) -> str:
339
+ """Finalize the run and mint a LIVE verifiable receipt on zambo.dev.
340
+
341
+ Returns the shareable audit URL (https://zambo.dev/run/<id>).
342
+ Free tier, no API key, no signup.
343
+ """
344
+ return _mint_audit_url(self.finalize(final_answer=final_answer))
345
+
346
+ def explain(self, final_answer=None) -> str:
347
+ """Render a human-readable "why" summary of the run.
348
+
349
+ Built for Track 02 ("The Agent That Can Explain Why"): every
350
+ step becomes one line of what the agent did and what evidence
351
+ it acted on, ending with the verification status.
352
+ """
353
+ workflow = self.finalize(final_answer=final_answer)
354
+ problems = verify_receipt(workflow)
355
+ lines = [
356
+ f"Goal: {workflow['goal']}",
357
+ f"Steps: {len(workflow['steps'])} (status: {workflow['status']})",
358
+ "",
359
+ ]
360
+ for step in workflow["steps"]:
361
+ lines.append(
362
+ f"{step['seq']}. {step['tool']} [{step['status']}] "
363
+ f"evidence sha256:{step['receipt_hash'][:12]}..."
364
+ )
365
+ lines.append("")
366
+ lines.append(f"Merkle root: {workflow['merkle_root'][:16]}...")
367
+ if problems:
368
+ lines.append("Verification: FAILED")
369
+ lines.extend(f" - {p}" for p in problems)
370
+ else:
371
+ lines.append("Verification: VALID (offline check passed)")
372
+ lines.append(f"Check it live: {self._verify_url}")
373
+ return "\n".join(lines)
374
+
375
+ def to_json(self, final_answer=None, indent=2) -> str:
376
+ return json.dumps(
377
+ self.finalize(final_answer=final_answer),
378
+ indent=indent,
379
+ ensure_ascii=False,
380
+ )
381
+
382
+ def save(self, path, final_answer=None) -> str:
383
+ with open(path, "w", encoding="utf-8") as f:
384
+ f.write(self.to_json(final_answer=final_answer))
385
+ return path
386
+
387
+ def reset(self):
388
+ self._steps = []
389
+ self._seq = 0
390
+
391
+ @property
392
+ def step_count(self) -> int:
393
+ return len(self._steps)
394
+
395
+ @property
396
+ def session_id(self) -> str:
397
+ return self._session_id
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: aer1kit
3
+ Version: 0.1.0
4
+ Summary: Verifiable AER-1 execution receipts for hackathon agents. Three lines and your agent can explain why.
5
+ Author: Brennan Zambo
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://zambo.dev
8
+ Project-URL: Live verifier, https://zambo.dev/verify
9
+ Project-URL: Live demo, https://zambo.dev/demo
10
+ Project-URL: IETF Draft, https://datatracker.ietf.org/doc/draft-zambo-aer1/
11
+ Keywords: aer-1,receipts,agents,hackathon,verifiable,mcp
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # aer1kit: verifiable execution receipts for your hackathon agent
19
+
20
+ Built for the [Open Agent Hackathon 2026](https://hackathon.genai.works/event/open-agent-hackathon-2026), Track 02: **"The Agent That Can Explain Why."**
21
+
22
+ Judges ask "why did your agent do that?" Most teams answer with a story. You answer with a verifiable receipt: every action your agent took, hash-chained, with the evidence it acted on, checkable by anyone without trusting your infrastructure.
23
+
24
+ ## What is AER-1?
25
+
26
+ AER-1 (Agent Execution Receipts) is an open IETF draft (`draft-zambo-aer1`) that defines a verifiable receipt format for agent tool calls. Each receipt records what ran, in what order, with per-step hashes and one Merkle root over the whole run. Anyone can verify it offline, or check it live at [zambo.dev/verify](https://zambo.dev/verify).
27
+
28
+ ## Why this wins Track 02
29
+
30
+ Track 02 rewards agents that can explain themselves. A trace says "the model claims this ran." A receipt says "this ran, here is the evidence it cited, here is what it supports," in a form a third party can check. That is the whole track, and this kit gives it to you in three lines.
31
+
32
+ ## Quickstart (3 steps)
33
+
34
+ **1. Clone and install (no dependencies, no API keys):**
35
+
36
+ ```bash
37
+ git clone <this-repo>
38
+ cd aer1-hackathon-kit
39
+ pip install .
40
+ ```
41
+
42
+ **2. Add three lines to your agent:**
43
+
44
+ ```python
45
+ from aer1kit import ReceiptEmitter
46
+
47
+ emitter = ReceiptEmitter(goal="what my agent is trying to do")
48
+
49
+ # after every action your agent takes:
50
+ emitter.record("web_search", {"query": "...", "results": [...]})
51
+ emitter.record("send_email", {"to": "...", "subject": "..."})
52
+
53
+ # at the end of the run:
54
+ url = emitter.mint() # live verifiable receipt, free, no signup
55
+ print(url) # https://zambo.dev/run/<id> - share it with the judges
56
+ ```
57
+
58
+ **3. See it work:**
59
+
60
+ ```bash
61
+ python demo.py
62
+ ```
63
+
64
+ The demo runs a scripted research agent (no API keys needed), prints the human-readable "why" explanation, verifies the receipt offline, and mints a live receipt you can open in a browser.
65
+
66
+ ## What `record()` captures
67
+
68
+ Each call records one receipt step:
69
+
70
+ - `tool`: short label for the action (`web_search`, `book_flight`, ...)
71
+ - `evidence`: dict of what the action observed or produced. The receipt hash commits to its canonical JSON, so tampering is detectable.
72
+ - `status`: `ok`, `error`, or `skipped`
73
+ - extra kwargs become informational fields: `agent="planner"`, `model="gpt-4o"`, ...
74
+
75
+ ## The "explain why" helper
76
+
77
+ ```python
78
+ print(emitter.explain(final_answer="..."))
79
+ ```
80
+
81
+ Renders the run as a readable account: goal, each step with its evidence hash, Merkle root, and verification status. Paste it into your demo or your submission writeup.
82
+
83
+ ## Verifying receipts
84
+
85
+ - **Offline:** `emitter.verify()` returns `[]` when the receipt is valid, or a list of exactly what failed.
86
+ - **Live:** paste any receipt JSON at [zambo.dev/verify](https://zambo.dev/verify).
87
+ - **Try it now:** run any call at [zambo.dev/demo](https://zambo.dev/demo) and inspect the receipt it produces.
88
+
89
+ ## Files
90
+
91
+ - `aer1kit/emitter.py` - the whole kit. Stdlib only, no dependencies.
92
+ - `examples/explain_why_agent.py` - a complete Track 02 example agent.
93
+ - `demo.py` - one-command demo ending in a live receipt URL.
94
+ - `tests/` - offline verification tests.
95
+
96
+ ## Links
97
+
98
+ - Hackathon: https://hackathon.genai.works/event/open-agent-hackathon-2026
99
+ - Live demo: https://zambo.dev/demo
100
+ - Live verifier: https://zambo.dev/verify
101
+ - AER-1 IETF draft: https://datatracker.ietf.org/doc/draft-zambo-aer1/
102
+
103
+ ## License
104
+
105
+ Apache-2.0
@@ -0,0 +1,6 @@
1
+ aer1kit/__init__.py,sha256=YScODcjnOr3DjBM95Iq6Y2XU5QCGBEotG9Y9dqrB4Pg,337
2
+ aer1kit/emitter.py,sha256=V7ZO6TkAEdZ4fCo8rR_or6kdvhyM8kVSG_S9FBLSxGI,14619
3
+ aer1kit-0.1.0.dist-info/METADATA,sha256=TnG6U-sWGLCuuGsIOZ3WpKKuitljZja50fgcSw2uPEs,4246
4
+ aer1kit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ aer1kit-0.1.0.dist-info/top_level.txt,sha256=ZAvFhX_jxUxwthwTl1Ex6oFv45k2dx87oo8NW9Ug2wQ,8
6
+ aer1kit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aer1kit