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.
- treeship_commerce/__init__.py +26 -0
- treeship_commerce/demo.py +127 -0
- treeship_commerce/lifecycle.py +102 -0
- treeship_commerce/receipts.py +330 -0
- treeship_commerce-0.28.0.dist-info/METADATA +119 -0
- treeship_commerce-0.28.0.dist-info/RECORD +8 -0
- treeship_commerce-0.28.0.dist-info/WHEEL +5 -0
- treeship_commerce-0.28.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
treeship_commerce
|