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
|
@@ -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,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)
|