treeship-commerce 0.28.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.
@@ -0,0 +1,26 @@
1
+ """Treeship receipts for anthropics/commerce-agents.
2
+
3
+ One shared executor runs every tool call on all three commerce-agents
4
+ runtimes (Messages API, Agent SDK, Managed Agents). Wrapping it once gives
5
+ every call a signed intent receipt before it runs and a signed result
6
+ receipt after, chained from the session's root. See ``receipts.py``.
7
+ """
8
+
9
+ from .receipts import (
10
+ TreeshipExecutorMixin,
11
+ TreeshipReceipts,
12
+ args_digest,
13
+ attach,
14
+ receipted,
15
+ text_digest,
16
+ )
17
+
18
+ __all__ = [
19
+ "TreeshipExecutorMixin",
20
+ "TreeshipReceipts",
21
+ "args_digest",
22
+ "attach",
23
+ "receipted",
24
+ "text_digest",
25
+ ]
26
+ __version__ = "0.28.0"
@@ -0,0 +1,127 @@
1
+ """A receipted shopping session over the retail mock, no model and no API key.
2
+
3
+ python -m treeship_commerce.demo # from a clone of commerce-agents, venv active
4
+ python -m treeship_commerce.demo --report # also publish, if a hub is attached
5
+
6
+ Drives the reference's ``ShoppingToolExecutor`` over ``examples/retail``'s
7
+ mock backend exactly the way the reference's own tests do, with receipts on:
8
+ a search, a product read, an add to the cart, an add the provenance gate
9
+ holds, and a checkout hand-off. Then it seals the Treeship session and prints
10
+ where the package is and how to verify it. Every receipt id printed is real.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import hashlib
18
+ import os
19
+ import sys
20
+ import uuid
21
+
22
+ from treeship_sdk import Treeship
23
+
24
+ from . import TreeshipReceipts, attach, receipted
25
+ from .lifecycle import close_session, session_status, start_session
26
+
27
+ ACTOR = "agent://shopping"
28
+
29
+
30
+ def _build_executor(session_id: str):
31
+ try:
32
+ from commerce_common.memory import InMemoryMemoryStore
33
+ from commerce_common.skills import SkillRegistry
34
+ from shopping_agent import ShoppingAgentConfig, ShoppingSessionContext, ShoppingSessionState
35
+ from shopping_agent.executor import ShoppingToolExecutor, build_memory
36
+ from shopping_agent_sdk import load_mock_backend
37
+ except ImportError as err: # pragma: no cover - environment, not logic
38
+ sys.exit(
39
+ f"commerce-agents packages are not installed ({err}). From a clone of "
40
+ "anthropics/commerce-agents: pip install -r requirements.txt"
41
+ )
42
+ config = ShoppingAgentConfig(brand_name="ACME", assistant_name="Scout")
43
+ cls = receipted(ShoppingToolExecutor)
44
+ return cls(
45
+ backend=load_mock_backend(),
46
+ config=config,
47
+ skills=SkillRegistry([]),
48
+ session=ShoppingSessionContext(session_id=session_id, user_id="demo-user"),
49
+ state=ShoppingSessionState(),
50
+ memory=build_memory(config, InMemoryMemoryStore()),
51
+ inline_context=True,
52
+ )
53
+
54
+
55
+ async def run(report: bool) -> int:
56
+ ts = Treeship()
57
+ commerce_session = f"demo-{uuid.uuid4().hex}" # the reference treats this as a credential
58
+ root = start_session(ts, name="commerce:retail-demo", actor=ACTOR)
59
+ executor = attach(
60
+ _build_executor(commerce_session),
61
+ TreeshipReceipts(ts, actor=ACTOR, session_id=commerce_session, parent_id=root),
62
+ )
63
+ receipts: TreeshipReceipts = executor.treeship_receipts
64
+
65
+ async def call(name: str, tool_input: dict) -> None:
66
+ before = len(receipts.recorded)
67
+ outcome = await executor.execute(name, tool_input)
68
+ status = (
69
+ "blocked:" + outcome.blocked
70
+ if outcome.blocked
71
+ else ("error" if outcome.is_error else "ok")
72
+ )
73
+ new = receipts.recorded[before:]
74
+ ids = " ".join(new) if new else "(no receipt written)"
75
+ print(f" {name:<22} {status:<20} {ids}")
76
+
77
+ tag = hashlib.sha256(commerce_session.encode()).hexdigest()[:12]
78
+ print(f"session root {root}")
79
+ print(f"commerce session sha256:{tag} (tag; the id itself is never written)")
80
+ print("tool calls intent-id result-id")
81
+ await call("search_products", {"query": "tent"})
82
+ seen = list(executor._state.seen_products)
83
+ if not seen:
84
+ print("the retail mock returned nothing for 'tent'; stopping")
85
+ return 1
86
+ await call("get_product_details", {"product_id": seen[0]})
87
+ await call("add_to_cart", {"product_id": seen[0], "quantity": 1})
88
+ await call("add_to_cart", {"product_id": "p-not-from-this-session", "quantity": 1})
89
+ await call("checkout", {"note": "Ready when you are."})
90
+
91
+ status = session_status(ts)
92
+ print(
93
+ f"\nsession receipts={status['receipts']} events={status['events']} "
94
+ f"root_verified={status['root_verified']}"
95
+ )
96
+ if receipts.dropped:
97
+ print(f"WARNING {receipts.dropped} receipt(s) not written; the chain has gaps")
98
+ sealed = close_session(
99
+ ts,
100
+ summary=(
101
+ f"Retail demo: {len(receipts.recorded) // 2} tool calls signed, one add held by the "
102
+ "provenance gate, checkout handed off. No order placed, no card charged."
103
+ ),
104
+ headline="Receipted shopping session over the ACME retail mock",
105
+ )
106
+ print(f"package {sealed.get('package')}")
107
+ print(f"verify treeship verify {receipts.head}")
108
+ print(f" treeship package verify {sealed.get('package')}")
109
+ if report:
110
+ result = ts.session_report()
111
+ print(f"report {result.receipt_url}")
112
+ return 0 if receipts.dropped == 0 else 2
113
+
114
+
115
+ def main() -> None:
116
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
117
+ parser.add_argument(
118
+ "--report", action="store_true", help="publish the session report to the attached hub"
119
+ )
120
+ args = parser.parse_args()
121
+ if os.environ.get("TREESHIP_DISABLE") == "1":
122
+ sys.exit("TREESHIP_DISABLE=1 is set; this demo exists to write receipts")
123
+ sys.exit(asyncio.run(run(report=args.report)))
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,102 @@
1
+ """Treeship session lifecycle around a commerce session.
2
+
3
+ The per-call receipts live in ``receipts.py``. These helpers open and seal the
4
+ Treeship session that contains them, so a host can do::
5
+
6
+ ts = Treeship(env=env)
7
+ root = start_session(ts, name="storefront:retail", actor="agent://shopping")
8
+ receipts = TreeshipReceipts(ts, actor="agent://shopping", session_id=sid, parent_id=root)
9
+ ...
10
+ package = close_session(ts, summary="12 tool calls, 1 held by the provenance gate")
11
+
12
+ They shell out to the CLI the same way the SDK does and parse the last JSON
13
+ document on stdout, because ``attest`` may print a warning object first.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import subprocess
21
+ from typing import Any, Mapping, Sequence
22
+
23
+ from treeship_sdk import Treeship, TreeshipError
24
+
25
+
26
+ def _run_json(
27
+ client: Treeship,
28
+ args: Sequence[str],
29
+ *,
30
+ env: Mapping[str, str] | None = None,
31
+ cwd: str | os.PathLike[str] | None = None,
32
+ ) -> dict[str, Any]:
33
+ """Run one CLI command. ``cwd`` matters: ``session start`` and ``session
34
+ event`` find the workspace by walking up from the working directory, so a
35
+ host that runs elsewhere would start a session in the wrong tree."""
36
+ merged = {**os.environ, **(env or {})}
37
+ proc = subprocess.run(
38
+ [client.binary, *args, "--format", "json"],
39
+ capture_output=True,
40
+ text=True,
41
+ env=merged,
42
+ cwd=cwd,
43
+ timeout=60,
44
+ )
45
+ if proc.returncode != 0:
46
+ raise TreeshipError(
47
+ f"treeship {' '.join(args[:2])} failed (exit={proc.returncode}): "
48
+ f"{proc.stderr.strip() or proc.stdout.strip() or '<no output>'}",
49
+ list(args),
50
+ )
51
+ docs = [
52
+ json.loads(chunk)
53
+ for chunk in proc.stdout.replace("}\n{", "}\n\x00{").split("\x00")
54
+ if chunk.strip()
55
+ ]
56
+ if not docs:
57
+ raise TreeshipError(f"treeship {' '.join(args[:2])} printed no JSON", list(args))
58
+ return docs[-1]
59
+
60
+
61
+ def start_session(
62
+ client: Treeship,
63
+ *,
64
+ name: str,
65
+ actor: str,
66
+ env: Mapping[str, str] | None = None,
67
+ cwd: str | os.PathLike[str] | None = None,
68
+ ) -> str:
69
+ """Start a Treeship session in the workspace at ``cwd`` (the working
70
+ directory by default) and return its ``root_artifact_id`` -- the parent
71
+ every tool receipt chains from."""
72
+ _run_json(client, ["session", "start", "--name", name, "--actor", actor], env=env, cwd=cwd)
73
+ status = _run_json(client, ["session", "status"], env=env, cwd=cwd)
74
+ root = status.get("root_artifact_id")
75
+ if not isinstance(root, str) or not root:
76
+ raise TreeshipError("session start left no root_artifact_id", ["session", "status"])
77
+ return root
78
+
79
+
80
+ def session_status(
81
+ client: Treeship,
82
+ *,
83
+ env: Mapping[str, str] | None = None,
84
+ cwd: str | os.PathLike[str] | None = None,
85
+ ) -> dict[str, Any]:
86
+ return _run_json(client, ["session", "status"], env=env, cwd=cwd)
87
+
88
+
89
+ def close_session(
90
+ client: Treeship,
91
+ *,
92
+ summary: str,
93
+ headline: str | None = None,
94
+ env: Mapping[str, str] | None = None,
95
+ cwd: str | os.PathLike[str] | None = None,
96
+ ) -> dict[str, Any]:
97
+ """Seal the session into a ``.treeship`` package. Returns the CLI's document
98
+ (``session_id``, ``receipts``, ``events``, ``package``)."""
99
+ args = ["session", "close", "--summary", summary]
100
+ if headline:
101
+ args += ["--headline", headline]
102
+ return _run_json(client, args, env=env, cwd=cwd)
@@ -0,0 +1,330 @@
1
+ """Signed receipts for every commerce-agents tool call.
2
+
3
+ Where this plugs in
4
+ -------------------
5
+ ``commerce_common.execution.BaseToolExecutor.execute`` is the one method every
6
+ tool call passes through on all three runtimes; the reference's own gates rely
7
+ on that. :class:`TreeshipExecutorMixin` overrides it: a signed **intent**
8
+ receipt before dispatch, the tool, then a signed **result** receipt. Each
9
+ receipt names its parent, so the session's chain reads intent → result →
10
+ intent → result … from the session-start root, and ``treeship verify`` walks
11
+ it as one chain.
12
+
13
+ What a receipt carries, and what it never carries
14
+ -------------------------------------------------
15
+ The reference fences third-party content and logs only a *digest* of the
16
+ session id, because the id is also the request credential. A receipt keeps
17
+ that discipline: the tool name, a SHA-256 of the canonical arguments, a
18
+ SHA-256 of the result text, the outcome (``ok`` / ``blocked`` with the gate's
19
+ name / ``error``), event types, timing, and the same twelve-hex session tag
20
+ the reference's own log lines use. Never the arguments, never the result text,
21
+ never the session id.
22
+
23
+ Recording never breaks the agent path
24
+ -------------------------------------
25
+ This module records; it does not gate. A tool runs whether or not its receipt
26
+ could be written, and a failure to record warns once and moves on -- the same
27
+ rule the reference applies to its own attestation-shaped concerns and the rule
28
+ ``@treeship/mcp`` follows. The one thing it will not do is invent: a missing
29
+ intent is recorded as ``intent_recorded: false`` on the result, never as a
30
+ fabricated id. ``TREESHIP_DISABLE=1`` turns recording off entirely.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import hashlib
37
+ import json
38
+ import logging
39
+ import os
40
+ import time
41
+ from typing import Any, Callable, Mapping, Sequence
42
+
43
+ from treeship_sdk import Treeship
44
+
45
+ try: # The reference's own helper, so tags line up with its log lines.
46
+ from commerce_common.turn import session_tag as _session_tag
47
+ except ImportError: # pragma: no cover - only when commerce-agents is absent
48
+
49
+ def _session_tag(session_id: str | None) -> str:
50
+ return hashlib.sha256(session_id.encode()).hexdigest()[:12] if session_id else "-"
51
+
52
+
53
+ logger = logging.getLogger("treeship_commerce")
54
+
55
+ INTENT_ACTION = "commerce.tool.{name}.intent"
56
+ RESULT_ACTION = "commerce.tool.{name}.result"
57
+
58
+ # Exit codes the timeline event carries. Blocked is distinct from error on
59
+ # purpose: a held call is the gate working, not the tool failing.
60
+ _EXIT_CODES = {"ok": 0, "error": 1, "blocked": 2}
61
+
62
+
63
+ def _canonical(tool_input: Mapping[str, Any] | None) -> bytes:
64
+ return json.dumps(
65
+ dict(tool_input or {}), sort_keys=True, separators=(",", ":"), ensure_ascii=False
66
+ ).encode("utf-8")
67
+
68
+
69
+ def args_digest(tool_input: Mapping[str, Any] | None) -> str:
70
+ """``sha256:<hex>`` over the canonical JSON of the tool's arguments.
71
+
72
+ Canonical means sorted keys, no whitespace, UTF-8: the same arguments
73
+ always produce the same digest, so a holder of the arguments can check
74
+ the receipt, and a holder of the receipt learns nothing about them.
75
+ """
76
+ return "sha256:" + hashlib.sha256(_canonical(tool_input)).hexdigest()
77
+
78
+
79
+ def text_digest(text: str) -> str:
80
+ """``sha256:<hex>`` of a result text. The text itself is fenced third-party
81
+ content on the reference and stays out of the receipt."""
82
+ return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
83
+
84
+
85
+ def outcome_status(outcome: Any) -> str:
86
+ """``blocked`` when a gate held the call, else ``error`` or ``ok``."""
87
+ if getattr(outcome, "blocked", None):
88
+ return "blocked"
89
+ if getattr(outcome, "is_error", False):
90
+ return "error"
91
+ return "ok"
92
+
93
+
94
+ class TreeshipReceipts:
95
+ """The recorder one executor instance carries.
96
+
97
+ ``client`` is a configured :class:`treeship_sdk.Treeship`. ``actor`` is the
98
+ URI the receipts name (``agent://shopping`` or ``agent://merchant`` by
99
+ convention). ``session_id`` is the *commerce* session id; only its tag is
100
+ ever written. ``parent_id`` seeds the chain -- pass the Treeship session's
101
+ ``root_artifact_id`` so the tool receipts count into that session.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ client: Treeship,
107
+ *,
108
+ actor: str,
109
+ session_id: str | None = None,
110
+ role: str = "shopping",
111
+ parent_id: str | None = None,
112
+ timeline: bool = True,
113
+ ) -> None:
114
+ self.client = client
115
+ self.actor = actor
116
+ self.role = role
117
+ self.session_tag = _session_tag(session_id)
118
+ self.timeline = timeline
119
+ self._head: str | None = parent_id
120
+ self._lock = asyncio.Lock()
121
+ self._warned = False
122
+ self._timeline_unavailable_warned = False
123
+ self.recorded: list[str] = []
124
+ """Artifact ids written by this recorder, in chain order."""
125
+ self.dropped = 0
126
+ """Receipts that could not be written. Non-zero means the chain has
127
+ gaps that ``intent_recorded: false`` on later results points at."""
128
+
129
+ @property
130
+ def disabled(self) -> bool:
131
+ return os.environ.get("TREESHIP_DISABLE") == "1"
132
+
133
+ @property
134
+ def head(self) -> str | None:
135
+ """The most recent artifact in the chain, or the seed parent."""
136
+ return self._head
137
+
138
+ def _warn(self, context: str, err: BaseException) -> None:
139
+ self.dropped += 1
140
+ if not self._warned:
141
+ self._warned = True
142
+ logger.warning(
143
+ "treeship receipt not written (%s): %s. Tool calls continue; later "
144
+ "results record intent_recorded=false where the intent is missing. "
145
+ "Set TREESHIP_DISABLE=1 to silence recording entirely.",
146
+ context,
147
+ err,
148
+ )
149
+ elif os.environ.get("TREESHIP_DEBUG") == "1":
150
+ logger.warning("treeship receipt not written (%s): %s", context, err)
151
+
152
+ async def _attest(self, action: str, meta: dict[str, Any], parent: str | None) -> str | None:
153
+ if self.disabled:
154
+ return None
155
+ try:
156
+ result = await asyncio.to_thread(
157
+ self.client.attest_action, self.actor, action, parent, None, meta
158
+ )
159
+ except Exception as err: # noqa: BLE001 -- a recorder must never break the tool
160
+ self._warn(action, err)
161
+ return None
162
+ async with self._lock:
163
+ self._head = result.artifact_id
164
+ self.recorded.append(result.artifact_id)
165
+ return result.artifact_id
166
+
167
+ async def intent(self, name: str, tool_input: Mapping[str, Any] | None) -> str | None:
168
+ """Sign that ``name`` is about to run with these (digested) arguments."""
169
+ async with self._lock:
170
+ parent = self._head
171
+ return await self._attest(
172
+ INTENT_ACTION.format(name=name),
173
+ {
174
+ "tool": name,
175
+ "role": self.role,
176
+ "args_digest": args_digest(tool_input),
177
+ "session_tag": self.session_tag,
178
+ },
179
+ parent,
180
+ )
181
+
182
+ async def result(
183
+ self,
184
+ name: str,
185
+ outcome: Any,
186
+ *,
187
+ intent_id: str | None,
188
+ elapsed_ms: int,
189
+ ) -> str | None:
190
+ """Sign what ``name`` produced: status, gate, digests, events, timing."""
191
+ status = outcome_status(outcome)
192
+ meta: dict[str, Any] = {
193
+ "tool": name,
194
+ "role": self.role,
195
+ "status": status,
196
+ "result_digest": text_digest(getattr(outcome, "result_text", "") or ""),
197
+ "events": [getattr(e, "type", str(e)) for e in getattr(outcome, "events", ())],
198
+ "elapsed_ms": int(elapsed_ms),
199
+ "session_tag": self.session_tag,
200
+ # False is a statement, not a default: this result's parent is then
201
+ # the previous chain head, and a reader can see the intent is missing.
202
+ "intent_recorded": intent_id is not None,
203
+ }
204
+ if status == "blocked":
205
+ meta["gate"] = outcome.blocked
206
+ async with self._lock:
207
+ parent = self._head
208
+ artifact_id = await self._attest(RESULT_ACTION.format(name=name), meta, parent)
209
+ if self.timeline:
210
+ await self._event(name, status, elapsed_ms)
211
+ return artifact_id
212
+
213
+ async def _event(self, name: str, status: str, elapsed_ms: int) -> None:
214
+ """Append the call to the Treeship session timeline (unsigned; it is what
215
+ the receipt page renders). The signed receipts are the evidence."""
216
+ if self.disabled:
217
+ return
218
+ session_event = getattr(self.client, "session_event", None)
219
+ if session_event is None:
220
+ # treeship-sdk < 0.28 has no session_event. The signed receipts are
221
+ # the evidence; the timeline is a rendering convenience. Say so
222
+ # once rather than raise into the tool call.
223
+ if not self._timeline_unavailable_warned:
224
+ self._timeline_unavailable_warned = True
225
+ logger.warning(
226
+ "treeship-sdk %s has no session_event(); timeline events are skipped "
227
+ "(signed receipts are still written). Upgrade treeship-sdk to restore them.",
228
+ getattr(self.client, "__module__", "?"),
229
+ )
230
+ return
231
+ try:
232
+ await asyncio.to_thread(
233
+ session_event,
234
+ "agent.called_tool",
235
+ tool=name,
236
+ actor=self.actor,
237
+ exit_code=_EXIT_CODES[status],
238
+ duration_ms=int(elapsed_ms),
239
+ )
240
+ except Exception as err: # noqa: BLE001 -- a recorder must never break the tool
241
+ self._warn(f"session event {name}", err)
242
+
243
+
244
+ class TreeshipExecutorMixin:
245
+ """Put this first in the bases of a commerce-agents executor class::
246
+
247
+ class ReceiptedShoppingToolExecutor(TreeshipExecutorMixin, ShoppingToolExecutor):
248
+ pass
249
+
250
+ or let :func:`receipted` build that class. Give the instance its recorder
251
+ with :func:`attach`, or give the class a ``recorder`` factory through
252
+ :func:`receipted` so every runtime that constructs executors itself (all
253
+ three of the reference's do, through ``executor_class``) gets one per
254
+ executor on first use. Without either, the executor behaves exactly as
255
+ the reference does.
256
+ """
257
+
258
+ treeship_receipts: TreeshipReceipts | None = None
259
+ treeship_recorder_factory: Callable[[Any], TreeshipReceipts] | None = None
260
+
261
+ def _treeship_recorder(self) -> TreeshipReceipts | None:
262
+ if self.treeship_receipts is None and self.treeship_recorder_factory is not None:
263
+ # One recorder per executor. The reference builds one executor per
264
+ # session (Messages API), per toolset (Agent SDK), or per MCP
265
+ # connection (Managed Agents), so this is one chain per session.
266
+ self.treeship_receipts = self.treeship_recorder_factory(self)
267
+ return self.treeship_receipts
268
+
269
+ async def execute(self, name: str, tool_input: dict[str, Any] | None) -> Any:
270
+ receipts = self._treeship_recorder()
271
+ if receipts is None:
272
+ return await super().execute(name, tool_input) # type: ignore[misc]
273
+ intent_id = await receipts.intent(name, tool_input)
274
+ started = time.monotonic()
275
+ outcome = await super().execute(name, tool_input) # type: ignore[misc]
276
+ elapsed_ms = int((time.monotonic() - started) * 1000)
277
+ await receipts.result(name, outcome, intent_id=intent_id, elapsed_ms=elapsed_ms)
278
+ return outcome
279
+
280
+
281
+ def receipted(
282
+ executor_cls: type,
283
+ *,
284
+ recorder: Callable[[Any], TreeshipReceipts] | None = None,
285
+ ) -> type:
286
+ """``ShoppingToolExecutor`` in, ``ReceiptedShoppingToolExecutor`` out.
287
+
288
+ All three of the reference's runtimes take an ``executor_class``
289
+ (``ShoppingAgent(executor_class=)``, ``ShoppingToolset(executor_class=)``,
290
+ ``build_server(executor_class=)``) and construct executors themselves, so
291
+ there is no instance to :func:`attach` to. Pass ``recorder``, a callable
292
+ from the executor to its :class:`TreeshipReceipts`, and each executor gets
293
+ one on its first tool call. The executor's ``_session`` carries the
294
+ commerce session id the recorder should tag::
295
+
296
+ receipted(ShoppingToolExecutor, recorder=lambda ex: TreeshipReceipts(
297
+ ts, actor="agent://shopping", session_id=ex._session.session_id, parent_id=root))
298
+ """
299
+ if issubclass(executor_cls, TreeshipExecutorMixin) and recorder is None:
300
+ return executor_cls
301
+ body: dict[str, Any] = {}
302
+ if recorder is not None:
303
+ body["treeship_recorder_factory"] = staticmethod(recorder)
304
+ bases = (
305
+ (executor_cls,)
306
+ if issubclass(executor_cls, TreeshipExecutorMixin)
307
+ else (
308
+ TreeshipExecutorMixin,
309
+ executor_cls,
310
+ )
311
+ )
312
+ return type(f"Receipted{executor_cls.__name__.removeprefix('Receipted')}", bases, body)
313
+
314
+
315
+ def attach(executor: Any, receipts: TreeshipReceipts) -> Any:
316
+ """Give an executor instance its recorder. The executor's class must carry
317
+ :class:`TreeshipExecutorMixin`; attaching to a plain reference executor
318
+ would record nothing and say nothing, which is the failure this refuses."""
319
+ if not isinstance(executor, TreeshipExecutorMixin):
320
+ raise TypeError(
321
+ f"{type(executor).__name__} does not carry TreeshipExecutorMixin; build it from "
322
+ f"receipted({type(executor).__name__}) so execute() actually records."
323
+ )
324
+ executor.treeship_receipts = receipts
325
+ return executor
326
+
327
+
328
+ def event_types(outcome: Any) -> Sequence[str]:
329
+ """The event types a tool outcome emitted, as recorded on its receipt."""
330
+ return [getattr(e, "type", str(e)) for e in getattr(outcome, "events", ())]
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: treeship-commerce
3
+ Version: 0.28.0
4
+ Summary: Signed, offline-verifiable receipts for every tool call in anthropics/commerce-agents, on all three of its runtimes.
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://treeship.dev
7
+ Project-URL: Documentation, https://docs.treeship.dev/integrations/commerce-agents
8
+ Project-URL: Source, https://github.com/zerkerlabs/treeship/tree/main/integrations/commerce-agents
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: treeship-sdk>=0.27.0
12
+
13
+ # Treeship for Claude Commerce Agents
14
+
15
+ Signed, offline-verifiable receipts for every tool call in
16
+ [anthropics/commerce-agents](https://github.com/anthropics/commerce-agents), on all three of
17
+ its runtimes.
18
+
19
+ The reference draws its own boundary in `docs/safety.md`: the approval surface, payment,
20
+ and log hygiene are "what a deployment owns". This package is what a deployment adds for
21
+ the record of what happened. It records; it does not gate. The reference's provenance
22
+ gates, caps, and host approval still decide what runs.
23
+
24
+ ## What it does
25
+
26
+ `commerce_common.execution.BaseToolExecutor.execute` is the one method every tool call
27
+ passes through on the Messages API, the Agent SDK, and Managed Agents. `TreeshipExecutorMixin`
28
+ overrides it:
29
+
30
+ 1. a signed **intent** receipt before dispatch: tool, SHA-256 of the canonical arguments,
31
+ session tag;
32
+ 2. the tool, exactly as the reference runs it;
33
+ 3. a signed **result** receipt: status (`ok`, `blocked` with the gate's name, `error`),
34
+ SHA-256 of the result text, event types, timing.
35
+
36
+ Each receipt names its parent, so a session reads `intent → result → intent → result …` from
37
+ the Treeship session's root, and `treeship verify` walks it as one chain. A held call is a
38
+ signed refusal, not a missing receipt.
39
+
40
+ Never written: the arguments, the result text (fenced third-party content on the
41
+ reference), or the commerce session id (the request credential). The receipt carries the
42
+ same twelve-hex session tag the reference's own log lines use, so an operator holding the id
43
+ can correlate and a reader cannot.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # from a clone of anthropics/commerce-agents, with its venv active
49
+ pip install -r requirements.txt # their seven packages (unregistered on PyPI)
50
+ pip install treeship-sdk
51
+ pip install "treeship-commerce @ git+https://github.com/zerkerlabs/treeship.git#subdirectory=integrations/commerce-agents" # PyPI publication follows the next release
52
+ curl -fsSL https://treeship.dev/install | sh && treeship init
53
+ ```
54
+
55
+ ## Use
56
+
57
+ ```python
58
+ from treeship_sdk import Treeship
59
+ from treeship_commerce import TreeshipReceipts, attach, receipted
60
+ from treeship_commerce.lifecycle import close_session, start_session
61
+ from shopping_agent.executor import ShoppingToolExecutor
62
+
63
+ ts = Treeship()
64
+ root = start_session(ts, name="storefront:acme", actor="agent://shopping")
65
+
66
+ executor = receipted(ShoppingToolExecutor)(backend=..., config=..., skills=..., session=..., state=..., memory=...)
67
+ attach(executor, TreeshipReceipts(ts, actor="agent://shopping", session_id=session.session_id, parent_id=root))
68
+
69
+ # ... the runtime calls executor.execute(...) as it always did ...
70
+
71
+ close_session(ts, summary="...") # seals a .treeship package; `treeship session report` publishes it
72
+ ```
73
+
74
+ All three runtimes construct executors themselves through `executor_class`
75
+ (`ShoppingAgent`, `ShoppingToolset`, the MCP server's `build_server`). Give
76
+ `receipted()` a `recorder` factory and each executor gets its own recorder on
77
+ its first tool call:
78
+
79
+ ```python
80
+ ReceiptedShopping = receipted(ShoppingToolExecutor, recorder=lambda ex: TreeshipReceipts(
81
+ ts, actor="agent://shopping", session_id=ex._session.session_id, parent_id=root))
82
+ ```
83
+
84
+ Same for `MerchantToolExecutor`.
85
+
86
+ Recording never breaks the agent path: a receipt that cannot be written warns once, is
87
+ counted in `TreeshipReceipts.dropped`, and later results say `intent_recorded: false` where
88
+ the intent is missing. Nothing is invented. `TREESHIP_DISABLE=1` turns recording off.
89
+
90
+ ## Demo
91
+
92
+ ```bash
93
+ TREESHIP_BIN=... python -m treeship_commerce.demo
94
+ ```
95
+
96
+ Runs the reference's shopping executor over the retail mock with no model and no API key:
97
+ a search, a product read, an add, an add the provenance gate holds, a checkout hand-off.
98
+ Prints every receipt id, seals the session, and shows the `treeship verify` command.
99
+
100
+ ## What this does not do (yet)
101
+
102
+ - **Approvals.** The merchant `apply_change` gate checks a mark the host sets. Turning that
103
+ mark into a signed, single-use Treeship approval (nonce echoed by the apply receipt,
104
+ enforced by the Approval Use Journal) is the next piece.
105
+ - **Checkout hand-off receipt.** Signing the cart digest and hosted-checkout URL digest at
106
+ `checkout_handoff`, chained to the host's order placement.
107
+ - **Prove the work is correct.** A receipt is evidence of what ran and what the gates
108
+ decided. It does not make a wrong answer right.
109
+
110
+ ## Tests
111
+
112
+ ```bash
113
+ TREESHIP_BIN=/path/to/treeship python -m pytest
114
+ ```
115
+
116
+ Six cases on a real isolated ship and the real retail mock: chain order and linkage, a held
117
+ call signed as blocked with its gate, digests-only content, recording failure leaving the
118
+ tool untouched, `TREESHIP_DISABLE`, and `attach` refusing an executor that would record
119
+ nothing.
@@ -0,0 +1,8 @@
1
+ treeship_commerce/__init__.py,sha256=2DV_k19AcDxFcWLm_LySLP4YiwCtz3QcYRUTtBwGdWA,639
2
+ treeship_commerce/demo.py,sha256=QOQPOTkgYKGaBwQmE2Qdj1x5M4tMaheXOHctu2ctAdI,5173
3
+ treeship_commerce/lifecycle.py,sha256=ynyAmjMutxLY9E1oU0bZ5LVdFrCQ2xsgchM11H6JA5g,3463
4
+ treeship_commerce/receipts.py,sha256=4-BBD7A8MiRNNRDRmPuoaVQZ8QIxQ5jmvlkxV8pEzXw,13861
5
+ treeship_commerce-0.28.0.dist-info/METADATA,sha256=ZqiR4DR5iVnHZ0ObLNZAtUs1sMr2hvHjWu73dHp6dcs,5274
6
+ treeship_commerce-0.28.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ treeship_commerce-0.28.0.dist-info/top_level.txt,sha256=vnKaxb6_PjDR1XOHrth9aj9gmR74YeQAGRVOhp-n7g8,18
8
+ treeship_commerce-0.28.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
+ treeship_commerce