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,27 @@
|
|
|
1
|
+
"""Backend factory — get the right StorageBackend for a namespace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..namespace import get_namespace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_backend(namespace_slug: str = "default"):
|
|
9
|
+
"""Return the StorageBackend for the given namespace slug."""
|
|
10
|
+
from .local import LocalBackend
|
|
11
|
+
|
|
12
|
+
ns = get_namespace(namespace_slug)
|
|
13
|
+
if ns is None:
|
|
14
|
+
return LocalBackend()
|
|
15
|
+
profile = ns.profile
|
|
16
|
+
if profile.tier == "local":
|
|
17
|
+
return LocalBackend()
|
|
18
|
+
elif profile.tier == "server":
|
|
19
|
+
from .server import ServerBackend
|
|
20
|
+
|
|
21
|
+
return ServerBackend(profile.storage_url or "sqlite:///default")
|
|
22
|
+
elif profile.tier == "decentralised":
|
|
23
|
+
from .anchor import DecentralisedBackend
|
|
24
|
+
from .local import LocalBackend as _L
|
|
25
|
+
|
|
26
|
+
return DecentralisedBackend(inner=_L(), profile=profile)
|
|
27
|
+
return LocalBackend()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""DecentralisedBackend — wraps any StorageBackend and adds blockchain anchoring post-sim."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from ..deployment import StorageBackend
|
|
10
|
+
from ..namespace import DeploymentProfile
|
|
11
|
+
|
|
12
|
+
# AnchorRegistry ABI (minimal — only the anchor function)
|
|
13
|
+
_ANCHOR_ABI_SELECTOR = bytes.fromhex(
|
|
14
|
+
"a4b2c1d3"
|
|
15
|
+
) # keccak256("anchor(bytes32,string,string)")[:4] placeholder
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DecentralisedBackend:
|
|
19
|
+
def __init__(self, inner: StorageBackend, profile: DeploymentProfile) -> None:
|
|
20
|
+
self._inner = inner
|
|
21
|
+
self._profile = profile
|
|
22
|
+
|
|
23
|
+
def write_file(self, sim_id: str, filename: str, data: bytes, mode: int | None = None) -> None:
|
|
24
|
+
self._inner.write_file(sim_id, filename, data, mode)
|
|
25
|
+
|
|
26
|
+
def read_file(self, sim_id: str, filename: str) -> bytes | None:
|
|
27
|
+
return self._inner.read_file(sim_id, filename)
|
|
28
|
+
|
|
29
|
+
def list_sim_ids(self) -> list[str]:
|
|
30
|
+
return self._inner.list_sim_ids()
|
|
31
|
+
|
|
32
|
+
def post_sim_hook(self, sim_id: str, namespace: str, trace: dict) -> None:
|
|
33
|
+
"""Compute claims commitment and submit anchor transaction."""
|
|
34
|
+
key = os.environ.get("HYPERMIND_ANCHOR_KEY", "").strip()
|
|
35
|
+
rpc_url = self._profile.anchor_rpc_url or ""
|
|
36
|
+
# Resolve env var reference like "$BASE_RPC_URL"
|
|
37
|
+
if rpc_url.startswith("$"):
|
|
38
|
+
rpc_url = os.environ.get(rpc_url[1:], "")
|
|
39
|
+
if not key or not rpc_url:
|
|
40
|
+
# Anchor silently skipped — key or RPC not configured
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
commitment = _compute_commitment(sim_id, namespace, trace)
|
|
44
|
+
tx_hash, block = _submit_anchor(
|
|
45
|
+
rpc_url, key, commitment, sim_id, namespace, self._profile.anchor_contract
|
|
46
|
+
)
|
|
47
|
+
# Update status.json with anchor data
|
|
48
|
+
status_bytes = self._inner.read_file(sim_id, "status.json")
|
|
49
|
+
if status_bytes:
|
|
50
|
+
status = json.loads(status_bytes)
|
|
51
|
+
status["anchor_tx_hash"] = tx_hash
|
|
52
|
+
status["anchor_block"] = block
|
|
53
|
+
status["anchor_chain"] = self._profile.anchor_chain or "unknown"
|
|
54
|
+
status["anchor_commitment"] = commitment.hex()
|
|
55
|
+
self._inner.write_file(sim_id, "status.json", json.dumps(status, indent=2).encode())
|
|
56
|
+
except Exception as e:
|
|
57
|
+
# Anchor failure is non-fatal — log and continue
|
|
58
|
+
import structlog
|
|
59
|
+
|
|
60
|
+
structlog.get_logger().warning("anchor_failed", sim_id=sim_id, error=str(e))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _compute_commitment(sim_id: str, namespace: str, trace: dict) -> bytes:
|
|
64
|
+
"""SHA-256 of sorted claim kids + sim_id + namespace. Reproducible from trace.json."""
|
|
65
|
+
agents = trace.get("agents", [])
|
|
66
|
+
kids = sorted(c.get("kid", "") for a in agents for c in a.get("published_claims", []))
|
|
67
|
+
finished_at = str(trace.get("finished_at", 0))
|
|
68
|
+
payload = "|".join([sim_id, namespace, finished_at, *kids])
|
|
69
|
+
return hashlib.sha256(payload.encode()).digest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _submit_anchor(
|
|
73
|
+
rpc_url: str,
|
|
74
|
+
private_key_hex: str,
|
|
75
|
+
commitment: bytes,
|
|
76
|
+
sim_id: str,
|
|
77
|
+
namespace: str,
|
|
78
|
+
contract_addr: str | None,
|
|
79
|
+
) -> tuple[str, int]:
|
|
80
|
+
"""Submit anchor transaction via raw JSON-RPC. Returns (tx_hash, block_number)."""
|
|
81
|
+
# Use httpx for JSON-RPC — already a dependency via tool_handlers
|
|
82
|
+
|
|
83
|
+
# Note: eth_utils_lite is a minimal helper we'll implement inline below
|
|
84
|
+
# to avoid adding web3 as a dependency.
|
|
85
|
+
raise NotImplementedError(
|
|
86
|
+
"Blockchain anchor requires configuring HYPERMIND_ANCHOR_KEY and anchor_rpc_url. "
|
|
87
|
+
"See docs/deployment.md for setup instructions."
|
|
88
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""LocalBackend — wraps registry.py for zero-change behaviour on the 'local' tier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..registry import sim_dir, sims_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LocalBackend:
|
|
9
|
+
def write_file(self, sim_id: str, filename: str, data: bytes, mode: int | None = None) -> None:
|
|
10
|
+
path = sim_dir(sim_id) / filename
|
|
11
|
+
if mode is not None:
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
15
|
+
tmp.write_bytes(data)
|
|
16
|
+
os.chmod(tmp, mode)
|
|
17
|
+
tmp.replace(path)
|
|
18
|
+
else:
|
|
19
|
+
path.write_bytes(data)
|
|
20
|
+
|
|
21
|
+
def read_file(self, sim_id: str, filename: str) -> bytes | None:
|
|
22
|
+
path = sim_dir(sim_id) / filename
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return None
|
|
25
|
+
return path.read_bytes()
|
|
26
|
+
|
|
27
|
+
def list_sim_ids(self) -> list[str]:
|
|
28
|
+
d = sims_dir()
|
|
29
|
+
return sorted(p.name for p in d.iterdir() if p.is_dir())
|
|
30
|
+
|
|
31
|
+
def post_sim_hook(self, sim_id: str, namespace: str, trace: dict) -> None:
|
|
32
|
+
pass # local tier: no post-sim action
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""ServerBackend — SQLite (MVP) or Postgres storage for shared/multi-user deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_sqlite_path(storage_url: str) -> str:
|
|
12
|
+
"""Extract file path from sqlite:///path or sqlite:////abs/path."""
|
|
13
|
+
if storage_url.startswith("sqlite:///"):
|
|
14
|
+
return storage_url[len("sqlite:///") :]
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"Unsupported storage_url format: {storage_url!r}. Use sqlite:///path/to/db.sqlite"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ServerBackend:
|
|
21
|
+
def __init__(self, storage_url: str) -> None:
|
|
22
|
+
self._db_path = _parse_sqlite_path(storage_url)
|
|
23
|
+
self._init_db()
|
|
24
|
+
|
|
25
|
+
def _conn(self) -> sqlite3.Connection:
|
|
26
|
+
con = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
27
|
+
con.row_factory = sqlite3.Row
|
|
28
|
+
return con
|
|
29
|
+
|
|
30
|
+
def _init_db(self) -> None:
|
|
31
|
+
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with self._conn() as con:
|
|
33
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
34
|
+
con.execute("PRAGMA synchronous=NORMAL")
|
|
35
|
+
con.execute("PRAGMA busy_timeout=5000")
|
|
36
|
+
con.execute("""
|
|
37
|
+
CREATE TABLE IF NOT EXISTS sim_files (
|
|
38
|
+
sim_id TEXT NOT NULL,
|
|
39
|
+
filename TEXT NOT NULL,
|
|
40
|
+
data BLOB NOT NULL,
|
|
41
|
+
updated_at INTEGER NOT NULL,
|
|
42
|
+
PRIMARY KEY (sim_id, filename)
|
|
43
|
+
)
|
|
44
|
+
""")
|
|
45
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_sim_files_sim_id ON sim_files(sim_id)")
|
|
46
|
+
|
|
47
|
+
def write_file(self, sim_id: str, filename: str, data: bytes, mode: int | None = None) -> None:
|
|
48
|
+
with self._conn() as con:
|
|
49
|
+
con.execute(
|
|
50
|
+
"INSERT OR REPLACE INTO sim_files (sim_id, filename, data, updated_at) VALUES (?,?,?,?)",
|
|
51
|
+
(sim_id, filename, data, int(time.time() * 1000)),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def read_file(self, sim_id: str, filename: str) -> bytes | None:
|
|
55
|
+
with self._conn() as con:
|
|
56
|
+
row = con.execute(
|
|
57
|
+
"SELECT data FROM sim_files WHERE sim_id=? AND filename=?", (sim_id, filename)
|
|
58
|
+
).fetchone()
|
|
59
|
+
return bytes(row["data"]) if row else None
|
|
60
|
+
|
|
61
|
+
def list_sim_ids(self) -> list[str]:
|
|
62
|
+
with self._conn() as con:
|
|
63
|
+
rows = con.execute(
|
|
64
|
+
"SELECT DISTINCT sim_id FROM sim_files WHERE filename='status.json' ORDER BY sim_id DESC"
|
|
65
|
+
).fetchall()
|
|
66
|
+
return [r["sim_id"] for r in rows]
|
|
67
|
+
|
|
68
|
+
def read_trace(self, sim_id: str) -> dict | None:
|
|
69
|
+
"""Return the parsed trace.json for *sim_id*, or None if not found."""
|
|
70
|
+
raw = self.read_file(sim_id, "trace.json")
|
|
71
|
+
if raw is None:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(raw)
|
|
75
|
+
except (json.JSONDecodeError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def post_sim_hook(self, sim_id: str, namespace: str, trace: dict) -> None:
|
|
79
|
+
pass # server tier: no anchor
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Implementation of the ``hmctl simlab`` command.
|
|
2
|
+
|
|
3
|
+
Boots the SimLab ASGI app via uvicorn on a single port (default 8765),
|
|
4
|
+
serves the Vue SPA at ``/`` and the API at ``/v1/sims/*``, optionally
|
|
5
|
+
opens a browser tab.
|
|
6
|
+
|
|
7
|
+
Auto-loads ``.env`` from the current working directory or any parent
|
|
8
|
+
directory (until a project root marker like pyproject.toml or .git is
|
|
9
|
+
found), so OPENROUTER_API_KEY etc. are picked up without manual export.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import threading
|
|
17
|
+
import webbrowser
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from hypermind.simlab.app import simlab_app
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _install_sigterm_handler() -> None:
|
|
24
|
+
"""Install a SIGTERM handler that allows up to 10 s for in-flight
|
|
25
|
+
requests to drain before exiting cleanly.
|
|
26
|
+
|
|
27
|
+
Uvicorn installs its own SIGTERM handler when it starts, so this is
|
|
28
|
+
installed *before* ``uvicorn.run()`` — uvicorn will overwrite it. The
|
|
29
|
+
handler is here as a safety net for cases where the process is started
|
|
30
|
+
without uvicorn's signal machinery (e.g. direct Python subprocess).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def _handle_sigterm(signum: int, frame: object) -> None: # type: ignore[type-arg]
|
|
34
|
+
def _deferred_exit() -> None:
|
|
35
|
+
import time
|
|
36
|
+
|
|
37
|
+
time.sleep(0.1) # brief drain window for in-flight requests
|
|
38
|
+
os._exit(0)
|
|
39
|
+
|
|
40
|
+
t = threading.Thread(target=_deferred_exit, daemon=True)
|
|
41
|
+
t.start()
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
# May fail in non-main threads or on platforms that don't support it.
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_env_file() -> Path | None:
|
|
51
|
+
"""Walk up from cwd looking for a .env; load it without overwriting
|
|
52
|
+
existing env vars. Returns the path loaded, or None.
|
|
53
|
+
"""
|
|
54
|
+
here = Path.cwd().resolve()
|
|
55
|
+
for parent in [here, *here.parents]:
|
|
56
|
+
env_path = parent / ".env"
|
|
57
|
+
if env_path.exists():
|
|
58
|
+
for raw in env_path.read_text().splitlines():
|
|
59
|
+
line = raw.strip()
|
|
60
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
61
|
+
continue
|
|
62
|
+
key, _, value = line.partition("=")
|
|
63
|
+
key = key.strip()
|
|
64
|
+
value = value.strip().strip('"').strip("'")
|
|
65
|
+
# Don't override an explicitly-exported var
|
|
66
|
+
if key and key not in os.environ:
|
|
67
|
+
os.environ[key] = value
|
|
68
|
+
return env_path
|
|
69
|
+
if (parent / "pyproject.toml").exists() or (parent / ".git").is_dir():
|
|
70
|
+
break
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_simlab(*, port: int, host: str = "127.0.0.1", open_browser: bool = True) -> None:
|
|
75
|
+
"""Block on a uvicorn server hosting the SimLab app."""
|
|
76
|
+
try:
|
|
77
|
+
import uvicorn
|
|
78
|
+
except ImportError as err: # pragma: no cover - install-time guard
|
|
79
|
+
raise SystemExit(
|
|
80
|
+
"uvicorn is required for `hmctl simlab`. "
|
|
81
|
+
"Install with `pip install 'hypermind[server]'` or `pip install uvicorn`."
|
|
82
|
+
) from err
|
|
83
|
+
|
|
84
|
+
env_loaded = _load_env_file()
|
|
85
|
+
has_key = bool(os.environ.get("OPENROUTER_API_KEY", "").strip())
|
|
86
|
+
|
|
87
|
+
app = simlab_app()
|
|
88
|
+
url = f"http://{host}:{port}/"
|
|
89
|
+
|
|
90
|
+
if open_browser:
|
|
91
|
+
try:
|
|
92
|
+
webbrowser.open(url, new=2)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
print(f" HyperMind SimLab listening on {url}")
|
|
97
|
+
print(f" API: {url}v1/sims")
|
|
98
|
+
print(f" Health: {url}v1/healthz")
|
|
99
|
+
if env_loaded:
|
|
100
|
+
print(f" Loaded: {env_loaded}")
|
|
101
|
+
if has_key:
|
|
102
|
+
print(" LLM: OPENROUTER_API_KEY detected — real LLM mode available")
|
|
103
|
+
else:
|
|
104
|
+
print(" LLM: no OPENROUTER_API_KEY — sims will run synthetic")
|
|
105
|
+
print(" (add to .env or `export OPENROUTER_API_KEY=…` to enable)")
|
|
106
|
+
print(" (ctrl-c to stop)")
|
|
107
|
+
_install_sigterm_handler()
|
|
108
|
+
uvicorn.run(app, host=host, port=port, log_level="warning", lifespan="on", access_log=False)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""SimConfig — declarative configuration for a persona-panel simulation run.
|
|
2
|
+
|
|
3
|
+
This is the on-the-wire shape POSTed to `/v1/sims` and the dict-shape
|
|
4
|
+
written to `~/.hypermind/sims/{id}/config.json`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODEL = "openai/gpt-4o-mini"
|
|
13
|
+
DEFAULT_QUESTIONS = "msft_outlook_q10"
|
|
14
|
+
DEFAULT_MODE = "binary-forecast"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SimConfig:
|
|
19
|
+
personas: int = 6
|
|
20
|
+
replicas: int = 5
|
|
21
|
+
# Either a free-text topic the panel should deliberate on, OR a saved
|
|
22
|
+
# question-set name. ``topic`` wins if both are set.
|
|
23
|
+
topic: str = ""
|
|
24
|
+
n_questions: int = 8
|
|
25
|
+
questions: str = DEFAULT_QUESTIONS # legacy: saved set name or path
|
|
26
|
+
model: str = DEFAULT_MODEL
|
|
27
|
+
enable_tools: bool = False
|
|
28
|
+
deliberation_rounds: int = 2
|
|
29
|
+
no_llm: bool = False
|
|
30
|
+
debug: bool = False # terminal narrative; not a UI concern, but kept for parity
|
|
31
|
+
# Swarm composition — three layered ways to specify the panel:
|
|
32
|
+
# 1. ``swarm`` slug (highest priority): full Swarm recipe — members,
|
|
33
|
+
# tools, and bias_prompt all come from the saved swarm.
|
|
34
|
+
# 2. ``personas_override``: ad-hoc list of persona slugs (each with
|
|
35
|
+
# ``replicas`` copies). Used by tests + the CLI.
|
|
36
|
+
# 3. fallback: count-based ``personas: int`` slicing the first N
|
|
37
|
+
# built-in personas (legacy behavior, backward compatible).
|
|
38
|
+
swarm: str | None = None
|
|
39
|
+
personas_override: list[str] | None = None
|
|
40
|
+
namespace: str = "default"
|
|
41
|
+
# Deliberation mode — selects the result shape (binary probability,
|
|
42
|
+
# governance receipt, scenario tree, ...). Resolved against the
|
|
43
|
+
# registry in ``simlab.modes``. Default preserves legacy behaviour.
|
|
44
|
+
mode: str = DEFAULT_MODE
|
|
45
|
+
# Mode-specific input payload (e.g. model_card for governance,
|
|
46
|
+
# drivers list for what-if-tree). Validated by the mode registry,
|
|
47
|
+
# not by SimConfig itself, so adding a new mode does not require
|
|
48
|
+
# touching this file.
|
|
49
|
+
mode_payload: dict[str, Any] | None = None
|
|
50
|
+
# Pre-generated questions from the topic-adapter pre-flight step.
|
|
51
|
+
# When set, _scenario_impl skips the in-worker generate_questions_for_topic
|
|
52
|
+
# call entirely — saves one LLM round-trip off the hot path.
|
|
53
|
+
# Not persisted to config.json (excluded from to_dict via from_dict filter).
|
|
54
|
+
_pregenerated_questions: list | None = None
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
d = asdict(self)
|
|
58
|
+
d.pop("_pregenerated_questions", None)
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, d: dict[str, Any]) -> SimConfig:
|
|
63
|
+
# Permissive — silently drop unknown keys; clamp numeric ranges
|
|
64
|
+
known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
|
65
|
+
clean = {k: v for k, v in d.items() if k in known}
|
|
66
|
+
cfg = cls(**clean)
|
|
67
|
+
cfg.validate()
|
|
68
|
+
return cfg
|
|
69
|
+
|
|
70
|
+
def validate(self) -> None:
|
|
71
|
+
# Mode must be registered. Lazy-import to avoid a circular dep
|
|
72
|
+
# (modes registry imports config types).
|
|
73
|
+
from .modes import get_mode, is_registered_mode
|
|
74
|
+
|
|
75
|
+
if not is_registered_mode(self.mode):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"unknown mode {self.mode!r}; registered modes: "
|
|
78
|
+
f"see hypermind.simlab.modes.list_modes()"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Modes that read only ``mode_payload`` skip all legacy-field
|
|
82
|
+
# validation — they don't use personas/replicas/topic/questions.
|
|
83
|
+
mode_spec = get_mode(self.mode)
|
|
84
|
+
if not mode_spec.uses_legacy_fields:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Legacy validation (binary-forecast and any other mode that opts
|
|
88
|
+
# into the original SimConfig fields).
|
|
89
|
+
roster_overridden = bool(self.swarm) or bool(self.personas_override)
|
|
90
|
+
if not roster_overridden:
|
|
91
|
+
if not 1 <= self.personas <= 50:
|
|
92
|
+
raise ValueError(f"personas must be 1..50, got {self.personas}")
|
|
93
|
+
if not 1 <= self.replicas <= 200:
|
|
94
|
+
raise ValueError(f"replicas must be 1..200, got {self.replicas}")
|
|
95
|
+
if not 1 <= self.deliberation_rounds <= 10:
|
|
96
|
+
raise ValueError(f"deliberation_rounds must be 1..10, got {self.deliberation_rounds}")
|
|
97
|
+
# Either a topic or a saved set is acceptable. Topic wins.
|
|
98
|
+
topic = (self.topic or "").strip()
|
|
99
|
+
if not topic and not self.questions:
|
|
100
|
+
raise ValueError("provide either a topic or a question-set name")
|
|
101
|
+
if not 1 <= self.n_questions <= 200:
|
|
102
|
+
raise ValueError(f"n_questions must be 1..200, got {self.n_questions}")
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def n_agents(self) -> int:
|
|
106
|
+
return self.personas * self.replicas
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""StorageBackend protocol — all persistence routes through this interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class StorageBackend(Protocol):
|
|
10
|
+
"""Persistence interface for simulation artifacts."""
|
|
11
|
+
|
|
12
|
+
def write_file(self, sim_id: str, filename: str, data: bytes, mode: int | None = None) -> None:
|
|
13
|
+
"""Write a file for a given sim_id."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def read_file(self, sim_id: str, filename: str) -> bytes | None:
|
|
17
|
+
"""Read a file for a given sim_id. Returns None if not found."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def list_sim_ids(self) -> list[str]:
|
|
21
|
+
"""List all known sim_ids managed by this backend."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def post_sim_hook(self, sim_id: str, namespace: str, trace: dict) -> None:
|
|
25
|
+
"""Called after a sim completes. Used for anchoring in decentralised tier."""
|
|
26
|
+
...
|