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 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
+ )