hypermind 0.11.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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
hypermind/agent.py
ADDED
|
@@ -0,0 +1,2896 @@
|
|
|
1
|
+
"""HyperMindAgent — the primary user-facing class.
|
|
2
|
+
|
|
3
|
+
v0.1 in-process implementation. Real deployments swap the GossipBus and
|
|
4
|
+
TransparencyService instances for libp2p + a real SCITT TS; the public
|
|
5
|
+
API is unchanged.
|
|
6
|
+
|
|
7
|
+
This class implements:
|
|
8
|
+
- publish_claim / subscribe / on_dispute / dispute / synthesize
|
|
9
|
+
- consult (panel selection + aggregation)
|
|
10
|
+
- reputation_of / oracle_resolve / audit_export
|
|
11
|
+
- introspect / why_blocked / discover_mentors / request_vouch
|
|
12
|
+
- working_memory / checkpoint
|
|
13
|
+
- sandbox testbed factory
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import secrets
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import timedelta
|
|
26
|
+
from typing import Any, Literal
|
|
27
|
+
|
|
28
|
+
import cbor2
|
|
29
|
+
|
|
30
|
+
# L4 separability hook (spec §23): when an encrypted envelope arrives and
|
|
31
|
+
# HPKE decrypt fails, the agent MUST still verify the signed metadata and
|
|
32
|
+
# emit a structured BlockReason("PAYLOAD-OPAQUE"), preserving the audit trail.
|
|
33
|
+
from hypermind.agent_card import AgentCard
|
|
34
|
+
from hypermind.capability import CapToken, TokenBucket
|
|
35
|
+
from hypermind.consult import (
|
|
36
|
+
ArgumentDAG,
|
|
37
|
+
ArgumentEdge,
|
|
38
|
+
CalibTruthSnapshot,
|
|
39
|
+
ConsultResult,
|
|
40
|
+
PanelMember,
|
|
41
|
+
RouteStrategy,
|
|
42
|
+
build_posterior_mixture,
|
|
43
|
+
select_panel,
|
|
44
|
+
)
|
|
45
|
+
from hypermind.contract_net import (
|
|
46
|
+
RFP,
|
|
47
|
+
Award,
|
|
48
|
+
Bid,
|
|
49
|
+
BidScorer,
|
|
50
|
+
ReputationWeightedScorer,
|
|
51
|
+
award_preimage,
|
|
52
|
+
passes_bidder_filter,
|
|
53
|
+
topic_scope_for_rfp,
|
|
54
|
+
)
|
|
55
|
+
from hypermind.crypto.hashing import blake3_32
|
|
56
|
+
from hypermind.crypto.hlc import HLC
|
|
57
|
+
from hypermind.crypto.signing import ALG_ED25519, ALG_MLDSA65_ED25519, Keypair
|
|
58
|
+
from hypermind.dispute import Dispute
|
|
59
|
+
from hypermind.errors import (
|
|
60
|
+
AuthError,
|
|
61
|
+
BlockReason,
|
|
62
|
+
DisputeError,
|
|
63
|
+
HyperMindError,
|
|
64
|
+
ProtocolVersionError,
|
|
65
|
+
RampError,
|
|
66
|
+
RateLimitError,
|
|
67
|
+
)
|
|
68
|
+
from hypermind.knowledge.reputation import ReputationKernel, ReputationSnapshot
|
|
69
|
+
from hypermind.knowledge.transparency import (
|
|
70
|
+
TransparencyService,
|
|
71
|
+
WitnessCosignature,
|
|
72
|
+
)
|
|
73
|
+
from hypermind.observability.recorder import get_recorder
|
|
74
|
+
from hypermind.pin_policy import PinPolicy
|
|
75
|
+
from hypermind.revocation import InMemoryRevocationStore, RevocationStore
|
|
76
|
+
from hypermind.transport.bus import GossipBus, InProcessBus, Subscription
|
|
77
|
+
from hypermind.uncertainty import Uncertainty
|
|
78
|
+
from hypermind.wire import (
|
|
79
|
+
EncryptedStatement,
|
|
80
|
+
Kid,
|
|
81
|
+
SignedStatement,
|
|
82
|
+
new_namespace_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
ClaimType = Literal[
|
|
86
|
+
"eval_result",
|
|
87
|
+
"threat_indicator",
|
|
88
|
+
"synthesis",
|
|
89
|
+
"oracle_resolution",
|
|
90
|
+
"custom",
|
|
91
|
+
"task_submit",
|
|
92
|
+
"task_complete",
|
|
93
|
+
"task_fail",
|
|
94
|
+
"agent_card",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -----------------------------------------------------------------------
|
|
99
|
+
# BlockReason is defined in `hypermind.errors` (re-exported above) so that
|
|
100
|
+
# every `HyperMindError` may carry one as `reason=`. See errors.py for the
|
|
101
|
+
# stability contract on the [rule_id] __str__ prefix.
|
|
102
|
+
# -----------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# -----------------------------------------------------------------------
|
|
106
|
+
# Vouch / mentor records (used by discover_mentors / request_vouch)
|
|
107
|
+
# -----------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class VouchRequest:
|
|
112
|
+
requester: bytes
|
|
113
|
+
mentor: bytes
|
|
114
|
+
room: str
|
|
115
|
+
issued_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class RFPHandle:
|
|
120
|
+
"""Issuer-side handle returned by :meth:`HyperMindAgent.request_for_proposal`.
|
|
121
|
+
|
|
122
|
+
Wraps the in-memory :class:`hypermind.contract_net.RFP` and offers
|
|
123
|
+
an awaitable :meth:`bids` helper that resolves once at least
|
|
124
|
+
``min_count`` bids are recorded against the RFP.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
rfp: Any # contract_net.RFP — annotated as Any to avoid forward-ref import cycles
|
|
128
|
+
agent: HyperMindAgent
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def rfp_id(self) -> bytes:
|
|
132
|
+
return self.rfp.rfp_id
|
|
133
|
+
|
|
134
|
+
async def bids(self, *, min_count: int = 1, timeout_s: float = 5.0) -> list[Any]:
|
|
135
|
+
"""Wait for ≥ ``min_count`` bids to land, then return the list.
|
|
136
|
+
|
|
137
|
+
Polls `agent._world.rfp_bid_event`; resets the event after each
|
|
138
|
+
wake so multiple awaiters can each see new arrivals.
|
|
139
|
+
"""
|
|
140
|
+
world = self.agent._world
|
|
141
|
+
if world is None:
|
|
142
|
+
return []
|
|
143
|
+
deadline = time.monotonic() + timeout_s
|
|
144
|
+
while True:
|
|
145
|
+
current = list(world.bids_by_rfp.get(self.rfp_id, []))
|
|
146
|
+
if len(current) >= min_count:
|
|
147
|
+
return current
|
|
148
|
+
remaining = deadline - time.monotonic()
|
|
149
|
+
if remaining <= 0:
|
|
150
|
+
return current
|
|
151
|
+
world.rfp_bid_event.clear()
|
|
152
|
+
try:
|
|
153
|
+
await asyncio.wait_for(world.rfp_bid_event.wait(), timeout=remaining)
|
|
154
|
+
except TimeoutError:
|
|
155
|
+
return list(world.bids_by_rfp.get(self.rfp_id, []))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class BidHandle:
|
|
160
|
+
"""Bidder-side handle returned by :meth:`HyperMindAgent.bid`.
|
|
161
|
+
|
|
162
|
+
The bidder can `await handle.award(timeout_s=...)` to block until
|
|
163
|
+
the auctioneer publishes an AWARD against the RFP. Returns the
|
|
164
|
+
:class:`hypermind.contract_net.Award` if this bidder won, or `None`
|
|
165
|
+
if another bidder won (or the timeout fired before AWARD).
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
bid: Any
|
|
169
|
+
agent: HyperMindAgent
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def bid_kid(self) -> bytes:
|
|
173
|
+
return self.bid.bid_kid
|
|
174
|
+
|
|
175
|
+
async def award(self, *, timeout_s: float = 5.0) -> Any | None:
|
|
176
|
+
world = self.agent._world
|
|
177
|
+
if world is None:
|
|
178
|
+
return None
|
|
179
|
+
deadline = time.monotonic() + timeout_s
|
|
180
|
+
while True:
|
|
181
|
+
current = world.awards.get(self.bid.rfp_id)
|
|
182
|
+
if current is not None and not current.revoked:
|
|
183
|
+
return current if current.winning_bid_kid == self.bid_kid else None
|
|
184
|
+
remaining = deadline - time.monotonic()
|
|
185
|
+
if remaining <= 0:
|
|
186
|
+
return None
|
|
187
|
+
world.rfp_award_event.clear()
|
|
188
|
+
try:
|
|
189
|
+
await asyncio.wait_for(world.rfp_award_event.wait(), timeout=remaining)
|
|
190
|
+
except TimeoutError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class AwardHandle:
|
|
196
|
+
"""Issuer-side handle returned by :meth:`HyperMindAgent.award`."""
|
|
197
|
+
|
|
198
|
+
award: Any
|
|
199
|
+
agent: HyperMindAgent
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def award_kid(self) -> bytes:
|
|
203
|
+
return self.award.award_kid
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def winning_bid_kid(self) -> bytes:
|
|
207
|
+
return self.award.winning_bid_kid
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def revoked(self) -> bool:
|
|
211
|
+
# Re-read the world's view; revoke_award() replaces the Award.
|
|
212
|
+
world = self.agent._world
|
|
213
|
+
if world is None:
|
|
214
|
+
return self.award.revoked
|
|
215
|
+
current = world.awards.get(self.award.rfp_id)
|
|
216
|
+
if current is None:
|
|
217
|
+
return self.award.revoked
|
|
218
|
+
return current.revoked
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class _DraftEntry:
|
|
223
|
+
statement: SignedStatement
|
|
224
|
+
confirmer_acks: set[bytes] = field(default_factory=set)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# -----------------------------------------------------------------------
|
|
228
|
+
# Shared world (the in-process namespace).
|
|
229
|
+
# -----------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Process-global registry of in-process namespaces (ROADMAP §0.4.1).
|
|
233
|
+
# Used by cross-namespace federated CONSULT to discover sibling worlds
|
|
234
|
+
# without callers having to thread `_NamespaceWorld` references manually.
|
|
235
|
+
_NAMESPACE_REGISTRY: dict[bytes, _NamespaceWorld] = {}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _register_namespace(world: _NamespaceWorld) -> None:
|
|
239
|
+
_NAMESPACE_REGISTRY[bytes(world.namespace_id)] = world
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _lookup_namespace(namespace_id: bytes) -> _NamespaceWorld | None:
|
|
243
|
+
return _NAMESPACE_REGISTRY.get(bytes(namespace_id))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class _NamespaceWorld:
|
|
248
|
+
"""All agents in the same testbed share one of these.
|
|
249
|
+
|
|
250
|
+
Holds the gossip bus, transparency service, reputation kernel, and a
|
|
251
|
+
central registry of agents so `discover_mentors`, `consult`, and
|
|
252
|
+
`reputation_of` can find peers.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
namespace: str
|
|
256
|
+
namespace_id: bytes
|
|
257
|
+
bus: GossipBus
|
|
258
|
+
ts: TransparencyService
|
|
259
|
+
reputation: ReputationKernel
|
|
260
|
+
agents: dict[bytes, HyperMindAgent] = field(default_factory=dict)
|
|
261
|
+
sealed: dict[Kid, SignedStatement] = field(default_factory=dict)
|
|
262
|
+
disputes: dict[Kid, Dispute] = field(default_factory=dict)
|
|
263
|
+
# Panel votes recorded for ORACLE_RESOLUTION back-propagation
|
|
264
|
+
# (ROADMAP §0.3.2). Map claim_kid → list of (voter_kid, vote_bool,
|
|
265
|
+
# topic). Used by `oracle_resolve(..., backprop=True)` to reward
|
|
266
|
+
# voters who agreed with the oracle.
|
|
267
|
+
panel_votes: dict[Kid, list[tuple[bytes, bool, str]]] = field(default_factory=dict)
|
|
268
|
+
# rooms map name → {min reputation to publish, members}
|
|
269
|
+
rooms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
270
|
+
#: Signalled whenever a new dispute is added to self.disputes.
|
|
271
|
+
dispute_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
272
|
+
#: Optional durable storage backend (StorageBackend). When attached,
|
|
273
|
+
#: every sealed claim, dispute, and reputation snapshot is mirrored
|
|
274
|
+
#: to disk so process restart preserves world state.
|
|
275
|
+
storage: Any = None
|
|
276
|
+
#: Optional :class:`TokenCostTracker` (v0.6+). When attached, every
|
|
277
|
+
#: LLM responder records its token usage there for /v1/cost endpoint.
|
|
278
|
+
cost_tracker: Any = None
|
|
279
|
+
#: Minimum acceptable `protocol_version` for inbound statements
|
|
280
|
+
#: (ROADMAP §0.5.1; rule_id HM-PROTO-VER-001). Statements with
|
|
281
|
+
#: ``protocol_version < min_protocol_version`` are rejected
|
|
282
|
+
#: pre-signature-verify with :class:`ProtocolVersionError`.
|
|
283
|
+
min_protocol_version: int = 0
|
|
284
|
+
#: Per-namespace Recorder (WS-G v0.7). Each sandbox/testbed gets
|
|
285
|
+
#: its own scoped recorder so per-namespace observability does not
|
|
286
|
+
#: pummel the process-global ring buffer. ``None`` = lazy-init
|
|
287
|
+
#: in ``__post_init__`` from ``namespace_id``.
|
|
288
|
+
recorder: Any = None
|
|
289
|
+
#: Per-namespace key revocation store (P1.4 / spec §12). Verifiers
|
|
290
|
+
#: consult this before accepting a CapToken or SignedStatement —
|
|
291
|
+
#: a revoked kid past its ``revoked_at_hlc`` raises AuthError
|
|
292
|
+
#: (rule_id ``HM-KEY-REVOKED-001``). Lazy-initialised to an empty
|
|
293
|
+
#: ``InMemoryRevocationStore`` so sandboxes have a working store
|
|
294
|
+
#: without explicit setup.
|
|
295
|
+
revocation_store: RevocationStore | None = None
|
|
296
|
+
#: Trust & Access Layer — Rooms + vouch log + decay. Lazy-init in
|
|
297
|
+
#: ``__post_init__`` so the world's ReputationKernel is fully built
|
|
298
|
+
#: before TAL takes a reference to it.
|
|
299
|
+
tal: Any = None
|
|
300
|
+
# T3 (Contract-Net §27) — in-memory views of open RFPs, received
|
|
301
|
+
# bids, and finalised awards. Each map is keyed by the canonical
|
|
302
|
+
# identifier of the record type:
|
|
303
|
+
# - rfps: rfp_id (32 bytes) → RFP
|
|
304
|
+
# - bids_by_rfp: rfp_id → list[Bid]
|
|
305
|
+
# - awards: rfp_id → Award
|
|
306
|
+
# The signed wire records ride on top of self.sealed; these maps
|
|
307
|
+
# are denormalised projections so the auctioneer can query "all
|
|
308
|
+
# bids for this RFP" without scanning the full sealed registry.
|
|
309
|
+
rfps: dict[bytes, Any] = field(default_factory=dict)
|
|
310
|
+
bids_by_rfp: dict[bytes, list[Any]] = field(default_factory=dict)
|
|
311
|
+
awards: dict[bytes, Any] = field(default_factory=dict)
|
|
312
|
+
#: Set when a bid is delivered for an open RFP — used by RFPHandle
|
|
313
|
+
#: to wake bidders waiting via `await rfp_handle.bids(...)`.
|
|
314
|
+
rfp_bid_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
315
|
+
#: Set when an AWARD lands; used by BidHandle to resolve.
|
|
316
|
+
rfp_award_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
317
|
+
|
|
318
|
+
def __post_init__(self) -> None:
|
|
319
|
+
if self.revocation_store is None:
|
|
320
|
+
self.revocation_store = InMemoryRevocationStore()
|
|
321
|
+
if self.tal is None:
|
|
322
|
+
from hypermind.tal import TAL
|
|
323
|
+
|
|
324
|
+
self.tal = TAL(self.reputation)
|
|
325
|
+
if self.recorder is None:
|
|
326
|
+
# Late import to avoid recorder<->agent circular import.
|
|
327
|
+
from hypermind.observability.recorder import Recorder
|
|
328
|
+
|
|
329
|
+
ns_tag = (
|
|
330
|
+
self.namespace_id.hex()
|
|
331
|
+
if isinstance(self.namespace_id, (bytes, bytearray))
|
|
332
|
+
else str(self.namespace_id)
|
|
333
|
+
)
|
|
334
|
+
self.recorder = Recorder.scoped(ns_tag)
|
|
335
|
+
# ROADMAP §0.4.1 — register so cross-namespace federated
|
|
336
|
+
# CONSULT can discover this world by namespace_id.
|
|
337
|
+
_register_namespace(self)
|
|
338
|
+
# P1-04: auto-wire gossip bus into the TS so append_and_cosign()
|
|
339
|
+
# fires STH cross-gossip (Gap 10) without per-agent setup.
|
|
340
|
+
ns_hex = (
|
|
341
|
+
self.namespace_id.hex()
|
|
342
|
+
if isinstance(self.namespace_id, (bytes, bytearray))
|
|
343
|
+
else str(self.namespace_id)
|
|
344
|
+
)
|
|
345
|
+
self.ts.attach_gossip(self.bus, ns_hex)
|
|
346
|
+
# If a durable backend is attached, replay any prior state into
|
|
347
|
+
# this in-memory world so restart is transparent. This is a no-op
|
|
348
|
+
# when storage is None (the default).
|
|
349
|
+
if self.storage is not None:
|
|
350
|
+
self._restore_from_storage()
|
|
351
|
+
|
|
352
|
+
def _restore_from_storage(self) -> None:
|
|
353
|
+
"""Re-load claims, disputes, and reputation from the storage backend.
|
|
354
|
+
|
|
355
|
+
Idempotent: safe to call multiple times; later calls are no-ops
|
|
356
|
+
once state is materialised.
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
for stmt in self.storage.load_claims():
|
|
360
|
+
self.sealed.setdefault(stmt.kid, stmt)
|
|
361
|
+
for kid, dispute in self.storage.load_disputes().items():
|
|
362
|
+
self.disputes.setdefault(kid, dispute)
|
|
363
|
+
for kid, topic, alpha, beta, last_ms in self.storage.load_reputation():
|
|
364
|
+
p = self.reputation._get(kid, topic)
|
|
365
|
+
p.alpha = alpha
|
|
366
|
+
p.beta = beta
|
|
367
|
+
p.last_update_ms = last_ms
|
|
368
|
+
except Exception:
|
|
369
|
+
# Restore is best-effort: a corrupt row should not prevent
|
|
370
|
+
# process startup. Operators should run `hmctl doctor` to
|
|
371
|
+
# diagnose. Real production code paths would log here.
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
def persist_claim(self, stmt: SignedStatement) -> None:
|
|
375
|
+
"""Mirror a sealed claim to durable storage if attached. Best-effort."""
|
|
376
|
+
if self.storage is None:
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
self.storage.append_claim(stmt)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
def persist_dispute(self, kid: Kid, dispute: Dispute) -> None:
|
|
384
|
+
"""Mirror a dispute to durable storage if attached. Best-effort."""
|
|
385
|
+
if self.storage is None:
|
|
386
|
+
return
|
|
387
|
+
try:
|
|
388
|
+
self.storage.append_dispute(kid, dispute)
|
|
389
|
+
except Exception:
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
def checkpoint_reputation(self) -> None:
|
|
393
|
+
"""Snapshot every Beta posterior to storage. Call before close()."""
|
|
394
|
+
if self.storage is None:
|
|
395
|
+
return
|
|
396
|
+
rows: list[tuple[bytes, str, float, float, int]] = []
|
|
397
|
+
for (kid, topic), p in self.reputation._posteriors.items():
|
|
398
|
+
rows.append((kid, topic, p.alpha, p.beta, p.last_update_ms))
|
|
399
|
+
try:
|
|
400
|
+
self.storage.snapshot_reputation(rows)
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
def check_protocol_version(self, stmt: SignedStatement) -> None:
|
|
405
|
+
"""Reject ``stmt`` if its protocol_version is below policy.
|
|
406
|
+
|
|
407
|
+
Called pre-signature-verify so refusal is policy-shaped, not a
|
|
408
|
+
crypto failure (HM-PROTO-VER-001).
|
|
409
|
+
"""
|
|
410
|
+
if stmt.protocol_version < self.min_protocol_version:
|
|
411
|
+
raise ProtocolVersionError(
|
|
412
|
+
f"statement protocol_version={stmt.protocol_version} is "
|
|
413
|
+
f"below namespace min_protocol_version="
|
|
414
|
+
f"{self.min_protocol_version}",
|
|
415
|
+
statement_version=stmt.protocol_version,
|
|
416
|
+
min_required=self.min_protocol_version,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def start_anti_entropy(
|
|
420
|
+
self,
|
|
421
|
+
*,
|
|
422
|
+
interval_s: float = 30.0,
|
|
423
|
+
max_backoff_s: float = 300.0,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Start the self-healing AE background coroutine (spec §10.2, P2-02).
|
|
426
|
+
|
|
427
|
+
Spawns a :class:`BackgroundAELoop` that periodically reconciles this
|
|
428
|
+
world's sealed-claim set against all known peer worlds registered in
|
|
429
|
+
the global namespace registry. On failure the loop backs off
|
|
430
|
+
exponentially up to ``max_backoff_s`` then retries.
|
|
431
|
+
|
|
432
|
+
Safe to call multiple times — subsequent calls are no-ops if the
|
|
433
|
+
loop is already running.
|
|
434
|
+
"""
|
|
435
|
+
from hypermind.transport.anti_entropy import BackgroundAELoop
|
|
436
|
+
|
|
437
|
+
if hasattr(self, "_ae_loop") and self._ae_loop.running: # type: ignore[has-type]
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
async def _run_ae() -> None:
|
|
441
|
+
local_kids = set(self.sealed.keys())
|
|
442
|
+
for ns_id, remote_world in _NAMESPACE_REGISTRY.items():
|
|
443
|
+
if ns_id == self.namespace_id:
|
|
444
|
+
continue
|
|
445
|
+
remote_kids = set(remote_world.sealed.keys())
|
|
446
|
+
# Local-only: we have something the remote doesn't — push it.
|
|
447
|
+
for kid in local_kids - remote_kids:
|
|
448
|
+
stmt = self.sealed.get(kid)
|
|
449
|
+
if stmt is not None:
|
|
450
|
+
remote_world.sealed.setdefault(kid, stmt)
|
|
451
|
+
# Remote-only: pull it locally.
|
|
452
|
+
for kid in remote_kids - local_kids:
|
|
453
|
+
stmt = remote_world.sealed.get(kid)
|
|
454
|
+
if stmt is not None:
|
|
455
|
+
self.sealed.setdefault(kid, stmt)
|
|
456
|
+
|
|
457
|
+
self._ae_loop: BackgroundAELoop = BackgroundAELoop(
|
|
458
|
+
run_ae=_run_ae,
|
|
459
|
+
interval_s=interval_s,
|
|
460
|
+
max_backoff_s=max_backoff_s,
|
|
461
|
+
)
|
|
462
|
+
self._ae_loop.start()
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# -----------------------------------------------------------------------
|
|
466
|
+
# HyperMindAgent
|
|
467
|
+
# -----------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class HyperMindAgent:
|
|
471
|
+
"""Async-first entry point for the HyperMind protocol SDK (spec §3).
|
|
472
|
+
|
|
473
|
+
Every public verb is ``async def``. For callers that cannot use ``await``,
|
|
474
|
+
:class:`hypermind.sync.SyncAgent` wraps the same surface synchronously.
|
|
475
|
+
The preferred factory for new code is ``await hypermind.open(mode="sandbox")``;
|
|
476
|
+
for multi-agent tests use :meth:`testbed_cohort` as an async context manager.
|
|
477
|
+
|
|
478
|
+
Examples:
|
|
479
|
+
Single agent (sandbox):
|
|
480
|
+
|
|
481
|
+
>>> import hypermind
|
|
482
|
+
>>> async with await hypermind.open(mode="sandbox") as agent:
|
|
483
|
+
... kid = await agent.publish_claim(
|
|
484
|
+
... topic="ai-safety/eval",
|
|
485
|
+
... payload={"score": 0.91},
|
|
486
|
+
... )
|
|
487
|
+
|
|
488
|
+
Multi-agent testbed:
|
|
489
|
+
|
|
490
|
+
>>> from hypermind.testing import testbed_cohort
|
|
491
|
+
>>> async with testbed_cohort(3) as (alice, bob, carol):
|
|
492
|
+
... result = await alice.consult(
|
|
493
|
+
... topic="threat-intel",
|
|
494
|
+
... question="Is this indicator benign?",
|
|
495
|
+
... panel=[bob.keypair.kid, carol.keypair.kid],
|
|
496
|
+
... )
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
def __init__(
|
|
500
|
+
self,
|
|
501
|
+
*,
|
|
502
|
+
keypair: Keypair,
|
|
503
|
+
namespace: str,
|
|
504
|
+
rooms: list[str] | None = None,
|
|
505
|
+
pin_policy: PinPolicy | None = None,
|
|
506
|
+
bootstraps: list[str] | None = None,
|
|
507
|
+
hlc_skew_max: timedelta = timedelta(seconds=2),
|
|
508
|
+
sandbox: bool = False,
|
|
509
|
+
cap_token: CapToken | None = None,
|
|
510
|
+
_world: _NamespaceWorld | None = None,
|
|
511
|
+
role: Literal["agent", "oracle", "pinner"] = "agent",
|
|
512
|
+
) -> None:
|
|
513
|
+
self.keypair = keypair
|
|
514
|
+
self.namespace = namespace
|
|
515
|
+
self.rooms = list(rooms or [])
|
|
516
|
+
self.pin_policy = pin_policy
|
|
517
|
+
self.bootstraps = list(bootstraps or [])
|
|
518
|
+
self.hlc_skew_max = hlc_skew_max
|
|
519
|
+
self.is_sandbox = sandbox
|
|
520
|
+
self.cap_token = cap_token
|
|
521
|
+
self.role = role
|
|
522
|
+
self._memory: Any = None # optional RelataMemoryStore (P1); see attach_memory
|
|
523
|
+
self._closed = False
|
|
524
|
+
self._world = _world # in-process namespace; injected by testbed()
|
|
525
|
+
self._hlc = HLC.now(node_id=keypair.kid)
|
|
526
|
+
self._dispute_callbacks: dict[Kid, list[Callable[[Dispute], Any]]] = {}
|
|
527
|
+
self._subscriptions: list[Subscription] = []
|
|
528
|
+
self._working_memory: dict[str, Any] = {}
|
|
529
|
+
# P1-03: per-(root_kid + action) token buckets for rate-caveat enforcement.
|
|
530
|
+
self._rate_buckets: dict[str, Any] = {}
|
|
531
|
+
# T5-02: optional placement hint biasing topic→host selection.
|
|
532
|
+
# See `HyperMindAgent.place_on()`.
|
|
533
|
+
self._placement_hint: str | None = None
|
|
534
|
+
# 30-day ramp accounting (see RAMP-* invariants)
|
|
535
|
+
self._ramp_start_ms = int(time.time() * 1000)
|
|
536
|
+
self._ramp_paused = False
|
|
537
|
+
self._ramp_paused_days = 0
|
|
538
|
+
self._observed_outcomes = 0 # progresses ramp toward "active"
|
|
539
|
+
# WS-G v0.7: prefer the world's per-namespace recorder when one
|
|
540
|
+
# exists; fall back to the process-global recorder for no-world
|
|
541
|
+
# (unit-test) agents so existing tests keep their semantics.
|
|
542
|
+
if self._world is not None and getattr(self._world, "recorder", None) is not None:
|
|
543
|
+
self._recorder = self._world.recorder
|
|
544
|
+
else:
|
|
545
|
+
self._recorder = get_recorder()
|
|
546
|
+
|
|
547
|
+
if self._world is None:
|
|
548
|
+
# No-network mode: the agent is callable but cannot publish
|
|
549
|
+
# to remote peers. Useful for unit-testing crypto/wire.
|
|
550
|
+
return
|
|
551
|
+
self._world.agents[keypair.kid] = self
|
|
552
|
+
|
|
553
|
+
def attach_storage(self, backend: Any) -> None:
|
|
554
|
+
"""Attach a durable :class:`~hypermind.storage.backend.StorageBackend`
|
|
555
|
+
and replay any prior state into this world.
|
|
556
|
+
|
|
557
|
+
Prefer this over poking ``agent._world.storage`` directly — it also
|
|
558
|
+
fires the crash-recovery replay so a restart is transparent. Call once
|
|
559
|
+
after construction; the backend must already be ``open()``-ed.
|
|
560
|
+
"""
|
|
561
|
+
if self._world is None:
|
|
562
|
+
raise RuntimeError(
|
|
563
|
+
"attach_storage() requires a networked world; this agent was "
|
|
564
|
+
"constructed in no-network mode."
|
|
565
|
+
)
|
|
566
|
+
self._world.storage = backend
|
|
567
|
+
self._world._restore_from_storage()
|
|
568
|
+
|
|
569
|
+
def attach_memory(self, store: Any) -> None:
|
|
570
|
+
"""Attach a per-agent private memory store (P1 — path b).
|
|
571
|
+
|
|
572
|
+
The store (e.g. :class:`~hypermind.mind.memory_store.RelataMemoryStore`)
|
|
573
|
+
must already be ``open()``-ed. This is the agent's *unsigned* scratchpad
|
|
574
|
+
of learnings/experiences — distinct from the signed ledger attached via
|
|
575
|
+
:meth:`attach_storage`. Enables :meth:`remember` / :meth:`recall`.
|
|
576
|
+
"""
|
|
577
|
+
self._memory = store
|
|
578
|
+
|
|
579
|
+
async def remember(
|
|
580
|
+
self, content: str, *, confidence: float = 1.0, memory_class: str = "semantic"
|
|
581
|
+
) -> str:
|
|
582
|
+
"""Store a learning/experience in private memory; return its id.
|
|
583
|
+
|
|
584
|
+
Requires a memory store attached via :meth:`attach_memory`.
|
|
585
|
+
"""
|
|
586
|
+
if self._memory is None:
|
|
587
|
+
raise RuntimeError(
|
|
588
|
+
"remember() requires a memory store; call attach_memory() first."
|
|
589
|
+
)
|
|
590
|
+
return self._memory.remember(content, confidence=confidence, memory_class=memory_class)
|
|
591
|
+
|
|
592
|
+
async def remember_batch(
|
|
593
|
+
self,
|
|
594
|
+
items: list,
|
|
595
|
+
*,
|
|
596
|
+
session_id: str | None = None,
|
|
597
|
+
) -> list[str]:
|
|
598
|
+
"""Batch store learnings; return ids index-aligned with ``items``.
|
|
599
|
+
|
|
600
|
+
Requires a memory store attached via :meth:`attach_memory`.
|
|
601
|
+
Each item is a ``str`` or a ``dict`` with ``content`` + optional fields.
|
|
602
|
+
"""
|
|
603
|
+
if self._memory is None:
|
|
604
|
+
raise RuntimeError(
|
|
605
|
+
"remember_batch() requires a memory store; call attach_memory() first."
|
|
606
|
+
)
|
|
607
|
+
return self._memory.remember_batch(items, session_id=session_id)
|
|
608
|
+
|
|
609
|
+
async def recall(
|
|
610
|
+
self, query: str, *, top_k: int = 5, as_of: str | None = None
|
|
611
|
+
) -> list[dict[str, Any]]:
|
|
612
|
+
"""Recall from private memory. Pass ``as_of`` for bi-temporal recall
|
|
613
|
+
("what did I know at T?"). Requires :meth:`attach_memory`.
|
|
614
|
+
"""
|
|
615
|
+
if self._memory is None:
|
|
616
|
+
raise RuntimeError(
|
|
617
|
+
"recall() requires a memory store; call attach_memory() first."
|
|
618
|
+
)
|
|
619
|
+
return self._memory.recall(query, top_k=top_k, as_of=as_of)
|
|
620
|
+
|
|
621
|
+
# ---- factories ------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
@classmethod
|
|
624
|
+
async def open(
|
|
625
|
+
cls,
|
|
626
|
+
*,
|
|
627
|
+
mode: Literal["sandbox", "libp2p", "production"] = "sandbox",
|
|
628
|
+
namespace: str = "default",
|
|
629
|
+
pq: bool = True,
|
|
630
|
+
legacy_ed25519: bool = False,
|
|
631
|
+
**kwargs: Any,
|
|
632
|
+
) -> HyperMindAgent:
|
|
633
|
+
"""Canonical factory — single entry point for v1.0.
|
|
634
|
+
|
|
635
|
+
`mode="sandbox"` (default): in-process bus, single agent, suitable
|
|
636
|
+
for demos and tests.
|
|
637
|
+
`mode="libp2p"`: production federation transport (lands v0.2 —
|
|
638
|
+
currently raises NotImplementedError).
|
|
639
|
+
|
|
640
|
+
``pq`` (default True, v0.7+): generate a Composite Ed25519 +
|
|
641
|
+
ML-DSA-65 keypair so this agent's outbound traffic is
|
|
642
|
+
post-quantum-secure under hybrid composition. Pass
|
|
643
|
+
``legacy_ed25519=True`` to force the classical-only path
|
|
644
|
+
(emits ``DeprecationWarning``); ``pq=False`` is treated as a
|
|
645
|
+
synonym.
|
|
646
|
+
|
|
647
|
+
Use this instead of `from_env()` (deprecated) or `sandbox()` (kept
|
|
648
|
+
for ≤5-line demos).
|
|
649
|
+
"""
|
|
650
|
+
if legacy_ed25519:
|
|
651
|
+
pq = False
|
|
652
|
+
if not pq:
|
|
653
|
+
import warnings as _w
|
|
654
|
+
|
|
655
|
+
_w.warn(
|
|
656
|
+
"classical-only signatures will be the legacy path from v0.7; "
|
|
657
|
+
"use pq=True for hybrid",
|
|
658
|
+
DeprecationWarning,
|
|
659
|
+
stacklevel=2,
|
|
660
|
+
)
|
|
661
|
+
if mode == "sandbox":
|
|
662
|
+
return cls.sandbox(namespace=namespace, pq=pq, **kwargs)
|
|
663
|
+
elif mode == "production":
|
|
664
|
+
# In-process agent + a durable backend resolved from the env.
|
|
665
|
+
# RelataDB is the default when configured (RELATA_URL /
|
|
666
|
+
# HYPERMIND_STORAGE); falls back to SQLite, then in-memory.
|
|
667
|
+
from hypermind.storage import storage_from_env
|
|
668
|
+
|
|
669
|
+
agent = cls.sandbox(namespace=namespace, pq=pq, **kwargs)
|
|
670
|
+
backend = storage_from_env(namespace_id=namespace)
|
|
671
|
+
if backend is not None:
|
|
672
|
+
backend.open()
|
|
673
|
+
agent.attach_storage(backend)
|
|
674
|
+
else:
|
|
675
|
+
import warnings as _w
|
|
676
|
+
|
|
677
|
+
_w.warn(
|
|
678
|
+
"mode='production' but no storage configured; running "
|
|
679
|
+
"in-memory. Set RELATA_URL (RelataDB) or "
|
|
680
|
+
"HYPERMIND_STORAGE=sqlite:/path for durability.",
|
|
681
|
+
stacklevel=2,
|
|
682
|
+
)
|
|
683
|
+
return agent
|
|
684
|
+
elif mode == "libp2p":
|
|
685
|
+
# T5-01: the libp2p path is feature-flagged on the optional
|
|
686
|
+
# `hypermind_libp2p` PyO3 binding (companion crate in
|
|
687
|
+
# ``transport-rust/``). If the binding is not installed,
|
|
688
|
+
# surface a clear, actionable error pointing at the install
|
|
689
|
+
# extra rather than constructing a bus that would later
|
|
690
|
+
# raise on every method call.
|
|
691
|
+
from hypermind.transport.libp2p import (
|
|
692
|
+
Libp2pBackendNotInstalled,
|
|
693
|
+
Libp2pBus,
|
|
694
|
+
backend_available,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
if not backend_available():
|
|
698
|
+
raise Libp2pBackendNotInstalled()
|
|
699
|
+
Libp2pBus(**kwargs) # pragma: no cover (binding not yet on PyPI)
|
|
700
|
+
raise NotImplementedError( # pragma: no cover
|
|
701
|
+
"libp2p backend wiring lands with the hypermind-libp2p "
|
|
702
|
+
"binding (v0.10.1+); construct a HyperMindAgent around "
|
|
703
|
+
"the bus directly until then"
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
raise ValueError(f"Unknown mode={mode!r}; expected 'sandbox' or 'libp2p'")
|
|
707
|
+
|
|
708
|
+
@classmethod
|
|
709
|
+
def from_env(
|
|
710
|
+
cls,
|
|
711
|
+
*,
|
|
712
|
+
rooms: list[str] | None = None,
|
|
713
|
+
sandbox: bool = False,
|
|
714
|
+
pq: bool = True,
|
|
715
|
+
legacy_ed25519: bool = False,
|
|
716
|
+
) -> HyperMindAgent:
|
|
717
|
+
"""Production entry point.
|
|
718
|
+
|
|
719
|
+
v0.1 ships only the sandbox (in-process) backend. The libp2p
|
|
720
|
+
production backend ships in v0.2; see the roadmap for status.
|
|
721
|
+
|
|
722
|
+
For local development, prefer `HyperMindAgent.sandbox()` (single
|
|
723
|
+
agent) or `HyperMindAgent.testbed()` (cohort) — both are clearer
|
|
724
|
+
than `from_env(sandbox=True)`.
|
|
725
|
+
|
|
726
|
+
.. deprecated:: 0.7
|
|
727
|
+
Use :meth:`HyperMindAgent.open` instead.
|
|
728
|
+
"""
|
|
729
|
+
import warnings
|
|
730
|
+
|
|
731
|
+
warnings.warn(
|
|
732
|
+
"HyperMindAgent.from_env() is deprecated; use "
|
|
733
|
+
"HyperMindAgent.open(mode=...) instead. "
|
|
734
|
+
"See docs/migration/v0.7.md.",
|
|
735
|
+
PendingDeprecationWarning,
|
|
736
|
+
stacklevel=2,
|
|
737
|
+
)
|
|
738
|
+
if not sandbox:
|
|
739
|
+
raise NotImplementedError(
|
|
740
|
+
"Production transport not yet available in v0.1.\n"
|
|
741
|
+
" • For a single local agent: HyperMindAgent.sandbox()\n"
|
|
742
|
+
" • For a multi-agent cohort: HyperMindAgent.testbed(n=...)\n"
|
|
743
|
+
" • The libp2p production backend ships in v0.2."
|
|
744
|
+
)
|
|
745
|
+
if legacy_ed25519:
|
|
746
|
+
pq = False
|
|
747
|
+
return cls.sandbox(
|
|
748
|
+
room=(rooms[0] if rooms else "default"),
|
|
749
|
+
pq=pq,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
@classmethod
|
|
753
|
+
def sandbox(
|
|
754
|
+
cls,
|
|
755
|
+
*,
|
|
756
|
+
room: str = "default",
|
|
757
|
+
namespace: str = "sandbox",
|
|
758
|
+
ramp_complete: bool = True,
|
|
759
|
+
pq: bool = True,
|
|
760
|
+
legacy_ed25519: bool = False,
|
|
761
|
+
) -> HyperMindAgent:
|
|
762
|
+
"""Build a SINGLE pre-vouched agent in a sandbox namespace.
|
|
763
|
+
|
|
764
|
+
The most common starter pattern — use this when you want one
|
|
765
|
+
local agent to experiment with. Returns an open agent (use
|
|
766
|
+
`async with` to clean up).
|
|
767
|
+
|
|
768
|
+
For multi-agent scenarios use `HyperMindAgent.testbed(n)`; for
|
|
769
|
+
async-friendly cohort cleanup use `hypermind.testbed_cohort(n)`.
|
|
770
|
+
|
|
771
|
+
``pq`` (default True, v0.7+): generate a Composite Ed25519 +
|
|
772
|
+
ML-DSA-65 keypair. Pass ``legacy_ed25519=True`` (or ``pq=False``)
|
|
773
|
+
to fall back to the classical-only path; that path emits a
|
|
774
|
+
``DeprecationWarning`` since v0.7.
|
|
775
|
+
"""
|
|
776
|
+
if legacy_ed25519:
|
|
777
|
+
pq = False
|
|
778
|
+
return cls.testbed(
|
|
779
|
+
agents=1,
|
|
780
|
+
room=room,
|
|
781
|
+
namespace=namespace,
|
|
782
|
+
ramp_complete=ramp_complete,
|
|
783
|
+
pq=pq,
|
|
784
|
+
)[0]
|
|
785
|
+
|
|
786
|
+
@staticmethod
|
|
787
|
+
def testbed(
|
|
788
|
+
agents: int = 2,
|
|
789
|
+
*,
|
|
790
|
+
room: str = "default",
|
|
791
|
+
namespace: str = "sandbox",
|
|
792
|
+
ramp_complete: bool = True,
|
|
793
|
+
pq: bool = True,
|
|
794
|
+
legacy_ed25519: bool = False,
|
|
795
|
+
recorder: Any = None,
|
|
796
|
+
) -> list[HyperMindAgent]:
|
|
797
|
+
"""Build N pre-vouched agents sharing a namespace.
|
|
798
|
+
|
|
799
|
+
Used by tests and by `hmctl sandbox cohort`. Returned agents are
|
|
800
|
+
already past the 30-day ramp (so `publish_claim` works
|
|
801
|
+
immediately). Set `ramp_complete=False` to test ramp gates.
|
|
802
|
+
|
|
803
|
+
``pq`` (default True, v0.7+): mint Composite ML-DSA-65 + Ed25519
|
|
804
|
+
keypairs for both witnesses and agents. Pass
|
|
805
|
+
``legacy_ed25519=True`` (or ``pq=False``) for the classical-only
|
|
806
|
+
path; that path emits a ``DeprecationWarning``.
|
|
807
|
+
|
|
808
|
+
.. deprecated:: 0.5
|
|
809
|
+
External callers should migrate to
|
|
810
|
+
:func:`hypermind.testing.testbed_cohort` (an async-with cohort
|
|
811
|
+
with automatic cleanup). The list-returning factory keeps
|
|
812
|
+
working for now and is scheduled for removal in v1.0.
|
|
813
|
+
"""
|
|
814
|
+
# ROADMAP §0.5.4: external callers see DeprecationWarning; SDK-internal
|
|
815
|
+
# callers (sandbox(), testbed_cohort()) suppress it so demos stay clean.
|
|
816
|
+
import sys as _sys
|
|
817
|
+
import warnings as _warnings
|
|
818
|
+
|
|
819
|
+
frame = _sys._getframe(1)
|
|
820
|
+
caller_module = frame.f_globals.get("__name__", "")
|
|
821
|
+
if not caller_module.startswith("hypermind."):
|
|
822
|
+
_warnings.warn(
|
|
823
|
+
"HyperMindAgent.testbed(...) returning a plain list is "
|
|
824
|
+
"deprecated; use hypermind.testing.testbed_cohort(n=...) "
|
|
825
|
+
"for an async-with cohort with automatic cleanup. "
|
|
826
|
+
"The list factory is scheduled for removal in v1.0.",
|
|
827
|
+
DeprecationWarning,
|
|
828
|
+
stacklevel=2,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
if legacy_ed25519:
|
|
832
|
+
pq = False
|
|
833
|
+
if not pq:
|
|
834
|
+
import warnings as _w
|
|
835
|
+
|
|
836
|
+
_w.warn(
|
|
837
|
+
"classical-only signatures will be the legacy path from v0.7; "
|
|
838
|
+
"use pq=True for hybrid",
|
|
839
|
+
DeprecationWarning,
|
|
840
|
+
stacklevel=2,
|
|
841
|
+
)
|
|
842
|
+
kp_alg = ALG_MLDSA65_ED25519 if pq else ALG_ED25519
|
|
843
|
+
world = _NamespaceWorld(
|
|
844
|
+
namespace=namespace,
|
|
845
|
+
namespace_id=new_namespace_id(),
|
|
846
|
+
bus=InProcessBus(),
|
|
847
|
+
ts=TransparencyService(name=f"{namespace}-ts"),
|
|
848
|
+
reputation=ReputationKernel(),
|
|
849
|
+
# I0: caller-supplied scoped recorder; lazy-init falls back to namespace_id
|
|
850
|
+
recorder=recorder,
|
|
851
|
+
)
|
|
852
|
+
# Mint witness cohort (3 witnesses for the sandbox).
|
|
853
|
+
world.ts.witness_keypairs = [Keypair.generate(alg=kp_alg) for _ in range(3)]
|
|
854
|
+
world.ts.min_witness_quorum = 1 # sandbox quorum
|
|
855
|
+
|
|
856
|
+
agents_list: list[HyperMindAgent] = []
|
|
857
|
+
for _ in range(agents):
|
|
858
|
+
kp = Keypair.generate(alg=kp_alg)
|
|
859
|
+
agent = HyperMindAgent(
|
|
860
|
+
keypair=kp,
|
|
861
|
+
namespace=namespace,
|
|
862
|
+
rooms=[room],
|
|
863
|
+
pin_policy=None,
|
|
864
|
+
sandbox=True,
|
|
865
|
+
_world=world,
|
|
866
|
+
)
|
|
867
|
+
if ramp_complete:
|
|
868
|
+
# Bootstrap reputation above threshold.
|
|
869
|
+
world.reputation.record_outcome(kp.kid, room, correct=True, weight=10.0)
|
|
870
|
+
agent._observed_outcomes = 200
|
|
871
|
+
world.rooms.setdefault(room, {"min_rep": 0.0, "members": set()})["members"].add(kp.kid)
|
|
872
|
+
agents_list.append(agent)
|
|
873
|
+
return agents_list
|
|
874
|
+
|
|
875
|
+
@classmethod
|
|
876
|
+
def join_testbed(cls, existing: HyperMindAgent, *, room: str | None = None) -> HyperMindAgent:
|
|
877
|
+
"""Add a new agent to an existing testbed (shared world)."""
|
|
878
|
+
if existing._world is None:
|
|
879
|
+
raise RuntimeError(
|
|
880
|
+
"join_testbed() requires an existing testbed agent.\n"
|
|
881
|
+
" Hint: build a base cohort with HyperMindAgent.testbed(n=…) "
|
|
882
|
+
"first, then join_testbed(any_member) to add another agent."
|
|
883
|
+
)
|
|
884
|
+
kp = Keypair.generate()
|
|
885
|
+
agent = HyperMindAgent(
|
|
886
|
+
keypair=kp,
|
|
887
|
+
namespace=existing.namespace,
|
|
888
|
+
rooms=existing.rooms if room is None else [room],
|
|
889
|
+
sandbox=True,
|
|
890
|
+
_world=existing._world,
|
|
891
|
+
)
|
|
892
|
+
for r in agent.rooms:
|
|
893
|
+
existing._world.rooms.setdefault(r, {"min_rep": 0.0, "members": set()})["members"].add(
|
|
894
|
+
kp.kid
|
|
895
|
+
)
|
|
896
|
+
existing._world.reputation.record_outcome(kp.kid, r, correct=True, weight=10.0)
|
|
897
|
+
agent._observed_outcomes = 200
|
|
898
|
+
return agent
|
|
899
|
+
|
|
900
|
+
# ---- async lifecycle ------------------------------------------------
|
|
901
|
+
|
|
902
|
+
async def __aenter__(self) -> HyperMindAgent:
|
|
903
|
+
return self
|
|
904
|
+
|
|
905
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
906
|
+
await self.close()
|
|
907
|
+
|
|
908
|
+
async def close(self) -> None:
|
|
909
|
+
if self._closed:
|
|
910
|
+
return
|
|
911
|
+
# Stop any running deliberator task before deregistering.
|
|
912
|
+
_delib = getattr(self, "_mind_deliberator", None)
|
|
913
|
+
if _delib is not None:
|
|
914
|
+
await _delib.stop()
|
|
915
|
+
for sub in self._subscriptions:
|
|
916
|
+
sub.unsubscribe()
|
|
917
|
+
# Deregister from the namespace world so long-running test suites
|
|
918
|
+
# and notebook kernels don't leak agents/worlds (closes self-heal
|
|
919
|
+
# Cycle-3 Finding #17). When the world has no agents left, also
|
|
920
|
+
# drop it from the process-global registry.
|
|
921
|
+
if self._world is not None:
|
|
922
|
+
self._world.agents.pop(self.keypair.kid, None)
|
|
923
|
+
if not self._world.agents:
|
|
924
|
+
_NAMESPACE_REGISTRY.pop(bytes(self._world.namespace_id), None)
|
|
925
|
+
self._closed = True
|
|
926
|
+
|
|
927
|
+
# ---- placement (T5-02) ---------------------------------------------
|
|
928
|
+
|
|
929
|
+
def place_on(self, host_kid: str | None) -> None:
|
|
930
|
+
"""Hint that this agent's topics should land on ``host_kid``.
|
|
931
|
+
|
|
932
|
+
The hint is consumed by placement policies (see
|
|
933
|
+
:mod:`hypermind.transport.placement`): when a candidate host
|
|
934
|
+
list contains the hinted kid, that host wins regardless of the
|
|
935
|
+
consistent-hash score. Pass ``None`` to clear the hint and fall
|
|
936
|
+
back to the default placement policy.
|
|
937
|
+
"""
|
|
938
|
+
self._placement_hint = host_kid
|
|
939
|
+
|
|
940
|
+
@property
|
|
941
|
+
def placement_hint(self) -> str | None:
|
|
942
|
+
"""The current placement hint, or ``None`` if unset."""
|
|
943
|
+
return self._placement_hint
|
|
944
|
+
|
|
945
|
+
# ---- core verbs -----------------------------------------------------
|
|
946
|
+
|
|
947
|
+
async def publish_claim(
|
|
948
|
+
self,
|
|
949
|
+
payload: dict[str, Any],
|
|
950
|
+
*,
|
|
951
|
+
claim_type: ClaimType = "custom",
|
|
952
|
+
uncertainty: Uncertainty,
|
|
953
|
+
parent_kids: Sequence[Kid] = (),
|
|
954
|
+
topic: str | None = None,
|
|
955
|
+
) -> Kid:
|
|
956
|
+
"""Sign + seal + cosign + gossip a claim, return its kid."""
|
|
957
|
+
self._require_open()
|
|
958
|
+
self._enforce_ramp()
|
|
959
|
+
topic = topic or (self.rooms[0] if self.rooms else "default")
|
|
960
|
+
if topic not in self.rooms:
|
|
961
|
+
raise self._block(
|
|
962
|
+
"ROOM-NOT-JOINED",
|
|
963
|
+
"spec/07-trust-access-layer.md §7.A",
|
|
964
|
+
f"agent has not joined room {topic!r}.\n"
|
|
965
|
+
f" Joined rooms: {self.rooms!r}\n"
|
|
966
|
+
f" Hint: pass `topic=` matching a joined room, or rebuild "
|
|
967
|
+
f"the agent with `room=` set to the desired room.",
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
self._require_cap("publish_claim", topic=topic)
|
|
971
|
+
|
|
972
|
+
# Build payload bytes (CBOR canonical; payload itself is opaque
|
|
973
|
+
# to the wire layer, but encoding it deterministically guarantees
|
|
974
|
+
# cross-impl reproducibility).
|
|
975
|
+
# v0.6 — embed the publisher's predicted mean so oracle_resolve()
|
|
976
|
+
# can record a Brier score for closed-loop calibration. The
|
|
977
|
+
# extra field is opaque to the wire layer and is ignored by
|
|
978
|
+
# legacy verifiers.
|
|
979
|
+
encoded = cbor2.dumps(
|
|
980
|
+
{
|
|
981
|
+
"claim_type": claim_type,
|
|
982
|
+
"topic": topic,
|
|
983
|
+
"payload": payload,
|
|
984
|
+
"parent_kids": list(parent_kids),
|
|
985
|
+
"predicted_mean": float(uncertainty.mean()),
|
|
986
|
+
},
|
|
987
|
+
canonical=True,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
self._hlc = self._hlc.tick()
|
|
991
|
+
confirmer_set = self._select_confirmers(topic, exclude=self.keypair.kid)
|
|
992
|
+
statement = SignedStatement.sign(
|
|
993
|
+
keypair=self.keypair,
|
|
994
|
+
record_type="SEAL",
|
|
995
|
+
namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
|
|
996
|
+
hlc=self._hlc,
|
|
997
|
+
payload=encoded,
|
|
998
|
+
receipt_uncertainty=None,
|
|
999
|
+
confirmer_set=confirmer_set,
|
|
1000
|
+
)
|
|
1001
|
+
if self._world is not None:
|
|
1002
|
+
# Outbound gate (HM-PROTO-VER-001; ROADMAP §0.5.1).
|
|
1003
|
+
self._world.check_protocol_version(statement)
|
|
1004
|
+
|
|
1005
|
+
# Append to TS log + cosign atomically (closes self-heal Cycle-1
|
|
1006
|
+
# Finding #6: cosign must bind the (root, size) pair atomically).
|
|
1007
|
+
if self._world:
|
|
1008
|
+
_proof, cosigs = self._world.ts.append_and_cosign(statement.kid)
|
|
1009
|
+
self._world.sealed[statement.kid] = statement
|
|
1010
|
+
self._world.persist_claim(statement)
|
|
1011
|
+
await self._world.bus.publish(
|
|
1012
|
+
f"/hm/{self.namespace}/{topic}/seal",
|
|
1013
|
+
self._encode_envelope(statement, cosigs=cosigs),
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
self._recorder.emit(
|
|
1017
|
+
namespace=self.namespace,
|
|
1018
|
+
agent_id=self.keypair.kid.hex(),
|
|
1019
|
+
event_type="seal",
|
|
1020
|
+
ts_hlc=str(self._hlc),
|
|
1021
|
+
kid=statement.kid.hex(),
|
|
1022
|
+
topic=topic,
|
|
1023
|
+
claim_type=claim_type,
|
|
1024
|
+
)
|
|
1025
|
+
self._recorder.incr(
|
|
1026
|
+
"hypermind_seal_total",
|
|
1027
|
+
namespace=self.namespace,
|
|
1028
|
+
topic=topic,
|
|
1029
|
+
result="ok",
|
|
1030
|
+
)
|
|
1031
|
+
return statement.kid
|
|
1032
|
+
|
|
1033
|
+
async def subscribe(
|
|
1034
|
+
self,
|
|
1035
|
+
topic: str,
|
|
1036
|
+
callback: Callable[[ClaimEvent], Awaitable[None]],
|
|
1037
|
+
) -> Subscription:
|
|
1038
|
+
"""Subscribe to all sealed claims on a topic."""
|
|
1039
|
+
self._require_open()
|
|
1040
|
+
if not self._world:
|
|
1041
|
+
raise RuntimeError(
|
|
1042
|
+
"subscribe() requires a world.\n"
|
|
1043
|
+
" Hint: build the agent via HyperMindAgent.sandbox() or "
|
|
1044
|
+
"HyperMindAgent.testbed(n=…) so a shared in-process world is "
|
|
1045
|
+
"set up. Naked-constructor agents are crypto-only and have no "
|
|
1046
|
+
"transport."
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
world = self._world
|
|
1050
|
+
|
|
1051
|
+
async def _handler(raw: bytes) -> None:
|
|
1052
|
+
envelope = cbor2.loads(raw)
|
|
1053
|
+
# L4 separability (spec §23): if the envelope carries an
|
|
1054
|
+
# EncryptedStatement and HPKE decrypt raises, we MUST still
|
|
1055
|
+
# verify the signed metadata and emit a structured
|
|
1056
|
+
# BlockReason("PAYLOAD-OPAQUE"). The signed-but-undecryptable
|
|
1057
|
+
# record stays visible to the audit trail.
|
|
1058
|
+
if (
|
|
1059
|
+
EncryptedStatement is not None
|
|
1060
|
+
and isinstance(envelope, dict)
|
|
1061
|
+
and "encrypted_statement" in envelope
|
|
1062
|
+
):
|
|
1063
|
+
try:
|
|
1064
|
+
enc = EncryptedStatement.from_bytes(envelope["encrypted_statement"])
|
|
1065
|
+
plaintext = enc.decrypt(self.keypair) # type: ignore[attr-defined]
|
|
1066
|
+
envelope = dict(envelope)
|
|
1067
|
+
envelope["statement"] = plaintext
|
|
1068
|
+
except Exception as exc:
|
|
1069
|
+
signed_meta = envelope.get("statement")
|
|
1070
|
+
meta_kid_hex: str | None = None
|
|
1071
|
+
try:
|
|
1072
|
+
if signed_meta is not None:
|
|
1073
|
+
meta_stmt = SignedStatement.from_bytes(signed_meta)
|
|
1074
|
+
meta_kid_hex = meta_stmt.kid.hex()
|
|
1075
|
+
except Exception:
|
|
1076
|
+
meta_kid_hex = None
|
|
1077
|
+
self._recorder.emit(
|
|
1078
|
+
namespace=self.namespace,
|
|
1079
|
+
agent_id=self.keypair.kid.hex(),
|
|
1080
|
+
event_type="payload_opaque",
|
|
1081
|
+
severity="warning",
|
|
1082
|
+
rule_id="PAYLOAD-OPAQUE",
|
|
1083
|
+
decrypt_error=type(exc).__name__,
|
|
1084
|
+
signed_meta_kid=meta_kid_hex,
|
|
1085
|
+
)
|
|
1086
|
+
self._recorder.incr(
|
|
1087
|
+
"hypermind_payload_opaque_total",
|
|
1088
|
+
namespace=self.namespace,
|
|
1089
|
+
rule_id="PAYLOAD-OPAQUE",
|
|
1090
|
+
)
|
|
1091
|
+
return
|
|
1092
|
+
stmt = SignedStatement.from_bytes(envelope["statement"])
|
|
1093
|
+
# Policy gate: reject pre-signature-verify if protocol_version is
|
|
1094
|
+
# below the namespace's min_protocol_version (HM-PROTO-VER-001;
|
|
1095
|
+
# ROADMAP §0.5.1). Errors are recorded so refusal is auditable.
|
|
1096
|
+
try:
|
|
1097
|
+
world.check_protocol_version(stmt)
|
|
1098
|
+
except ProtocolVersionError as e:
|
|
1099
|
+
self._recorder.incr(
|
|
1100
|
+
"hypermind_protocol_version_rejects_total",
|
|
1101
|
+
namespace=self.namespace,
|
|
1102
|
+
rule_id=e.rule_id or "HM-PROTO-VER-001",
|
|
1103
|
+
)
|
|
1104
|
+
self._recorder.emit(
|
|
1105
|
+
namespace=self.namespace,
|
|
1106
|
+
agent_id=self.keypair.kid.hex(),
|
|
1107
|
+
event_type="protocol_version_reject",
|
|
1108
|
+
severity="warning",
|
|
1109
|
+
statement_version=e.statement_version,
|
|
1110
|
+
min_required=e.min_required,
|
|
1111
|
+
)
|
|
1112
|
+
return
|
|
1113
|
+
# I6 — Receive-side cosignature verification (Henrik's
|
|
1114
|
+
# CVE-class fix). When the inbound envelope carries
|
|
1115
|
+
# WitnessCosignatures, every one MUST verify against this
|
|
1116
|
+
# world's transparency service before delivery into the mind
|
|
1117
|
+
# layer. Pre-I6 envelopes (no "size" field) are accepted as
|
|
1118
|
+
# before for back-compat — same-process testbed traffic.
|
|
1119
|
+
cosig_count = 0
|
|
1120
|
+
cosig_failures = 0
|
|
1121
|
+
cosig_verified = 0
|
|
1122
|
+
for raw_cosig in envelope.get("cosigs") or []:
|
|
1123
|
+
if not isinstance(raw_cosig, dict):
|
|
1124
|
+
continue
|
|
1125
|
+
cosig_count += 1
|
|
1126
|
+
size = raw_cosig.get("size")
|
|
1127
|
+
if size is None:
|
|
1128
|
+
# Legacy envelope; cannot verify. Skip without
|
|
1129
|
+
# failing so old peers don't get rejected mid-rollout.
|
|
1130
|
+
continue
|
|
1131
|
+
try:
|
|
1132
|
+
from hypermind.knowledge.transparency import WitnessCosignature
|
|
1133
|
+
|
|
1134
|
+
cosig = WitnessCosignature(
|
|
1135
|
+
witness_kid=raw_cosig["witness"],
|
|
1136
|
+
sth_root=raw_cosig["sth"],
|
|
1137
|
+
tree_size=int(size),
|
|
1138
|
+
signature=raw_cosig["sig"],
|
|
1139
|
+
)
|
|
1140
|
+
if world.ts.verify_cosignature(cosig):
|
|
1141
|
+
cosig_verified += 1
|
|
1142
|
+
else:
|
|
1143
|
+
cosig_failures += 1
|
|
1144
|
+
except Exception:
|
|
1145
|
+
cosig_failures += 1
|
|
1146
|
+
if cosig_failures > 0:
|
|
1147
|
+
self._recorder.emit(
|
|
1148
|
+
namespace=self.namespace,
|
|
1149
|
+
agent_id=self.keypair.kid.hex(),
|
|
1150
|
+
event_type="cosig_failed",
|
|
1151
|
+
severity="warning",
|
|
1152
|
+
claim_kid=stmt.kid.hex(),
|
|
1153
|
+
failures=cosig_failures,
|
|
1154
|
+
verified=cosig_verified,
|
|
1155
|
+
total=cosig_count,
|
|
1156
|
+
)
|
|
1157
|
+
# Drop the message — never deliver an unverifiable claim.
|
|
1158
|
+
return
|
|
1159
|
+
if cosig_verified > 0:
|
|
1160
|
+
self._recorder.emit(
|
|
1161
|
+
namespace=self.namespace,
|
|
1162
|
+
agent_id=self.keypair.kid.hex(),
|
|
1163
|
+
event_type="cosig_verified",
|
|
1164
|
+
claim_kid=stmt.kid.hex(),
|
|
1165
|
+
verified=cosig_verified,
|
|
1166
|
+
)
|
|
1167
|
+
await callback(ClaimEvent(statement=stmt, raw=envelope))
|
|
1168
|
+
|
|
1169
|
+
sub = await self._world.bus.subscribe(f"/hm/{self.namespace}/{topic}/seal", _handler)
|
|
1170
|
+
self._subscriptions.append(sub)
|
|
1171
|
+
return sub
|
|
1172
|
+
|
|
1173
|
+
def on_dispute(
|
|
1174
|
+
self,
|
|
1175
|
+
target_kid: Kid,
|
|
1176
|
+
callback: Callable[[Dispute], Any],
|
|
1177
|
+
) -> Subscription:
|
|
1178
|
+
"""Register a synchronous callback for disputes against `target_kid`."""
|
|
1179
|
+
self._require_open()
|
|
1180
|
+
self._dispute_callbacks.setdefault(target_kid, []).append(callback)
|
|
1181
|
+
|
|
1182
|
+
def _cancel() -> None:
|
|
1183
|
+
try:
|
|
1184
|
+
self._dispute_callbacks[target_kid].remove(callback)
|
|
1185
|
+
except (ValueError, KeyError):
|
|
1186
|
+
pass
|
|
1187
|
+
|
|
1188
|
+
return Subscription(topic=f"dispute:{target_kid.hex()}", _cancel=_cancel)
|
|
1189
|
+
|
|
1190
|
+
async def dispute(
|
|
1191
|
+
self,
|
|
1192
|
+
target_kid: Kid,
|
|
1193
|
+
*,
|
|
1194
|
+
reason: str,
|
|
1195
|
+
bond_topic_scope: str,
|
|
1196
|
+
evidence_cid: bytes | None = None,
|
|
1197
|
+
) -> Kid:
|
|
1198
|
+
"""Open a counter-statement against an existing claim."""
|
|
1199
|
+
self._require_open()
|
|
1200
|
+
self._enforce_ramp()
|
|
1201
|
+
self._require_cap("dispute", topic=bond_topic_scope or None)
|
|
1202
|
+
if not bond_topic_scope:
|
|
1203
|
+
raise DisputeError(
|
|
1204
|
+
"bond_topic_scope required (TAL-ESCROW-EXCLUSIVE).\n"
|
|
1205
|
+
" Hint: pass the topic (e.g. 'ai-safety/eval') the bond "
|
|
1206
|
+
"is scoped to. Bonds cannot be wash-traded across topics."
|
|
1207
|
+
)
|
|
1208
|
+
if self._world and target_kid not in self._world.sealed:
|
|
1209
|
+
sealed_count = len(self._world.sealed)
|
|
1210
|
+
sample = [k.hex()[:12] + "…" for k in list(self._world.sealed)[:3]]
|
|
1211
|
+
raise DisputeError(
|
|
1212
|
+
f"target kid {target_kid.hex()[:12]}… not found among "
|
|
1213
|
+
f"{sealed_count} sealed claim(s) in this namespace.\n"
|
|
1214
|
+
f" First few known kids: {sample!r}\n"
|
|
1215
|
+
f" Hint: dispute targets must be SEALED claims in the SAME "
|
|
1216
|
+
f"namespace as the disputer. Did you mean a different agent's "
|
|
1217
|
+
f"claim, or did you accidentally pass an agent_id?"
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
# Build dispute payload.
|
|
1221
|
+
encoded = cbor2.dumps(
|
|
1222
|
+
{
|
|
1223
|
+
"reason": reason,
|
|
1224
|
+
"evidence_cid": evidence_cid,
|
|
1225
|
+
},
|
|
1226
|
+
canonical=True,
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
bond_amount = 100.0 # sandbox default
|
|
1230
|
+
bond: dict[str, Any] = {
|
|
1231
|
+
"namespace_id": self._world.namespace_id if self._world else new_namespace_id(),
|
|
1232
|
+
"amount": bond_amount,
|
|
1233
|
+
"escrow_kid": self.keypair.kid,
|
|
1234
|
+
"topic_scope_hash": _hash_topic_scope(bond_topic_scope),
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
self._hlc = self._hlc.tick()
|
|
1238
|
+
statement = SignedStatement.sign(
|
|
1239
|
+
keypair=self.keypair,
|
|
1240
|
+
record_type="DISPUTE",
|
|
1241
|
+
namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
|
|
1242
|
+
hlc=self._hlc,
|
|
1243
|
+
payload=encoded,
|
|
1244
|
+
dispute_state="OPEN",
|
|
1245
|
+
dispute_target_kid=target_kid,
|
|
1246
|
+
dispute_subtype="OPEN",
|
|
1247
|
+
bond_commitment=bond,
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
if self._world:
|
|
1251
|
+
d = Dispute(
|
|
1252
|
+
target_kid=target_kid,
|
|
1253
|
+
initiator=self.keypair.kid,
|
|
1254
|
+
bond_topic_scope=bond_topic_scope,
|
|
1255
|
+
bond_amount=bond_amount,
|
|
1256
|
+
)
|
|
1257
|
+
self._world.disputes[statement.kid] = d
|
|
1258
|
+
self._world.sealed[statement.kid] = statement
|
|
1259
|
+
self._world.persist_claim(statement)
|
|
1260
|
+
self._world.persist_dispute(statement.kid, d)
|
|
1261
|
+
self._world.dispute_event.set()
|
|
1262
|
+
|
|
1263
|
+
# Notify the original claim's publisher synchronously so
|
|
1264
|
+
# `on_dispute` callbacks fire deterministically in tests.
|
|
1265
|
+
target_stmt = self._world.sealed.get(target_kid)
|
|
1266
|
+
if target_stmt is not None:
|
|
1267
|
+
target_publisher = target_stmt.issuer_kid
|
|
1268
|
+
publisher_agent = self._world.agents.get(target_publisher)
|
|
1269
|
+
if publisher_agent is not None:
|
|
1270
|
+
for cb in publisher_agent._dispute_callbacks.get(target_kid, []):
|
|
1271
|
+
await _invoke_dispute_callback(cb, d, recorder=self._recorder)
|
|
1272
|
+
|
|
1273
|
+
await self._world.bus.publish(
|
|
1274
|
+
f"/hm/{self.namespace}/dispute",
|
|
1275
|
+
self._encode_envelope(statement),
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
self._recorder.emit(
|
|
1279
|
+
namespace=self.namespace,
|
|
1280
|
+
agent_id=self.keypair.kid.hex(),
|
|
1281
|
+
event_type="dispute_open",
|
|
1282
|
+
ts_hlc=str(self._hlc),
|
|
1283
|
+
kid=statement.kid.hex(),
|
|
1284
|
+
target_kid=target_kid.hex(),
|
|
1285
|
+
bond_topic_scope=bond_topic_scope,
|
|
1286
|
+
)
|
|
1287
|
+
self._recorder.incr("hypermind_dispute_open_total")
|
|
1288
|
+
return statement.kid
|
|
1289
|
+
|
|
1290
|
+
# ---- T3: Contract-Net Protocol (§27) -------------------------------
|
|
1291
|
+
|
|
1292
|
+
async def request_for_proposal(
|
|
1293
|
+
self,
|
|
1294
|
+
*,
|
|
1295
|
+
topic: str,
|
|
1296
|
+
requirements: dict[str, Any],
|
|
1297
|
+
deadline_ms: int,
|
|
1298
|
+
bidder_filter: dict[str, Any] | None = None,
|
|
1299
|
+
) -> RFPHandle:
|
|
1300
|
+
"""Publish an RFP_OPEN record on this namespace's gossip bus.
|
|
1301
|
+
|
|
1302
|
+
Returns an :class:`RFPHandle` that the issuer can await for
|
|
1303
|
+
incoming bids and use to compute / publish the AWARD.
|
|
1304
|
+
|
|
1305
|
+
Spec §27.2 normative MUSTs:
|
|
1306
|
+
- The RFP is signed (this method's SignedStatement.sign() call).
|
|
1307
|
+
- The bidder_filter (if supplied) is enforced at AWARD time;
|
|
1308
|
+
bids below the floor are published as BID_REJECT records
|
|
1309
|
+
before the AWARD is computed.
|
|
1310
|
+
"""
|
|
1311
|
+
self._require_open()
|
|
1312
|
+
self._enforce_ramp()
|
|
1313
|
+
self._require_cap("request_for_proposal", topic=topic)
|
|
1314
|
+
if topic not in self.rooms:
|
|
1315
|
+
raise self._block(
|
|
1316
|
+
"ROOM-NOT-JOINED",
|
|
1317
|
+
"spec/27-contract-net.md §27.2",
|
|
1318
|
+
f"agent has not joined room {topic!r} (required for RFP).\n"
|
|
1319
|
+
f" Joined rooms: {self.rooms!r}",
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
encoded = cbor2.dumps(
|
|
1323
|
+
{
|
|
1324
|
+
"topic": topic,
|
|
1325
|
+
"requirements": requirements,
|
|
1326
|
+
"deadline_ms": int(deadline_ms),
|
|
1327
|
+
"bidder_filter": bidder_filter,
|
|
1328
|
+
},
|
|
1329
|
+
canonical=True,
|
|
1330
|
+
)
|
|
1331
|
+
self._hlc = self._hlc.tick()
|
|
1332
|
+
statement = SignedStatement.sign(
|
|
1333
|
+
keypair=self.keypair,
|
|
1334
|
+
record_type="RFP_OPEN",
|
|
1335
|
+
namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
|
|
1336
|
+
hlc=self._hlc,
|
|
1337
|
+
payload=encoded,
|
|
1338
|
+
)
|
|
1339
|
+
rfp_id = statement.kid # rfp_id ≡ kid of the RFP_OPEN envelope
|
|
1340
|
+
rfp = RFP(
|
|
1341
|
+
rfp_id=rfp_id,
|
|
1342
|
+
issuer_kid=self.keypair.kid,
|
|
1343
|
+
topic=topic,
|
|
1344
|
+
requirements=dict(requirements),
|
|
1345
|
+
deadline_hlc_ms=int(deadline_ms),
|
|
1346
|
+
bidder_filter=dict(bidder_filter) if bidder_filter else None,
|
|
1347
|
+
)
|
|
1348
|
+
if self._world is not None:
|
|
1349
|
+
self._world.sealed[rfp_id] = statement
|
|
1350
|
+
self._world.rfps[rfp_id] = rfp
|
|
1351
|
+
self._world.bids_by_rfp.setdefault(rfp_id, [])
|
|
1352
|
+
self._world.persist_claim(statement)
|
|
1353
|
+
await self._world.bus.publish(
|
|
1354
|
+
f"/hm/{self.namespace}/cnp/rfp",
|
|
1355
|
+
self._encode_envelope(statement),
|
|
1356
|
+
)
|
|
1357
|
+
self._recorder.emit(
|
|
1358
|
+
namespace=self.namespace,
|
|
1359
|
+
agent_id=self.keypair.kid.hex(),
|
|
1360
|
+
event_type="cnp_rfp_open",
|
|
1361
|
+
ts_hlc=str(self._hlc),
|
|
1362
|
+
rfp_id=rfp_id.hex(),
|
|
1363
|
+
topic=topic,
|
|
1364
|
+
)
|
|
1365
|
+
self._recorder.incr("hypermind_cnp_rfp_total", topic=topic)
|
|
1366
|
+
return RFPHandle(rfp=rfp, agent=self)
|
|
1367
|
+
|
|
1368
|
+
async def bid(
|
|
1369
|
+
self,
|
|
1370
|
+
*,
|
|
1371
|
+
rfp_id: Kid,
|
|
1372
|
+
payload: dict[str, Any],
|
|
1373
|
+
score_self: float,
|
|
1374
|
+
) -> BidHandle:
|
|
1375
|
+
"""Publish a BID record against an open RFP.
|
|
1376
|
+
|
|
1377
|
+
``score_self`` is the bidder's claimed posterior on delivering
|
|
1378
|
+
the requirement; it is fed into the auctioneer's scorer (default
|
|
1379
|
+
:class:`ReputationWeightedScorer`). The auctioneer multiplies it
|
|
1380
|
+
by the bidder's topic reputation, so a low-rep bidder cannot
|
|
1381
|
+
win simply by claiming `score_self=1.0`.
|
|
1382
|
+
"""
|
|
1383
|
+
self._require_open()
|
|
1384
|
+
self._enforce_ramp()
|
|
1385
|
+
if not (0.0 <= float(score_self) <= 1.0):
|
|
1386
|
+
raise ValueError("score_self must be in [0.0, 1.0]")
|
|
1387
|
+
if self._world is None:
|
|
1388
|
+
raise RuntimeError("bid() requires a world (use sandbox/testbed)")
|
|
1389
|
+
if rfp_id not in self._world.rfps:
|
|
1390
|
+
raise HyperMindError(
|
|
1391
|
+
f"bid: rfp_id {rfp_id.hex()[:12]}… not found in this namespace.\n"
|
|
1392
|
+
f" Hint: bid() must be called inside the same namespace as "
|
|
1393
|
+
f"request_for_proposal()."
|
|
1394
|
+
)
|
|
1395
|
+
rfp_obj: RFP = self._world.rfps[rfp_id]
|
|
1396
|
+
self._require_cap("bid", topic=rfp_obj.topic)
|
|
1397
|
+
|
|
1398
|
+
encoded = cbor2.dumps(
|
|
1399
|
+
{
|
|
1400
|
+
"rfp_id": bytes(rfp_id),
|
|
1401
|
+
"bidder_kid": self.keypair.kid,
|
|
1402
|
+
"payload": payload,
|
|
1403
|
+
"bid_score_self": float(score_self),
|
|
1404
|
+
},
|
|
1405
|
+
canonical=True,
|
|
1406
|
+
)
|
|
1407
|
+
self._hlc = self._hlc.tick()
|
|
1408
|
+
statement = SignedStatement.sign(
|
|
1409
|
+
keypair=self.keypair,
|
|
1410
|
+
record_type="BID",
|
|
1411
|
+
namespace_id=self._world.namespace_id,
|
|
1412
|
+
hlc=self._hlc,
|
|
1413
|
+
payload=encoded,
|
|
1414
|
+
)
|
|
1415
|
+
bid_obj = Bid(
|
|
1416
|
+
rfp_id=bytes(rfp_id),
|
|
1417
|
+
bid_kid=statement.kid,
|
|
1418
|
+
bidder_kid=self.keypair.kid,
|
|
1419
|
+
payload=dict(payload),
|
|
1420
|
+
bid_score_self=float(score_self),
|
|
1421
|
+
)
|
|
1422
|
+
self._world.sealed[statement.kid] = statement
|
|
1423
|
+
self._world.bids_by_rfp.setdefault(bytes(rfp_id), []).append(bid_obj)
|
|
1424
|
+
self._world.persist_claim(statement)
|
|
1425
|
+
self._world.rfp_bid_event.set()
|
|
1426
|
+
# Reset the event after a tick so subsequent waiters can re-arm.
|
|
1427
|
+
await self._world.bus.publish(
|
|
1428
|
+
f"/hm/{self.namespace}/cnp/bid",
|
|
1429
|
+
self._encode_envelope(statement),
|
|
1430
|
+
)
|
|
1431
|
+
self._recorder.emit(
|
|
1432
|
+
namespace=self.namespace,
|
|
1433
|
+
agent_id=self.keypair.kid.hex(),
|
|
1434
|
+
event_type="cnp_bid",
|
|
1435
|
+
ts_hlc=str(self._hlc),
|
|
1436
|
+
bid_kid=statement.kid.hex(),
|
|
1437
|
+
rfp_id=bytes(rfp_id).hex(),
|
|
1438
|
+
)
|
|
1439
|
+
self._recorder.incr("hypermind_cnp_bid_total", topic=rfp_obj.topic)
|
|
1440
|
+
return BidHandle(bid=bid_obj, agent=self)
|
|
1441
|
+
|
|
1442
|
+
async def award(
|
|
1443
|
+
self,
|
|
1444
|
+
rfp_id: Kid,
|
|
1445
|
+
winning_bid_kid: Kid | None = None,
|
|
1446
|
+
*,
|
|
1447
|
+
signer_quorum: int = 1,
|
|
1448
|
+
co_signers: list[Any] | None = None,
|
|
1449
|
+
scorer: BidScorer | None = None,
|
|
1450
|
+
) -> AwardHandle:
|
|
1451
|
+
"""Compute (or accept) the winning bid and publish an AWARD record.
|
|
1452
|
+
|
|
1453
|
+
- ``winning_bid_kid``: caller-selected winner; when ``None``, the
|
|
1454
|
+
configured ``scorer`` (default :class:`ReputationWeightedScorer`)
|
|
1455
|
+
picks the highest-scoring bid that passed the bidder_filter.
|
|
1456
|
+
- ``signer_quorum``: 1 ⇒ ordinary single-signer AWARD (Ed25519 or
|
|
1457
|
+
hybrid PQ per the agent's keypair). >1 ⇒ FROST k-of-n threshold
|
|
1458
|
+
signature; ``co_signers`` MUST then be a list of length n
|
|
1459
|
+
containing FROST-share-bearing :class:`Keypair`-shape objects
|
|
1460
|
+
(see :mod:`hypermind.crypto.frost`).
|
|
1461
|
+
|
|
1462
|
+
Spec §27.3 normative MUSTs:
|
|
1463
|
+
- When ``signer_quorum > 1``, the AWARD payload MUST carry
|
|
1464
|
+
the kids of all n FROST signers (so verifiers can re-derive
|
|
1465
|
+
the group public key) and the aggregated (R, z) signature
|
|
1466
|
+
binds :func:`contract_net.award_preimage` (rfp_id, winning_bid_kid).
|
|
1467
|
+
"""
|
|
1468
|
+
self._require_open()
|
|
1469
|
+
if self._world is None:
|
|
1470
|
+
raise RuntimeError("award() requires a world (use sandbox/testbed)")
|
|
1471
|
+
if rfp_id not in self._world.rfps:
|
|
1472
|
+
raise HyperMindError(f"award: rfp_id {rfp_id.hex()[:12]}… not found")
|
|
1473
|
+
rfp_obj: RFP = self._world.rfps[rfp_id]
|
|
1474
|
+
if rfp_obj.issuer_kid != self.keypair.kid:
|
|
1475
|
+
raise AuthError(
|
|
1476
|
+
"only the RFP issuer may publish AWARD",
|
|
1477
|
+
failed_caveat="cnp_award_issuer",
|
|
1478
|
+
telemetry={"rule_id": "HM-CNP-AWARD-ISSUER"},
|
|
1479
|
+
)
|
|
1480
|
+
self._require_cap("award", topic=rfp_obj.topic)
|
|
1481
|
+
scorer = scorer or ReputationWeightedScorer()
|
|
1482
|
+
|
|
1483
|
+
# Filter bids and emit BID_REJECT for filtered-out ones.
|
|
1484
|
+
all_bids: list[Bid] = list(self._world.bids_by_rfp.get(rfp_id, []))
|
|
1485
|
+
eligible: list[Bid] = []
|
|
1486
|
+
rejected: list[tuple[Bid, str]] = []
|
|
1487
|
+
for b in all_bids:
|
|
1488
|
+
ok, rule_id = passes_bidder_filter(
|
|
1489
|
+
b, rfp_obj.bidder_filter, self._world.reputation, rfp_obj.topic
|
|
1490
|
+
)
|
|
1491
|
+
if ok:
|
|
1492
|
+
eligible.append(b)
|
|
1493
|
+
else:
|
|
1494
|
+
rejected.append((b, rule_id or "HM-CNP-BIDDER-FILTER"))
|
|
1495
|
+
|
|
1496
|
+
for rejected_bid, rule_id in rejected:
|
|
1497
|
+
await self._publish_bid_reject(
|
|
1498
|
+
rfp_id=rfp_id,
|
|
1499
|
+
bid_kid=rejected_bid.bid_kid,
|
|
1500
|
+
reason_rule_id=rule_id,
|
|
1501
|
+
topic=rfp_obj.topic,
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
if winning_bid_kid is None:
|
|
1505
|
+
if not eligible:
|
|
1506
|
+
raise HyperMindError(
|
|
1507
|
+
"award: no eligible bids after bidder_filter; RFP cannot be awarded"
|
|
1508
|
+
)
|
|
1509
|
+
scored = [
|
|
1510
|
+
(
|
|
1511
|
+
scorer.score(b, rfp_obj.requirements, self._world.reputation, rfp_obj.topic),
|
|
1512
|
+
b,
|
|
1513
|
+
)
|
|
1514
|
+
for b in eligible
|
|
1515
|
+
]
|
|
1516
|
+
# Stable winner pick — highest score wins; ties break on bid_kid bytes.
|
|
1517
|
+
scored.sort(key=lambda x: (x[0], x[1].bid_kid), reverse=True)
|
|
1518
|
+
winning_bid_kid = scored[0][1].bid_kid
|
|
1519
|
+
|
|
1520
|
+
# Locate the winning bid object.
|
|
1521
|
+
win_bid = next((b for b in all_bids if b.bid_kid == winning_bid_kid), None)
|
|
1522
|
+
if win_bid is None:
|
|
1523
|
+
raise HyperMindError(f"award: winning_bid_kid {winning_bid_kid.hex()[:12]}… not found")
|
|
1524
|
+
|
|
1525
|
+
signer_kids: list[bytes]
|
|
1526
|
+
unprotected_extras: dict[str, Any] = {}
|
|
1527
|
+
if signer_quorum > 1:
|
|
1528
|
+
if not co_signers or len(co_signers) < signer_quorum:
|
|
1529
|
+
raise ValueError(
|
|
1530
|
+
f"award: signer_quorum={signer_quorum} requires "
|
|
1531
|
+
f"len(co_signers) ≥ {signer_quorum}"
|
|
1532
|
+
)
|
|
1533
|
+
# FROST k-of-n threshold sign over the canonical preimage.
|
|
1534
|
+
from hypermind.crypto import frost
|
|
1535
|
+
|
|
1536
|
+
# Each co_signer is expected to carry a FrostShare in
|
|
1537
|
+
# `.frost_share`; the auctioneer's own kid is included as the
|
|
1538
|
+
# nominal "issuer" so the SignedStatement envelope retains a
|
|
1539
|
+
# stable kid attribution. The FROST aggregate R||z lives in
|
|
1540
|
+
# the unprotected header `frost_signature` for verifiers, and
|
|
1541
|
+
# the AWARD record's signature itself is over the canonical
|
|
1542
|
+
# sig_input (Ed25519 of the auctioneer) — the FROST aggregate
|
|
1543
|
+
# is the auxiliary k-of-n attestation, NOT the only proof.
|
|
1544
|
+
preimage = award_preimage(rfp_id, winning_bid_kid)
|
|
1545
|
+
# Accept either FrostShare instances (preferred) or objects
|
|
1546
|
+
# exposing a `.frost_share` attribute (e.g. a wrapper class).
|
|
1547
|
+
shares: list[Any] = []
|
|
1548
|
+
for s in co_signers[:signer_quorum]:
|
|
1549
|
+
if isinstance(s, frost.FrostShare):
|
|
1550
|
+
shares.append(s)
|
|
1551
|
+
elif hasattr(s, "frost_share"):
|
|
1552
|
+
shares.append(s.frost_share)
|
|
1553
|
+
else:
|
|
1554
|
+
raise ValueError(
|
|
1555
|
+
"award: every co_signer must be a FrostShare or "
|
|
1556
|
+
"expose a `.frost_share` attribute"
|
|
1557
|
+
)
|
|
1558
|
+
R, z = frost.threshold_sign(shares, preimage)
|
|
1559
|
+
# Derive a stable identifier per signer: index-tagged short kid
|
|
1560
|
+
# over the share's index + group_public. The per-signer Ed25519
|
|
1561
|
+
# kid is not part of FROST; we record signer indices instead so
|
|
1562
|
+
# auditors can verify which shares contributed.
|
|
1563
|
+
signer_kids = [
|
|
1564
|
+
hashlib.sha256(
|
|
1565
|
+
b"hm:cnp-frost-signer:" + i.to_bytes(2, "big") + bytes(shares[0].group_public)
|
|
1566
|
+
).digest()
|
|
1567
|
+
for i in [sh.index for sh in shares]
|
|
1568
|
+
]
|
|
1569
|
+
unprotected_extras["frost_signature"] = {"R": R, "z": z}
|
|
1570
|
+
unprotected_extras["frost_quorum"] = [int(signer_quorum), len(co_signers)]
|
|
1571
|
+
unprotected_extras["frost_signer_indices"] = [int(sh.index) for sh in shares]
|
|
1572
|
+
unprotected_extras["frost_group_public"] = bytes(shares[0].group_public)
|
|
1573
|
+
else:
|
|
1574
|
+
signer_kids = [self.keypair.kid]
|
|
1575
|
+
|
|
1576
|
+
encoded = cbor2.dumps(
|
|
1577
|
+
{
|
|
1578
|
+
"rfp_id": bytes(rfp_id),
|
|
1579
|
+
"winning_bid_kid": bytes(winning_bid_kid),
|
|
1580
|
+
"winning_bidder_kid": win_bid.bidder_kid,
|
|
1581
|
+
"award_signer_kids": [bytes(s) for s in signer_kids],
|
|
1582
|
+
},
|
|
1583
|
+
canonical=True,
|
|
1584
|
+
)
|
|
1585
|
+
self._hlc = self._hlc.tick()
|
|
1586
|
+
statement = SignedStatement.sign(
|
|
1587
|
+
keypair=self.keypair,
|
|
1588
|
+
record_type="AWARD",
|
|
1589
|
+
namespace_id=self._world.namespace_id,
|
|
1590
|
+
hlc=self._hlc,
|
|
1591
|
+
payload=encoded,
|
|
1592
|
+
)
|
|
1593
|
+
if unprotected_extras:
|
|
1594
|
+
statement.unprotected.update(unprotected_extras)
|
|
1595
|
+
|
|
1596
|
+
award_obj = Award(
|
|
1597
|
+
rfp_id=bytes(rfp_id),
|
|
1598
|
+
award_kid=statement.kid,
|
|
1599
|
+
winning_bid_kid=bytes(winning_bid_kid),
|
|
1600
|
+
winning_bidder_kid=win_bid.bidder_kid,
|
|
1601
|
+
award_signer_kids=[bytes(s) for s in signer_kids],
|
|
1602
|
+
)
|
|
1603
|
+
self._world.sealed[statement.kid] = statement
|
|
1604
|
+
self._world.awards[bytes(rfp_id)] = award_obj
|
|
1605
|
+
self._world.persist_claim(statement)
|
|
1606
|
+
self._world.rfp_award_event.set()
|
|
1607
|
+
await self._world.bus.publish(
|
|
1608
|
+
f"/hm/{self.namespace}/cnp/award",
|
|
1609
|
+
self._encode_envelope(statement),
|
|
1610
|
+
)
|
|
1611
|
+
self._recorder.emit(
|
|
1612
|
+
namespace=self.namespace,
|
|
1613
|
+
agent_id=self.keypair.kid.hex(),
|
|
1614
|
+
event_type="cnp_award",
|
|
1615
|
+
ts_hlc=str(self._hlc),
|
|
1616
|
+
award_kid=statement.kid.hex(),
|
|
1617
|
+
rfp_id=bytes(rfp_id).hex(),
|
|
1618
|
+
winning_bid_kid=bytes(winning_bid_kid).hex(),
|
|
1619
|
+
quorum=signer_quorum,
|
|
1620
|
+
n_signers=len(signer_kids),
|
|
1621
|
+
)
|
|
1622
|
+
self._recorder.incr(
|
|
1623
|
+
"hypermind_cnp_award_total",
|
|
1624
|
+
topic=rfp_obj.topic,
|
|
1625
|
+
quorum=str(signer_quorum),
|
|
1626
|
+
)
|
|
1627
|
+
return AwardHandle(award=award_obj, agent=self)
|
|
1628
|
+
|
|
1629
|
+
async def _publish_bid_reject(
|
|
1630
|
+
self,
|
|
1631
|
+
*,
|
|
1632
|
+
rfp_id: Kid,
|
|
1633
|
+
bid_kid: Kid,
|
|
1634
|
+
reason_rule_id: str,
|
|
1635
|
+
topic: str,
|
|
1636
|
+
) -> Kid:
|
|
1637
|
+
"""Publish a BID_REJECT record (used by award() and dispute_award())."""
|
|
1638
|
+
encoded = cbor2.dumps(
|
|
1639
|
+
{
|
|
1640
|
+
"rfp_id": bytes(rfp_id),
|
|
1641
|
+
"bid_kid": bytes(bid_kid),
|
|
1642
|
+
"reason_rule_id": reason_rule_id,
|
|
1643
|
+
},
|
|
1644
|
+
canonical=True,
|
|
1645
|
+
)
|
|
1646
|
+
self._hlc = self._hlc.tick()
|
|
1647
|
+
statement = SignedStatement.sign(
|
|
1648
|
+
keypair=self.keypair,
|
|
1649
|
+
record_type="BID_REJECT",
|
|
1650
|
+
namespace_id=self._world.namespace_id if self._world else new_namespace_id(),
|
|
1651
|
+
hlc=self._hlc,
|
|
1652
|
+
payload=encoded,
|
|
1653
|
+
)
|
|
1654
|
+
if self._world is not None:
|
|
1655
|
+
self._world.sealed[statement.kid] = statement
|
|
1656
|
+
self._world.persist_claim(statement)
|
|
1657
|
+
await self._world.bus.publish(
|
|
1658
|
+
f"/hm/{self.namespace}/cnp/bid_reject",
|
|
1659
|
+
self._encode_envelope(statement),
|
|
1660
|
+
)
|
|
1661
|
+
self._recorder.emit(
|
|
1662
|
+
namespace=self.namespace,
|
|
1663
|
+
agent_id=self.keypair.kid.hex(),
|
|
1664
|
+
event_type="cnp_bid_reject",
|
|
1665
|
+
rule_id=reason_rule_id,
|
|
1666
|
+
bid_kid=bytes(bid_kid).hex(),
|
|
1667
|
+
rfp_id=bytes(rfp_id).hex(),
|
|
1668
|
+
)
|
|
1669
|
+
self._recorder.incr(
|
|
1670
|
+
"hypermind_cnp_bid_reject_total",
|
|
1671
|
+
topic=topic,
|
|
1672
|
+
rule_id=reason_rule_id,
|
|
1673
|
+
)
|
|
1674
|
+
return statement.kid
|
|
1675
|
+
|
|
1676
|
+
async def dispute_award(
|
|
1677
|
+
self,
|
|
1678
|
+
*,
|
|
1679
|
+
rfp_id: Kid,
|
|
1680
|
+
award_kid: Kid,
|
|
1681
|
+
evidence: dict[str, Any],
|
|
1682
|
+
) -> Kid:
|
|
1683
|
+
"""Open a dispute against an AWARD record.
|
|
1684
|
+
|
|
1685
|
+
Wraps the standard dispute FSM; the bond's topic_scope_hash binds
|
|
1686
|
+
to ``"rfp:" + hex(rfp_id)`` (see
|
|
1687
|
+
:func:`contract_net.topic_scope_for_rfp`) so closing the dispute
|
|
1688
|
+
on a different topic-scope is rejected by ``Dispute.close()``.
|
|
1689
|
+
|
|
1690
|
+
On a successful CLOSED outcome (decision="upheld"), the caller
|
|
1691
|
+
SHOULD invoke :meth:`revoke_award` to publish a BID_REJECT for
|
|
1692
|
+
the previously-winning bid; the RFP can then be re-awarded.
|
|
1693
|
+
"""
|
|
1694
|
+
if self._world is None:
|
|
1695
|
+
raise RuntimeError("dispute_award() requires a world")
|
|
1696
|
+
if award_kid not in self._world.sealed:
|
|
1697
|
+
raise DisputeError(f"award_kid {award_kid.hex()[:12]}… not found in sealed registry")
|
|
1698
|
+
scope = topic_scope_for_rfp(rfp_id)
|
|
1699
|
+
# Reuse the existing dispute() verb; it already enforces caps,
|
|
1700
|
+
# bond topic-scope hashing, and FSM open.
|
|
1701
|
+
return await self.dispute(
|
|
1702
|
+
award_kid,
|
|
1703
|
+
reason=str(evidence.get("reason", "award disputed")),
|
|
1704
|
+
bond_topic_scope=scope,
|
|
1705
|
+
evidence_cid=cbor2.dumps(evidence, canonical=True),
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
async def revoke_award(
|
|
1709
|
+
self,
|
|
1710
|
+
*,
|
|
1711
|
+
rfp_id: Kid,
|
|
1712
|
+
award_kid: Kid,
|
|
1713
|
+
) -> Kid:
|
|
1714
|
+
"""Mark an AWARD revoked by publishing BID_REJECT for the winning bid.
|
|
1715
|
+
|
|
1716
|
+
Called by the RFP issuer after a successful dispute upholds the
|
|
1717
|
+
complaint. The Award record in self._world.awards is mutated
|
|
1718
|
+
(revoked=True) so a subsequent re-award can be issued. Returns
|
|
1719
|
+
the kid of the BID_REJECT envelope.
|
|
1720
|
+
"""
|
|
1721
|
+
if self._world is None:
|
|
1722
|
+
raise RuntimeError("revoke_award() requires a world")
|
|
1723
|
+
award_obj = self._world.awards.get(bytes(rfp_id))
|
|
1724
|
+
if award_obj is None or award_obj.award_kid != award_kid:
|
|
1725
|
+
raise HyperMindError(
|
|
1726
|
+
f"revoke_award: no current award for rfp_id={rfp_id.hex()[:12]}… "
|
|
1727
|
+
f"matching award_kid={award_kid.hex()[:12]}…"
|
|
1728
|
+
)
|
|
1729
|
+
rfp_obj: RFP = self._world.rfps[rfp_id]
|
|
1730
|
+
reject_kid = await self._publish_bid_reject(
|
|
1731
|
+
rfp_id=rfp_id,
|
|
1732
|
+
bid_kid=award_obj.winning_bid_kid,
|
|
1733
|
+
reason_rule_id="HM-CNP-AWARD-REVOKED",
|
|
1734
|
+
topic=rfp_obj.topic,
|
|
1735
|
+
)
|
|
1736
|
+
# Replace with a revoked sentinel so re-award can proceed.
|
|
1737
|
+
self._world.awards[bytes(rfp_id)] = Award(
|
|
1738
|
+
rfp_id=award_obj.rfp_id,
|
|
1739
|
+
award_kid=award_obj.award_kid,
|
|
1740
|
+
winning_bid_kid=award_obj.winning_bid_kid,
|
|
1741
|
+
winning_bidder_kid=award_obj.winning_bidder_kid,
|
|
1742
|
+
award_signer_kids=list(award_obj.award_signer_kids),
|
|
1743
|
+
revoked=True,
|
|
1744
|
+
)
|
|
1745
|
+
# Drop the disqualified bid from the eligible list so a re-award
|
|
1746
|
+
# will pick a different winner.
|
|
1747
|
+
bids = self._world.bids_by_rfp.get(bytes(rfp_id), [])
|
|
1748
|
+
self._world.bids_by_rfp[bytes(rfp_id)] = [
|
|
1749
|
+
b for b in bids if b.bid_kid != award_obj.winning_bid_kid
|
|
1750
|
+
]
|
|
1751
|
+
return reject_kid
|
|
1752
|
+
|
|
1753
|
+
async def synthesize(
|
|
1754
|
+
self,
|
|
1755
|
+
parent_kids: list[Kid],
|
|
1756
|
+
summary_payload: dict[str, Any],
|
|
1757
|
+
*,
|
|
1758
|
+
model_attribution: dict[str, Any] | None = None,
|
|
1759
|
+
uncertainty: Uncertainty,
|
|
1760
|
+
) -> Kid:
|
|
1761
|
+
"""Aggregate parent claims into a synthesis. Provenance preserved."""
|
|
1762
|
+
self._require_cap("synthesize")
|
|
1763
|
+
if not parent_kids:
|
|
1764
|
+
raise ValueError(
|
|
1765
|
+
"synthesize() requires at least one parent_kid.\n"
|
|
1766
|
+
" Hint: pass the kids of claims you want to aggregate, e.g. "
|
|
1767
|
+
"agent.synthesize(parent_kids=[k1, k2], ...)."
|
|
1768
|
+
)
|
|
1769
|
+
merged = {
|
|
1770
|
+
"summary": summary_payload,
|
|
1771
|
+
"parents": [k.hex() for k in parent_kids],
|
|
1772
|
+
"model_attribution": model_attribution,
|
|
1773
|
+
}
|
|
1774
|
+
return await self.publish_claim(
|
|
1775
|
+
merged,
|
|
1776
|
+
claim_type="synthesis",
|
|
1777
|
+
uncertainty=uncertainty,
|
|
1778
|
+
parent_kids=list(parent_kids),
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
async def consult(
|
|
1782
|
+
self,
|
|
1783
|
+
query: str,
|
|
1784
|
+
*,
|
|
1785
|
+
panel_size: int = 5,
|
|
1786
|
+
route_strategy: RouteStrategy = "disagreement",
|
|
1787
|
+
timeout: timedelta = timedelta(seconds=30),
|
|
1788
|
+
topic: str | None = None,
|
|
1789
|
+
responder: Callable[[bytes, str], Awaitable[Any]] | None = None,
|
|
1790
|
+
cross_namespace: list[bytes] | None = None,
|
|
1791
|
+
# ---- v0.6 deliberation knobs (backward-compat defaults) ----
|
|
1792
|
+
max_deliberation_rounds: int = 1,
|
|
1793
|
+
convergence_threshold: float = 0.05,
|
|
1794
|
+
# P3-09: blind window buffer — hold responses for this many ms before
|
|
1795
|
+
# scoring, preventing late agents from conditioning on early results.
|
|
1796
|
+
blind_window_ms: int = 0,
|
|
1797
|
+
) -> ConsultResult:
|
|
1798
|
+
"""Aggregate posteriors from a panel of peer agents.
|
|
1799
|
+
|
|
1800
|
+
``cross_namespace`` (ROADMAP §0.4.1): list of namespace_id bytes
|
|
1801
|
+
whose worlds are also eligible for panel selection. Each cross-
|
|
1802
|
+
namespace participant's reputation is read from THEIR namespace,
|
|
1803
|
+
not the calling namespace — reputation is per-namespace by
|
|
1804
|
+
invariant.
|
|
1805
|
+
"""
|
|
1806
|
+
self._require_open()
|
|
1807
|
+
self._require_cap("consult", topic=topic)
|
|
1808
|
+
if not self._world:
|
|
1809
|
+
raise RuntimeError(
|
|
1810
|
+
"consult() requires a world.\n"
|
|
1811
|
+
" Hint: build the agent via HyperMindAgent.sandbox() or "
|
|
1812
|
+
"HyperMindAgent.testbed(n=…) so peer agents are reachable."
|
|
1813
|
+
)
|
|
1814
|
+
topic = topic or (self.rooms[0] if self.rooms else "default")
|
|
1815
|
+
|
|
1816
|
+
# Per-candidate world map: {kid → world they belong to}. The
|
|
1817
|
+
# local namespace's agents map first; cross-namespace overlays
|
|
1818
|
+
# follow. Reputation is always read from the agent's HOME world.
|
|
1819
|
+
candidate_worlds: dict[bytes, _NamespaceWorld] = {}
|
|
1820
|
+
for kid in self._world.agents.keys():
|
|
1821
|
+
if kid == self.keypair.kid:
|
|
1822
|
+
continue
|
|
1823
|
+
if topic in self._world.agents[kid].rooms:
|
|
1824
|
+
candidate_worlds[kid] = self._world
|
|
1825
|
+
|
|
1826
|
+
for ns_id in cross_namespace or []:
|
|
1827
|
+
other = _lookup_namespace(bytes(ns_id))
|
|
1828
|
+
if other is None or other is self._world:
|
|
1829
|
+
continue
|
|
1830
|
+
for kid, agent in other.agents.items():
|
|
1831
|
+
if kid == self.keypair.kid:
|
|
1832
|
+
continue
|
|
1833
|
+
if topic in agent.rooms and kid not in candidate_worlds:
|
|
1834
|
+
candidate_worlds[kid] = other
|
|
1835
|
+
|
|
1836
|
+
candidates = list(candidate_worlds.keys())
|
|
1837
|
+
if not candidates:
|
|
1838
|
+
raise HyperMindError(
|
|
1839
|
+
f"consult: no candidates available for topic {topic!r}.\n"
|
|
1840
|
+
f" Hint: at least one OTHER agent must be in the same room "
|
|
1841
|
+
f"and namespace. Use HyperMindAgent.testbed(n=…, room={topic!r}) "
|
|
1842
|
+
f"to spin up a cohort."
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
def _rep_for(c: bytes) -> float:
|
|
1846
|
+
# Read reputation from the candidate's HOME world — per-namespace
|
|
1847
|
+
# by invariant (ROADMAP §0.4.1).
|
|
1848
|
+
#
|
|
1849
|
+
# v0.6 — multiply by per-(agent, topic) calibration adjustment.
|
|
1850
|
+
# Well-calibrated agents get up to 2.0× weight; overconfident
|
|
1851
|
+
# agents get down to 0.5×. Returns 1.0 (no-op) before any
|
|
1852
|
+
# oracle resolutions have populated the Brier tracker, so v0.5
|
|
1853
|
+
# behaviour is preserved on day one.
|
|
1854
|
+
home = candidate_worlds.get(c, self._world)
|
|
1855
|
+
assert home is not None # candidates only exist when a world is set
|
|
1856
|
+
base_score = home.reputation.snapshot(c, topic).score
|
|
1857
|
+
try:
|
|
1858
|
+
adj = home.reputation.calibration_adjustment(c, topic)
|
|
1859
|
+
except Exception:
|
|
1860
|
+
adj = 1.0
|
|
1861
|
+
return base_score * adj
|
|
1862
|
+
|
|
1863
|
+
chosen = select_panel(
|
|
1864
|
+
candidates,
|
|
1865
|
+
panel_size,
|
|
1866
|
+
strategy=route_strategy,
|
|
1867
|
+
posterior_estimate=lambda c: _default_posterior_for(_rep_for(c)),
|
|
1868
|
+
reputation=_rep_for,
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
# Each peer responds with its posterior; for sandbox we inject a
|
|
1872
|
+
# default responder (Beta scaled by reputation) unless one is provided.
|
|
1873
|
+
# v0.6+ responders may return ``(Uncertainty, StructuredResponse)`` —
|
|
1874
|
+
# the unpacker normalises both forms transparently.
|
|
1875
|
+
from hypermind.deliberation import run_deliberation
|
|
1876
|
+
|
|
1877
|
+
route_reason = route_strategy if isinstance(route_strategy, str) else "callable"
|
|
1878
|
+
|
|
1879
|
+
def _emit_responder_error(kid: bytes, exc: BaseException) -> None:
|
|
1880
|
+
try:
|
|
1881
|
+
self._recorder.emit(
|
|
1882
|
+
event_type="consult_responder_error",
|
|
1883
|
+
severity="warn",
|
|
1884
|
+
fields={
|
|
1885
|
+
"responder_kid": kid.hex(),
|
|
1886
|
+
"error_type": type(exc).__name__,
|
|
1887
|
+
"error_msg": str(exc)[:200],
|
|
1888
|
+
},
|
|
1889
|
+
namespace=self.namespace,
|
|
1890
|
+
agent_id=self.keypair.kid.hex(),
|
|
1891
|
+
)
|
|
1892
|
+
except Exception:
|
|
1893
|
+
pass # never let a recorder failure mask the consult
|
|
1894
|
+
|
|
1895
|
+
# P3-09: blind window — sleep before collecting responses so that
|
|
1896
|
+
# agents dispatched concurrently cannot condition on peers that finish
|
|
1897
|
+
# early. The sleep runs BEFORE response collection so the buffer
|
|
1898
|
+
# applies uniformly to all responders in this round.
|
|
1899
|
+
if blind_window_ms > 0:
|
|
1900
|
+
await asyncio.sleep(blind_window_ms / 1000.0)
|
|
1901
|
+
|
|
1902
|
+
try:
|
|
1903
|
+
deliberation = await run_deliberation(
|
|
1904
|
+
chosen=chosen,
|
|
1905
|
+
query=query,
|
|
1906
|
+
responder=responder,
|
|
1907
|
+
default_posterior_for=_default_posterior_for,
|
|
1908
|
+
rep_for=_rep_for,
|
|
1909
|
+
max_rounds=max(1, int(max_deliberation_rounds)),
|
|
1910
|
+
convergence_threshold=convergence_threshold,
|
|
1911
|
+
# Each round gets the full timeout — multi-round panels
|
|
1912
|
+
# naturally take longer, and we'd rather honour the
|
|
1913
|
+
# caller's per-round budget than divide it.
|
|
1914
|
+
timeout_per_round_s=timeout.total_seconds(),
|
|
1915
|
+
route_reason=route_reason,
|
|
1916
|
+
on_responder_error=_emit_responder_error,
|
|
1917
|
+
)
|
|
1918
|
+
except TimeoutError as e:
|
|
1919
|
+
raise HyperMindError(
|
|
1920
|
+
f"consult: timed out after {timeout.total_seconds()}s waiting "
|
|
1921
|
+
f"for {panel_size} panel response(s).\n"
|
|
1922
|
+
f" Hint: increase `timeout=` or check that responder agents are "
|
|
1923
|
+
f"healthy. Default in-process responders should reply instantly."
|
|
1924
|
+
) from e
|
|
1925
|
+
|
|
1926
|
+
panel: list[PanelMember] = list(deliberation.final_panel)
|
|
1927
|
+
|
|
1928
|
+
# P2-10: Annotate cross-namespace panel members with their home
|
|
1929
|
+
# namespace_id and a snapshot of their foreign reputation.
|
|
1930
|
+
# Same-namespace members keep namespace_id=None (no annotation).
|
|
1931
|
+
for member in panel:
|
|
1932
|
+
home_world = candidate_worlds.get(member.agent_id)
|
|
1933
|
+
if home_world is not None and home_world is not self._world:
|
|
1934
|
+
member.namespace_id = (
|
|
1935
|
+
home_world.namespace_id.hex()
|
|
1936
|
+
if isinstance(home_world.namespace_id, (bytes, bytearray))
|
|
1937
|
+
else str(home_world.namespace_id)
|
|
1938
|
+
)
|
|
1939
|
+
member.foreign_reputation_snapshot = _rep_for(member.agent_id)
|
|
1940
|
+
|
|
1941
|
+
structured_panel: list[Any] = [m.structured for m in panel if m.structured is not None]
|
|
1942
|
+
|
|
1943
|
+
if max_deliberation_rounds > 1:
|
|
1944
|
+
try:
|
|
1945
|
+
self._recorder.emit(
|
|
1946
|
+
event_type="consult_deliberation",
|
|
1947
|
+
severity="info",
|
|
1948
|
+
fields={
|
|
1949
|
+
"n_rounds": deliberation.n_rounds,
|
|
1950
|
+
"converged": deliberation.converged,
|
|
1951
|
+
"converged_reason": deliberation.converged_reason,
|
|
1952
|
+
"final_disagreement": deliberation.final_disagreement,
|
|
1953
|
+
},
|
|
1954
|
+
namespace=self.namespace,
|
|
1955
|
+
agent_id=self.keypair.kid.hex(),
|
|
1956
|
+
)
|
|
1957
|
+
except Exception:
|
|
1958
|
+
pass
|
|
1959
|
+
|
|
1960
|
+
if not panel:
|
|
1961
|
+
raise HyperMindError(
|
|
1962
|
+
"consult: every panel responder failed; cannot aggregate.",
|
|
1963
|
+
reason=BlockReason(
|
|
1964
|
+
rule_id="CONSULT-PANEL-INSUFFICIENT",
|
|
1965
|
+
spec_ref="spec/09-consult.md#panel-aggregation",
|
|
1966
|
+
hint=(
|
|
1967
|
+
"Ensure at least one panel member is available"
|
|
1968
|
+
" and above reputation threshold."
|
|
1969
|
+
),
|
|
1970
|
+
),
|
|
1971
|
+
)
|
|
1972
|
+
|
|
1973
|
+
# Final round's aggregate is canonical; aggregate_panel() is
|
|
1974
|
+
# deterministic so this is identical to recomputing.
|
|
1975
|
+
aggregated = deliberation.final_posterior
|
|
1976
|
+
disagreement = deliberation.final_disagreement
|
|
1977
|
+
|
|
1978
|
+
# Issue a CONSULT-shaped receipt sealing the result.
|
|
1979
|
+
self._hlc = self._hlc.tick()
|
|
1980
|
+
receipt = SignedStatement.sign(
|
|
1981
|
+
keypair=self.keypair,
|
|
1982
|
+
record_type="RECEIPT",
|
|
1983
|
+
namespace_id=self._world.namespace_id,
|
|
1984
|
+
hlc=self._hlc,
|
|
1985
|
+
payload=cbor2.dumps(
|
|
1986
|
+
{
|
|
1987
|
+
"query": query,
|
|
1988
|
+
"panel": [m.agent_id for m in panel],
|
|
1989
|
+
"aggregated": aggregated.to_cbor(),
|
|
1990
|
+
"disagreement": disagreement,
|
|
1991
|
+
},
|
|
1992
|
+
canonical=True,
|
|
1993
|
+
),
|
|
1994
|
+
receipt_uncertainty=aggregated,
|
|
1995
|
+
)
|
|
1996
|
+
self._world.ts.append_and_cosign(receipt.kid)
|
|
1997
|
+
self._world.sealed[receipt.kid] = receipt
|
|
1998
|
+
self._world.persist_claim(receipt)
|
|
1999
|
+
|
|
2000
|
+
composition_hash = blake3_32(b"".join(sorted(c for c in chosen))).hex()
|
|
2001
|
+
|
|
2002
|
+
# Build argument DAG: receipt elicits each member, plus revision
|
|
2003
|
+
# edges from any deliberation rounds (qualifies/rebuts/supports).
|
|
2004
|
+
elicitation_edges = [
|
|
2005
|
+
ArgumentEdge(
|
|
2006
|
+
src_kid=receipt.kid,
|
|
2007
|
+
dst_kid=m.agent_id,
|
|
2008
|
+
edge_type="elicits",
|
|
2009
|
+
)
|
|
2010
|
+
for m in panel
|
|
2011
|
+
]
|
|
2012
|
+
revision_edges = deliberation.all_revision_edges()
|
|
2013
|
+
return ConsultResult(
|
|
2014
|
+
query=query,
|
|
2015
|
+
posterior=aggregated,
|
|
2016
|
+
panel=panel,
|
|
2017
|
+
argument_dag=ArgumentDAG(
|
|
2018
|
+
edges=elicitation_edges + revision_edges,
|
|
2019
|
+
),
|
|
2020
|
+
disagreement=disagreement,
|
|
2021
|
+
calibration_truth_axis=CalibTruthSnapshot.from_metrics(
|
|
2022
|
+
calibration_brier=aggregated.brier(1)
|
|
2023
|
+
if aggregated.mean() > 0.5
|
|
2024
|
+
else aggregated.brier(0),
|
|
2025
|
+
truth_coverage_ratio=0.0, # no oracle yet in sandbox
|
|
2026
|
+
),
|
|
2027
|
+
receipt_kid=receipt.kid,
|
|
2028
|
+
panel_composition_hash=composition_hash,
|
|
2029
|
+
structured_panel=structured_panel,
|
|
2030
|
+
mixture=build_posterior_mixture(panel),
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
async def consult_stream(
|
|
2034
|
+
self,
|
|
2035
|
+
query: str,
|
|
2036
|
+
*,
|
|
2037
|
+
panel_size: int = 5,
|
|
2038
|
+
topic: str | None = None,
|
|
2039
|
+
blind_window_ms: int = 0,
|
|
2040
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
2041
|
+
"""Streaming variant of consult(). Yields partial results as panel members respond.
|
|
2042
|
+
|
|
2043
|
+
Each yielded item:
|
|
2044
|
+
{
|
|
2045
|
+
"member_kid": str, # hex agent id
|
|
2046
|
+
"posterior": {"mean": float | None},
|
|
2047
|
+
"reasoning": str | None,
|
|
2048
|
+
"namespace_id": str | None, # set for cross-namespace members
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
`blind_window_ms` inserts an artificial delay between yields so callers
|
|
2052
|
+
can simulate a "blind window" where members cannot see earlier answers.
|
|
2053
|
+
"""
|
|
2054
|
+
result = await self.consult(query, panel_size=panel_size, topic=topic)
|
|
2055
|
+
for member in result.panel:
|
|
2056
|
+
if blind_window_ms > 0:
|
|
2057
|
+
await asyncio.sleep(blind_window_ms / 1000.0)
|
|
2058
|
+
agent_id = member.agent_id
|
|
2059
|
+
kid_str = agent_id.hex() if isinstance(agent_id, (bytes, bytearray)) else str(agent_id)
|
|
2060
|
+
yield {
|
|
2061
|
+
"member_kid": kid_str,
|
|
2062
|
+
"posterior": {
|
|
2063
|
+
"mean": member.posterior.mean() if member.posterior is not None else None
|
|
2064
|
+
},
|
|
2065
|
+
"reasoning": getattr(member, "reasoning", None)
|
|
2066
|
+
or (member.structured.reasoning if member.structured is not None else None),
|
|
2067
|
+
"namespace_id": member.namespace_id,
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async def reputation_of(
|
|
2071
|
+
self,
|
|
2072
|
+
other: bytes,
|
|
2073
|
+
*,
|
|
2074
|
+
topic: str | None = None,
|
|
2075
|
+
) -> ReputationSnapshot:
|
|
2076
|
+
if not self._world:
|
|
2077
|
+
raise RuntimeError(
|
|
2078
|
+
"reputation_of() requires a world.\n"
|
|
2079
|
+
" Hint: build the agent via HyperMindAgent.sandbox() or "
|
|
2080
|
+
"HyperMindAgent.testbed(n=…)."
|
|
2081
|
+
)
|
|
2082
|
+
topic = topic or (self.rooms[0] if self.rooms else "default")
|
|
2083
|
+
return self._world.reputation.snapshot(other, topic)
|
|
2084
|
+
|
|
2085
|
+
async def record_panel_vote(
|
|
2086
|
+
self,
|
|
2087
|
+
claim_kid: Kid,
|
|
2088
|
+
voter_kid: bytes,
|
|
2089
|
+
vote: bool,
|
|
2090
|
+
*,
|
|
2091
|
+
topic: str | None = None,
|
|
2092
|
+
) -> None:
|
|
2093
|
+
"""Record that ``voter_kid`` voted ``vote`` on ``claim_kid``.
|
|
2094
|
+
|
|
2095
|
+
Used to enable ORACLE_RESOLUTION back-propagation
|
|
2096
|
+
(ROADMAP §0.3.2): when an oracle later resolves the same claim,
|
|
2097
|
+
voters who agreed with the oracle's decision earn a reputation
|
|
2098
|
+
success; those who disagreed earn a failure. Per-topic.
|
|
2099
|
+
"""
|
|
2100
|
+
if self._world is None:
|
|
2101
|
+
raise RuntimeError("record_panel_vote() requires a world")
|
|
2102
|
+
topic = topic or (self.rooms[0] if self.rooms else "default")
|
|
2103
|
+
self._world.panel_votes.setdefault(claim_kid, []).append(
|
|
2104
|
+
(bytes(voter_kid), bool(vote), topic)
|
|
2105
|
+
)
|
|
2106
|
+
|
|
2107
|
+
async def oracle_resolve(
|
|
2108
|
+
self,
|
|
2109
|
+
claim_kid: Kid,
|
|
2110
|
+
*,
|
|
2111
|
+
outcome: bool,
|
|
2112
|
+
evidence_cid: bytes | None = None,
|
|
2113
|
+
backprop: bool = True,
|
|
2114
|
+
) -> Kid:
|
|
2115
|
+
"""Oracle role only: resolve a claim with ground truth.
|
|
2116
|
+
|
|
2117
|
+
Closes the calibration loop — the publisher's reputation is
|
|
2118
|
+
updated based on the agreement between their claim and the
|
|
2119
|
+
outcome.
|
|
2120
|
+
"""
|
|
2121
|
+
if not isinstance(outcome, bool):
|
|
2122
|
+
raise TypeError(
|
|
2123
|
+
f"oracle_resolve outcome must be bool, got {type(outcome).__name__!r}: {outcome!r}"
|
|
2124
|
+
)
|
|
2125
|
+
self._require_cap("oracle_resolve")
|
|
2126
|
+
if self.role != "oracle":
|
|
2127
|
+
raise AuthError(
|
|
2128
|
+
"only oracle agents may oracle_resolve",
|
|
2129
|
+
failed_caveat="role",
|
|
2130
|
+
)
|
|
2131
|
+
if self._world is None:
|
|
2132
|
+
raise RuntimeError(
|
|
2133
|
+
"oracle_resolve() requires a world.\n"
|
|
2134
|
+
" Hint: build the oracle via HyperMindAgent.testbed(…) and set "
|
|
2135
|
+
"`agent.role = 'oracle'` post-construction."
|
|
2136
|
+
)
|
|
2137
|
+
target = self._world.sealed.get(claim_kid)
|
|
2138
|
+
if target is None:
|
|
2139
|
+
raise HyperMindError(
|
|
2140
|
+
f"oracle_resolve: claim {claim_kid.hex()[:12]}… not found in "
|
|
2141
|
+
f"this namespace ({len(self._world.sealed)} sealed claim(s)).\n"
|
|
2142
|
+
f" Hint: oracles can only resolve claims that exist in their "
|
|
2143
|
+
f"own world. Check the namespace matches the claim's origin."
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
# Compute outcome-adjustment for the publisher's reputation.
|
|
2147
|
+
decoded = cbor2.loads(target.payload)
|
|
2148
|
+
topic = decoded.get("topic", "default")
|
|
2149
|
+
|
|
2150
|
+
publisher = target.issuer_kid
|
|
2151
|
+
# Record outcome for the publisher; weight outcomes 1.0.
|
|
2152
|
+
self._world.reputation.record_outcome(publisher, topic, correct=outcome)
|
|
2153
|
+
|
|
2154
|
+
# v0.6 — closed-loop calibration. If the publisher embedded their
|
|
2155
|
+
# predicted mean (publish_claim does this by default), record a
|
|
2156
|
+
# Brier score on the publisher's per-(agent, topic) tracker so
|
|
2157
|
+
# future consult dispatches can shrink/boost their effective_n.
|
|
2158
|
+
predicted = decoded.get("predicted_mean")
|
|
2159
|
+
if predicted is not None:
|
|
2160
|
+
try:
|
|
2161
|
+
self._world.reputation.brier_tracker.record(
|
|
2162
|
+
agent_kid=publisher,
|
|
2163
|
+
topic=topic,
|
|
2164
|
+
predicted_confidence=float(predicted),
|
|
2165
|
+
outcome=1.0 if outcome else 0.0,
|
|
2166
|
+
)
|
|
2167
|
+
except Exception:
|
|
2168
|
+
pass
|
|
2169
|
+
|
|
2170
|
+
# ROADMAP §0.3.2: back-propagate to panel voters who weighed in
|
|
2171
|
+
# on this claim. Voters who agreed with the oracle earn one
|
|
2172
|
+
# success outcome on the claim's topic; those who disagreed earn
|
|
2173
|
+
# one failure outcome. Bounded by per-claim, per-voter de-dup so
|
|
2174
|
+
# repeated oracle calls don't double-count.
|
|
2175
|
+
if backprop:
|
|
2176
|
+
votes = self._world.panel_votes.get(claim_kid, [])
|
|
2177
|
+
already = self._world.panel_votes.get(b"_resolved:" + claim_kid, [])
|
|
2178
|
+
already_set = {(v[0], v[2]) for v in already}
|
|
2179
|
+
newly_credited: list[tuple[bytes, bool, str]] = []
|
|
2180
|
+
for voter, vote, vote_topic in votes:
|
|
2181
|
+
key = (voter, vote_topic)
|
|
2182
|
+
if key in already_set:
|
|
2183
|
+
continue
|
|
2184
|
+
self._world.reputation.record_outcome(voter, vote_topic, correct=(vote == outcome))
|
|
2185
|
+
newly_credited.append((voter, vote, vote_topic))
|
|
2186
|
+
already_set.add(key)
|
|
2187
|
+
if newly_credited:
|
|
2188
|
+
self._world.panel_votes.setdefault(b"_resolved:" + claim_kid, []).extend(
|
|
2189
|
+
newly_credited
|
|
2190
|
+
)
|
|
2191
|
+
|
|
2192
|
+
# Emit an ORACLE_RESOLUTION receipt.
|
|
2193
|
+
self._hlc = self._hlc.tick()
|
|
2194
|
+
statement = SignedStatement.sign(
|
|
2195
|
+
keypair=self.keypair,
|
|
2196
|
+
record_type="RECEIPT",
|
|
2197
|
+
namespace_id=self._world.namespace_id,
|
|
2198
|
+
hlc=self._hlc,
|
|
2199
|
+
payload=cbor2.dumps(
|
|
2200
|
+
{
|
|
2201
|
+
"kind": "oracle_resolution",
|
|
2202
|
+
"target_kid": claim_kid,
|
|
2203
|
+
"outcome": outcome,
|
|
2204
|
+
"evidence_cid": evidence_cid,
|
|
2205
|
+
},
|
|
2206
|
+
canonical=True,
|
|
2207
|
+
),
|
|
2208
|
+
receipt_uncertainty=Uncertainty.beta(
|
|
2209
|
+
10 if outcome else 1,
|
|
2210
|
+
1 if outcome else 10,
|
|
2211
|
+
),
|
|
2212
|
+
)
|
|
2213
|
+
self._world.ts.append_and_cosign(statement.kid)
|
|
2214
|
+
self._world.sealed[statement.kid] = statement
|
|
2215
|
+
self._world.persist_claim(statement)
|
|
2216
|
+
return statement.kid
|
|
2217
|
+
|
|
2218
|
+
async def audit_export(self, *, since_hlc: str | None = None) -> AsyncIterator[bytes]:
|
|
2219
|
+
"""Yield signed JSONL audit records since the given HLC, in a
|
|
2220
|
+
signed hash-chain so a verifier can detect truncation/reorder.
|
|
2221
|
+
|
|
2222
|
+
Closes self-heal Cycle-1 Findings #5 (chain) and #8 (HLC ordering):
|
|
2223
|
+
- Each record is signed over `(prev_hash || seq || session_id || body)`
|
|
2224
|
+
so dropping or reordering breaks the chain.
|
|
2225
|
+
- `since_hlc` filtering is performed by parsing both endpoints'
|
|
2226
|
+
`(logical_ms, counter)` so lexicographic string comparison
|
|
2227
|
+
doesn't misorder integer-bearing strings.
|
|
2228
|
+
- A final "end-of-audit" record signs the last hash so silent
|
|
2229
|
+
truncation at the tail is detectable.
|
|
2230
|
+
- bytes-valued telemetry fields are hex-encoded for JSON.
|
|
2231
|
+
"""
|
|
2232
|
+
import hashlib as _h
|
|
2233
|
+
import secrets as _s
|
|
2234
|
+
|
|
2235
|
+
recorder = self._recorder
|
|
2236
|
+
# Snapshot under the recorder's lock so concurrent emit() during
|
|
2237
|
+
# iteration cannot reorder/skip entries (Finding #5 from Cycle-1
|
|
2238
|
+
# data review). The internal lock is exposed deliberately.
|
|
2239
|
+
with recorder._lock:
|
|
2240
|
+
snapshot = list(recorder.records)
|
|
2241
|
+
# WS-G v0.7: when the agent uses a per-namespace scoped recorder,
|
|
2242
|
+
# also pull the process-global recorder's view so direct callers
|
|
2243
|
+
# of ``get_recorder().emit(...)`` (operator hooks, tests) still
|
|
2244
|
+
# surface in the audit export. Scoped recorders fan-out to the
|
|
2245
|
+
# global, so the same ``LogRecord`` instance appears in both
|
|
2246
|
+
# buffers — id() dedup is exact.
|
|
2247
|
+
_global = get_recorder()
|
|
2248
|
+
if _global is not recorder:
|
|
2249
|
+
with _global._lock:
|
|
2250
|
+
seen_ids = {id(r) for r in snapshot}
|
|
2251
|
+
for r in _global.records:
|
|
2252
|
+
if id(r) not in seen_ids:
|
|
2253
|
+
snapshot.append(r)
|
|
2254
|
+
snapshot.sort(key=lambda r: r.ts_wall_ms)
|
|
2255
|
+
|
|
2256
|
+
since_l, since_c = _parse_hlc_for_filter(since_hlc)
|
|
2257
|
+
session_id = _s.token_bytes(16).hex()
|
|
2258
|
+
prev_hash = b"\x00" * 32
|
|
2259
|
+
seq = 0
|
|
2260
|
+
|
|
2261
|
+
def _emit(body_dict: dict[str, Any]) -> bytes:
|
|
2262
|
+
nonlocal prev_hash, seq
|
|
2263
|
+
body = json.dumps(_jsonable(body_dict), sort_keys=True).encode()
|
|
2264
|
+
preimage = prev_hash + seq.to_bytes(8, "big") + session_id.encode() + body
|
|
2265
|
+
sig = self.keypair.sign(preimage)
|
|
2266
|
+
prev_hash = _h.sha256(preimage + sig).digest()
|
|
2267
|
+
line = (
|
|
2268
|
+
body
|
|
2269
|
+
+ b"\t"
|
|
2270
|
+
+ sig.hex().encode()
|
|
2271
|
+
+ b"\t"
|
|
2272
|
+
+ str(seq).encode()
|
|
2273
|
+
+ b"\t"
|
|
2274
|
+
+ session_id.encode()
|
|
2275
|
+
+ b"\n"
|
|
2276
|
+
)
|
|
2277
|
+
seq += 1
|
|
2278
|
+
return line
|
|
2279
|
+
|
|
2280
|
+
for record in snapshot:
|
|
2281
|
+
rec_l, rec_c = _parse_hlc_for_filter(record.ts_hlc)
|
|
2282
|
+
if since_hlc and (rec_l, rec_c) < (since_l, since_c):
|
|
2283
|
+
continue
|
|
2284
|
+
yield _emit(
|
|
2285
|
+
{
|
|
2286
|
+
"ts_wall_ms": record.ts_wall_ms,
|
|
2287
|
+
"ts_hlc": record.ts_hlc,
|
|
2288
|
+
"namespace": record.namespace,
|
|
2289
|
+
"agent_id": record.agent_id,
|
|
2290
|
+
"event_type": record.event_type,
|
|
2291
|
+
"severity": record.severity,
|
|
2292
|
+
**record.fields,
|
|
2293
|
+
}
|
|
2294
|
+
)
|
|
2295
|
+
|
|
2296
|
+
# End-of-audit terminator binds the final hash; verifiers detect
|
|
2297
|
+
# truncation when this is missing.
|
|
2298
|
+
yield _emit({"event_type": "audit_end", "total": seq})
|
|
2299
|
+
|
|
2300
|
+
# ---- working memory ------------------------------------------------
|
|
2301
|
+
|
|
2302
|
+
def working_memory(self) -> dict[str, Any]:
|
|
2303
|
+
"""Mutable scratch space; not gossiped until checkpoint()."""
|
|
2304
|
+
return self._working_memory
|
|
2305
|
+
|
|
2306
|
+
async def checkpoint(self, *, label: str | None = None) -> Kid:
|
|
2307
|
+
"""Seal the current working memory as an immutable kid.
|
|
2308
|
+
|
|
2309
|
+
Snapshots the working-memory dict synchronously before the
|
|
2310
|
+
async publish; concurrent mutations after the snapshot do NOT
|
|
2311
|
+
affect the sealed payload (closes self-heal Cycle-1 Finding #6
|
|
2312
|
+
from data review).
|
|
2313
|
+
"""
|
|
2314
|
+
self._require_cap("checkpoint")
|
|
2315
|
+
snapshot = _deep_snapshot(self._working_memory)
|
|
2316
|
+
return await self.publish_claim(
|
|
2317
|
+
{"working_memory": snapshot, "label": label},
|
|
2318
|
+
claim_type="custom",
|
|
2319
|
+
uncertainty=Uncertainty.beta(1, 1),
|
|
2320
|
+
)
|
|
2321
|
+
|
|
2322
|
+
# ---- AgentCard publication (spec §3.2 / P3-01) ---------------------
|
|
2323
|
+
|
|
2324
|
+
async def publish_card(self, card: AgentCard | None = None) -> Kid:
|
|
2325
|
+
"""Publish this agent's self-description as a sealed claim.
|
|
2326
|
+
|
|
2327
|
+
If ``card`` is ``None`` an :class:`~hypermind.agent_card.AgentCard`
|
|
2328
|
+
is auto-built from the agent's current attributes: ``keypair.kid``,
|
|
2329
|
+
``rooms`` (used as ``topics``), ``role``, and the installed SDK
|
|
2330
|
+
version.
|
|
2331
|
+
|
|
2332
|
+
The card rides in a standard SEAL envelope as ``claim_type="agent_card"``
|
|
2333
|
+
so peers can discover it through the normal transparency log.
|
|
2334
|
+
|
|
2335
|
+
Returns the 32-byte statement kid so callers can cite or audit
|
|
2336
|
+
the published card.
|
|
2337
|
+
"""
|
|
2338
|
+
if card is None:
|
|
2339
|
+
from hypermind import __version__ as _sdk_version
|
|
2340
|
+
|
|
2341
|
+
card = AgentCard(
|
|
2342
|
+
agent_kid=self.keypair.kid,
|
|
2343
|
+
display_name="",
|
|
2344
|
+
topics=list(self.rooms),
|
|
2345
|
+
roles=[self.role],
|
|
2346
|
+
capabilities=[],
|
|
2347
|
+
endpoint="",
|
|
2348
|
+
sdk_version=_sdk_version,
|
|
2349
|
+
)
|
|
2350
|
+
return await self.publish_claim(
|
|
2351
|
+
card.to_payload(),
|
|
2352
|
+
claim_type="agent_card",
|
|
2353
|
+
uncertainty=Uncertainty.beta(1, 1),
|
|
2354
|
+
)
|
|
2355
|
+
|
|
2356
|
+
# ---- DX surface (spec/08 — MUST-expose) ----------------------------
|
|
2357
|
+
|
|
2358
|
+
async def introspect(self) -> dict[str, Any]:
|
|
2359
|
+
"""Full capability snapshot — spec/08-onboarding-contract.md §8.A."""
|
|
2360
|
+
return {
|
|
2361
|
+
"version": "0.1.0a0",
|
|
2362
|
+
"wire_version": 0,
|
|
2363
|
+
"agent_id": self.keypair.kid.hex(),
|
|
2364
|
+
"did_key": self.keypair.did_key,
|
|
2365
|
+
"namespace": self.namespace,
|
|
2366
|
+
"rooms": self.rooms,
|
|
2367
|
+
"role": self.role,
|
|
2368
|
+
"sandbox": self.is_sandbox,
|
|
2369
|
+
"ramp": {
|
|
2370
|
+
"started_at_ms": self._ramp_start_ms,
|
|
2371
|
+
"paused": self._ramp_paused,
|
|
2372
|
+
"paused_days": self._ramp_paused_days,
|
|
2373
|
+
"observed_outcomes": self._observed_outcomes,
|
|
2374
|
+
"complete": self._observed_outcomes >= 200,
|
|
2375
|
+
},
|
|
2376
|
+
"capabilities": {
|
|
2377
|
+
"publish_claim": self._observed_outcomes >= 200,
|
|
2378
|
+
"dispute": self._observed_outcomes >= 200,
|
|
2379
|
+
"consult": True,
|
|
2380
|
+
"oracle_resolve": self.role == "oracle",
|
|
2381
|
+
},
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
async def why_blocked(self, action: str) -> BlockReason:
|
|
2385
|
+
"""Explain why an action is gated. Carries `rule_id + spec_ref`.
|
|
2386
|
+
|
|
2387
|
+
TAL-ONB-TRANSPARENT invariant: every refusal MUST carry these.
|
|
2388
|
+
"""
|
|
2389
|
+
if action in ("publish_claim", "dispute", "synthesize"):
|
|
2390
|
+
if self._ramp_paused:
|
|
2391
|
+
return BlockReason(
|
|
2392
|
+
rule_id="RAMP-PAUSED",
|
|
2393
|
+
spec_ref="spec/07-trust-access-layer.md §RAMP_PAUSE",
|
|
2394
|
+
human_explanation=(
|
|
2395
|
+
"namespace ramp is paused; publish capabilities are gated "
|
|
2396
|
+
"until RAMP_RESUME is signed by ≥3 founding cohort"
|
|
2397
|
+
),
|
|
2398
|
+
current_value="paused",
|
|
2399
|
+
required_value="active",
|
|
2400
|
+
)
|
|
2401
|
+
if self._observed_outcomes < 200:
|
|
2402
|
+
return BlockReason(
|
|
2403
|
+
rule_id="ADMIT-RAMP-INCOMPLETE",
|
|
2404
|
+
spec_ref="spec/08-onboarding-contract.md §8.B",
|
|
2405
|
+
human_explanation=(
|
|
2406
|
+
"agent has not completed the 30-day reputation ramp; "
|
|
2407
|
+
"200 observed outcomes required"
|
|
2408
|
+
),
|
|
2409
|
+
current_value=self._observed_outcomes,
|
|
2410
|
+
required_value=200,
|
|
2411
|
+
retry_after_seconds=86400,
|
|
2412
|
+
)
|
|
2413
|
+
if action == "oracle_resolve" and self.role != "oracle":
|
|
2414
|
+
return BlockReason(
|
|
2415
|
+
rule_id="ORACLE-ROLE-REQUIRED",
|
|
2416
|
+
spec_ref="spec/04-knowledge-model.md §ORACLE_RESOLUTION",
|
|
2417
|
+
human_explanation="only agents with role=oracle may oracle_resolve",
|
|
2418
|
+
current_value=self.role,
|
|
2419
|
+
required_value="oracle",
|
|
2420
|
+
)
|
|
2421
|
+
return BlockReason(
|
|
2422
|
+
rule_id="UNBLOCKED",
|
|
2423
|
+
spec_ref="spec/08-onboarding-contract.md §8.C",
|
|
2424
|
+
human_explanation=f"action {action!r} is not currently blocked",
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
async def discover_mentors(self, room: str) -> list[bytes]:
|
|
2428
|
+
"""Return ranked mentor candidates for `room` (high reputation, active)."""
|
|
2429
|
+
if not self._world:
|
|
2430
|
+
return []
|
|
2431
|
+
members = self._world.rooms.get(room, {}).get("members", set())
|
|
2432
|
+
rep = self._world.reputation
|
|
2433
|
+
ranked = sorted(
|
|
2434
|
+
(m for m in members if m != self.keypair.kid),
|
|
2435
|
+
key=lambda m: rep.snapshot(m, room).score,
|
|
2436
|
+
reverse=True,
|
|
2437
|
+
)
|
|
2438
|
+
# If a TAL Room exists for this name, filter to its members so
|
|
2439
|
+
# the reputation gate is enforced. Fall back to the legacy
|
|
2440
|
+
# `world.rooms` view when no TAL Room has been created.
|
|
2441
|
+
tal = getattr(self._world, "tal", None)
|
|
2442
|
+
if tal is not None and tal.get_room(room) is not None:
|
|
2443
|
+
ranked = [m for m in ranked if tal.trust_query(m, room).in_room]
|
|
2444
|
+
return ranked
|
|
2445
|
+
|
|
2446
|
+
async def request_vouch(self, mentor: bytes, room: str) -> VouchRequest:
|
|
2447
|
+
"""Issue an out-of-band vouch request. v0.1 sandbox auto-grants."""
|
|
2448
|
+
self._require_cap("request_vouch")
|
|
2449
|
+
if self._world:
|
|
2450
|
+
self._world.reputation.vouch(mentor, self.keypair.kid, room, strength=2.0)
|
|
2451
|
+
tal = getattr(self._world, "tal", None)
|
|
2452
|
+
if tal is not None and tal.get_room(room) is not None:
|
|
2453
|
+
tal.vouch(
|
|
2454
|
+
mentor,
|
|
2455
|
+
self.keypair.kid,
|
|
2456
|
+
room_id=room,
|
|
2457
|
+
topic=room,
|
|
2458
|
+
now_hlc_ms=self._hlc.logical_ms,
|
|
2459
|
+
)
|
|
2460
|
+
return VouchRequest(requester=self.keypair.kid, mentor=mentor, room=room)
|
|
2461
|
+
|
|
2462
|
+
# ---- sandbox helpers ------------------------------------------------
|
|
2463
|
+
|
|
2464
|
+
def advance_time(self, delta: timedelta) -> None:
|
|
2465
|
+
"""Decouple wall-clock from HLC for ramp/dispute TTL tests."""
|
|
2466
|
+
self._hlc = HLC(
|
|
2467
|
+
logical_ms=self._hlc.logical_ms + int(delta.total_seconds() * 1000),
|
|
2468
|
+
counter=0,
|
|
2469
|
+
node_id=self._hlc.node_id,
|
|
2470
|
+
)
|
|
2471
|
+
self._ramp_start_ms -= int(delta.total_seconds() * 1000)
|
|
2472
|
+
|
|
2473
|
+
def pause_ramp(self) -> None:
|
|
2474
|
+
self._ramp_paused = True
|
|
2475
|
+
|
|
2476
|
+
def resume_ramp(self) -> None:
|
|
2477
|
+
self._ramp_paused = False
|
|
2478
|
+
|
|
2479
|
+
async def next_dispute(self, target_kid: Kid, *, timeout: float = 5.0) -> Dispute:
|
|
2480
|
+
"""Test helper: wait for the next dispute against `target_kid`."""
|
|
2481
|
+
if not self._world:
|
|
2482
|
+
raise RuntimeError(
|
|
2483
|
+
"next_dispute() requires a world.\n"
|
|
2484
|
+
" Hint: build the agent via HyperMindAgent.sandbox() or "
|
|
2485
|
+
"HyperMindAgent.testbed(n=…)."
|
|
2486
|
+
)
|
|
2487
|
+
deadline = time.time() + timeout
|
|
2488
|
+
while True:
|
|
2489
|
+
for d in self._world.disputes.values():
|
|
2490
|
+
if d.target_kid == target_kid:
|
|
2491
|
+
return d
|
|
2492
|
+
remaining = deadline - time.time()
|
|
2493
|
+
if remaining <= 0:
|
|
2494
|
+
break
|
|
2495
|
+
self._world.dispute_event.clear()
|
|
2496
|
+
# Re-check after clearing to avoid a race where the dispute
|
|
2497
|
+
# arrived between the scan above and the clear here.
|
|
2498
|
+
for d in self._world.disputes.values():
|
|
2499
|
+
if d.target_kid == target_kid:
|
|
2500
|
+
return d
|
|
2501
|
+
try:
|
|
2502
|
+
await asyncio.wait_for(self._world.dispute_event.wait(), timeout=remaining)
|
|
2503
|
+
except TimeoutError:
|
|
2504
|
+
break
|
|
2505
|
+
raise TimeoutError(f"no dispute against {target_kid.hex()[:12]}... within {timeout}s")
|
|
2506
|
+
|
|
2507
|
+
# ---- task delegation (spec §4.4) -----------------------------------
|
|
2508
|
+
|
|
2509
|
+
async def submit_task(
|
|
2510
|
+
self,
|
|
2511
|
+
target_kid: bytes,
|
|
2512
|
+
goal: str,
|
|
2513
|
+
payload: dict | None = None,
|
|
2514
|
+
*,
|
|
2515
|
+
topic: str = "tasks",
|
|
2516
|
+
timeout: float = 30.0,
|
|
2517
|
+
) -> TaskHandle: # noqa: F821
|
|
2518
|
+
"""Delegate a task to another agent. Returns a TaskHandle to await the result.
|
|
2519
|
+
|
|
2520
|
+
The task is gossiped as a TASK_SUBMIT claim on `topic`. The assignee
|
|
2521
|
+
should call `complete_task(task_id, result)` when done; this agent
|
|
2522
|
+
subscribes for TASK_COMPLETE claims on the same topic and resolves the
|
|
2523
|
+
handle's future automatically via `on_task_complete`.
|
|
2524
|
+
"""
|
|
2525
|
+
import uuid
|
|
2526
|
+
|
|
2527
|
+
from hypermind.tasks import TaskHandle
|
|
2528
|
+
|
|
2529
|
+
task_id = uuid.uuid4().hex
|
|
2530
|
+
# Ensure the task topic is accessible (join it lazily so callers don't
|
|
2531
|
+
# need to pre-join "tasks").
|
|
2532
|
+
if topic not in self.rooms:
|
|
2533
|
+
self.rooms.append(topic)
|
|
2534
|
+
if self._world is not None:
|
|
2535
|
+
self._world.rooms.setdefault(topic, {"min_rep": 0.0, "members": set()})[
|
|
2536
|
+
"members"
|
|
2537
|
+
].add(self.keypair.kid)
|
|
2538
|
+
|
|
2539
|
+
handle = TaskHandle(
|
|
2540
|
+
task_id=task_id,
|
|
2541
|
+
submitter_kid=self.keypair.kid,
|
|
2542
|
+
assignee_kid=target_kid,
|
|
2543
|
+
topic=topic,
|
|
2544
|
+
)
|
|
2545
|
+
|
|
2546
|
+
if not hasattr(self, "_pending_tasks"):
|
|
2547
|
+
self._pending_tasks: dict[str, TaskHandle] = {}
|
|
2548
|
+
self._pending_tasks[task_id] = handle
|
|
2549
|
+
|
|
2550
|
+
await self.publish_claim(
|
|
2551
|
+
{
|
|
2552
|
+
"task_id": task_id,
|
|
2553
|
+
"goal": goal,
|
|
2554
|
+
"payload": payload or {},
|
|
2555
|
+
"assignee": target_kid.hex(),
|
|
2556
|
+
},
|
|
2557
|
+
claim_type="task_submit",
|
|
2558
|
+
uncertainty=Uncertainty.beta(1, 1),
|
|
2559
|
+
topic=topic,
|
|
2560
|
+
)
|
|
2561
|
+
return handle
|
|
2562
|
+
|
|
2563
|
+
async def complete_task(
|
|
2564
|
+
self,
|
|
2565
|
+
task_id: str,
|
|
2566
|
+
result: Any,
|
|
2567
|
+
*,
|
|
2568
|
+
topic: str = "tasks",
|
|
2569
|
+
) -> None:
|
|
2570
|
+
"""Mark a task as complete. Called by the assignee agent.
|
|
2571
|
+
|
|
2572
|
+
Publishes a TASK_COMPLETE claim on `topic`. The submitter's
|
|
2573
|
+
`on_task_complete` subscription will resolve the TaskHandle future.
|
|
2574
|
+
"""
|
|
2575
|
+
if topic not in self.rooms:
|
|
2576
|
+
self.rooms.append(topic)
|
|
2577
|
+
if self._world is not None:
|
|
2578
|
+
self._world.rooms.setdefault(topic, {"min_rep": 0.0, "members": set()})[
|
|
2579
|
+
"members"
|
|
2580
|
+
].add(self.keypair.kid)
|
|
2581
|
+
|
|
2582
|
+
await self.publish_claim(
|
|
2583
|
+
{"task_id": task_id, "result": result, "status": "complete"},
|
|
2584
|
+
claim_type="task_complete",
|
|
2585
|
+
uncertainty=Uncertainty.beta(9, 1),
|
|
2586
|
+
topic=topic,
|
|
2587
|
+
)
|
|
2588
|
+
|
|
2589
|
+
async def on_task_complete(
|
|
2590
|
+
self,
|
|
2591
|
+
*,
|
|
2592
|
+
topic: str = "tasks",
|
|
2593
|
+
) -> Subscription:
|
|
2594
|
+
"""Subscribe to TASK_COMPLETE claims on `topic` and resolve pending TaskHandles.
|
|
2595
|
+
|
|
2596
|
+
Returns the Subscription so the caller can unsubscribe when done.
|
|
2597
|
+
Call this once after `submit_task` to wire up the result delivery loop.
|
|
2598
|
+
"""
|
|
2599
|
+
from hypermind.tasks import TaskHandle # noqa: F401 (used in type comment)
|
|
2600
|
+
|
|
2601
|
+
agent_self = self
|
|
2602
|
+
|
|
2603
|
+
async def _on_claim(event: ClaimEvent) -> None:
|
|
2604
|
+
try:
|
|
2605
|
+
decoded = cbor2.loads(event.statement.payload)
|
|
2606
|
+
except Exception:
|
|
2607
|
+
return
|
|
2608
|
+
if decoded.get("claim_type") != "task_complete":
|
|
2609
|
+
return
|
|
2610
|
+
# publish_claim wraps the caller's dict under "payload"; unwrap it.
|
|
2611
|
+
inner = decoded.get("payload", {})
|
|
2612
|
+
task_id = inner.get("task_id")
|
|
2613
|
+
result_val = inner.get("result")
|
|
2614
|
+
if task_id is None:
|
|
2615
|
+
return
|
|
2616
|
+
pending = getattr(agent_self, "_pending_tasks", {})
|
|
2617
|
+
handle = pending.get(task_id)
|
|
2618
|
+
if handle is not None:
|
|
2619
|
+
handle._resolve(result_val)
|
|
2620
|
+
|
|
2621
|
+
return await self.subscribe(topic, _on_claim)
|
|
2622
|
+
|
|
2623
|
+
# ---- internal helpers ----------------------------------------------
|
|
2624
|
+
|
|
2625
|
+
def _require_open(self) -> None:
|
|
2626
|
+
if self._closed:
|
|
2627
|
+
raise RuntimeError(
|
|
2628
|
+
"agent is closed.\n"
|
|
2629
|
+
" Hint: this agent has already been closed (you exited the "
|
|
2630
|
+
"`async with` block or called .close() explicitly). Build a "
|
|
2631
|
+
"fresh agent to continue."
|
|
2632
|
+
)
|
|
2633
|
+
|
|
2634
|
+
def _enforce_ramp(self) -> None:
|
|
2635
|
+
if self._ramp_paused:
|
|
2636
|
+
raise RampError(
|
|
2637
|
+
"namespace ramp is paused; publish capabilities are gated until "
|
|
2638
|
+
"RAMP_RESUME is signed by ≥3 founding cohort.\n"
|
|
2639
|
+
" Hint: in tests, call agent.resume_ramp() to clear the pause.",
|
|
2640
|
+
state="paused",
|
|
2641
|
+
)
|
|
2642
|
+
if self._observed_outcomes < 200:
|
|
2643
|
+
raise RampError(
|
|
2644
|
+
f"30-day ramp incomplete ({self._observed_outcomes}/200 outcomes).\n"
|
|
2645
|
+
f" Hint: in tests, build the agent with "
|
|
2646
|
+
f"`HyperMindAgent.testbed(..., ramp_complete=True)` (default), or "
|
|
2647
|
+
f"call `await agent.why_blocked('publish_claim')` for the full "
|
|
2648
|
+
f"BlockReason with rule_id and spec_ref.",
|
|
2649
|
+
state="pending",
|
|
2650
|
+
)
|
|
2651
|
+
|
|
2652
|
+
def _require_cap(
|
|
2653
|
+
self,
|
|
2654
|
+
action: str,
|
|
2655
|
+
*,
|
|
2656
|
+
topic: str | None = None,
|
|
2657
|
+
tool: str | None = None,
|
|
2658
|
+
) -> None:
|
|
2659
|
+
"""Enforce cap_token constraints for the given action (P1-01).
|
|
2660
|
+
|
|
2661
|
+
- Returns immediately when no cap_token is set (unconstrained agent).
|
|
2662
|
+
- Calls ``cap_token.authorize(...)`` which raises ``AuthError`` on
|
|
2663
|
+
caveat failure — topic mismatch, expiry, tool mismatch, etc.
|
|
2664
|
+
- After scope checks, enforces any ``rate`` caveat via ``TokenBucket``
|
|
2665
|
+
(P1-03 / HM-RATE-CAP-001). Raises ``RateLimitError`` if exhausted.
|
|
2666
|
+
"""
|
|
2667
|
+
if self.cap_token is None:
|
|
2668
|
+
return
|
|
2669
|
+
|
|
2670
|
+
self.cap_token.authorize(
|
|
2671
|
+
action=action,
|
|
2672
|
+
topic=topic or "",
|
|
2673
|
+
tool=tool or "",
|
|
2674
|
+
revocation_store=self._world.revocation_store if self._world else None,
|
|
2675
|
+
hlc=(self._hlc.logical_ms, self._hlc.counter),
|
|
2676
|
+
)
|
|
2677
|
+
|
|
2678
|
+
# P1-03: enforce rate caveat if present on this token.
|
|
2679
|
+
|
|
2680
|
+
rate_spec: str | None = None
|
|
2681
|
+
for cav in self.cap_token.caveats:
|
|
2682
|
+
if cav.kind == "rate":
|
|
2683
|
+
rate_spec = str(cav.value)
|
|
2684
|
+
break # first rate caveat wins (most restrictive after attenuation)
|
|
2685
|
+
|
|
2686
|
+
if rate_spec is not None:
|
|
2687
|
+
# Key: root_kid (stable across attenuations) + action so that different
|
|
2688
|
+
# actions share per-action buckets, not one global bucket.
|
|
2689
|
+
bucket_key = f"{self.cap_token.root_issuer.hex()}:{action}"
|
|
2690
|
+
if bucket_key not in self._rate_buckets:
|
|
2691
|
+
self._rate_buckets[bucket_key] = TokenBucket(rate_spec)
|
|
2692
|
+
bucket: TokenBucket = self._rate_buckets[bucket_key]
|
|
2693
|
+
if not bucket.consume():
|
|
2694
|
+
raise RateLimitError(
|
|
2695
|
+
f"rate caveat exhausted for action={action!r} "
|
|
2696
|
+
f"(limit={rate_spec}); try again later",
|
|
2697
|
+
telemetry={"rule_id": "HM-RATE-CAP-001", "action": action, "limit": rate_spec},
|
|
2698
|
+
)
|
|
2699
|
+
|
|
2700
|
+
def _select_confirmers(self, topic: str, *, exclude: bytes) -> list[bytes]:
|
|
2701
|
+
"""Pick ≥3 confirmer keys (quorum size for sandbox)."""
|
|
2702
|
+
if not self._world:
|
|
2703
|
+
# Single-process unit-test mode: synthesize ephemeral kids.
|
|
2704
|
+
return [secrets.token_bytes(32) for _ in range(3)]
|
|
2705
|
+
peers = [k for k in self._world.agents.keys() if k != exclude]
|
|
2706
|
+
if len(peers) >= 3:
|
|
2707
|
+
return peers[:3]
|
|
2708
|
+
# Pad with witness keys to satisfy ≥3 confirmer_set requirement.
|
|
2709
|
+
peers.extend([w.kid for w in self._world.ts.witness_keypairs])
|
|
2710
|
+
return peers[: max(3, len(peers))]
|
|
2711
|
+
|
|
2712
|
+
def _encode_envelope(
|
|
2713
|
+
self,
|
|
2714
|
+
statement: SignedStatement,
|
|
2715
|
+
*,
|
|
2716
|
+
cosigs: list[WitnessCosignature] | None = None,
|
|
2717
|
+
) -> bytes:
|
|
2718
|
+
# I6: include tree_size so receivers can reconstruct
|
|
2719
|
+
# WitnessCosignature and verify inline. Pre-I6 envelopes that
|
|
2720
|
+
# omit "size" remain decodable — the receiver-side verifier
|
|
2721
|
+
# treats absence as opt-out (see _verify_envelope_cosigs).
|
|
2722
|
+
return cbor2.dumps(
|
|
2723
|
+
{
|
|
2724
|
+
"statement": statement.to_bytes(),
|
|
2725
|
+
"cosigs": [
|
|
2726
|
+
{
|
|
2727
|
+
"witness": c.witness_kid,
|
|
2728
|
+
"sth": c.sth_root,
|
|
2729
|
+
"size": c.tree_size,
|
|
2730
|
+
"sig": c.signature,
|
|
2731
|
+
}
|
|
2732
|
+
for c in (cosigs or [])
|
|
2733
|
+
],
|
|
2734
|
+
},
|
|
2735
|
+
canonical=True,
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
def _block(self, rule_id: str, spec_ref: str, msg: str) -> AuthError:
|
|
2739
|
+
err = AuthError(
|
|
2740
|
+
msg,
|
|
2741
|
+
failed_caveat=rule_id,
|
|
2742
|
+
telemetry={"rule_id": rule_id, "spec_ref": spec_ref},
|
|
2743
|
+
)
|
|
2744
|
+
return err
|
|
2745
|
+
|
|
2746
|
+
|
|
2747
|
+
# -----------------------------------------------------------------------
|
|
2748
|
+
# helpers / aux types
|
|
2749
|
+
# -----------------------------------------------------------------------
|
|
2750
|
+
|
|
2751
|
+
|
|
2752
|
+
@dataclass
|
|
2753
|
+
class ClaimEvent:
|
|
2754
|
+
"""Delivered to subscribe() handlers."""
|
|
2755
|
+
|
|
2756
|
+
statement: SignedStatement
|
|
2757
|
+
raw: dict[str, Any]
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def _default_posterior_for(reputation_score: float) -> Uncertainty:
|
|
2761
|
+
"""Toy posterior: agents with higher rep produce confidently-positive
|
|
2762
|
+
Beta posteriors; lower rep produces near-uniform."""
|
|
2763
|
+
alpha = 1.0 + 9.0 * max(reputation_score, 0.0)
|
|
2764
|
+
beta = 1.0 + 9.0 * max(1 - reputation_score, 0.0)
|
|
2765
|
+
return Uncertainty.beta(alpha, beta)
|
|
2766
|
+
|
|
2767
|
+
|
|
2768
|
+
def _hash_topic_scope(scope: str) -> bytes:
|
|
2769
|
+
"""Bond topic-scope hash (TAL-ESCROW-EXCLUSIVE)."""
|
|
2770
|
+
return blake3_32(scope.encode("utf-8"))
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
import contextlib # noqa: E402 (placed near factories for grouping)
|
|
2774
|
+
import inspect # noqa: E402
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
async def _invoke_dispute_callback(
|
|
2778
|
+
cb: Callable[[Dispute], Any],
|
|
2779
|
+
dispute: Dispute,
|
|
2780
|
+
*,
|
|
2781
|
+
recorder: Any,
|
|
2782
|
+
) -> None:
|
|
2783
|
+
"""Invoke a user-supplied on_dispute callback safely.
|
|
2784
|
+
|
|
2785
|
+
Accepts both sync and async callbacks. Exceptions are NOT silently
|
|
2786
|
+
swallowed — they're recorded via `hypermind_handler_errors_total`
|
|
2787
|
+
so a misbehaving callback is auditable instead of invisible.
|
|
2788
|
+
"""
|
|
2789
|
+
try:
|
|
2790
|
+
result = cb(dispute)
|
|
2791
|
+
if inspect.isawaitable(result):
|
|
2792
|
+
await result
|
|
2793
|
+
except Exception as e:
|
|
2794
|
+
try:
|
|
2795
|
+
recorder.incr(
|
|
2796
|
+
"hypermind_handler_errors_total",
|
|
2797
|
+
kind=type(e).__name__,
|
|
2798
|
+
where="on_dispute",
|
|
2799
|
+
)
|
|
2800
|
+
recorder.emit(
|
|
2801
|
+
namespace="",
|
|
2802
|
+
agent_id="",
|
|
2803
|
+
event_type="dispute_callback_error",
|
|
2804
|
+
severity="error",
|
|
2805
|
+
error=type(e).__name__,
|
|
2806
|
+
message=str(e),
|
|
2807
|
+
)
|
|
2808
|
+
except Exception: # pragma: no cover
|
|
2809
|
+
pass
|
|
2810
|
+
|
|
2811
|
+
|
|
2812
|
+
@contextlib.asynccontextmanager
|
|
2813
|
+
async def testbed_cohort(
|
|
2814
|
+
n: int = 2,
|
|
2815
|
+
*,
|
|
2816
|
+
room: str = "default",
|
|
2817
|
+
namespace: str = "sandbox",
|
|
2818
|
+
ramp_complete: bool = True,
|
|
2819
|
+
recorder: Any = None,
|
|
2820
|
+
) -> AsyncIterator[list[HyperMindAgent]]:
|
|
2821
|
+
"""Async context manager — yield a list of N agents and clean up
|
|
2822
|
+
them all on exit. The recommended factory for multi-agent demos.
|
|
2823
|
+
|
|
2824
|
+
``recorder`` (I0): optional pre-scoped Recorder. When supplied, every
|
|
2825
|
+
agent's events carry that recorder's ``namespace_id`` so callers like
|
|
2826
|
+
the SimLab runner can isolate events per sim_id without post-hoc
|
|
2827
|
+
filtering. Default ``None`` preserves the legacy lazy-init behaviour
|
|
2828
|
+
keyed on the agent world's namespace_id.
|
|
2829
|
+
|
|
2830
|
+
Example:
|
|
2831
|
+
async with testbed_cohort(3, room="ai-safety") as (alice, bob, carol):
|
|
2832
|
+
await alice.publish_claim(...)
|
|
2833
|
+
...
|
|
2834
|
+
# all three agents are closed automatically
|
|
2835
|
+
"""
|
|
2836
|
+
agents = HyperMindAgent.testbed(
|
|
2837
|
+
agents=n,
|
|
2838
|
+
room=room,
|
|
2839
|
+
namespace=namespace,
|
|
2840
|
+
ramp_complete=ramp_complete,
|
|
2841
|
+
recorder=recorder,
|
|
2842
|
+
)
|
|
2843
|
+
try:
|
|
2844
|
+
yield agents
|
|
2845
|
+
finally:
|
|
2846
|
+
for a in agents:
|
|
2847
|
+
await a.close()
|
|
2848
|
+
|
|
2849
|
+
|
|
2850
|
+
def _parse_hlc_for_filter(s: str | None) -> tuple[int, int]:
|
|
2851
|
+
"""Parse `HLC(<logical_ms>.<counter>@<node>)` into (l, c) for ordering.
|
|
2852
|
+
|
|
2853
|
+
Falls back to (0, 0) on absence/parse-failure so unparseable HLCs sort
|
|
2854
|
+
before any well-formed one. Used by `audit_export(since_hlc=...)` to
|
|
2855
|
+
avoid the lexicographic-string-comparison bug (Finding #8 from
|
|
2856
|
+
Cycle-1 data review).
|
|
2857
|
+
"""
|
|
2858
|
+
if not s:
|
|
2859
|
+
return (0, 0)
|
|
2860
|
+
try:
|
|
2861
|
+
# Accept "HLC(123.4@…)" or "123.4" forms.
|
|
2862
|
+
body = s
|
|
2863
|
+
if body.startswith("HLC("):
|
|
2864
|
+
body = body[4:].split("@", 1)[0].rstrip(")")
|
|
2865
|
+
if "." in body:
|
|
2866
|
+
l_str, c_str = body.split(".", 1)
|
|
2867
|
+
else:
|
|
2868
|
+
l_str, c_str = body, "0"
|
|
2869
|
+
return (int(float(l_str)), int(float(c_str)))
|
|
2870
|
+
except (ValueError, IndexError):
|
|
2871
|
+
return (0, 0)
|
|
2872
|
+
|
|
2873
|
+
|
|
2874
|
+
def _jsonable(obj: Any) -> Any:
|
|
2875
|
+
"""Recursively convert bytes → hex so json.dumps doesn't fail on
|
|
2876
|
+
fields the recorder may emit (kid, agent_id are already hex strings,
|
|
2877
|
+
but custom telemetry may carry raw bytes)."""
|
|
2878
|
+
if isinstance(obj, bytes):
|
|
2879
|
+
return obj.hex()
|
|
2880
|
+
if isinstance(obj, dict):
|
|
2881
|
+
return {k: _jsonable(v) for k, v in obj.items()}
|
|
2882
|
+
if isinstance(obj, (list, tuple)):
|
|
2883
|
+
return [_jsonable(v) for v in obj]
|
|
2884
|
+
return obj
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
def _deep_snapshot(obj: Any) -> Any:
|
|
2888
|
+
"""Deep-copy primitive containers so a checkpoint cannot be
|
|
2889
|
+
retroactively mutated by the caller after the await point."""
|
|
2890
|
+
if isinstance(obj, dict):
|
|
2891
|
+
return {k: _deep_snapshot(v) for k, v in obj.items()}
|
|
2892
|
+
if isinstance(obj, list):
|
|
2893
|
+
return [_deep_snapshot(v) for v in obj]
|
|
2894
|
+
if isinstance(obj, tuple):
|
|
2895
|
+
return tuple(_deep_snapshot(v) for v in obj)
|
|
2896
|
+
return obj
|