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
@@ -0,0 +1,239 @@
1
+ """Single-slot worker that runs persona-panel sims sequentially.
2
+
3
+ Why single-slot? Concurrent runs would (a) collide on OpenRouter rate
4
+ limits, (b) make the live UI confusing (whose claim is whose?), (c)
5
+ fight over shared in-process state (the global recorder, FROST seen-set).
6
+
7
+ Lifecycle:
8
+ submit(config) → returns sim_id
9
+ → registry: create dir, write config.json + status.json (queued)
10
+ → enqueue
11
+ worker loop:
12
+ → pop config
13
+ → status: running
14
+ → run_persona_sim(config, output_dir, recorder=Recorder.scoped(sim_id))
15
+ → status: done | failed (with stderr.log captured)
16
+ → loop
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import io
24
+ import time
25
+ import traceback
26
+ from dataclasses import dataclass
27
+
28
+ from hypermind.observability.recorder import Recorder
29
+
30
+ from .config import SimConfig
31
+ from .registry import (
32
+ append_stderr,
33
+ new_sim_dir,
34
+ sim_dir,
35
+ update_status,
36
+ write_config,
37
+ write_status,
38
+ )
39
+ from .scenario import run_persona_sim
40
+
41
+ QUEUE_CAP = 10
42
+
43
+
44
+ @dataclass
45
+ class _QueuedJob:
46
+ sim_id: str
47
+ config: SimConfig
48
+
49
+
50
+ class SimRunner:
51
+ """In-process single-slot sim runner. One per app instance."""
52
+
53
+ def __init__(self, *, queue_cap: int = QUEUE_CAP) -> None:
54
+ self._queue: asyncio.Queue[_QueuedJob] = asyncio.Queue(maxsize=queue_cap)
55
+ self._worker_task: asyncio.Task | None = None
56
+ self._current_sim_id: str | None = None
57
+ self._stopped = False
58
+ self._reaped = False # orphan reaper runs once per process
59
+
60
+ def start(self) -> None:
61
+ if self._worker_task is None or self._worker_task.done():
62
+ self._worker_task = asyncio.create_task(self._worker_loop())
63
+
64
+ def reap_orphans_at_boot(self) -> None:
65
+ """Mark any sim still in 'running' or 'queued' state on disk
66
+ as failed. Call this exactly once, on process startup, before
67
+ any submit() — otherwise it would clobber freshly-queued sims.
68
+ """
69
+ if self._reaped:
70
+ return
71
+ self._reap_orphans()
72
+ self._reaped = True
73
+
74
+ def _reap_orphans(self) -> None:
75
+ """At startup, mark stale `running` / `queued` sims as failed."""
76
+ from .registry import append_stderr, sims_dir, update_status
77
+
78
+ for d in sims_dir().iterdir():
79
+ if not d.is_dir():
80
+ continue
81
+ status_path = d / "status.json"
82
+ if not status_path.exists():
83
+ continue
84
+ try:
85
+ import json as _json
86
+
87
+ status = _json.loads(status_path.read_text())
88
+ except (OSError, ValueError):
89
+ continue
90
+ state = status.get("state")
91
+ if state in ("running", "queued"):
92
+ update_status(
93
+ d.name,
94
+ state="failed",
95
+ finished_at=time.time(),
96
+ error="orphaned: simlab process restarted before this run finished",
97
+ )
98
+ append_stderr(d.name, "[reaper] marked as failed at simlab startup\n")
99
+
100
+ async def stop(self) -> None:
101
+ self._stopped = True
102
+ if self._worker_task and not self._worker_task.done():
103
+ self._worker_task.cancel()
104
+ try:
105
+ await self._worker_task
106
+ except asyncio.CancelledError:
107
+ pass
108
+
109
+ @property
110
+ def current_sim_id(self) -> str | None:
111
+ return self._current_sim_id
112
+
113
+ @property
114
+ def queue_size(self) -> int:
115
+ return self._queue.qsize()
116
+
117
+ async def submit(self, config: SimConfig) -> str:
118
+ """Allocate sim_id + persist config + enqueue. Returns sim_id."""
119
+ config.validate()
120
+ sim_id, _path = new_sim_dir()
121
+ write_config(sim_id, config.to_dict())
122
+ write_status(
123
+ sim_id,
124
+ {
125
+ "sim_id": sim_id,
126
+ "state": "queued",
127
+ "queued_at": time.time(),
128
+ "progress": {"q_done": 0, "q_total": 0, "phase": "queued"},
129
+ },
130
+ )
131
+ try:
132
+ self._queue.put_nowait(_QueuedJob(sim_id=sim_id, config=config))
133
+ except asyncio.QueueFull:
134
+ update_status(sim_id, state="failed", error="queue full", finished_at=time.time())
135
+ raise
136
+ # Lazy-start worker on first submission.
137
+ self.start()
138
+ return sim_id
139
+
140
+ async def _worker_loop(self) -> None:
141
+ # Reap orphans on the worker loop's first tick — runs in the
142
+ # asyncio event loop, after lifespan.startup has completed,
143
+ # so it can never block the boot path.
144
+ if not self._reaped:
145
+ try:
146
+ self._reap_orphans()
147
+ except Exception:
148
+ pass
149
+ self._reaped = True
150
+ while not self._stopped:
151
+ try:
152
+ job = await self._queue.get()
153
+ except asyncio.CancelledError:
154
+ return
155
+ await self._run_one(job)
156
+
157
+ async def _run_one(self, job: _QueuedJob) -> None:
158
+ self._current_sim_id = job.sim_id
159
+ output_dir = sim_dir(job.sim_id)
160
+ recorder = Recorder.scoped(job.sim_id)
161
+ update_status(
162
+ job.sim_id,
163
+ state="running",
164
+ started_at=time.time(),
165
+ progress={"q_done": 0, "q_total": 0, "phase": "running"},
166
+ )
167
+
168
+ # Capture stderr to stderr.log so failures are diagnosable from the UI.
169
+ stderr_buf = io.StringIO()
170
+ try:
171
+ with contextlib.redirect_stderr(stderr_buf):
172
+ result = await run_persona_sim(
173
+ job.config,
174
+ output_dir,
175
+ sim_id=job.sim_id,
176
+ recorder=recorder,
177
+ status_cb=lambda upd: update_status(job.sim_id, progress=upd),
178
+ )
179
+ update_status(
180
+ job.sim_id,
181
+ state="done",
182
+ finished_at=time.time(),
183
+ n_agents=result.n_agents,
184
+ n_questions=result.n_questions,
185
+ ciq_score=result.ciq_score,
186
+ eig_mean=result.eig_mean,
187
+ progress={
188
+ "q_done": result.n_questions,
189
+ "q_total": result.n_questions,
190
+ "phase": "done",
191
+ },
192
+ )
193
+ # Invalidate the knowledge index so the next /v1/knowledge call
194
+ # rebuilds with this run's claims/citations/disputes folded in.
195
+ try:
196
+ from .knowledge import invalidate as _invalidate_knowledge
197
+
198
+ _invalidate_knowledge()
199
+ except Exception:
200
+ pass
201
+ except asyncio.CancelledError:
202
+ update_status(
203
+ job.sim_id,
204
+ state="failed",
205
+ finished_at=time.time(),
206
+ error="CancelledError: sim worker cancelled",
207
+ )
208
+ raise
209
+ except Exception as exc:
210
+ update_status(
211
+ job.sim_id,
212
+ state="failed",
213
+ finished_at=time.time(),
214
+ error=f"{type(exc).__name__}: {exc}",
215
+ )
216
+ append_stderr(job.sim_id, traceback.format_exc())
217
+ finally:
218
+ captured = stderr_buf.getvalue()
219
+ if captured:
220
+ append_stderr(job.sim_id, captured)
221
+ self._current_sim_id = None
222
+ self._queue.task_done()
223
+
224
+
225
+ # Process-singleton runner (one per ASGI app instance).
226
+ _RUNNER: SimRunner | None = None
227
+
228
+
229
+ def get_runner() -> SimRunner:
230
+ global _RUNNER
231
+ if _RUNNER is None:
232
+ _RUNNER = SimRunner()
233
+ return _RUNNER
234
+
235
+
236
+ def reset_runner_for_tests() -> None:
237
+ """Recreate the singleton — used by tests that share an event loop."""
238
+ global _RUNNER
239
+ _RUNNER = None
@@ -0,0 +1,147 @@
1
+ """Public entry point for the persona-panel simulator.
2
+
3
+ `run_persona_sim(config, output_dir, recorder=None, status_cb=None)` is the
4
+ single async function callable by:
5
+ - the FastAPI/ASGI runner in `src/hypermind/simlab/runner.py`
6
+ - tests in `tests/simlab/`
7
+ - the legacy shim at `examples/scenario_persona_panel.py`
8
+
9
+ It writes:
10
+ output_dir/trace.json full Trace dataclass-as-dict
11
+ output_dir/persona_audit.jsonl audit log captured for replay determinism
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from types import SimpleNamespace
21
+ from typing import Any
22
+
23
+ from hypermind.observability.recorder import Recorder
24
+
25
+ from .config import SimConfig
26
+ from .modes import get_mode
27
+
28
+
29
+ @dataclass
30
+ class SimResult:
31
+ sim_id: str
32
+ output_dir: Path
33
+ trace_path: Path
34
+ audit_path: Path
35
+ n_agents: int
36
+ n_questions: int
37
+ elapsed_s: float
38
+ ciq_score: float | None
39
+ eig_mean: float | None = None
40
+
41
+
42
+ async def run_persona_sim(
43
+ config: SimConfig,
44
+ output_dir: Path,
45
+ *,
46
+ sim_id: str,
47
+ recorder: Recorder | None = None,
48
+ status_cb: Callable[[dict[str, Any]], None] | None = None,
49
+ ) -> SimResult:
50
+ """Run a persona-panel simulation, writing outputs into `output_dir`.
51
+
52
+ Parameters
53
+ ----------
54
+ config
55
+ Validated SimConfig.
56
+ output_dir
57
+ Existing directory where trace.json, persona_audit.jsonl, and any
58
+ other artifacts are written.
59
+ sim_id
60
+ Stable identifier (used for log fields; recorder scoping is the
61
+ runner's job).
62
+ recorder
63
+ Per-sim scoped recorder. If None, the global recorder is used.
64
+ Today the global recorder receives all events via Recorder._parent
65
+ fan-out; threading the recorder through the agent layer directly
66
+ is a future refinement.
67
+ status_cb
68
+ Optional callback invoked with dict updates during the run, e.g.
69
+ ``{"phase": "running", "q_done": 3, "q_total": 10}``. The runner
70
+ uses this to write status.json without coupling scenario.py to
71
+ the registry layer.
72
+ """
73
+ config.validate()
74
+ output_dir = Path(output_dir)
75
+ output_dir.mkdir(parents=True, exist_ok=True)
76
+ trace_path = output_dir / "trace.json"
77
+ audit_path = output_dir / "persona_audit.jsonl"
78
+ html_path = output_dir / "viewer.html" # legacy compatibility
79
+
80
+ # Build a SimpleNamespace proxy so we can call the existing main()
81
+ # without copying its 200-line body. Field names mirror the legacy
82
+ # argparse Namespace exactly (--enable-tools → enable_tools, etc.).
83
+ args = SimpleNamespace(
84
+ personas=config.personas,
85
+ replicas=config.replicas,
86
+ questions=config.questions,
87
+ topic=config.topic,
88
+ n_questions=config.n_questions,
89
+ trace=str(trace_path),
90
+ html=str(html_path),
91
+ debug=config.debug,
92
+ enable_tools=config.enable_tools,
93
+ llm_model=config.model,
94
+ deliberation_rounds=config.deliberation_rounds,
95
+ no_llm=config.no_llm,
96
+ # Swarm composition — see SimConfig docstring
97
+ swarm=config.swarm,
98
+ personas_override=config.personas_override,
99
+ # Pass sim_id through so _scenario_impl can publish persona
100
+ # metadata to the SSE-enrichment registry under this key.
101
+ sim_id=sim_id,
102
+ # Namespace slug — determines which HyperMind namespace the agents
103
+ # publish claims into. Defaults to "default" for new sims.
104
+ namespace=config.namespace,
105
+ # Mode + mode_payload — surfaced on args so mode run_fns that
106
+ # accept the legacy SimpleNamespace shape can read both. The
107
+ # binary-forecast mode ignores these.
108
+ mode=config.mode,
109
+ mode_payload=config.mode_payload or {},
110
+ # Pre-generated questions — skip the in-worker LLM question-gen call.
111
+ _pregenerated_questions=config._pregenerated_questions,
112
+ )
113
+
114
+ if status_cb:
115
+ status_cb({"phase": "running", "q_done": 0})
116
+
117
+ # Dispatch to the registered mode. ``binary-forecast`` (default)
118
+ # delegates to the legacy ``_scenario_impl.main``; other modes own
119
+ # their own writers but still drop a ``trace.json`` at args.trace.
120
+ mode_spec = get_mode(config.mode)
121
+ await mode_spec.run_fn(args, recorder=recorder)
122
+
123
+ # Read back the trace to surface KPIs to the runner.
124
+ summary = json.loads(trace_path.read_text())
125
+ # Stamp the trace with the mode that produced it so the SPA can
126
+ # branch the result view. Modes that already write ``mode`` (e.g. a
127
+ # future governance mode that emits its own trace shape) are left
128
+ # untouched.
129
+ if "mode" not in summary:
130
+ summary["mode"] = config.mode
131
+ trace_path.write_text(json.dumps(summary, indent=2, default=str))
132
+ ciq = (summary.get("collective_iq") or {}).get("score")
133
+ eig_mean = (summary.get("collective_iq") or {}).get("eig_mean")
134
+ return SimResult(
135
+ sim_id=sim_id,
136
+ output_dir=output_dir,
137
+ trace_path=trace_path,
138
+ audit_path=audit_path,
139
+ n_agents=summary.get("n_agents", config.n_agents),
140
+ n_questions=summary.get("n_questions", 0),
141
+ elapsed_s=summary.get("elapsed_s", 0.0),
142
+ ciq_score=ciq,
143
+ eig_mean=eig_mean,
144
+ )
145
+
146
+
147
+ __all__ = ["SimConfig", "SimResult", "run_persona_sim"]
@@ -0,0 +1,180 @@
1
+ """Swarm group library — saved recipes that compose personas into a panel.
2
+
3
+ A Swarm is a deployable unit. It references personas by slug (built-in
4
+ or user-custom from ``personas.py``), assigns per-member replica counts,
5
+ selects which tools the swarm has access to, and optionally appends a
6
+ shared bias prompt to every member's system message.
7
+
8
+ Disk: ``~/.hypermind/swarms/{slug}.json``.
9
+
10
+ Public API:
11
+ Swarm, SwarmMember — dataclasses
12
+ load_swarms() — list[Swarm], sorted by created_at desc
13
+ get_swarm(slug)
14
+ save_swarm(s) — atomic write; validates persona refs exist
15
+ delete_swarm(slug)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import time
22
+ from dataclasses import asdict, dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .personas import get_persona
27
+ from .registry import _write_json_atomic, home
28
+
29
+ VALID_TOOLS = frozenset({"web_search", "retrieve_document", "query_knowledge_base"})
30
+
31
+
32
+ @dataclass
33
+ class SwarmMember:
34
+ persona_slug: str
35
+ replicas: int = 1
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return asdict(self)
39
+
40
+ @classmethod
41
+ def from_dict(cls, d: dict[str, Any]) -> SwarmMember:
42
+ return cls(
43
+ persona_slug=str(d.get("persona_slug", "")),
44
+ replicas=int(d.get("replicas", 1)),
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class Swarm:
50
+ slug: str
51
+ name: str
52
+ description: str = ""
53
+ members: list[SwarmMember] = field(default_factory=list)
54
+ tools: list[str] = field(default_factory=list)
55
+ bias_prompt: str = ""
56
+ created_at_ms: int | None = None
57
+
58
+ def to_dict(self) -> dict[str, Any]:
59
+ return {
60
+ "slug": self.slug,
61
+ "name": self.name,
62
+ "description": self.description,
63
+ "members": [m.to_dict() for m in self.members],
64
+ "tools": list(self.tools),
65
+ "bias_prompt": self.bias_prompt,
66
+ "created_at_ms": self.created_at_ms,
67
+ }
68
+
69
+ @classmethod
70
+ def from_dict(cls, d: dict[str, Any]) -> Swarm:
71
+ return cls(
72
+ slug=str(d.get("slug", "")),
73
+ name=str(d.get("name", "")),
74
+ description=str(d.get("description", "")),
75
+ members=[SwarmMember.from_dict(m) for m in d.get("members", [])],
76
+ tools=list(d.get("tools", []) or []),
77
+ bias_prompt=str(d.get("bias_prompt", "")),
78
+ created_at_ms=d.get("created_at_ms"),
79
+ )
80
+
81
+ @property
82
+ def total_agents(self) -> int:
83
+ return sum(max(1, m.replicas) for m in self.members)
84
+
85
+ def validate(self, *, check_persona_refs: bool = True) -> None:
86
+ if not self.slug or not all(c.isalnum() or c in "-_" for c in self.slug):
87
+ raise ValueError(
88
+ f"slug must be alphanumeric/dash/underscore: {self.slug!r}",
89
+ )
90
+ if not self.name.strip():
91
+ raise ValueError("name is required")
92
+ if not self.members:
93
+ raise ValueError("swarm must have at least one member")
94
+ if self.total_agents > 60:
95
+ raise ValueError(
96
+ f"total agents must be ≤60, got {self.total_agents} "
97
+ "(reduce replicas or remove members)",
98
+ )
99
+ for m in self.members:
100
+ if m.replicas < 1 or m.replicas > 10:
101
+ raise ValueError(
102
+ f"replicas must be 1..10, got {m.replicas} for persona {m.persona_slug!r}",
103
+ )
104
+ if check_persona_refs and get_persona(m.persona_slug) is None:
105
+ raise ValueError(
106
+ f"unknown persona slug {m.persona_slug!r} in swarm {self.slug!r}",
107
+ )
108
+ try:
109
+ from .tool_registry import list_catalog as _list_catalog
110
+
111
+ catalog_slugs = {td.slug for td in _list_catalog()}
112
+ except Exception:
113
+ catalog_slugs = set()
114
+ valid_tools = catalog_slugs | VALID_TOOLS
115
+ for t in self.tools:
116
+ if t not in valid_tools:
117
+ raise ValueError(
118
+ f"tool {t!r} not in catalog; valid options: {sorted(valid_tools)}",
119
+ )
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Disk layout: ~/.hypermind/swarms/{slug}.json
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def swarms_dir() -> Path:
128
+ d = home() / "swarms"
129
+ d.mkdir(parents=True, exist_ok=True)
130
+ return d
131
+
132
+
133
+ def load_swarms() -> list[Swarm]:
134
+ """All saved swarms, newest-first."""
135
+ out: list[Swarm] = []
136
+ for path in swarms_dir().glob("*.json"):
137
+ try:
138
+ data = json.loads(path.read_text())
139
+ s = Swarm.from_dict(data)
140
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
141
+ continue
142
+ out.append(s)
143
+ out.sort(key=lambda s: -(s.created_at_ms or 0))
144
+ return out
145
+
146
+
147
+ def get_swarm(slug: str) -> Swarm | None:
148
+ path = swarms_dir() / f"{slug}.json"
149
+ if not path.exists():
150
+ return None
151
+ try:
152
+ return Swarm.from_dict(json.loads(path.read_text()))
153
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
154
+ return None
155
+
156
+
157
+ def save_swarm(s: Swarm) -> None:
158
+ """Persist a swarm. Validates persona references exist on save."""
159
+ if s.created_at_ms is None:
160
+ s.created_at_ms = int(time.time() * 1000)
161
+ s.validate()
162
+ _write_json_atomic(swarms_dir() / f"{s.slug}.json", s.to_dict())
163
+
164
+
165
+ def delete_swarm(slug: str) -> None:
166
+ path = swarms_dir() / f"{slug}.json"
167
+ if path.exists():
168
+ path.unlink()
169
+
170
+
171
+ __all__ = [
172
+ "VALID_TOOLS",
173
+ "Swarm",
174
+ "SwarmMember",
175
+ "delete_swarm",
176
+ "get_swarm",
177
+ "load_swarms",
178
+ "save_swarm",
179
+ "swarms_dir",
180
+ ]
@@ -0,0 +1,4 @@
1
+ # Tool handler package — one module per catalog entry.
2
+ # Each module exports:
3
+ # async def handle(args: dict, credentials: dict) -> str
4
+ # async def test_connection(credentials: dict) -> tuple[bool, str]
@@ -0,0 +1,39 @@
1
+ """Alpha Vantage financial data handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+
11
+ async def handle(args: dict[str, Any], credentials: dict[str, str]) -> str:
12
+ api_key = credentials.get("api_key", "")
13
+ if not api_key:
14
+ return json.dumps({"error": "alpha_vantage: no api_key configured"})
15
+ symbol = args.get("symbol", "").upper()
16
+ function = args.get("function", "GLOBAL_QUOTE")
17
+ qs = urllib.parse.urlencode({"function": function, "symbol": symbol, "apikey": api_key})
18
+ try:
19
+ with urllib.request.urlopen(f"https://www.alphavantage.co/query?{qs}", timeout=10) as resp:
20
+ data = json.loads(resp.read())
21
+ if "Note" in data:
22
+ return json.dumps({"error": "Rate limit reached", "symbol": symbol})
23
+ if "Error Message" in data:
24
+ return json.dumps({"error": data["Error Message"], "symbol": symbol})
25
+ return json.dumps({"symbol": symbol, "function": function, "data": data})
26
+ except Exception as e:
27
+ return json.dumps({"error": str(e), "symbol": symbol})
28
+
29
+
30
+ async def test_connection(credentials: dict[str, str]) -> tuple[bool, str]:
31
+ try:
32
+ result = json.loads(
33
+ await handle({"symbol": "IBM", "function": "GLOBAL_QUOTE"}, credentials)
34
+ )
35
+ if "error" in result:
36
+ return False, result["error"]
37
+ return True, "Connected — Alpha Vantage responding"
38
+ except Exception as e:
39
+ return False, str(e)
@@ -0,0 +1,46 @@
1
+ """Brave Web Search handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ async def handle(args: dict[str, Any], credentials: dict[str, str]) -> str:
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ api_key = credentials.get("api_key", "")
14
+ if not api_key:
15
+ return json.dumps({"error": "brave_web_search: no api_key configured"})
16
+ query = args.get("query", "")
17
+ max_results = int(args.get("max_results", 5))
18
+ qs = urllib.parse.urlencode({"q": query, "count": max_results})
19
+ req = urllib.request.Request(
20
+ f"https://api.search.brave.com/res/v1/web/search?{qs}",
21
+ headers={"Accept": "application/json", "X-Subscription-Token": api_key},
22
+ )
23
+ try:
24
+ with urllib.request.urlopen(req, timeout=10) as resp:
25
+ data = json.loads(resp.read())
26
+ results = [
27
+ {
28
+ "title": w.get("title", ""),
29
+ "url": w.get("url", ""),
30
+ "description": w.get("description", ""),
31
+ }
32
+ for w in data.get("web", {}).get("results", [])[:max_results]
33
+ ]
34
+ return json.dumps({"query": query, "results": results})
35
+ except Exception as e:
36
+ return json.dumps({"error": str(e), "query": query, "results": []})
37
+
38
+
39
+ async def test_connection(credentials: dict[str, str]) -> tuple[bool, str]:
40
+ try:
41
+ result = json.loads(await handle({"query": "test", "max_results": 1}, credentials))
42
+ if "error" in result:
43
+ return False, result["error"]
44
+ return True, f"Connected — {len(result.get('results', []))} result(s) returned"
45
+ except Exception as e:
46
+ return False, str(e)