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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zambo-haystack
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Verifiable execution receipts for Haystack agents. Every tool call mints a verifiable AER-1 receipt at zambo.dev.
5
5
  Author-email: Zambo <brennanzambo@zambo.dev>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zambo-haystack"
7
- version = "0.1.2"
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
- result = _client.call_tool(tool_name, dict(kwargs))
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}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zambo-haystack
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Verifiable execution receipts for Haystack agents. Every tool call mints a verifiable AER-1 receipt at zambo.dev.
5
5
  Author-email: Zambo <brennanzambo@zambo.dev>
6
6
  License: MIT
@@ -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