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.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
hypermind/sync.py ADDED
@@ -0,0 +1,191 @@
1
+ """Synchronous wrapper around :class:`hypermind.HyperMindAgent`.
2
+
3
+ Some integrators (notebooks, CLIs, simple scripts) cannot or do not
4
+ want to use ``async``/``await``. ``SyncAgent`` is a thin wrapper that
5
+ exposes every public async verb of ``HyperMindAgent`` as a sync method.
6
+
7
+ Implementation strategy (Jupyter-safe):
8
+
9
+ * If the calling thread already has a running event loop (e.g. inside
10
+ a Jupyter cell), ``SyncAgent`` lazily spawns a dedicated worker
11
+ thread that owns its own ``asyncio.new_event_loop()`` and uses
12
+ ``asyncio.run_coroutine_threadsafe`` to dispatch coroutines to it.
13
+ * Otherwise, ``asyncio.run`` is used per call.
14
+
15
+ The wrapper is generated by **introspection** — every ``async def``
16
+ public method on :class:`HyperMindAgent` is auto-wrapped, so there is
17
+ nothing to keep in sync by hand. Sync property/passthrough access for
18
+ non-async helpers (``working_memory``, ``advance_time``, etc.) is also
19
+ forwarded.
20
+
21
+ ROADMAP §0.5.7. Stable from v0.5; signatures track ``HyperMindAgent``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import inspect
28
+ import threading
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from hypermind.agent import HyperMindAgent
32
+
33
+ if TYPE_CHECKING: # pragma: no cover
34
+ from collections.abc import Coroutine
35
+
36
+
37
+ # ---------------------------------------------------------------------
38
+ # Module-level worker loop (Jupyter-safe path).
39
+ # ---------------------------------------------------------------------
40
+
41
+ _worker_lock = threading.Lock()
42
+ _worker_loop: asyncio.AbstractEventLoop | None = None
43
+ _worker_thread: threading.Thread | None = None
44
+
45
+
46
+ def _ensure_worker_loop() -> asyncio.AbstractEventLoop:
47
+ """Lazily start the dedicated worker thread + loop.
48
+
49
+ The worker is daemonised so it does not block interpreter shutdown.
50
+ """
51
+ global _worker_loop, _worker_thread
52
+ with _worker_lock:
53
+ if _worker_loop is not None and _worker_thread is not None and _worker_thread.is_alive():
54
+ return _worker_loop
55
+ loop = asyncio.new_event_loop()
56
+ ready = threading.Event()
57
+
58
+ def _run() -> None:
59
+ asyncio.set_event_loop(loop)
60
+ ready.set()
61
+ loop.run_forever()
62
+
63
+ thread = threading.Thread(target=_run, name="hypermind-sync-worker", daemon=True)
64
+ thread.start()
65
+ ready.wait()
66
+ _worker_loop = loop
67
+ _worker_thread = thread
68
+ return loop
69
+
70
+
71
+ def _run_coro(coro: Coroutine[Any, Any, Any]) -> Any:
72
+ """Run a coroutine to completion from a synchronous context.
73
+
74
+ If the current thread already has a running loop (Jupyter), schedule
75
+ on the worker loop via ``run_coroutine_threadsafe``; otherwise use
76
+ ``asyncio.run``.
77
+ """
78
+ try:
79
+ asyncio.get_running_loop()
80
+ running_loop = True
81
+ except RuntimeError:
82
+ running_loop = False
83
+ if running_loop:
84
+ loop = _ensure_worker_loop()
85
+ fut = asyncio.run_coroutine_threadsafe(coro, loop)
86
+ return fut.result()
87
+ return asyncio.run(coro)
88
+
89
+
90
+ # ---------------------------------------------------------------------
91
+ # SyncAgent
92
+ # ---------------------------------------------------------------------
93
+
94
+
95
+ class SyncAgent:
96
+ """Synchronous wrapper around a single :class:`HyperMindAgent`.
97
+
98
+ Every public ``async def`` method of ``HyperMindAgent`` is exposed
99
+ as a sync method here with the same signature minus the ``async``.
100
+ Non-async public attributes (e.g. ``working_memory``, ``namespace``,
101
+ ``rooms``) are forwarded through ``__getattr__``.
102
+
103
+ Use :meth:`SyncAgent.sandbox` or :meth:`SyncAgent.testbed_cohort` to
104
+ construct.
105
+ """
106
+
107
+ __slots__ = ("_agent",)
108
+
109
+ def __init__(self, agent: HyperMindAgent) -> None:
110
+ object.__setattr__(self, "_agent", agent)
111
+
112
+ # ---- factories -----------------------------------------------------
113
+
114
+ @classmethod
115
+ def sandbox(cls, **kwargs: Any) -> SyncAgent:
116
+ """Sync analogue of :meth:`HyperMindAgent.sandbox`."""
117
+ return cls(HyperMindAgent.sandbox(**kwargs))
118
+
119
+ @classmethod
120
+ def testbed_cohort(cls, n: int = 2, **kwargs: Any) -> list[SyncAgent]:
121
+ """Sync analogue of :func:`hypermind.testing.testbed_cohort`.
122
+
123
+ Returns a plain list of wrapped agents. Caller is responsible for
124
+ calling :meth:`close` (or using :class:`contextlib.ExitStack`) —
125
+ there is no async-with cleanup in the sync surface.
126
+ """
127
+ agents = HyperMindAgent.testbed(agents=n, **kwargs)
128
+ return [cls(a) for a in agents]
129
+
130
+ # ---- context-manager passthrough ----------------------------------
131
+
132
+ def __enter__(self) -> SyncAgent:
133
+ return self
134
+
135
+ def __exit__(self, *_exc: Any) -> None:
136
+ self.close()
137
+
138
+ # ---- forwarded attribute access -----------------------------------
139
+
140
+ def __getattr__(self, name: str) -> Any:
141
+ # Avoid recursion on lookup of private slot.
142
+ if name == "_agent":
143
+ raise AttributeError(name)
144
+ agent = object.__getattribute__(self, "_agent")
145
+ attr = getattr(agent, name)
146
+ if inspect.iscoroutinefunction(attr):
147
+
148
+ def _sync_wrapper(*args: Any, **kwargs: Any) -> Any:
149
+ return _run_coro(attr(*args, **kwargs))
150
+
151
+ _sync_wrapper.__name__ = name
152
+ _sync_wrapper.__doc__ = attr.__doc__
153
+ return _sync_wrapper
154
+ if inspect.isasyncgenfunction(attr):
155
+ # Drain an async generator into a synchronous list. For
156
+ # truly streaming outputs, callers SHOULD stay on
157
+ # ``HyperMindAgent`` and the async path; the sync surface
158
+ # materialises the full sequence in memory.
159
+ def _sync_drain(*args: Any, **kwargs: Any) -> list[Any]:
160
+ async def _collect() -> list[Any]:
161
+ out: list[Any] = []
162
+ async for item in attr(*args, **kwargs):
163
+ out.append(item)
164
+ return out
165
+
166
+ return _run_coro(_collect())
167
+
168
+ _sync_drain.__name__ = name
169
+ _sync_drain.__doc__ = attr.__doc__
170
+ return _sync_drain
171
+ return attr
172
+
173
+
174
+ # ---------------------------------------------------------------------
175
+ # Verb-parity assertion (introspection-driven test surface).
176
+ # ---------------------------------------------------------------------
177
+
178
+
179
+ def _public_async_verbs(cls: type) -> list[str]:
180
+ """Return sorted names of public ``async def`` (or async-generator)
181
+ methods of ``cls``."""
182
+ out: list[str] = []
183
+ for name, member in inspect.getmembers(cls):
184
+ if name.startswith("_"):
185
+ continue
186
+ if inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction(member):
187
+ out.append(name)
188
+ return sorted(out)
189
+
190
+
191
+ __all__ = ["SyncAgent", "_public_async_verbs"]
hypermind/tal.py ADDED
@@ -0,0 +1,175 @@
1
+ """Trust & Access Layer (TAL) — reputation-gated Rooms with vouch decay.
2
+
3
+ Implements the application-layer membership control described in
4
+ ``docs/spec/07-trust-access-layer.md``: Rooms with a per-Room minimum
5
+ reputation, vouches with HLC timestamps, and exponential decay applied
6
+ to old vouch contributions.
7
+
8
+ This module is the v0.1 surface — a minimal subset of §7 sufficient to
9
+ gate ``discover_mentors`` / ``request_vouch`` and unblock the federated
10
+ learning loop. The full §7 wire messages (ROOM_DEFINE, ROOM_JOIN,
11
+ VOUCH, VOUCH_REVOKE) land in v0.2.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from hypermind.knowledge.reputation import ReputationKernel
19
+
20
+
21
+ class TALError(Exception):
22
+ """Raised when a TAL operation violates a Room policy."""
23
+
24
+
25
+ @dataclass
26
+ class Room:
27
+ room_id: str
28
+ min_reputation: float # 0.0 = open
29
+ members: set[bytes] = field(default_factory=set)
30
+ creator_kid: bytes = b""
31
+
32
+
33
+ @dataclass
34
+ class TrustStatus:
35
+ in_room: bool
36
+ reputation_snapshot: float
37
+ vouch_count: int
38
+
39
+
40
+ @dataclass
41
+ class _VouchRecord:
42
+ voucher_kid: bytes
43
+ vouchee_kid: bytes
44
+ room_id: str
45
+ topic: str
46
+ issued_hlc_ms: int
47
+ initial_alpha_contribution: float
48
+ remaining_factor: float = 1.0 # 1.0 = no decay yet
49
+
50
+
51
+ # Decay rate per day (matches reputation kernel's smooth-slash λ).
52
+ _DECAY_LAMBDA_PER_DAY = 0.02
53
+ _MS_PER_DAY = 1000 * 60 * 60 * 24
54
+
55
+
56
+ class TAL:
57
+ """Rooms + vouch log with lazy decay, layered on a ReputationKernel."""
58
+
59
+ def __init__(self, reputation_kernel: ReputationKernel) -> None:
60
+ self._rep = reputation_kernel
61
+ self._rooms: dict[str, Room] = {}
62
+ self._vouch_log: list[_VouchRecord] = []
63
+
64
+ # ---- Rooms ----------------------------------------------------------
65
+
66
+ def create_room(self, room_id: str, min_reputation: float, creator_kid: bytes) -> Room:
67
+ if room_id in self._rooms:
68
+ raise TALError(f"room {room_id!r} already exists")
69
+ room = Room(
70
+ room_id=room_id,
71
+ min_reputation=min_reputation,
72
+ members={bytes(creator_kid)},
73
+ creator_kid=bytes(creator_kid),
74
+ )
75
+ self._rooms[room_id] = room
76
+ return room
77
+
78
+ def get_room(self, room_id: str) -> Room | None:
79
+ return self._rooms.get(room_id)
80
+
81
+ def join_room(self, agent_kid: bytes, room_id: str) -> None:
82
+ room = self._rooms.get(room_id)
83
+ if room is None:
84
+ raise TALError(f"room {room_id!r} does not exist")
85
+ if room.min_reputation > 0.0:
86
+ best = self._max_reputation(agent_kid)
87
+ if best < room.min_reputation:
88
+ raise TALError("reputation below room minimum")
89
+ room.members.add(bytes(agent_kid))
90
+
91
+ def _max_reputation(self, agent_kid: bytes) -> float:
92
+ """Best score across every (agent, topic) the kernel has seen."""
93
+ kid = bytes(agent_kid)
94
+ best = 0.0
95
+ for (a, topic), _p in self._rep._posteriors.items():
96
+ if a == kid:
97
+ score = self._rep.snapshot(kid, topic).score
98
+ if score > best:
99
+ best = score
100
+ return best
101
+
102
+ # ---- Vouches --------------------------------------------------------
103
+
104
+ def vouch(
105
+ self,
106
+ voucher_kid: bytes,
107
+ vouchee_kid: bytes,
108
+ room_id: str,
109
+ topic: str,
110
+ *,
111
+ now_hlc_ms: int = 0,
112
+ ) -> None:
113
+ """Record a vouch and delegate the α contribution to the kernel.
114
+
115
+ The reputation kernel applies the 5%-of-α-above-prior cap; the
116
+ vouch log here is the bookkeeping needed for ``decay_vouches``.
117
+ """
118
+ if room_id not in self._rooms:
119
+ raise TALError(f"room {room_id!r} does not exist")
120
+ voucher_kid = bytes(voucher_kid)
121
+ vouchee_kid = bytes(vouchee_kid)
122
+ before = self._rep._get(vouchee_kid, topic).alpha
123
+ self._rep.vouch(voucher_kid, vouchee_kid, topic, strength=1.0)
124
+ after = self._rep._get(vouchee_kid, topic).alpha
125
+ contribution = max(0.0, after - before)
126
+ self._vouch_log.append(
127
+ _VouchRecord(
128
+ voucher_kid=voucher_kid,
129
+ vouchee_kid=vouchee_kid,
130
+ room_id=room_id,
131
+ topic=topic,
132
+ issued_hlc_ms=int(now_hlc_ms),
133
+ initial_alpha_contribution=contribution,
134
+ remaining_factor=1.0,
135
+ )
136
+ )
137
+
138
+ def decay_vouches(self, now_hlc_ms: int) -> None:
139
+ """Exponentially decay vouches older than 24h.
140
+
141
+ For each vouch in the log: compute days_elapsed since issue,
142
+ target_factor = max(0, 1 - λ · days_elapsed). Reduce the
143
+ vouchee's α by the difference between the previously-applied
144
+ factor and the new one. Bounded below at 0 — never refunds.
145
+ """
146
+ for v in self._vouch_log:
147
+ elapsed_ms = max(0, now_hlc_ms - v.issued_hlc_ms)
148
+ elapsed_days = elapsed_ms / _MS_PER_DAY
149
+ if elapsed_days < 1.0:
150
+ continue
151
+ new_factor = max(0.0, 1.0 - _DECAY_LAMBDA_PER_DAY * elapsed_days)
152
+ if new_factor >= v.remaining_factor:
153
+ continue # nothing to remove
154
+ delta = (v.remaining_factor - new_factor) * v.initial_alpha_contribution
155
+ if delta <= 0:
156
+ continue
157
+ p = self._rep._get(v.vouchee_kid, v.topic)
158
+ p.alpha = max(1.0, p.alpha - delta)
159
+ v.remaining_factor = new_factor
160
+
161
+ # ---- Queries --------------------------------------------------------
162
+
163
+ def trust_query(self, kid: bytes, room_id: str) -> TrustStatus:
164
+ kid = bytes(kid)
165
+ room = self._rooms.get(room_id)
166
+ in_room = bool(room and kid in room.members)
167
+ rep = self._max_reputation(kid)
168
+ vouch_count = sum(
169
+ 1 for v in self._vouch_log if v.vouchee_kid == kid and v.room_id == room_id
170
+ )
171
+ return TrustStatus(
172
+ in_room=in_room,
173
+ reputation_snapshot=rep,
174
+ vouch_count=vouch_count,
175
+ )
hypermind/tasks.py ADDED
@@ -0,0 +1,334 @@
1
+ """Task delegation primitives — spec §4.4.
2
+
3
+ v0.10.1 (T1-04 / T1-05) adds a :class:`TaskGateway` Protocol so
4
+ :func:`submit_task` can dispatch in-process *or* over a network
5
+ transport (MCP / libp2p). The default :class:`InProcessGateway`
6
+ preserves the v0.x asyncio-Future semantics; :class:`MCPGateway`
7
+ dispatches over an :class:`MCPClient` and awaits a remote
8
+ ``submit_task`` reply.
9
+
10
+ Cancellation, timeout, and idempotent retry are gateway-level
11
+ concerns:
12
+
13
+ - :meth:`TaskHandle.cancel` flips the handle's status to ``cancelled``
14
+ and asks its gateway (if any) to propagate a TASK_CANCEL signal.
15
+ - :meth:`TaskHandle.result` uses :func:`asyncio.wait_for` for
16
+ timeouts; on timeout the handle is implicitly cancelled.
17
+ - :class:`MCPGateway` keys an in-memory cache by ``request_kid`` so a
18
+ duplicate :func:`submit_task` (same descriptor) returns the cached
19
+ :class:`TaskHandle` rather than dispatching twice.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import hashlib
26
+ import secrets
27
+ from dataclasses import dataclass, field
28
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
29
+
30
+ import cbor2
31
+
32
+ if TYPE_CHECKING: # pragma: no cover
33
+ from hypermind.crypto.signing import Keypair
34
+ from hypermind.mcp.client import MCPClient
35
+
36
+
37
+ @dataclass
38
+ class TaskDescriptor:
39
+ """The signed payload describing a unit of remote work.
40
+
41
+ Used by :class:`TaskGateway` implementations to dispatch a task
42
+ over a transport. Independent of any specific wire format —
43
+ gateways serialise the descriptor as they see fit.
44
+ """
45
+
46
+ task_id: str
47
+ submitter_kid: bytes
48
+ assignee_kid: bytes
49
+ topic: str
50
+ arguments: dict[str, Any] = field(default_factory=dict)
51
+ timeout_s: float = 30.0
52
+
53
+ def to_canonical_cbor(self) -> bytes:
54
+ """RFC 8949 §4.2 deterministic CBOR for hashing / idempotency."""
55
+ return cbor2.dumps(
56
+ {
57
+ "task_id": self.task_id,
58
+ "submitter_kid": self.submitter_kid,
59
+ "assignee_kid": self.assignee_kid,
60
+ "topic": self.topic,
61
+ "arguments": self.arguments,
62
+ "timeout_s": self.timeout_s,
63
+ },
64
+ canonical=True,
65
+ )
66
+
67
+ @property
68
+ def request_kid(self) -> bytes:
69
+ """SHA-256 of the canonical encoding — the idempotency key."""
70
+ return hashlib.sha256(self.to_canonical_cbor()).digest()
71
+
72
+
73
+ @dataclass
74
+ class TaskHandle:
75
+ """Awaitable handle for a submitted task (spec §4.4).
76
+
77
+ Returned by :func:`submit_task`; callers ``await handle.result()`` to
78
+ block until the assignee completes the work or the timeout fires.
79
+ Cancellation propagates to the gateway so remote tasks receive a
80
+ TASK_CANCEL signal when possible.
81
+
82
+ Examples:
83
+ >>> handle = await submit_task(
84
+ ... submitter_kid=alice.keypair.kid,
85
+ ... assignee_kid=bob.keypair.kid,
86
+ ... topic="ai-safety/eval",
87
+ ... arguments={"claim_id": kid.hex()},
88
+ ... timeout_s=30.0,
89
+ ... )
90
+ >>> result = await handle.result(timeout=30.0)
91
+ """
92
+
93
+ task_id: str # hex uuid
94
+ submitter_kid: bytes
95
+ assignee_kid: bytes
96
+ topic: str
97
+ _result_future: asyncio.Future = field(
98
+ default_factory=lambda: asyncio.get_event_loop().create_future(),
99
+ repr=False,
100
+ )
101
+ _status: Literal["pending", "running", "complete", "failed"] = "pending"
102
+ _gateway: TaskGateway | None = field(default=None, repr=False)
103
+ _request_kid: bytes = field(default=b"", repr=False)
104
+
105
+ @property
106
+ def status(self) -> str:
107
+ return self._status
108
+
109
+ @property
110
+ def request_kid(self) -> bytes:
111
+ return self._request_kid
112
+
113
+ async def result(self, timeout: float = 30.0) -> Any:
114
+ """Wait for task completion.
115
+
116
+ Returns the result payload or raises TaskTimeoutError/TaskFailedError.
117
+ On timeout the handle transitions to ``cancelled`` and the
118
+ gateway is asked to propagate a TASK_CANCEL signal (T1-05).
119
+ """
120
+ if self._status == "failed":
121
+ raise TaskFailedError(f"Task {self.task_id} failed or was cancelled")
122
+ try:
123
+ return await asyncio.wait_for(asyncio.shield(self._result_future), timeout=timeout)
124
+ except TimeoutError as e:
125
+ await self.cancel()
126
+ raise TaskTimeoutError(f"Task {self.task_id} timed out after {timeout}s") from e
127
+ except asyncio.CancelledError as e:
128
+ raise TaskFailedError(f"Task {self.task_id} was cancelled") from e
129
+
130
+ def _resolve(self, value: Any) -> None:
131
+ """Resolve the future with a result value. Called by the task framework."""
132
+ self._status = "complete"
133
+ if not self._result_future.done():
134
+ self._result_future.set_result(value)
135
+
136
+ def _fail(self, exc: BaseException | None = None) -> None:
137
+ """Mark the task as failed. Called by the task framework."""
138
+ self._status = "failed"
139
+ if not self._result_future.done():
140
+ if exc is not None:
141
+ self._result_future.set_exception(exc)
142
+ else:
143
+ self._result_future.cancel()
144
+
145
+ async def cancel(self) -> None:
146
+ """Cancel this task handle.
147
+
148
+ Propagates a TASK_CANCEL signal via the bound gateway when one
149
+ is set (T1-05). Local-only handles flip status without any
150
+ network traffic.
151
+ """
152
+ if self._status in ("complete", "failed"):
153
+ return
154
+ # Backwards-compat: cancel transitions to "failed" (v0.x contract).
155
+ self._status = "failed"
156
+ if not self._result_future.done():
157
+ self._result_future.cancel()
158
+ if self._gateway is not None:
159
+ try:
160
+ await self._gateway.cancel(self)
161
+ except Exception:
162
+ pass
163
+
164
+
165
+ class TaskTimeoutError(Exception):
166
+ """Raised when a task result is not received within the timeout."""
167
+
168
+
169
+ class TaskFailedError(Exception):
170
+ """Raised when a task fails or is cancelled."""
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Gateway Protocol + implementations (T1-04)
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ class TaskGateway(Protocol):
179
+ """Dispatch interface for :func:`submit_task`.
180
+
181
+ Implementations turn a :class:`TaskDescriptor` into a
182
+ :class:`TaskHandle` whose future is fulfilled when the task
183
+ completes (locally or remotely). Cancellation is propagated
184
+ through :meth:`cancel`.
185
+ """
186
+
187
+ async def dispatch(self, descriptor: TaskDescriptor) -> TaskHandle: ...
188
+
189
+ async def cancel(self, handle: TaskHandle) -> None: ...
190
+
191
+
192
+ class InProcessGateway:
193
+ """Default gateway — returns a bare :class:`TaskHandle`.
194
+
195
+ The handle is fulfilled by whoever owns the in-process bus / agent
196
+ plumbing (this module does not auto-resolve; v0.x callers were
197
+ already wiring resolution themselves).
198
+ """
199
+
200
+ async def dispatch(self, descriptor: TaskDescriptor) -> TaskHandle:
201
+ handle = TaskHandle(
202
+ task_id=descriptor.task_id,
203
+ submitter_kid=descriptor.submitter_kid,
204
+ assignee_kid=descriptor.assignee_kid,
205
+ topic=descriptor.topic,
206
+ _gateway=self,
207
+ _request_kid=descriptor.request_kid,
208
+ )
209
+ return handle
210
+
211
+ async def cancel(self, handle: TaskHandle) -> None:
212
+ # Local handle — nothing to propagate beyond the future cancellation
213
+ # already performed inside TaskHandle.cancel().
214
+ return None
215
+
216
+
217
+ class MCPGateway:
218
+ """Dispatch :func:`submit_task` over an :class:`MCPClient`.
219
+
220
+ The remote server MUST expose two tools:
221
+
222
+ - ``submit_task`` — accepts the serialised descriptor, returns a
223
+ ``{"task_id": ..., "result": ...}`` envelope on completion.
224
+ - ``cancel_task`` — accepts ``{"task_id": ...}``.
225
+
226
+ Idempotency: if the same descriptor (== same ``request_kid``) is
227
+ dispatched twice, the second call returns the cached
228
+ :class:`TaskHandle` from the first dispatch (T1-05).
229
+ """
230
+
231
+ def __init__(
232
+ self,
233
+ client: MCPClient,
234
+ *,
235
+ signing_keypair: Keypair,
236
+ cap_token_root: bytes,
237
+ ) -> None:
238
+ self._client = client
239
+ self._keypair = signing_keypair
240
+ self._cap_token_root = cap_token_root
241
+ self._pending: dict[bytes, TaskHandle] = {}
242
+ self._tasks: dict[bytes, asyncio.Task] = {}
243
+
244
+ async def dispatch(self, descriptor: TaskDescriptor) -> TaskHandle:
245
+ request_kid = descriptor.request_kid
246
+ # Idempotent retry: same descriptor → same handle.
247
+ if request_kid in self._pending:
248
+ return self._pending[request_kid]
249
+
250
+ handle = TaskHandle(
251
+ task_id=descriptor.task_id,
252
+ submitter_kid=descriptor.submitter_kid,
253
+ assignee_kid=descriptor.assignee_kid,
254
+ topic=descriptor.topic,
255
+ _gateway=self,
256
+ _request_kid=request_kid,
257
+ )
258
+ self._pending[request_kid] = handle
259
+
260
+ async def _run() -> None:
261
+ try:
262
+ envelope = await self._client.call_tool(
263
+ "submit_task",
264
+ {
265
+ "task_id": descriptor.task_id,
266
+ "submitter_kid": descriptor.submitter_kid.hex(),
267
+ "assignee_kid": descriptor.assignee_kid.hex(),
268
+ "topic": descriptor.topic,
269
+ "arguments": descriptor.arguments,
270
+ },
271
+ signing_keypair=self._keypair,
272
+ cap_token_root=self._cap_token_root,
273
+ )
274
+ content = envelope.get("content") if isinstance(envelope, dict) else envelope
275
+ handle._resolve(content)
276
+ except asyncio.CancelledError:
277
+ handle._fail(TaskFailedError("task cancelled"))
278
+ raise
279
+ except Exception as e:
280
+ handle._fail(e)
281
+
282
+ self._tasks[request_kid] = asyncio.create_task(_run())
283
+ return handle
284
+
285
+ async def cancel(self, handle: TaskHandle) -> None:
286
+ kid = handle.request_kid
287
+ task = self._tasks.pop(kid, None)
288
+ if task is not None and not task.done():
289
+ task.cancel()
290
+ # Best-effort remote cancel — ignore errors, the remote side
291
+ # may have already completed or never seen the task.
292
+ try:
293
+ await self._client.call_tool(
294
+ "cancel_task",
295
+ {"task_id": handle.task_id},
296
+ signing_keypair=self._keypair,
297
+ cap_token_root=self._cap_token_root,
298
+ )
299
+ except Exception:
300
+ pass
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # submit_task entry point
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ async def submit_task(
309
+ *,
310
+ submitter_kid: bytes,
311
+ assignee_kid: bytes,
312
+ topic: str,
313
+ arguments: dict[str, Any] | None = None,
314
+ timeout_s: float = 30.0,
315
+ gateway: TaskGateway | None = None,
316
+ task_id: str | None = None,
317
+ ) -> TaskHandle:
318
+ """Submit a task to a :class:`TaskGateway`.
319
+
320
+ Defaults to :class:`InProcessGateway` to preserve v0.x semantics
321
+ (callers that already wire resolution into the in-process bus
322
+ continue to work unchanged). Pass a :class:`MCPGateway` to
323
+ dispatch over a network transport (T1-04).
324
+ """
325
+ descriptor = TaskDescriptor(
326
+ task_id=task_id or secrets.token_hex(8),
327
+ submitter_kid=submitter_kid,
328
+ assignee_kid=assignee_kid,
329
+ topic=topic,
330
+ arguments=arguments or {},
331
+ timeout_s=timeout_s,
332
+ )
333
+ gw = gateway if gateway is not None else InProcessGateway()
334
+ return await gw.dispatch(descriptor)