treeship-commerce 0.28.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.
@@ -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,107 @@
1
+ # Treeship for Claude Commerce Agents
2
+
3
+ Signed, offline-verifiable receipts for every tool call in
4
+ [anthropics/commerce-agents](https://github.com/anthropics/commerce-agents), on all three of
5
+ its runtimes.
6
+
7
+ The reference draws its own boundary in `docs/safety.md`: the approval surface, payment,
8
+ and log hygiene are "what a deployment owns". This package is what a deployment adds for
9
+ the record of what happened. It records; it does not gate. The reference's provenance
10
+ gates, caps, and host approval still decide what runs.
11
+
12
+ ## What it does
13
+
14
+ `commerce_common.execution.BaseToolExecutor.execute` is the one method every tool call
15
+ passes through on the Messages API, the Agent SDK, and Managed Agents. `TreeshipExecutorMixin`
16
+ overrides it:
17
+
18
+ 1. a signed **intent** receipt before dispatch: tool, SHA-256 of the canonical arguments,
19
+ session tag;
20
+ 2. the tool, exactly as the reference runs it;
21
+ 3. a signed **result** receipt: status (`ok`, `blocked` with the gate's name, `error`),
22
+ SHA-256 of the result text, event types, timing.
23
+
24
+ Each receipt names its parent, so a session reads `intent → result → intent → result …` from
25
+ the Treeship session's root, and `treeship verify` walks it as one chain. A held call is a
26
+ signed refusal, not a missing receipt.
27
+
28
+ Never written: the arguments, the result text (fenced third-party content on the
29
+ reference), or the commerce session id (the request credential). The receipt carries the
30
+ same twelve-hex session tag the reference's own log lines use, so an operator holding the id
31
+ can correlate and a reader cannot.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ # from a clone of anthropics/commerce-agents, with its venv active
37
+ pip install -r requirements.txt # their seven packages (unregistered on PyPI)
38
+ pip install treeship-sdk
39
+ pip install "treeship-commerce @ git+https://github.com/zerkerlabs/treeship.git#subdirectory=integrations/commerce-agents" # PyPI publication follows the next release
40
+ curl -fsSL https://treeship.dev/install | sh && treeship init
41
+ ```
42
+
43
+ ## Use
44
+
45
+ ```python
46
+ from treeship_sdk import Treeship
47
+ from treeship_commerce import TreeshipReceipts, attach, receipted
48
+ from treeship_commerce.lifecycle import close_session, start_session
49
+ from shopping_agent.executor import ShoppingToolExecutor
50
+
51
+ ts = Treeship()
52
+ root = start_session(ts, name="storefront:acme", actor="agent://shopping")
53
+
54
+ executor = receipted(ShoppingToolExecutor)(backend=..., config=..., skills=..., session=..., state=..., memory=...)
55
+ attach(executor, TreeshipReceipts(ts, actor="agent://shopping", session_id=session.session_id, parent_id=root))
56
+
57
+ # ... the runtime calls executor.execute(...) as it always did ...
58
+
59
+ close_session(ts, summary="...") # seals a .treeship package; `treeship session report` publishes it
60
+ ```
61
+
62
+ All three runtimes construct executors themselves through `executor_class`
63
+ (`ShoppingAgent`, `ShoppingToolset`, the MCP server's `build_server`). Give
64
+ `receipted()` a `recorder` factory and each executor gets its own recorder on
65
+ its first tool call:
66
+
67
+ ```python
68
+ ReceiptedShopping = receipted(ShoppingToolExecutor, recorder=lambda ex: TreeshipReceipts(
69
+ ts, actor="agent://shopping", session_id=ex._session.session_id, parent_id=root))
70
+ ```
71
+
72
+ Same for `MerchantToolExecutor`.
73
+
74
+ Recording never breaks the agent path: a receipt that cannot be written warns once, is
75
+ counted in `TreeshipReceipts.dropped`, and later results say `intent_recorded: false` where
76
+ the intent is missing. Nothing is invented. `TREESHIP_DISABLE=1` turns recording off.
77
+
78
+ ## Demo
79
+
80
+ ```bash
81
+ TREESHIP_BIN=... python -m treeship_commerce.demo
82
+ ```
83
+
84
+ Runs the reference's shopping executor over the retail mock with no model and no API key:
85
+ a search, a product read, an add, an add the provenance gate holds, a checkout hand-off.
86
+ Prints every receipt id, seals the session, and shows the `treeship verify` command.
87
+
88
+ ## What this does not do (yet)
89
+
90
+ - **Approvals.** The merchant `apply_change` gate checks a mark the host sets. Turning that
91
+ mark into a signed, single-use Treeship approval (nonce echoed by the apply receipt,
92
+ enforced by the Approval Use Journal) is the next piece.
93
+ - **Checkout hand-off receipt.** Signing the cart digest and hosted-checkout URL digest at
94
+ `checkout_handoff`, chained to the host's order placement.
95
+ - **Prove the work is correct.** A receipt is evidence of what ran and what the gates
96
+ decided. It does not make a wrong answer right.
97
+
98
+ ## Tests
99
+
100
+ ```bash
101
+ TREESHIP_BIN=/path/to/treeship python -m pytest
102
+ ```
103
+
104
+ Six cases on a real isolated ship and the real retail mock: chain order and linkage, a held
105
+ call signed as blocked with its gate, digests-only content, recording failure leaving the
106
+ tool untouched, `TREESHIP_DISABLE`, and `attach` refusing an executor that would record
107
+ nothing.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "treeship-commerce"
7
+ version = "0.28.0"
8
+ description = "Signed, offline-verifiable receipts for every tool call in anthropics/commerce-agents, on all three of its runtimes."
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.11"
12
+ dependencies = ["treeship-sdk>=0.27.0"]
13
+ # The commerce-agents packages (commerce-common, shopping-agent-core, ...) are
14
+ # deliberately unregistered on PyPI; install them from a clone first
15
+ # (`pip install -r requirements.txt` in anthropics/commerce-agents). This
16
+ # package imports them lazily and says so when they are missing.
17
+
18
+ [project.urls]
19
+ Homepage = "https://treeship.dev"
20
+ Documentation = "https://docs.treeship.dev/integrations/commerce-agents"
21
+ Source = "https://github.com/zerkerlabs/treeship/tree/main/integrations/commerce-agents"
22
+
23
+ [tool.setuptools.packages.find]
24
+ include = ["treeship_commerce*"]
25
+
26
+ [tool.pytest.ini_options]
27
+ addopts = "-p asyncio"
28
+ asyncio_mode = "auto"
29
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,270 @@
1
+ # Every test here would pass for the wrong reason if it only checked that
2
+ # execute() returned. Each one names the receipt property it exists for.
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ import pytest
9
+ from treeship_sdk import Treeship
10
+
11
+ from treeship_commerce import TreeshipReceipts, args_digest, attach, receipted
12
+ from treeship_commerce.receipts import TreeshipExecutorMixin
13
+
14
+ from .conftest import COMMERCE_SESSION_ID, Ship, needs_cli
15
+
16
+ pytestmark = needs_cli
17
+
18
+
19
+ async def _first_product(executor) -> str:
20
+ outcome = await executor.execute("search_products", {"query": "tent"})
21
+ assert not outcome.refused, outcome.result_text
22
+ seen = list(executor._state.seen_products)
23
+ assert seen, "the retail mock returned no products for 'tent'"
24
+ return seen[0]
25
+
26
+
27
+ async def test_intent_precedes_result_and_chains_from_the_session_root(ship: Ship, executor):
28
+ product = await _first_product(executor)
29
+ outcome = await executor.execute("add_to_cart", {"product_id": product, "quantity": 1})
30
+ assert not outcome.refused, outcome.result_text
31
+
32
+ receipts: TreeshipReceipts = executor.treeship_receipts
33
+ assert receipts.dropped == 0
34
+ assert len(receipts.recorded) == 4 # intent, result, intent, result
35
+
36
+ chain = ship.chain(receipts.head)
37
+ actions = [c["statement"]["action"] for c in chain]
38
+ assert (
39
+ actions[0].startswith("session.start")
40
+ or chain[0]["record"]["artifact_id"] == ship.session_root
41
+ )
42
+ assert actions[1:] == [
43
+ "commerce.tool.search_products.intent",
44
+ "commerce.tool.search_products.result",
45
+ "commerce.tool.add_to_cart.intent",
46
+ "commerce.tool.add_to_cart.result",
47
+ ]
48
+ # Each result's parent is its own intent, so a result can never be read as
49
+ # belonging to a different call.
50
+ for intent, result in zip(chain[1::2], chain[2::2], strict=True):
51
+ assert result["statement"]["parentId"] == intent["record"]["artifact_id"]
52
+
53
+ verdict = ship.cli_json("verify", receipts.head)
54
+ assert verdict["outcome"] == "pass", verdict
55
+ assert verdict["chain_linkage_ok"] is True
56
+ assert verdict["total"] == 5 # root + 4
57
+
58
+ status = ship.cli_json("session", "status")
59
+ assert status["receipts"] == 4
60
+ # One timeline event per tool call -- when the SDK can append them. On
61
+ # treeship-sdk 0.27.0 (no session_event) the timeline stays at the
62
+ # session-start event and the signed receipts are the only record.
63
+ expected_events = 1 + (2 if hasattr(ship.client, "session_event") else 0)
64
+ assert status["events"] == expected_events
65
+
66
+
67
+ async def test_a_held_call_is_signed_as_blocked_with_the_gate_named(ship: Ship, executor):
68
+ from shopping_agent.gates import PROVENANCE_GATE
69
+
70
+ outcome = await executor.execute("add_to_cart", {"product_id": "p-never-seen", "quantity": 1})
71
+ assert outcome.blocked == PROVENANCE_GATE # the reference's gate did its job
72
+
73
+ receipts: TreeshipReceipts = executor.treeship_receipts
74
+ result = ship.artifacts()[receipts.head]["statement"]
75
+ assert result["action"] == "commerce.tool.add_to_cart.result"
76
+ meta = result["meta"]
77
+ assert meta["status"] == "blocked"
78
+ assert meta["gate"] == PROVENANCE_GATE
79
+ assert meta["intent_recorded"] is True
80
+ # Blocked is still a pass for the signature: the receipt is authentic
81
+ # evidence of a refusal, not a failed receipt.
82
+ assert ship.cli_json("verify", receipts.head)["outcome"] == "pass"
83
+
84
+
85
+ async def test_receipts_carry_digests_never_arguments_results_or_the_session_id(
86
+ ship: Ship, executor
87
+ ):
88
+ product = await _first_product(executor)
89
+ await executor.execute("get_product_details", {"product_id": product})
90
+ receipts: TreeshipReceipts = executor.treeship_receipts
91
+
92
+ arts = ship.artifacts()
93
+ for artifact_id in receipts.recorded:
94
+ raw = arts[artifact_id]["raw"]
95
+ statement = arts[artifact_id]["statement"]
96
+ assert COMMERCE_SESSION_ID not in raw, "the commerce session id is a credential"
97
+ assert '"tent"' not in raw, "raw arguments must not appear in a receipt"
98
+ assert "storefront_data" not in raw, "fenced result text must not appear in a receipt"
99
+ meta = statement["meta"]
100
+ assert (
101
+ meta["session_tag"]
102
+ == __import__("hashlib").sha256(COMMERCE_SESSION_ID.encode()).hexdigest()[:12]
103
+ )
104
+
105
+ intent = arts[receipts.recorded[0]]["statement"]
106
+ assert intent["meta"]["args_digest"] == args_digest({"query": "tent"})
107
+ details_result = arts[receipts.recorded[-1]]["statement"]
108
+ assert details_result["meta"]["result_digest"].startswith("sha256:")
109
+ assert details_result["meta"]["events"] == []
110
+
111
+
112
+ async def test_recording_failure_never_breaks_the_tool_and_is_counted(ship: Ship, executor):
113
+ # Point the recorder at a binary that does not exist. The tool must still
114
+ # run and return its real outcome; the recorder must count what it lost
115
+ # rather than pretend.
116
+ broken = Treeship(cli_path=str(ship.root / "no-such-treeship"), env=ship.env, cwd=ship.root)
117
+ attach(
118
+ executor,
119
+ TreeshipReceipts(
120
+ broken,
121
+ actor="agent://shopping",
122
+ session_id=COMMERCE_SESSION_ID,
123
+ parent_id=ship.session_root,
124
+ ),
125
+ )
126
+ outcome = await executor.execute("search_products", {"query": "tent"})
127
+ assert not outcome.refused
128
+ assert "tent" in outcome.result_text.lower() or executor._state.seen_products
129
+ receipts: TreeshipReceipts = executor.treeship_receipts
130
+ assert receipts.recorded == []
131
+ assert receipts.dropped >= 2 # intent + result (+ timeline event)
132
+ assert receipts.head == ship.session_root # the chain head never moved to a phantom id
133
+
134
+
135
+ async def test_disabled_records_nothing_and_changes_nothing(ship: Ship, executor, monkeypatch):
136
+ monkeypatch.setenv("TREESHIP_DISABLE", "1")
137
+ before = set(ship.artifacts())
138
+ outcome = await executor.execute("search_products", {"query": "tent"})
139
+ assert not outcome.refused
140
+ assert set(ship.artifacts()) == before
141
+ assert executor.treeship_receipts.recorded == []
142
+ assert executor.treeship_receipts.dropped == 0
143
+
144
+
145
+ async def test_attach_refuses_an_executor_that_would_record_nothing(ship: Ship):
146
+ from commerce_common.memory import InMemoryMemoryStore
147
+ from commerce_common.skills import SkillRegistry
148
+ from shopping_agent import ShoppingAgentConfig, ShoppingSessionContext, ShoppingSessionState
149
+ from shopping_agent.executor import ShoppingToolExecutor, build_memory
150
+ from shopping_agent_sdk import load_mock_backend
151
+
152
+ config = ShoppingAgentConfig(brand_name="ACME")
153
+ plain = ShoppingToolExecutor(
154
+ backend=load_mock_backend(),
155
+ config=config,
156
+ skills=SkillRegistry([]),
157
+ session=ShoppingSessionContext(session_id="s", user_id="u"),
158
+ state=ShoppingSessionState(),
159
+ memory=build_memory(config, InMemoryMemoryStore()),
160
+ inline_context=True,
161
+ )
162
+ with pytest.raises(TypeError):
163
+ attach(plain, TreeshipReceipts(ship.client, actor="agent://shopping"))
164
+
165
+
166
+ def test_receipted_puts_the_mixin_first_and_is_idempotent():
167
+ from shopping_agent.executor import ShoppingToolExecutor
168
+
169
+ cls = receipted(ShoppingToolExecutor)
170
+ assert cls.__mro__[1] is TreeshipExecutorMixin
171
+ assert issubclass(cls, ShoppingToolExecutor)
172
+ assert receipted(cls) is cls
173
+
174
+
175
+ def test_args_digest_is_canonical():
176
+ assert args_digest({"b": 1, "a": "x"}) == args_digest({"a": "x", "b": 1})
177
+ assert args_digest({}) == args_digest(None)
178
+ assert args_digest({"a": 1}) != args_digest({"a": 2})
179
+ assert json.dumps({"a": 1}) and args_digest({"a": 1}).startswith("sha256:")
180
+
181
+
182
+ async def test_a_recorder_factory_gives_each_executor_its_own_chain(ship: Ship):
183
+ # The runtimes construct executors themselves through `executor_class`,
184
+ # so the factory is the only way to record there. Two executors, two
185
+ # commerce sessions, two recorders, two tags -- never one shared chain.
186
+ from commerce_common.memory import InMemoryMemoryStore
187
+ from commerce_common.skills import SkillRegistry
188
+ from shopping_agent import ShoppingAgentConfig, ShoppingSessionContext, ShoppingSessionState
189
+ from shopping_agent.executor import ShoppingToolExecutor, build_memory
190
+ from shopping_agent_sdk import load_mock_backend
191
+
192
+ config = ShoppingAgentConfig(brand_name="ACME")
193
+ cls = receipted(
194
+ ShoppingToolExecutor,
195
+ recorder=lambda ex: TreeshipReceipts(
196
+ ship.client,
197
+ actor="agent://shopping",
198
+ session_id=ex._session.session_id,
199
+ parent_id=ship.session_root,
200
+ ),
201
+ )
202
+
203
+ def build(session_id: str):
204
+ return cls(
205
+ backend=load_mock_backend(),
206
+ config=config,
207
+ skills=SkillRegistry([]),
208
+ session=ShoppingSessionContext(session_id=session_id, user_id="u"),
209
+ state=ShoppingSessionState(),
210
+ memory=build_memory(config, InMemoryMemoryStore()),
211
+ inline_context=True,
212
+ )
213
+
214
+ a, b = build("session-A"), build("session-B")
215
+ assert a.treeship_receipts is None # nothing recorded until the first call
216
+ await a.execute("search_products", {"query": "tent"})
217
+ await b.execute("search_products", {"query": "tent"})
218
+ assert a.treeship_receipts is not b.treeship_receipts
219
+ assert a.treeship_receipts.session_tag != b.treeship_receipts.session_tag
220
+ assert len(a.treeship_receipts.recorded) == 2 and len(b.treeship_receipts.recorded) == 2
221
+ for ex in (a, b):
222
+ assert ship.cli_json("verify", ex.treeship_receipts.head)["outcome"] == "pass"
223
+
224
+
225
+ class _OldSdkClient:
226
+ """A treeship-sdk 0.27.0 client: attest_action exists, session_event does not."""
227
+
228
+ def __init__(self, inner):
229
+ self._inner = inner
230
+
231
+ def attest_action(self, *args, **kwargs):
232
+ return self._inner.attest_action(*args, **kwargs)
233
+
234
+
235
+ async def test_an_sdk_without_session_event_still_writes_signed_receipts(ship: Ship, executor):
236
+ # The SDK on PyPI at launch (0.27.0) predates session_event(). The
237
+ # receipts are the evidence; the timeline must degrade, never raise into
238
+ # the tool call. Before this guard, AttributeError escaped execute().
239
+ attach(
240
+ executor,
241
+ TreeshipReceipts(
242
+ _OldSdkClient(ship.client),
243
+ actor="agent://shopping",
244
+ session_id=COMMERCE_SESSION_ID,
245
+ parent_id=ship.session_root,
246
+ ),
247
+ )
248
+ outcome = await executor.execute("search_products", {"query": "tent"})
249
+ assert not outcome.refused
250
+ receipts: TreeshipReceipts = executor.treeship_receipts
251
+ assert len(receipts.recorded) == 2 and receipts.dropped == 0
252
+ assert ship.cli_json("verify", receipts.head)["outcome"] == "pass"
253
+
254
+
255
+ async def test_a_client_that_raises_anything_never_breaks_the_tool(ship: Ship, executor):
256
+ class Explodes:
257
+ def attest_action(self, *a, **k):
258
+ raise RuntimeError("simulated SDK bug")
259
+
260
+ def session_event(self, *a, **k):
261
+ raise KeyError("simulated SDK bug")
262
+
263
+ attach(
264
+ executor,
265
+ TreeshipReceipts(Explodes(), actor="agent://shopping", parent_id=ship.session_root),
266
+ )
267
+ outcome = await executor.execute("search_products", {"query": "tent"})
268
+ assert not outcome.refused
269
+ assert executor.treeship_receipts.recorded == []
270
+ assert executor.treeship_receipts.dropped >= 2
@@ -0,0 +1,118 @@
1
+ # The docs say one wrapper covers all three of the reference's runtimes
2
+ # through `executor_class`. Each test here drives a runtime the way the
3
+ # reference's own tests drive it and checks that receipts were written, so
4
+ # that claim is tested, not inferred from reading the source.
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ from treeship_commerce import TreeshipReceipts, receipted
11
+
12
+ from .conftest import Ship, needs_cli
13
+
14
+ pytestmark = needs_cli
15
+
16
+
17
+ def _factory(ship: Ship, made: list[TreeshipReceipts]):
18
+ def make(executor) -> TreeshipReceipts:
19
+ r = TreeshipReceipts(
20
+ ship.client,
21
+ actor="agent://shopping",
22
+ session_id=executor._session.session_id,
23
+ parent_id=ship.session_root,
24
+ )
25
+ made.append(r)
26
+ return r
27
+
28
+ return make
29
+
30
+
31
+ def _receipted_shopping(ship: Ship, made: list[TreeshipReceipts]):
32
+ from shopping_agent.executor import ShoppingToolExecutor
33
+
34
+ return receipted(ShoppingToolExecutor, recorder=_factory(ship, made))
35
+
36
+
37
+ async def test_messages_api_runtime_records_through_executor_class(ship: Ship):
38
+ from commerce_common.skills import SkillRegistry
39
+ from commerce_common.testing import FakeClient, text_message, tool_use_message
40
+ from shopping_agent import ShoppingSessionContext, ShoppingSessionState
41
+ from shopping_agent_runtime import ShoppingAgent
42
+ from shopping_agent_sdk import load_mock_backend
43
+
44
+ made: list[TreeshipReceipts] = []
45
+ # A scripted model: one tool call, then a text answer. No API key.
46
+ client = FakeClient(
47
+ [tool_use_message("search_products", {"query": "tent"}), text_message("Here.")]
48
+ )
49
+ agent = ShoppingAgent(
50
+ backend=load_mock_backend(),
51
+ skills=SkillRegistry([]),
52
+ client=client,
53
+ executor_class=_receipted_shopping(ship, made),
54
+ )
55
+ session = ShoppingSessionContext(session_id="messages-api-session", user_id="u-1")
56
+ state = ShoppingSessionState()
57
+ events = [
58
+ e async for e in agent.stream_turn([{"role": "user", "content": "a tent"}], session, state)
59
+ ]
60
+ assert any(e.type == "tool_call" for e in events), [e.type for e in events]
61
+ assert len(made) == 1, "one executor per turn, one recorder"
62
+ assert [a.split(".")[-1] for a in _actions(ship, made[0])] == ["intent", "result"]
63
+ assert ship.cli_json("verify", made[0].head)["outcome"] == "pass"
64
+
65
+
66
+ async def test_agent_sdk_toolset_records_through_executor_class(ship: Ship):
67
+ from shopping_agent_sdk import (
68
+ ShoppingToolset,
69
+ build_shopping_sdk_tools,
70
+ default_config,
71
+ load_mock_backend,
72
+ )
73
+
74
+ made: list[TreeshipReceipts] = []
75
+ toolset = ShoppingToolset(
76
+ backend=load_mock_backend(),
77
+ config=default_config(),
78
+ executor_class=_receipted_shopping(ship, made),
79
+ )
80
+ handlers = {t.name: t for t in build_shopping_sdk_tools(toolset)}
81
+ await handlers["search_products"].handler({"query": "headphones"})
82
+ held = await handlers["add_to_cart"].handler(
83
+ {"product_id": "AR-0000-never-seen", "quantity": 1}
84
+ )
85
+ assert held is not None
86
+ assert len(made) == 1
87
+ actions = [a.split(".")[-1] for a in _actions(ship, made[0])]
88
+ assert actions == ["intent", "result", "intent", "result"]
89
+ last = ship.artifacts()[made[0].head]["statement"]["meta"]
90
+ assert last["status"] == "blocked" and last["gate"] == "provenance"
91
+ assert ship.cli_json("verify", made[0].head)["outcome"] == "pass"
92
+
93
+
94
+ async def test_managed_agents_mcp_server_records_through_executor_class(ship: Ship):
95
+ from commerce_common.memory import InMemoryMemoryStore
96
+ from mcp.shared.memory import create_connected_server_and_client_session
97
+ from shopping_agent_sdk import REPO_ROOT
98
+
99
+ sys.path.insert(
100
+ 0, str(REPO_ROOT / "shopping-agent" / "managed-agents" / "storefront-mcp-server")
101
+ )
102
+ from storefront_mcp_server import build_server
103
+
104
+ made: list[TreeshipReceipts] = []
105
+ server = build_server(
106
+ memory_store=InMemoryMemoryStore(), executor_class=_receipted_shopping(ship, made)
107
+ )
108
+ async with create_connected_server_and_client_session(server._mcp_server) as client:
109
+ await client.call_tool("search_products", {"query": "yoga mat"})
110
+ await client.call_tool("search_products", {"query": "headphones"})
111
+ assert len(made) == 1, "one executor, and so one recorder, per MCP connection"
112
+ assert len(made[0].recorded) == 4
113
+ assert ship.cli_json("verify", made[0].head)["outcome"] == "pass"
114
+
115
+
116
+ def _actions(ship: Ship, receipts: TreeshipReceipts) -> list[str]:
117
+ arts = ship.artifacts()
118
+ return [arts[i]["statement"]["action"] for i in receipts.recorded]
@@ -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"