zambo-haystack 0.1.2__tar.gz → 0.2.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.
- {zambo_haystack-0.1.2/src/zambo_haystack.egg-info → zambo_haystack-0.2.0}/PKG-INFO +1 -1
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/pyproject.toml +1 -1
- zambo_haystack-0.2.0/src/zambo_haystack/__init__.py +46 -0
- zambo_haystack-0.2.0/src/zambo_haystack/aer1core.py +238 -0
- zambo_haystack-0.2.0/src/zambo_haystack/collector.py +202 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack/tools.py +40 -1
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0/src/zambo_haystack.egg-info}/PKG-INFO +1 -1
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack.egg-info/SOURCES.txt +4 -1
- zambo_haystack-0.2.0/tests/test_aer1_collector.py +112 -0
- zambo_haystack-0.1.2/src/zambo_haystack/__init__.py +0 -21
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/LICENSE +0 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/README.md +0 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/setup.cfg +0 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack/client.py +0 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack.egg-info/dependency_links.txt +0 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack.egg-info/requires.txt +0 -0
- {zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "zambo-haystack"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Verifiable execution receipts for Haystack agents. Every tool call mints a verifiable AER-1 receipt at zambo.dev."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""zambo-haystack: Zambo's 100+ live tools as Haystack tools.
|
|
2
|
+
|
|
3
|
+
Every tool call is executed by live Zambo (https://zambo.dev/api/mcp) and
|
|
4
|
+
mints a verifiable AER-1 execution receipt. No account, no API key, free
|
|
5
|
+
tier: 20 calls per tool per day.
|
|
6
|
+
|
|
7
|
+
Pass an AER1ReceiptCollector to zambo_tool() to also build a hash-chained
|
|
8
|
+
AER-1 verifiable workflow receipt covering the agent's whole run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import call_tool, list_tools, ZamboReceipt, ZamboResult
|
|
12
|
+
from .collector import (
|
|
13
|
+
AER1ReceiptCollector,
|
|
14
|
+
DEFAULT_VERIFY_URL,
|
|
15
|
+
WORKFLOW_TYPE,
|
|
16
|
+
WORKFLOW_VERSION,
|
|
17
|
+
merkle_root,
|
|
18
|
+
verify_workflow_receipt,
|
|
19
|
+
)
|
|
20
|
+
from .tools import (
|
|
21
|
+
finalize_default_receipt,
|
|
22
|
+
get_default_collector,
|
|
23
|
+
lookup_receipt,
|
|
24
|
+
zambo_tool,
|
|
25
|
+
zambo_tools,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.2.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"call_tool",
|
|
32
|
+
"list_tools",
|
|
33
|
+
"lookup_receipt",
|
|
34
|
+
"zambo_tool",
|
|
35
|
+
"zambo_tools",
|
|
36
|
+
"ZamboReceipt",
|
|
37
|
+
"ZamboResult",
|
|
38
|
+
"AER1ReceiptCollector",
|
|
39
|
+
"get_default_collector",
|
|
40
|
+
"finalize_default_receipt",
|
|
41
|
+
"DEFAULT_VERIFY_URL",
|
|
42
|
+
"WORKFLOW_TYPE",
|
|
43
|
+
"WORKFLOW_VERSION",
|
|
44
|
+
"merkle_root",
|
|
45
|
+
"verify_workflow_receipt",
|
|
46
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Shared AER-1 workflow-receipt mechanics (stdlib only).
|
|
2
|
+
|
|
3
|
+
Hash-chained step recording, Section 8.1 Merkle construction, and the
|
|
4
|
+
offline Table 2 / Table 3 verification procedure from draft-zambo-aer1.
|
|
5
|
+
Ported from the aer1-smolagents reference implementation. The collector
|
|
6
|
+
never touches the network and never changes agent behavior. It only
|
|
7
|
+
observes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
WORKFLOW_TYPE = "verifiable-workflow-receipt"
|
|
20
|
+
WORKFLOW_VERSION = "1"
|
|
21
|
+
|
|
22
|
+
# Default verify_url: the canonical home of the AER-1 specification, where
|
|
23
|
+
# the offline verification procedure is documented. Override per deployment.
|
|
24
|
+
DEFAULT_VERIFY_URL = "https://datatracker.ietf.org/doc/draft-zambo-aer1/"
|
|
25
|
+
|
|
26
|
+
_UUID_RE = re.compile(
|
|
27
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z"
|
|
28
|
+
)
|
|
29
|
+
_RFC3339_RE = re.compile(
|
|
30
|
+
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
|
|
31
|
+
r"(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})\Z"
|
|
32
|
+
)
|
|
33
|
+
_HEX64_RE = re.compile(r"^[0-9a-f]{64}\Z")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _new_uuid() -> str:
|
|
37
|
+
return str(uuid.uuid4())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _rfc3339(ts: float) -> str:
|
|
41
|
+
"""Epoch seconds to a strict RFC 3339 UTC timestamp."""
|
|
42
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_safe(value):
|
|
46
|
+
"""Recursively convert arbitrary values to JSON-serializable form."""
|
|
47
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
51
|
+
if isinstance(value, (list, tuple)):
|
|
52
|
+
return [_json_safe(v) for v in value]
|
|
53
|
+
if isinstance(value, set):
|
|
54
|
+
return sorted((_json_safe(v) for v in value), key=repr)
|
|
55
|
+
return str(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _canonical(obj) -> bytes:
|
|
59
|
+
"""Deterministic canonical bytes for hashing (sorted keys, no spaces)."""
|
|
60
|
+
return json.dumps(
|
|
61
|
+
_json_safe(obj),
|
|
62
|
+
sort_keys=True,
|
|
63
|
+
separators=(",", ":"),
|
|
64
|
+
ensure_ascii=False,
|
|
65
|
+
).encode("utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sha256_hex(data: bytes) -> str:
|
|
69
|
+
return hashlib.sha256(data).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def merkle_root(receipt_ids: list) -> str:
|
|
73
|
+
"""AER-1 Section 8.1 normative Merkle construction.
|
|
74
|
+
|
|
75
|
+
leaf = SHA-256 over the UTF-8 bytes of receipt_id; internal node =
|
|
76
|
+
SHA-256 over raw 32-byte left || raw 32-byte right; an odd trailing
|
|
77
|
+
node is duplicated.
|
|
78
|
+
"""
|
|
79
|
+
if not receipt_ids:
|
|
80
|
+
return hashlib.sha256(b"").hexdigest()
|
|
81
|
+
level = [hashlib.sha256(rid.encode("utf-8")).digest() for rid in receipt_ids]
|
|
82
|
+
while len(level) > 1:
|
|
83
|
+
if len(level) % 2:
|
|
84
|
+
level.append(level[-1])
|
|
85
|
+
level = [
|
|
86
|
+
hashlib.sha256(level[i] + level[i + 1]).digest()
|
|
87
|
+
for i in range(0, len(level), 2)
|
|
88
|
+
]
|
|
89
|
+
return level[0].hex()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _strict_rfc3339(value) -> bool:
|
|
93
|
+
"""Strict RFC 3339 check: shape, offset range, and real calendar date."""
|
|
94
|
+
if not isinstance(value, str) or not _RFC3339_RE.match(value):
|
|
95
|
+
return False
|
|
96
|
+
if not value.endswith("Z"):
|
|
97
|
+
off = value[-6:]
|
|
98
|
+
try:
|
|
99
|
+
oh, om = int(off[1:3]), int(off[4:6])
|
|
100
|
+
except ValueError:
|
|
101
|
+
return False
|
|
102
|
+
if not (0 <= oh <= 23 and 0 <= om <= 59):
|
|
103
|
+
return False
|
|
104
|
+
try:
|
|
105
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verify_workflow_receipt(workflow) -> list:
|
|
112
|
+
"""Offline AER-1 Section 8 verification of a workflow receipt.
|
|
113
|
+
|
|
114
|
+
Returns a list of failure reasons; an empty list means the receipt is
|
|
115
|
+
valid. Mirrors the Table 2 / Table 3 MUST checks of the AER-1
|
|
116
|
+
conformance kit: workflow members, step members, seq ordering 1..n,
|
|
117
|
+
no duplicate step receipt_id values (MM-1), and the Section 8.1
|
|
118
|
+
Merkle root recomputed over the ordered step receipt_id strings.
|
|
119
|
+
"""
|
|
120
|
+
failures = []
|
|
121
|
+
if not isinstance(workflow, dict):
|
|
122
|
+
return ["workflow is not a JSON object"]
|
|
123
|
+
if workflow.get("type") != WORKFLOW_TYPE:
|
|
124
|
+
failures.append('workflow type is not "verifiable-workflow-receipt"')
|
|
125
|
+
version = workflow.get("version")
|
|
126
|
+
if not isinstance(version, str) or not version:
|
|
127
|
+
failures.append("workflow version is not a non-empty string")
|
|
128
|
+
if not isinstance(workflow.get("workflow_id"), str) or not _UUID_RE.match(
|
|
129
|
+
workflow.get("workflow_id")
|
|
130
|
+
):
|
|
131
|
+
failures.append("workflow workflow_id is not a lowercase UUID")
|
|
132
|
+
if not isinstance(workflow.get("receipt_id"), str) or not _UUID_RE.match(
|
|
133
|
+
workflow.get("receipt_id")
|
|
134
|
+
):
|
|
135
|
+
failures.append("workflow receipt_id is not a lowercase UUID")
|
|
136
|
+
if not isinstance(workflow.get("session_id"), str) or not workflow.get("session_id"):
|
|
137
|
+
failures.append("workflow session_id is not a non-empty string")
|
|
138
|
+
if not isinstance(workflow.get("goal"), str) or not workflow.get("goal"):
|
|
139
|
+
failures.append("workflow goal is not a non-empty string")
|
|
140
|
+
if not isinstance(workflow.get("status"), str) or not workflow.get("status"):
|
|
141
|
+
failures.append("workflow status is not a non-empty string")
|
|
142
|
+
oh = workflow.get("output_hash")
|
|
143
|
+
if not isinstance(oh, str) or not _HEX64_RE.match(oh):
|
|
144
|
+
failures.append("workflow output_hash is not a 64-char lowercase hex digest")
|
|
145
|
+
vu = workflow.get("verify_url")
|
|
146
|
+
if not isinstance(vu, str) or not (
|
|
147
|
+
vu.startswith("http://") or vu.startswith("https://")
|
|
148
|
+
):
|
|
149
|
+
failures.append("workflow verify_url is not an http(s) URL string")
|
|
150
|
+
steps = workflow.get("steps")
|
|
151
|
+
if not isinstance(steps, list) or not steps:
|
|
152
|
+
failures.append("workflow steps is not a non-empty list")
|
|
153
|
+
return failures
|
|
154
|
+
for i, step in enumerate(steps):
|
|
155
|
+
if not isinstance(step, dict):
|
|
156
|
+
failures.append(f"step {i + 1} is not an object")
|
|
157
|
+
continue
|
|
158
|
+
seq = step.get("seq")
|
|
159
|
+
if isinstance(seq, bool) or not (
|
|
160
|
+
isinstance(seq, int) or (isinstance(seq, float) and seq.is_integer())
|
|
161
|
+
):
|
|
162
|
+
failures.append(f"step {i + 1} seq is not an integer")
|
|
163
|
+
if not isinstance(step.get("receipt_id"), str):
|
|
164
|
+
failures.append(f"step {i + 1} receipt_id is not a string")
|
|
165
|
+
if not isinstance(step.get("tool"), str) or not step.get("tool"):
|
|
166
|
+
failures.append(f"step {i + 1} tool is not a non-empty string")
|
|
167
|
+
rh = step.get("receipt_hash")
|
|
168
|
+
if not isinstance(rh, str) or not _HEX64_RE.match(rh):
|
|
169
|
+
failures.append(
|
|
170
|
+
f"step {i + 1} receipt_hash is not a 64-char lowercase hex digest"
|
|
171
|
+
)
|
|
172
|
+
if not _strict_rfc3339(step.get("started_at")):
|
|
173
|
+
failures.append(f"step {i + 1} started_at is not a valid RFC 3339 timestamp")
|
|
174
|
+
if not _strict_rfc3339(step.get("ended_at")):
|
|
175
|
+
failures.append(f"step {i + 1} ended_at is not a valid RFC 3339 timestamp")
|
|
176
|
+
if not isinstance(step.get("status"), str) or not step.get("status"):
|
|
177
|
+
failures.append(f"step {i + 1} status is not a non-empty string")
|
|
178
|
+
if failures:
|
|
179
|
+
return failures
|
|
180
|
+
n = len(steps)
|
|
181
|
+
for i, step in enumerate(steps):
|
|
182
|
+
if step["seq"] != i + 1:
|
|
183
|
+
failures.append(
|
|
184
|
+
f"step seq values are not 1..{n} in order "
|
|
185
|
+
f"(index {i} carries seq {step['seq']})"
|
|
186
|
+
)
|
|
187
|
+
break
|
|
188
|
+
seen = set()
|
|
189
|
+
for step in steps:
|
|
190
|
+
rid = step["receipt_id"]
|
|
191
|
+
if rid in seen:
|
|
192
|
+
failures.append(f"duplicate receipt_id: {rid}")
|
|
193
|
+
break
|
|
194
|
+
seen.add(rid)
|
|
195
|
+
root = merkle_root([step["receipt_id"] for step in steps])
|
|
196
|
+
mr = workflow.get("merkle_root")
|
|
197
|
+
if not isinstance(mr, str) or mr != root:
|
|
198
|
+
failures.append("merkle_root does not match the recomputed Section 8.1 root")
|
|
199
|
+
return failures
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def record_step(tool, content, started_at=None, ended_at=None, status="ok", seq=0):
|
|
203
|
+
"""Build one hash-chained workflow step record.
|
|
204
|
+
|
|
205
|
+
receipt_hash commits to the canonical JSON of the step content
|
|
206
|
+
(tool name, arguments, observations, output, error), so the hash
|
|
207
|
+
proves what the agent actually did.
|
|
208
|
+
"""
|
|
209
|
+
start = started_at if started_at is not None else time.time()
|
|
210
|
+
end = ended_at if ended_at is not None and ended_at >= start else start
|
|
211
|
+
return {
|
|
212
|
+
"seq": seq,
|
|
213
|
+
"receipt_id": _new_uuid(),
|
|
214
|
+
"tool": tool,
|
|
215
|
+
"receipt_hash": _sha256_hex(_canonical(content)),
|
|
216
|
+
"started_at": _rfc3339(start),
|
|
217
|
+
"ended_at": _rfc3339(end),
|
|
218
|
+
"status": status,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def assemble_workflow(steps, goal, session_id, verify_url, final_answer=None):
|
|
223
|
+
"""Assemble the AER-1 verifiable workflow receipt for observed steps."""
|
|
224
|
+
status = "error" if any(s["status"] == "error" for s in steps) else "ok"
|
|
225
|
+
answer_text = "" if final_answer is None else str(final_answer)
|
|
226
|
+
return {
|
|
227
|
+
"type": WORKFLOW_TYPE,
|
|
228
|
+
"version": WORKFLOW_VERSION,
|
|
229
|
+
"workflow_id": _new_uuid(),
|
|
230
|
+
"receipt_id": _new_uuid(),
|
|
231
|
+
"session_id": session_id,
|
|
232
|
+
"goal": goal,
|
|
233
|
+
"status": status,
|
|
234
|
+
"steps": list(steps),
|
|
235
|
+
"merkle_root": merkle_root([s["receipt_id"] for s in steps]),
|
|
236
|
+
"output_hash": _sha256_hex(answer_text.encode("utf-8")),
|
|
237
|
+
"verify_url": verify_url,
|
|
238
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""AER-1 verifiable workflow receipts for Haystack.
|
|
2
|
+
|
|
3
|
+
AER1ReceiptCollector observes Haystack tool executions and builds a
|
|
4
|
+
hash-chained AER-1 verifiable workflow receipt (draft-zambo-aer1,
|
|
5
|
+
Section 8) covering the whole run. verify() re-checks the receipt with
|
|
6
|
+
the same offline mechanics as the AER-1 conformance kit.
|
|
7
|
+
|
|
8
|
+
Native integration: pass ``collector=collector`` to ``zambo_tool()`` /
|
|
9
|
+
``zambo_tools()`` and every invocation of the built Haystack Tool is
|
|
10
|
+
recorded as a workflow step::
|
|
11
|
+
|
|
12
|
+
from zambo_haystack import zambo_tool, AER1ReceiptCollector
|
|
13
|
+
|
|
14
|
+
collector = AER1ReceiptCollector(goal="Check the price")
|
|
15
|
+
tool = zambo_tool("live_price", collector=collector)
|
|
16
|
+
# add the tool to your Haystack pipeline or agent as usual
|
|
17
|
+
receipt = collector.finalize()
|
|
18
|
+
assert collector.verify(receipt) == []
|
|
19
|
+
|
|
20
|
+
A module-level default collector is also available: omit ``collector``
|
|
21
|
+
and steps go to ``get_default_collector()``; call
|
|
22
|
+
``finalize_default_receipt()`` to emit its receipt.
|
|
23
|
+
|
|
24
|
+
The collector never touches the network and never changes agent behavior.
|
|
25
|
+
It only observes."""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import functools
|
|
30
|
+
import inspect
|
|
31
|
+
import json
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
from .aer1core import (
|
|
35
|
+
DEFAULT_VERIFY_URL,
|
|
36
|
+
WORKFLOW_TYPE,
|
|
37
|
+
WORKFLOW_VERSION,
|
|
38
|
+
_json_safe,
|
|
39
|
+
_new_uuid,
|
|
40
|
+
assemble_workflow,
|
|
41
|
+
merkle_root,
|
|
42
|
+
record_step,
|
|
43
|
+
verify_workflow_receipt,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AER1ReceiptCollector",
|
|
48
|
+
"DEFAULT_VERIFY_URL",
|
|
49
|
+
"WORKFLOW_TYPE",
|
|
50
|
+
"WORKFLOW_VERSION",
|
|
51
|
+
"merkle_root",
|
|
52
|
+
"verify_workflow_receipt",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AER1ReceiptCollector:
|
|
57
|
+
"""Haystack tool observer that builds AER-1 verifiable workflow receipts."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, goal=None, session_id=None, verify_url=None):
|
|
60
|
+
self._goal = goal
|
|
61
|
+
self._session_id = session_id or _new_uuid()
|
|
62
|
+
self._verify_url = verify_url or DEFAULT_VERIFY_URL
|
|
63
|
+
self._steps = []
|
|
64
|
+
self._seq = 0
|
|
65
|
+
|
|
66
|
+
# -- step recording ------------------------------------------------------
|
|
67
|
+
def record_tool_call(
|
|
68
|
+
self,
|
|
69
|
+
tool,
|
|
70
|
+
arguments=None,
|
|
71
|
+
output=None,
|
|
72
|
+
error=None,
|
|
73
|
+
started_at=None,
|
|
74
|
+
ended_at=None,
|
|
75
|
+
):
|
|
76
|
+
"""Record one observed tool call as a hash-chained workflow step."""
|
|
77
|
+
self._seq += 1
|
|
78
|
+
content = {
|
|
79
|
+
"tool": tool,
|
|
80
|
+
"arguments": _json_safe(arguments),
|
|
81
|
+
"output": _json_safe(output),
|
|
82
|
+
"error": str(error) if error is not None else None,
|
|
83
|
+
}
|
|
84
|
+
step = record_step(
|
|
85
|
+
tool=str(tool),
|
|
86
|
+
content=content,
|
|
87
|
+
started_at=started_at,
|
|
88
|
+
ended_at=ended_at,
|
|
89
|
+
status="error" if error is not None else "ok",
|
|
90
|
+
seq=self._seq,
|
|
91
|
+
)
|
|
92
|
+
self._steps.append(step)
|
|
93
|
+
return step
|
|
94
|
+
|
|
95
|
+
# -- native PydanticAI integration: the observe decorator ------------------
|
|
96
|
+
def observe(self, fn=None, *, name=None):
|
|
97
|
+
"""Decorate a tool function so every call is recorded as a step.
|
|
98
|
+
|
|
99
|
+
Works with sync and async functions. The wrapped function keeps its
|
|
100
|
+
name, signature, and docstring, so PydanticAI registers it exactly
|
|
101
|
+
as before; the only addition is the recorded step.
|
|
102
|
+
"""
|
|
103
|
+
def decorator(func):
|
|
104
|
+
tool_name = name or getattr(func, "__name__", "tool")
|
|
105
|
+
|
|
106
|
+
if inspect.iscoroutinefunction(func):
|
|
107
|
+
|
|
108
|
+
@functools.wraps(func)
|
|
109
|
+
async def async_wrapper(*args, **kwargs):
|
|
110
|
+
started = time.time()
|
|
111
|
+
try:
|
|
112
|
+
result = await func(*args, **kwargs)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
self.record_tool_call(
|
|
115
|
+
tool_name,
|
|
116
|
+
arguments={"args": args, "kwargs": kwargs},
|
|
117
|
+
error=exc,
|
|
118
|
+
started_at=started,
|
|
119
|
+
)
|
|
120
|
+
raise
|
|
121
|
+
self.record_tool_call(
|
|
122
|
+
tool_name,
|
|
123
|
+
arguments={"args": args, "kwargs": kwargs},
|
|
124
|
+
output=result,
|
|
125
|
+
started_at=started,
|
|
126
|
+
)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
return async_wrapper
|
|
130
|
+
|
|
131
|
+
@functools.wraps(func)
|
|
132
|
+
def sync_wrapper(*args, **kwargs):
|
|
133
|
+
started = time.time()
|
|
134
|
+
try:
|
|
135
|
+
result = func(*args, **kwargs)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
self.record_tool_call(
|
|
138
|
+
tool_name,
|
|
139
|
+
arguments={"args": args, "kwargs": kwargs},
|
|
140
|
+
error=exc,
|
|
141
|
+
started_at=started,
|
|
142
|
+
)
|
|
143
|
+
raise
|
|
144
|
+
self.record_tool_call(
|
|
145
|
+
tool_name,
|
|
146
|
+
arguments={"args": args, "kwargs": kwargs},
|
|
147
|
+
output=result,
|
|
148
|
+
started_at=started,
|
|
149
|
+
)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
return sync_wrapper
|
|
153
|
+
|
|
154
|
+
return decorator(fn) if fn is not None else decorator
|
|
155
|
+
|
|
156
|
+
# -- receipt assembly ------------------------------------------------------
|
|
157
|
+
def finalize(self, final_answer=None) -> dict:
|
|
158
|
+
"""Emit the AER-1 verifiable workflow receipt for the observed run."""
|
|
159
|
+
goal = self._goal or "haystack run"
|
|
160
|
+
return assemble_workflow(
|
|
161
|
+
steps=self._steps,
|
|
162
|
+
goal=goal,
|
|
163
|
+
session_id=self._session_id,
|
|
164
|
+
verify_url=self._verify_url,
|
|
165
|
+
final_answer=final_answer,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def verify(self, workflow=None) -> list:
|
|
169
|
+
"""Offline-verify a workflow receipt. [] means valid.
|
|
170
|
+
|
|
171
|
+
With no argument, finalizes the current run and verifies that
|
|
172
|
+
receipt. Pass an explicit receipt dict to verify that exact
|
|
173
|
+
object (for example, one loaded from disk).
|
|
174
|
+
"""
|
|
175
|
+
if workflow is None:
|
|
176
|
+
workflow = self.finalize()
|
|
177
|
+
return verify_workflow_receipt(workflow)
|
|
178
|
+
|
|
179
|
+
def to_json(self, workflow=None, indent=2) -> str:
|
|
180
|
+
"""Serialize a workflow receipt to JSON text."""
|
|
181
|
+
if workflow is None:
|
|
182
|
+
workflow = self.finalize()
|
|
183
|
+
return json.dumps(workflow, indent=indent, ensure_ascii=False)
|
|
184
|
+
|
|
185
|
+
def save(self, path, workflow=None) -> str:
|
|
186
|
+
"""Write a workflow receipt to path as JSON. Returns the path."""
|
|
187
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
188
|
+
f.write(self.to_json(workflow))
|
|
189
|
+
return path
|
|
190
|
+
|
|
191
|
+
def reset(self):
|
|
192
|
+
"""Clear recorded steps so the collector can observe a fresh run."""
|
|
193
|
+
self._steps = []
|
|
194
|
+
self._seq = 0
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def step_count(self) -> int:
|
|
198
|
+
return len(self._steps)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def session_id(self) -> str:
|
|
202
|
+
return self._session_id
|
|
@@ -16,6 +16,24 @@ from typing import Any, Callable
|
|
|
16
16
|
from haystack.tools import Tool
|
|
17
17
|
|
|
18
18
|
from . import client as _client
|
|
19
|
+
from .collector import AER1ReceiptCollector
|
|
20
|
+
|
|
21
|
+
# Module-level default collector: when zambo_tool() is called without an
|
|
22
|
+
# explicit collector, invocations are recorded here. Observation only.
|
|
23
|
+
_default_collector: AER1ReceiptCollector | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_default_collector() -> AER1ReceiptCollector:
|
|
27
|
+
"""Return the module-level default AER-1 collector, creating it on demand."""
|
|
28
|
+
global _default_collector
|
|
29
|
+
if _default_collector is None:
|
|
30
|
+
_default_collector = AER1ReceiptCollector(goal="haystack run")
|
|
31
|
+
return _default_collector
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def finalize_default_receipt(final_answer=None) -> dict:
|
|
35
|
+
"""Emit the AER-1 workflow receipt for the default collector's steps."""
|
|
36
|
+
return get_default_collector().finalize(final_answer=final_answer)
|
|
19
37
|
|
|
20
38
|
# (tool_name, arguments_json) -> (receipt_url, finished_at). Written by the
|
|
21
39
|
# tool wrapper on every invocation. Thread-safe. Query it with
|
|
@@ -41,6 +59,7 @@ def zambo_tool(
|
|
|
41
59
|
description: str | None = None,
|
|
42
60
|
params_json_schema: dict | None = None,
|
|
43
61
|
on_receipt: Callable[[str, str], Any] | None = None,
|
|
62
|
+
collector: AER1ReceiptCollector | None = None,
|
|
44
63
|
) -> Tool:
|
|
45
64
|
"""Build a Haystack Tool backed by a Zambo MCP tool.
|
|
46
65
|
|
|
@@ -52,15 +71,28 @@ def zambo_tool(
|
|
|
52
71
|
Defaults to a permissive object schema; Zambo validates server-side.
|
|
53
72
|
on_receipt: Optional callback invoked as on_receipt(tool_name,
|
|
54
73
|
receipt_url) after every successful invocation.
|
|
74
|
+
collector: Optional AER1ReceiptCollector. Every invocation is recorded
|
|
75
|
+
as a hash-chained workflow step (observation only; the tool's
|
|
76
|
+
behavior is unchanged). When omitted, the module-level default
|
|
77
|
+
collector is used (see get_default_collector()).
|
|
55
78
|
|
|
56
79
|
The tool's result text ends with a "Verifiable receipt: <url>" line, and
|
|
57
80
|
the URL is also recorded in the invocation registry (see
|
|
58
81
|
lookup_receipt()).
|
|
59
82
|
"""
|
|
60
83
|
schema = params_json_schema or {"type": "object", "properties": {}}
|
|
84
|
+
step_collector = collector if collector is not None else get_default_collector()
|
|
61
85
|
|
|
62
86
|
def _run(**kwargs: Any) -> str:
|
|
63
|
-
|
|
87
|
+
arguments = dict(kwargs)
|
|
88
|
+
started = time.time()
|
|
89
|
+
try:
|
|
90
|
+
result = _client.call_tool(tool_name, arguments)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
step_collector.record_tool_call(
|
|
93
|
+
tool_name, arguments=arguments, error=exc, started_at=started
|
|
94
|
+
)
|
|
95
|
+
raise
|
|
64
96
|
|
|
65
97
|
receipt_url = result.receipt.url if result.receipt else ""
|
|
66
98
|
arguments_json = json.dumps(kwargs, sort_keys=True)
|
|
@@ -69,6 +101,13 @@ def zambo_tool(
|
|
|
69
101
|
if on_receipt is not None:
|
|
70
102
|
on_receipt(tool_name, receipt_url)
|
|
71
103
|
|
|
104
|
+
step_collector.record_tool_call(
|
|
105
|
+
tool_name,
|
|
106
|
+
arguments=arguments,
|
|
107
|
+
output={"text": result.text[:2000], "receipt_url": receipt_url},
|
|
108
|
+
started_at=started,
|
|
109
|
+
)
|
|
110
|
+
|
|
72
111
|
output = result.text or "(empty result)"
|
|
73
112
|
if receipt_url:
|
|
74
113
|
output += f"\n\nVerifiable receipt: {receipt_url}"
|
|
@@ -2,10 +2,13 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/zambo_haystack/__init__.py
|
|
5
|
+
src/zambo_haystack/aer1core.py
|
|
5
6
|
src/zambo_haystack/client.py
|
|
7
|
+
src/zambo_haystack/collector.py
|
|
6
8
|
src/zambo_haystack/tools.py
|
|
7
9
|
src/zambo_haystack.egg-info/PKG-INFO
|
|
8
10
|
src/zambo_haystack.egg-info/SOURCES.txt
|
|
9
11
|
src/zambo_haystack.egg-info/dependency_links.txt
|
|
10
12
|
src/zambo_haystack.egg-info/requires.txt
|
|
11
|
-
src/zambo_haystack.egg-info/top_level.txt
|
|
13
|
+
src/zambo_haystack.egg-info/top_level.txt
|
|
14
|
+
tests/test_aer1_collector.py
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tests for the zambo-haystack 0.2.0 AER-1 collector.
|
|
2
|
+
|
|
3
|
+
The collector and aer1core are stdlib-only; no haystack-ai install needed.
|
|
4
|
+
Tool-wiring is tested without network by exercising the record path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import importlib.util
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import types
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load(name, relpath):
|
|
15
|
+
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src",
|
|
16
|
+
"zambo_haystack")
|
|
17
|
+
# Stub the parent package (without executing its __init__, which needs
|
|
18
|
+
# haystack-ai) so the collector's relative import resolves.
|
|
19
|
+
if "zambo_haystack" not in sys.modules:
|
|
20
|
+
pkg = types.ModuleType("zambo_haystack")
|
|
21
|
+
pkg.__path__ = [base]
|
|
22
|
+
sys.modules["zambo_haystack"] = pkg
|
|
23
|
+
path = os.path.join(base, relpath)
|
|
24
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
25
|
+
mod = importlib.util.module_from_spec(spec)
|
|
26
|
+
sys.modules[name] = mod
|
|
27
|
+
spec.loader.exec_module(mod)
|
|
28
|
+
return mod
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
aer1core = _load("zambo_haystack.aer1core", "aer1core.py")
|
|
32
|
+
collector_mod = _load("zambo_haystack.collector", "collector.py")
|
|
33
|
+
|
|
34
|
+
merkle_root = aer1core.merkle_root
|
|
35
|
+
AER1ReceiptCollector = collector_mod.AER1ReceiptCollector
|
|
36
|
+
WORKFLOW_TYPE = collector_mod.WORKFLOW_TYPE
|
|
37
|
+
verify_workflow_receipt = collector_mod.verify_workflow_receipt
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_collector():
|
|
41
|
+
return AER1ReceiptCollector(goal="test run")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_synthetic_steps_verify():
|
|
45
|
+
c = make_collector()
|
|
46
|
+
c.record_tool_call("live_price", arguments={"symbol": "BTC"}, output="$1")
|
|
47
|
+
c.record_tool_call("web_search", arguments={"q": "x"}, output="r")
|
|
48
|
+
receipt = c.finalize(final_answer="done")
|
|
49
|
+
assert verify_workflow_receipt(receipt) == []
|
|
50
|
+
assert receipt["type"] == WORKFLOW_TYPE
|
|
51
|
+
assert [s["seq"] for s in receipt["steps"]] == [1, 2]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_empty_run_does_not_verify():
|
|
55
|
+
c = make_collector()
|
|
56
|
+
failures = verify_workflow_receipt(c.finalize(final_answer="x"))
|
|
57
|
+
assert failures and any("steps" in f for f in failures)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_tampered_tool_fails():
|
|
61
|
+
c = make_collector()
|
|
62
|
+
c.record_tool_call("a", output="1")
|
|
63
|
+
receipt = c.finalize(final_answer="1")
|
|
64
|
+
assert c.verify(receipt) == []
|
|
65
|
+
bad = copy.deepcopy(receipt)
|
|
66
|
+
bad["steps"][0]["tool"] = ""
|
|
67
|
+
assert c.verify(bad) != []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_tampered_merkle_root_fails():
|
|
71
|
+
c = make_collector()
|
|
72
|
+
c.record_tool_call("a", output="1")
|
|
73
|
+
receipt = c.finalize(final_answer="1")
|
|
74
|
+
bad = copy.deepcopy(receipt)
|
|
75
|
+
bad["merkle_root"] = "0" * 64
|
|
76
|
+
assert any("merkle_root" in f for f in c.verify(bad))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_duplicate_receipt_id_fails():
|
|
80
|
+
c = make_collector()
|
|
81
|
+
c.record_tool_call("a", output="1")
|
|
82
|
+
c.record_tool_call("b", output="2")
|
|
83
|
+
receipt = c.finalize(final_answer="x")
|
|
84
|
+
bad = copy.deepcopy(receipt)
|
|
85
|
+
bad["steps"][1]["receipt_id"] = bad["steps"][0]["receipt_id"]
|
|
86
|
+
assert any("duplicate" in f for f in c.verify(bad))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_error_step_marks_workflow_error():
|
|
90
|
+
c = make_collector()
|
|
91
|
+
c.record_tool_call("broken", error=RuntimeError("boom"))
|
|
92
|
+
receipt = c.finalize()
|
|
93
|
+
assert receipt["status"] == "error"
|
|
94
|
+
assert verify_workflow_receipt(receipt) == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_observe_decorator():
|
|
98
|
+
c = make_collector()
|
|
99
|
+
|
|
100
|
+
@c.observe(name="pipeline_step")
|
|
101
|
+
def run_component(x):
|
|
102
|
+
return x * 2
|
|
103
|
+
|
|
104
|
+
assert run_component(21) == 42
|
|
105
|
+
assert c.step_count == 1
|
|
106
|
+
assert c.verify() == []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_merkle_reference_vector():
|
|
110
|
+
assert merkle_root([]) == (
|
|
111
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
112
|
+
)
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
"""zambo-haystack: Zambo's 100+ live tools as Haystack tools.
|
|
2
|
-
|
|
3
|
-
Every tool call is executed by live Zambo (https://zambo.dev/api/mcp) and
|
|
4
|
-
mints a verifiable AER-1 execution receipt. No account, no API key, free
|
|
5
|
-
tier: 20 calls per tool per day.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from .client import call_tool, list_tools, ZamboReceipt, ZamboResult
|
|
9
|
-
from .tools import lookup_receipt, zambo_tool, zambo_tools
|
|
10
|
-
|
|
11
|
-
__version__ = "0.1.0"
|
|
12
|
-
|
|
13
|
-
__all__ = [
|
|
14
|
-
"call_tool",
|
|
15
|
-
"list_tools",
|
|
16
|
-
"lookup_receipt",
|
|
17
|
-
"zambo_tool",
|
|
18
|
-
"zambo_tools",
|
|
19
|
-
"ZamboReceipt",
|
|
20
|
-
"ZamboResult",
|
|
21
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zambo_haystack-0.1.2 → zambo_haystack-0.2.0}/src/zambo_haystack.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|