succession-cli 0.2.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.
- succession/__init__.py +52 -0
- succession/acp.py +522 -0
- succession/acp_cli.py +133 -0
- succession/agent.py +288 -0
- succession/audit.py +470 -0
- succession/canonical.py +103 -0
- succession/catalog.py +571 -0
- succession/certificate.py +113 -0
- succession/chain.py +678 -0
- succession/cli.py +895 -0
- succession/data/__init__.py +1 -0
- succession/data/artifacts.json +1789 -0
- succession/data/base-sepolia.json +11 -0
- succession/dataroom.py +275 -0
- succession/demo.py +200 -0
- succession/demokeys.py +33 -0
- succession/envelope.py +140 -0
- succession/erc8004.py +603 -0
- succession/evaluator.py +459 -0
- succession/export.py +237 -0
- succession/fulfil.py +165 -0
- succession/importer.py +243 -0
- succession/marketplace.py +159 -0
- succession/mcp_server/__init__.py +309 -0
- succession/memory/__init__.py +23 -0
- succession/memory/base.py +172 -0
- succession/memory/credentials.py +135 -0
- succession/memory/sibyl.py +451 -0
- succession/merkle.py +226 -0
- succession/provenance.py +187 -0
- succession/publish.py +361 -0
- succession/redaction.py +238 -0
- succession/reputation.py +404 -0
- succession/scope.py +344 -0
- succession/seal.py +309 -0
- succession/seed.py +309 -0
- succession/settlement.py +543 -0
- succession/smp.py +386 -0
- succession/transfer.py +322 -0
- succession/valuation.py +306 -0
- succession_cli-0.2.0.dist-info/METADATA +56 -0
- succession_cli-0.2.0.dist-info/RECORD +44 -0
- succession_cli-0.2.0.dist-info/WHEEL +4 -0
- succession_cli-0.2.0.dist-info/entry_points.txt +4 -0
succession/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Succession — the property layer for agent memory.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
from succession import export_tenant, import_package, open_tenant
|
|
6
|
+
|
|
7
|
+
Everything else is reachable through the submodules: :mod:`succession.smp` for
|
|
8
|
+
the package format, :mod:`succession.merkle` for the integrity scheme,
|
|
9
|
+
:mod:`succession.valuation`, :mod:`succession.dataroom`, :mod:`succession.seal`,
|
|
10
|
+
and :mod:`succession.certificate`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .canonical import canonical_bytes, canonical_json
|
|
14
|
+
from .export import ExportResult, build_package, export_tenant, read_all
|
|
15
|
+
from .importer import ImportResult, IntegrityMismatch, import_package, verify_package
|
|
16
|
+
from .memory.sibyl import SibylMemory, open_tenant
|
|
17
|
+
from .merkle import MerkleTree, build_tree, to_hex, verify_proof
|
|
18
|
+
from .provenance import SignatureError, sign_header, verify_header
|
|
19
|
+
from .redaction import Disclosure, Sensitivity, mark, read_disclosure
|
|
20
|
+
from .smp import DATA_CATEGORIES, SMP_CATEGORIES, SMP_VERSION, SMPPackage
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DATA_CATEGORIES",
|
|
26
|
+
"SMP_CATEGORIES",
|
|
27
|
+
"SMP_VERSION",
|
|
28
|
+
"Disclosure",
|
|
29
|
+
"ExportResult",
|
|
30
|
+
"ImportResult",
|
|
31
|
+
"IntegrityMismatch",
|
|
32
|
+
"MerkleTree",
|
|
33
|
+
"SMPPackage",
|
|
34
|
+
"Sensitivity",
|
|
35
|
+
"SibylMemory",
|
|
36
|
+
"SignatureError",
|
|
37
|
+
"build_package",
|
|
38
|
+
"build_tree",
|
|
39
|
+
"canonical_bytes",
|
|
40
|
+
"canonical_json",
|
|
41
|
+
"export_tenant",
|
|
42
|
+
"import_package",
|
|
43
|
+
"mark",
|
|
44
|
+
"open_tenant",
|
|
45
|
+
"read_all",
|
|
46
|
+
"read_disclosure",
|
|
47
|
+
"sign_header",
|
|
48
|
+
"to_hex",
|
|
49
|
+
"verify_header",
|
|
50
|
+
"verify_package",
|
|
51
|
+
"verify_proof",
|
|
52
|
+
]
|
succession/acp.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Virtuals ACP — verifiable job history as the quality-of-earnings signal.
|
|
2
|
+
|
|
3
|
+
The build spec is specific about why this matters: a listing should surface the
|
|
4
|
+
agent's "real, independently verifiable ACP job history" so a buyer gets a
|
|
5
|
+
signal they can check **without trusting the seller's word**. Succession's own
|
|
6
|
+
data-room aggregates are computed from the seller's own memory — useful, but
|
|
7
|
+
self-reported. ACP job history is not: every record here carries an on-chain
|
|
8
|
+
job id against the ACP contract on Base, so a buyer (or a judge) can verify the
|
|
9
|
+
counts independently.
|
|
10
|
+
|
|
11
|
+
Three things this feeds, none of them decorative:
|
|
12
|
+
|
|
13
|
+
1. **The data room.** Completed job count, gross volume, and distinct
|
|
14
|
+
counterparties come from ACP, shown beside the self-reported figures and
|
|
15
|
+
labelled for what each one is.
|
|
16
|
+
2. **The valuation.** ``task_performance`` prefers the ACP completed-versus-
|
|
17
|
+
cancelled ratio over the journal-text heuristic whenever job history is
|
|
18
|
+
available. Real settlement outcomes beat guessing from strings.
|
|
19
|
+
3. **The memory asset itself.** Job history is synced into Sibyl Memory as
|
|
20
|
+
``acp-job`` entities, so it travels with the sale. The buyer inherits a
|
|
21
|
+
verifiable earnings record, not just a claim about one — which is precisely
|
|
22
|
+
what makes the customer book worth something.
|
|
23
|
+
|
|
24
|
+
Registration is a precondition, not a detail: ACP will not let an unregistered
|
|
25
|
+
agent be discovered or hired, so :func:`require_registered` gates listing.
|
|
26
|
+
|
|
27
|
+
The SDK import is deliberately lazy. ``virtuals-acp`` pulls a socket client and
|
|
28
|
+
wants wallet credentials at construction time; a contributor running the test
|
|
29
|
+
suite should not need either.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
|
|
39
|
+
from eth_utils import to_checksum_address
|
|
40
|
+
from decimal import Decimal
|
|
41
|
+
from typing import Any, Iterable, Protocol, runtime_checkable
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ACPJobRecord",
|
|
45
|
+
"ACPJobHistory",
|
|
46
|
+
"ACPSource",
|
|
47
|
+
"LiveACP",
|
|
48
|
+
"RecordedACP",
|
|
49
|
+
"ACPNotConfigured",
|
|
50
|
+
"AgentNotRegistered",
|
|
51
|
+
"require_registered",
|
|
52
|
+
"build_handover",
|
|
53
|
+
"verify_handover",
|
|
54
|
+
"sync_job_history",
|
|
55
|
+
"job_history_from_memory",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
#: ACP job phases, from ``virtuals_acp.models.ACPJobPhase``. Mirrored rather
|
|
59
|
+
#: than imported so reading history never drags in the socket client.
|
|
60
|
+
PHASE_COMPLETED = 4
|
|
61
|
+
PHASE_REJECTED = 5
|
|
62
|
+
PHASE_EXPIRED = 6
|
|
63
|
+
|
|
64
|
+
#: The Sibyl category ACP history is mirrored into. Routed to ``history/`` by
|
|
65
|
+
#: the SMP category map, so it transfers with the sale like any other record.
|
|
66
|
+
ACP_CATEGORY = "acp-job"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ACPNotConfigured(RuntimeError):
|
|
70
|
+
"""No ACP credentials in the environment."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AgentNotRegistered(RuntimeError):
|
|
74
|
+
"""The agent is not on the ACP service registry, so it cannot be listed."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ACPJobRecord:
|
|
79
|
+
"""One ACP job, reduced to what a buyer can independently verify.
|
|
80
|
+
|
|
81
|
+
``onchain_job_id`` is the point of this record. Everything else here is a
|
|
82
|
+
convenience; the id is what lets someone re-read the job from the ACP
|
|
83
|
+
contract and confirm the seller did not invent it.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
onchain_job_id: int
|
|
87
|
+
phase: int
|
|
88
|
+
price: str # decimal string — floats do not survive canonicalization
|
|
89
|
+
token: str
|
|
90
|
+
client_address: str
|
|
91
|
+
provider_address: str
|
|
92
|
+
evaluator_address: str
|
|
93
|
+
contract_address: str
|
|
94
|
+
settled_at: str = ""
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def completed(self) -> bool:
|
|
98
|
+
return self.phase == PHASE_COMPLETED
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def failed(self) -> bool:
|
|
102
|
+
return self.phase in (PHASE_REJECTED, PHASE_EXPIRED)
|
|
103
|
+
|
|
104
|
+
def counterparty(self, agent_address: str) -> str:
|
|
105
|
+
"""The other side of this job, from ``agent_address``'s point of view."""
|
|
106
|
+
me = agent_address.lower()
|
|
107
|
+
return (
|
|
108
|
+
self.client_address
|
|
109
|
+
if self.provider_address.lower() == me
|
|
110
|
+
else self.provider_address
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, Any]:
|
|
114
|
+
return {
|
|
115
|
+
"onchain_job_id": self.onchain_job_id,
|
|
116
|
+
"phase": self.phase,
|
|
117
|
+
"price": self.price,
|
|
118
|
+
"token": self.token,
|
|
119
|
+
"client_address": self.client_address,
|
|
120
|
+
"provider_address": self.provider_address,
|
|
121
|
+
"evaluator_address": self.evaluator_address,
|
|
122
|
+
"contract_address": self.contract_address,
|
|
123
|
+
"settled_at": self.settled_at,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, blob: dict[str, Any]) -> "ACPJobRecord":
|
|
128
|
+
return cls(**{k: blob[k] for k in blob if k in cls.__dataclass_fields__})
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_sdk(cls, job: Any) -> "ACPJobRecord":
|
|
132
|
+
"""Convert a ``virtuals_acp.job.ACPJob``.
|
|
133
|
+
|
|
134
|
+
``price`` arrives as a float from the SDK and is stringified through
|
|
135
|
+
Decimal here: the canonical serializer rejects floats outright, and an
|
|
136
|
+
earnings figure that renders differently on two machines would break the
|
|
137
|
+
integrity hash of any memory carrying it.
|
|
138
|
+
"""
|
|
139
|
+
return cls(
|
|
140
|
+
onchain_job_id=int(job.id),
|
|
141
|
+
phase=int(getattr(job.phase, "value", job.phase)),
|
|
142
|
+
price=str(Decimal(str(job.price))),
|
|
143
|
+
token=str(getattr(job, "price_token_address", "") or ""),
|
|
144
|
+
client_address=str(job.client_address),
|
|
145
|
+
provider_address=str(job.provider_address),
|
|
146
|
+
evaluator_address=str(getattr(job, "evaluator_address", "") or ""),
|
|
147
|
+
contract_address=str(getattr(job, "contract_address", "") or ""),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ACPJobHistory:
|
|
153
|
+
"""The quality-of-earnings signal, derived from ACP rather than from memory."""
|
|
154
|
+
|
|
155
|
+
agent_address: str
|
|
156
|
+
agent_id: int | None = None
|
|
157
|
+
agent_name: str = ""
|
|
158
|
+
registered: bool = False
|
|
159
|
+
jobs: tuple[ACPJobRecord, ...] = ()
|
|
160
|
+
fetched_at: str = ""
|
|
161
|
+
source: str = "live" # "live" | "memory" | "recorded"
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def completed(self) -> list[ACPJobRecord]:
|
|
165
|
+
return [j for j in self.jobs if j.completed]
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def failed(self) -> list[ACPJobRecord]:
|
|
169
|
+
return [j for j in self.jobs if j.failed]
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def gross_volume(self) -> Decimal:
|
|
173
|
+
return sum((Decimal(j.price) for j in self.completed), Decimal(0))
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def counterparties(self) -> set[str]:
|
|
177
|
+
return {j.counterparty(self.agent_address) for j in self.completed}
|
|
178
|
+
|
|
179
|
+
def success_rate(self) -> Decimal | None:
|
|
180
|
+
"""Completed over resolved. ``None`` when the sample is too small.
|
|
181
|
+
|
|
182
|
+
The same five-outcome floor the journal heuristic uses: two-for-two is
|
|
183
|
+
not a 100% success rate, it is a small sample, and a valuation that
|
|
184
|
+
treats it as one is wrong in the seller's favour.
|
|
185
|
+
"""
|
|
186
|
+
resolved = len(self.completed) + len(self.failed)
|
|
187
|
+
if resolved < 5:
|
|
188
|
+
return None
|
|
189
|
+
return Decimal(len(self.completed)) / Decimal(resolved)
|
|
190
|
+
|
|
191
|
+
def to_dict(self) -> dict[str, Any]:
|
|
192
|
+
return {
|
|
193
|
+
"agent_address": self.agent_address,
|
|
194
|
+
"agent_id": self.agent_id,
|
|
195
|
+
"agent_name": self.agent_name,
|
|
196
|
+
"registered": self.registered,
|
|
197
|
+
"source": self.source,
|
|
198
|
+
"fetched_at": self.fetched_at,
|
|
199
|
+
"completed_jobs": len(self.completed),
|
|
200
|
+
"failed_jobs": len(self.failed),
|
|
201
|
+
"gross_volume": str(self.gross_volume),
|
|
202
|
+
"distinct_counterparties": len(self.counterparties),
|
|
203
|
+
"success_rate": (
|
|
204
|
+
str(self.success_rate()) if self.success_rate() is not None else None
|
|
205
|
+
),
|
|
206
|
+
"verifiable_job_ids": sorted(j.onchain_job_id for j in self.completed),
|
|
207
|
+
"verification": (
|
|
208
|
+
"Each job id resolves against the ACP contract on Base Sepolia; "
|
|
209
|
+
"these counts can be re-derived without trusting the seller."
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@runtime_checkable
|
|
215
|
+
class ACPSource(Protocol):
|
|
216
|
+
"""Where job history comes from."""
|
|
217
|
+
|
|
218
|
+
def agent(self) -> dict[str, Any] | None: ...
|
|
219
|
+
def jobs(self) -> list[ACPJobRecord]: ...
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class LiveACP:
|
|
223
|
+
"""Reads job history from the real ACP API via ``virtuals-acp``.
|
|
224
|
+
|
|
225
|
+
Construction needs a whitelisted agent wallet, its private key, and the
|
|
226
|
+
agent's ACP entity id — the three things the ACP Tech Playbook issues when
|
|
227
|
+
an agent is registered. They are read from the environment because a private
|
|
228
|
+
key on a command line lands in shell history.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
ENV = ("WHITELISTED_WALLET_PRIVATE_KEY", "AGENT_WALLET_ADDRESS", "ACP_ENTITY_ID")
|
|
232
|
+
|
|
233
|
+
def __init__(self, *, page_size: int = 100, max_pages: int = 20) -> None:
|
|
234
|
+
missing = [name for name in self.ENV if not os.environ.get(name)]
|
|
235
|
+
if missing:
|
|
236
|
+
raise ACPNotConfigured(
|
|
237
|
+
"ACP credentials missing from the environment: "
|
|
238
|
+
+ ", ".join(missing)
|
|
239
|
+
+ ". Register the agent with the ACP Tech Playbook first — an "
|
|
240
|
+
"unregistered agent cannot be discovered or hired."
|
|
241
|
+
)
|
|
242
|
+
self.page_size = page_size
|
|
243
|
+
self.max_pages = max_pages
|
|
244
|
+
self._client: Any = None
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def client(self) -> Any:
|
|
248
|
+
if self._client is None:
|
|
249
|
+
# Imported here, not at module scope: the SDK opens a socket
|
|
250
|
+
# connection and wants credentials the test suite does not have.
|
|
251
|
+
from virtuals_acp import VirtualsACP
|
|
252
|
+
from virtuals_acp.configs import BASE_SEPOLIA_CONFIG
|
|
253
|
+
from virtuals_acp.contract_clients.contract_client import AcpContractClient
|
|
254
|
+
|
|
255
|
+
contract = AcpContractClient(
|
|
256
|
+
wallet_private_key=os.environ["WHITELISTED_WALLET_PRIVATE_KEY"],
|
|
257
|
+
agent_wallet_address=os.environ["AGENT_WALLET_ADDRESS"],
|
|
258
|
+
config=BASE_SEPOLIA_CONFIG,
|
|
259
|
+
entity_id=int(os.environ["ACP_ENTITY_ID"]),
|
|
260
|
+
)
|
|
261
|
+
# skip_socket_connection: this is a read path. Opening a live task
|
|
262
|
+
# socket to count finished jobs would leave a listener running for
|
|
263
|
+
# the lifetime of a CLI invocation.
|
|
264
|
+
self._client = VirtualsACP(contract, skip_socket_connection=True)
|
|
265
|
+
return self._client
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def wallet_address(self) -> str:
|
|
269
|
+
return str(self.client.wallet_address)
|
|
270
|
+
|
|
271
|
+
def agent(self) -> dict[str, Any] | None:
|
|
272
|
+
record = self.client.get_agent(self.wallet_address)
|
|
273
|
+
if record is None:
|
|
274
|
+
return None
|
|
275
|
+
return {
|
|
276
|
+
"id": getattr(record, "id", None),
|
|
277
|
+
"name": getattr(record, "name", ""),
|
|
278
|
+
"description": getattr(record, "description", ""),
|
|
279
|
+
"wallet_address": getattr(record, "wallet_address", self.wallet_address),
|
|
280
|
+
"cluster": getattr(record, "cluster", None),
|
|
281
|
+
"twitter_handle": getattr(record, "twitter_handle", None),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
def jobs(self) -> list[ACPJobRecord]:
|
|
285
|
+
"""Every settled job: completed and cancelled both.
|
|
286
|
+
|
|
287
|
+
Fetching only the completed ones would make the success rate a
|
|
288
|
+
tautology — you cannot compute a ratio from the numerator.
|
|
289
|
+
"""
|
|
290
|
+
out: list[ACPJobRecord] = []
|
|
291
|
+
for fetch in (self.client.get_completed_jobs, self.client.get_cancelled_jobs):
|
|
292
|
+
out.extend(self._page(fetch))
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
def _page(self, fetch: Any) -> Iterable[ACPJobRecord]:
|
|
296
|
+
for page in range(1, self.max_pages + 1):
|
|
297
|
+
batch = fetch(page=page, page_size=self.page_size)
|
|
298
|
+
if not batch:
|
|
299
|
+
return
|
|
300
|
+
for job in batch:
|
|
301
|
+
yield ACPJobRecord.from_sdk(job)
|
|
302
|
+
if len(batch) < self.page_size:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class RecordedACP:
|
|
307
|
+
"""Replays a snapshot previously fetched from the live API.
|
|
308
|
+
|
|
309
|
+
For offline development and for the hosted build. It is honest only because
|
|
310
|
+
the snapshot is real API output and the history it produces is labelled
|
|
311
|
+
``source="recorded"`` all the way to the UI — never presented as live.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, snapshot: dict[str, Any]) -> None:
|
|
315
|
+
self.snapshot = snapshot
|
|
316
|
+
|
|
317
|
+
@classmethod
|
|
318
|
+
def from_file(cls, path: str) -> "RecordedACP":
|
|
319
|
+
with open(path, encoding="utf-8") as handle:
|
|
320
|
+
return cls(json.load(handle))
|
|
321
|
+
|
|
322
|
+
def agent(self) -> dict[str, Any] | None:
|
|
323
|
+
return self.snapshot.get("agent")
|
|
324
|
+
|
|
325
|
+
def jobs(self) -> list[ACPJobRecord]:
|
|
326
|
+
return [ACPJobRecord.from_dict(j) for j in self.snapshot.get("jobs", [])]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _utc_now() -> str:
|
|
330
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def fetch_history(source: ACPSource, *, agent_address: str = "") -> ACPJobHistory:
|
|
334
|
+
"""Pull job history from any source into the neutral shape."""
|
|
335
|
+
agent = source.agent()
|
|
336
|
+
jobs = tuple(source.jobs())
|
|
337
|
+
address = agent_address or (agent or {}).get("wallet_address", "")
|
|
338
|
+
return ACPJobHistory(
|
|
339
|
+
agent_address=str(address),
|
|
340
|
+
agent_id=(agent or {}).get("id"),
|
|
341
|
+
agent_name=(agent or {}).get("name", ""),
|
|
342
|
+
registered=agent is not None,
|
|
343
|
+
jobs=jobs,
|
|
344
|
+
fetched_at=_utc_now(),
|
|
345
|
+
source="recorded" if isinstance(source, RecordedACP) else "live",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def require_registered(history: ACPJobHistory) -> None:
|
|
350
|
+
"""Gate listing on ACP registration.
|
|
351
|
+
|
|
352
|
+
ACP will not let an unregistered agent be discovered or hired, so listing
|
|
353
|
+
one for transfer would be selling an identity that cannot trade. Checking
|
|
354
|
+
at listing time rather than at settlement keeps a buyer's funds out of
|
|
355
|
+
escrow against a sale that could never complete — the same reasoning the
|
|
356
|
+
ListingContract applies to registry approval.
|
|
357
|
+
"""
|
|
358
|
+
if not history.registered:
|
|
359
|
+
raise AgentNotRegistered(
|
|
360
|
+
f"agent {history.agent_address!r} is not on the ACP service registry; "
|
|
361
|
+
"register it via the ACP Tech Playbook before listing"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# -- memory sync ----------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def sync_job_history(memory: Any, history: ACPJobHistory) -> int:
|
|
369
|
+
"""Mirror ACP job history into Sibyl Memory. Returns the record count.
|
|
370
|
+
|
|
371
|
+
This is what makes the integration part of the *asset* rather than a
|
|
372
|
+
decoration on the listing page. Once synced, the job history exports inside
|
|
373
|
+
the SMP package, hashes into the Merkle tree, and lands in the buyer's
|
|
374
|
+
tenant — so the successor agent inherits a verifiable earnings record, and
|
|
375
|
+
the buyer's own future resale can prove it.
|
|
376
|
+
|
|
377
|
+
Idempotent: jobs are keyed by their on-chain id, so re-syncing updates
|
|
378
|
+
rather than duplicates.
|
|
379
|
+
"""
|
|
380
|
+
written = 0
|
|
381
|
+
for job in history.jobs:
|
|
382
|
+
memory.client.set_entity(
|
|
383
|
+
ACP_CATEGORY,
|
|
384
|
+
str(job.onchain_job_id),
|
|
385
|
+
{
|
|
386
|
+
**job.to_dict(),
|
|
387
|
+
"counterparty": job.counterparty(history.agent_address),
|
|
388
|
+
"source": "virtuals-acp",
|
|
389
|
+
},
|
|
390
|
+
status="completed" if job.completed else "failed",
|
|
391
|
+
)
|
|
392
|
+
written += 1
|
|
393
|
+
|
|
394
|
+
memory.client.set_entity(
|
|
395
|
+
"identity",
|
|
396
|
+
"acp-registration",
|
|
397
|
+
{
|
|
398
|
+
"agent_id": history.agent_id,
|
|
399
|
+
"agent_name": history.agent_name,
|
|
400
|
+
"wallet_address": history.agent_address,
|
|
401
|
+
"registered": history.registered,
|
|
402
|
+
"synced_at": history.fetched_at,
|
|
403
|
+
"job_count": len(history.jobs),
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
return written
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def job_history_from_memory(memory: Any) -> ACPJobHistory:
|
|
410
|
+
"""Rebuild job history from a tenant's own records.
|
|
411
|
+
|
|
412
|
+
The buyer's side of the trip: after a transfer their store holds the ACP
|
|
413
|
+
records, and this reads them back without another API round trip. It is
|
|
414
|
+
also what lets the valuation use real settlement outcomes for an agent whose
|
|
415
|
+
credentials the current operator does not hold.
|
|
416
|
+
"""
|
|
417
|
+
registration: dict[str, Any] = {}
|
|
418
|
+
jobs: list[ACPJobRecord] = []
|
|
419
|
+
for entity in memory.entities():
|
|
420
|
+
if entity["category"] == ACP_CATEGORY:
|
|
421
|
+
jobs.append(ACPJobRecord.from_dict(entity["body"]))
|
|
422
|
+
elif entity["category"] == "identity" and entity["name"] == "acp-registration":
|
|
423
|
+
registration = entity["body"]
|
|
424
|
+
|
|
425
|
+
return ACPJobHistory(
|
|
426
|
+
agent_address=registration.get("wallet_address", ""),
|
|
427
|
+
agent_id=registration.get("agent_id"),
|
|
428
|
+
agent_name=registration.get("agent_name", ""),
|
|
429
|
+
registered=bool(registration.get("registered")),
|
|
430
|
+
jobs=tuple(jobs),
|
|
431
|
+
fetched_at=registration.get("synced_at", ""),
|
|
432
|
+
source="memory",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# -- standing handover ----------------------------------------------------
|
|
436
|
+
|
|
437
|
+
#: Domain tag for a handover attestation. Distinct from the provenance and
|
|
438
|
+
#: evaluation domains so one can never be replayed as another.
|
|
439
|
+
HANDOVER_DOMAIN = "Succession/1.0/acp-handover"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def build_handover(
|
|
443
|
+
*,
|
|
444
|
+
agent_identity: str,
|
|
445
|
+
entity_id: int | str,
|
|
446
|
+
agent_wallet: str,
|
|
447
|
+
buyer_identity: str,
|
|
448
|
+
buyer_address: str,
|
|
449
|
+
settlement_reference: str,
|
|
450
|
+
verified_hash: str,
|
|
451
|
+
private_key: str,
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
"""A signed statement that a lineage's ACP standing passes to a buyer.
|
|
454
|
+
|
|
455
|
+
**This does not transfer a registration, and nothing can.** A Virtuals ACP
|
|
456
|
+
entity is issued to a wallet through the Virtuals app, and the SDK surface
|
|
457
|
+
is entirely reads, job initiation and evaluation: `browse_agents`,
|
|
458
|
+
`get_agent`, `get_completed_jobs` and so on. There is no reassign, no
|
|
459
|
+
change-owner, and no update-wallet call to make. A library that claimed
|
|
460
|
+
otherwise would be lying about someone else's system.
|
|
461
|
+
|
|
462
|
+
So this is the closest honest thing. The seller signs, with the key the
|
|
463
|
+
contract recorded as the seller, a statement naming the entity whose job
|
|
464
|
+
history is inside the memory, the buyer receiving it, and the settlement
|
|
465
|
+
that paid for it. The buyer registers their own entity and presents this as
|
|
466
|
+
evidence of where the record came from.
|
|
467
|
+
|
|
468
|
+
What the buyer actually gets is therefore in two parts, and they are worth
|
|
469
|
+
keeping distinct:
|
|
470
|
+
|
|
471
|
+
* the *record* of what the agent earned, inside the memory, hashed into the
|
|
472
|
+
Merkle tree and provable against the committed root
|
|
473
|
+
* a signed attestation of succession, which is a claim a third party can
|
|
474
|
+
verify the origin of but which no registry is obliged to honour
|
|
475
|
+
|
|
476
|
+
What they do not get is the seller's position on the registry. Stating that
|
|
477
|
+
plainly is worth more than a mechanism that implies otherwise.
|
|
478
|
+
"""
|
|
479
|
+
from eth_account import Account
|
|
480
|
+
from eth_account.messages import encode_defunct
|
|
481
|
+
|
|
482
|
+
from .canonical import canonical_json
|
|
483
|
+
|
|
484
|
+
statement = {
|
|
485
|
+
"domain": HANDOVER_DOMAIN,
|
|
486
|
+
"agent_identity": agent_identity,
|
|
487
|
+
"acp_entity_id": str(entity_id),
|
|
488
|
+
"acp_agent_wallet": to_checksum_address(agent_wallet),
|
|
489
|
+
"succeeded_by": buyer_identity,
|
|
490
|
+
"succeeded_by_address": to_checksum_address(buyer_address),
|
|
491
|
+
"settlement_reference": settlement_reference,
|
|
492
|
+
"verified_hash": verified_hash,
|
|
493
|
+
"issued_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
494
|
+
"transfers_registration": False,
|
|
495
|
+
"note": (
|
|
496
|
+
"Attests succession of the job history carried in this memory. It "
|
|
497
|
+
"does not transfer the ACP registration, which is issued to a "
|
|
498
|
+
"wallet and cannot be reassigned."
|
|
499
|
+
),
|
|
500
|
+
}
|
|
501
|
+
signed = Account.sign_message(
|
|
502
|
+
encode_defunct(text=f"{HANDOVER_DOMAIN}\n{canonical_json(statement)}"),
|
|
503
|
+
private_key,
|
|
504
|
+
)
|
|
505
|
+
return {**statement, "signature": "0x" + signed.signature.hex().removeprefix("0x")}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def verify_handover(handover: dict[str, Any]) -> str:
|
|
509
|
+
"""Recover who signed a handover. Raises on anything malformed."""
|
|
510
|
+
from eth_account import Account
|
|
511
|
+
from eth_account.messages import encode_defunct
|
|
512
|
+
|
|
513
|
+
from .canonical import canonical_json
|
|
514
|
+
|
|
515
|
+
signature = handover.get("signature")
|
|
516
|
+
if not signature:
|
|
517
|
+
raise ValueError("handover carries no signature")
|
|
518
|
+
statement = {k: v for k, v in handover.items() if k != "signature"}
|
|
519
|
+
return Account.recover_message(
|
|
520
|
+
encode_defunct(text=f"{HANDOVER_DOMAIN}\n{canonical_json(statement)}"),
|
|
521
|
+
signature=signature,
|
|
522
|
+
)
|