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.
- treeship_commerce-0.28.0/PKG-INFO +119 -0
- treeship_commerce-0.28.0/README.md +107 -0
- treeship_commerce-0.28.0/pyproject.toml +29 -0
- treeship_commerce-0.28.0/setup.cfg +4 -0
- treeship_commerce-0.28.0/tests/test_receipts.py +270 -0
- treeship_commerce-0.28.0/tests/test_runtimes.py +118 -0
- treeship_commerce-0.28.0/treeship_commerce/__init__.py +26 -0
- treeship_commerce-0.28.0/treeship_commerce/demo.py +127 -0
- treeship_commerce-0.28.0/treeship_commerce/lifecycle.py +102 -0
- treeship_commerce-0.28.0/treeship_commerce/receipts.py +330 -0
- treeship_commerce-0.28.0/treeship_commerce.egg-info/PKG-INFO +119 -0
- treeship_commerce-0.28.0/treeship_commerce.egg-info/SOURCES.txt +13 -0
- treeship_commerce-0.28.0/treeship_commerce.egg-info/dependency_links.txt +1 -0
- treeship_commerce-0.28.0/treeship_commerce.egg-info/requires.txt +1 -0
- treeship_commerce-0.28.0/treeship_commerce.egg-info/top_level.txt +1 -0
|
@@ -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,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"
|