hypermind 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
hypermind/simlab/app.py
ADDED
|
@@ -0,0 +1,2538 @@
|
|
|
1
|
+
"""ASGI app exposing /v1/sims/* and the static SPA.
|
|
2
|
+
|
|
3
|
+
This is a standalone app (not bound to any HyperMindAgent). It re-uses
|
|
4
|
+
the helpers from ``hypermind.api.app`` (``_send_json``, ``_err``, etc.)
|
|
5
|
+
and follows the same manual-dispatch pattern.
|
|
6
|
+
|
|
7
|
+
Mounted at ``/`` by ``hmctl simlab``. Routes:
|
|
8
|
+
|
|
9
|
+
GET / → index.html (SPA)
|
|
10
|
+
GET /assets/* → static SPA bundle
|
|
11
|
+
GET /v1/healthz → liveness
|
|
12
|
+
GET /v1/sims → list of summaries
|
|
13
|
+
POST /v1/sims → enqueue new sim, returns {sim_id}
|
|
14
|
+
GET /v1/sims/{id} → full trace.json (200 once done)
|
|
15
|
+
GET /v1/sims/{id}/status → status.json
|
|
16
|
+
GET /v1/sims/{id}/events/stream → SSE per-sim recorder events
|
|
17
|
+
GET /v1/stats → namespace + federation summary
|
|
18
|
+
GET /v1/disputes → dispute records across all sims
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import mimetypes
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from hypermind import __version__
|
|
32
|
+
from hypermind.api.app import (
|
|
33
|
+
_read_body,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .config import SimConfig
|
|
37
|
+
from .knowledge import KnowledgeIndex, _ingest_trace, build_knowledge_index
|
|
38
|
+
from .registry import (
|
|
39
|
+
_write_json_atomic,
|
|
40
|
+
list_sims,
|
|
41
|
+
load_status,
|
|
42
|
+
load_trace,
|
|
43
|
+
sim_dir,
|
|
44
|
+
update_status,
|
|
45
|
+
)
|
|
46
|
+
from .runner import get_runner
|
|
47
|
+
|
|
48
|
+
_Scope = dict
|
|
49
|
+
_Receive = Callable[[], Awaitable[dict]]
|
|
50
|
+
_Send = Callable[[dict], Awaitable[None]]
|
|
51
|
+
|
|
52
|
+
# Validate sim_id to prevent path traversal attacks
|
|
53
|
+
_SIM_ID_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]{10}$")
|
|
54
|
+
|
|
55
|
+
# Maximum number of sims that may be queued at once.
|
|
56
|
+
MAX_QUEUE = 10
|
|
57
|
+
|
|
58
|
+
_CORS_HEADERS = [
|
|
59
|
+
(b"access-control-allow-origin", b"*"),
|
|
60
|
+
(b"access-control-allow-methods", b"GET, POST, PUT, DELETE, OPTIONS"),
|
|
61
|
+
(b"access-control-allow-headers", b"Content-Type, Authorization"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _add_cors(headers: list) -> list:
|
|
66
|
+
"""Return a copy of *headers* with CORS headers appended."""
|
|
67
|
+
return list(headers) + _CORS_HEADERS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _send_json(send: _Send, *, status: int, payload: Any) -> None:
|
|
71
|
+
"""Like api._send_json but always includes CORS headers."""
|
|
72
|
+
import json as _json
|
|
73
|
+
|
|
74
|
+
def _json_default(obj: Any) -> Any:
|
|
75
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
76
|
+
return obj.hex()
|
|
77
|
+
if hasattr(obj, "to_dict"):
|
|
78
|
+
return obj.to_dict()
|
|
79
|
+
if hasattr(obj, "__dict__"):
|
|
80
|
+
return obj.__dict__
|
|
81
|
+
return str(obj)
|
|
82
|
+
|
|
83
|
+
body = _json.dumps(payload, default=_json_default).encode("utf-8")
|
|
84
|
+
await send(
|
|
85
|
+
{
|
|
86
|
+
"type": "http.response.start",
|
|
87
|
+
"status": status,
|
|
88
|
+
"headers": _add_cors(
|
|
89
|
+
[
|
|
90
|
+
(b"content-type", b"application/json"),
|
|
91
|
+
(b"content-length", str(len(body)).encode()),
|
|
92
|
+
]
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _err(send: _Send, status: int, message: str) -> None:
|
|
100
|
+
"""Like api._err but always includes CORS headers."""
|
|
101
|
+
await _send_json(send, status=status, payload={"error": message})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---- SPA static dispatch ---------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ui_dist_dir() -> Path | None:
|
|
108
|
+
"""Locate the bundled SPA dist directory.
|
|
109
|
+
|
|
110
|
+
Resolution order:
|
|
111
|
+
1. The dev-source webui/dist (when running from a checkout)
|
|
112
|
+
2. The package's bundled simlab/ui/dist (after pip install)
|
|
113
|
+
"""
|
|
114
|
+
pkg_path = Path(__file__).resolve().parent / "ui" / "dist"
|
|
115
|
+
if pkg_path.exists():
|
|
116
|
+
return pkg_path
|
|
117
|
+
# Dev tree: webui/dist sibling of src/
|
|
118
|
+
for parent in Path(__file__).resolve().parents:
|
|
119
|
+
candidate = parent / "webui" / "dist"
|
|
120
|
+
if candidate.exists():
|
|
121
|
+
return candidate
|
|
122
|
+
if (parent / "pyproject.toml").exists():
|
|
123
|
+
break
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_PLACEHOLDER_INDEX = b"""<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
128
|
+
<title>HyperMind SimLab</title>
|
|
129
|
+
<style>body{font-family:system-ui;max-width:720px;margin:80px auto;padding:24px;
|
|
130
|
+
color:#11161D;background:#FDFDFC}h1{font-weight:500}code{background:#F6F7F9;
|
|
131
|
+
padding:2px 6px;border-radius:4px}</style></head>
|
|
132
|
+
<body><h1>HyperMind SimLab</h1>
|
|
133
|
+
<p>The Vue SPA has not been built yet.</p>
|
|
134
|
+
<p>From the repository root, run:</p>
|
|
135
|
+
<pre>cd webui && npm ci && npm run build</pre>
|
|
136
|
+
<p>Then refresh this page. The API is live at
|
|
137
|
+
<a href="/v1/sims"><code>/v1/sims</code></a>.</p>
|
|
138
|
+
</body></html>
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _serve_static(scope: _Scope, send: _Send, *, dist: Path | None) -> bool:
|
|
143
|
+
"""Serve a static file from the SPA dist. Returns True if handled."""
|
|
144
|
+
path: str = scope.get("path", "/")
|
|
145
|
+
method: str = scope.get("method", "GET").upper()
|
|
146
|
+
if method != "GET":
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
if dist is None:
|
|
150
|
+
# No SPA built yet — return placeholder for /, 404 for assets.
|
|
151
|
+
if path in ("/", "/index.html"):
|
|
152
|
+
await send(
|
|
153
|
+
{
|
|
154
|
+
"type": "http.response.start",
|
|
155
|
+
"status": 200,
|
|
156
|
+
"headers": [(b"content-type", b"text/html; charset=utf-8")],
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
await send(
|
|
160
|
+
{"type": "http.response.body", "body": _PLACEHOLDER_INDEX, "more_body": False}
|
|
161
|
+
)
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
if path in ("/", "/index.html"):
|
|
166
|
+
target = dist / "index.html"
|
|
167
|
+
else:
|
|
168
|
+
# Strip leading /
|
|
169
|
+
target = dist / path.lstrip("/")
|
|
170
|
+
|
|
171
|
+
# Resolve symlinks; reject path traversal.
|
|
172
|
+
try:
|
|
173
|
+
resolved = target.resolve()
|
|
174
|
+
resolved.relative_to(dist.resolve())
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
if not resolved.exists() or not resolved.is_file():
|
|
179
|
+
# SPA fallback: any unknown route serves index.html for client routing
|
|
180
|
+
if path.startswith("/v1/") or path.startswith("/assets/"):
|
|
181
|
+
return False
|
|
182
|
+
target = dist / "index.html"
|
|
183
|
+
if not target.exists():
|
|
184
|
+
return False
|
|
185
|
+
resolved = target.resolve()
|
|
186
|
+
|
|
187
|
+
ct = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream"
|
|
188
|
+
body = resolved.read_bytes()
|
|
189
|
+
await send(
|
|
190
|
+
{
|
|
191
|
+
"type": "http.response.start",
|
|
192
|
+
"status": 200,
|
|
193
|
+
"headers": [
|
|
194
|
+
(b"content-type", ct.encode()),
|
|
195
|
+
(b"content-length", str(len(body)).encode()),
|
|
196
|
+
(b"cache-control", b"no-cache"),
|
|
197
|
+
],
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---- /v1/namespaces handlers -----------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _handle_create_namespace(body: bytes, send: _Send) -> None:
|
|
208
|
+
import time
|
|
209
|
+
|
|
210
|
+
from .namespace import (
|
|
211
|
+
DeploymentProfile,
|
|
212
|
+
Namespace,
|
|
213
|
+
get_namespace,
|
|
214
|
+
save_namespace,
|
|
215
|
+
validate_slug,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
payload = json.loads(body) if body else {}
|
|
220
|
+
slug = payload.get("slug", "").strip()
|
|
221
|
+
validate_slug(slug)
|
|
222
|
+
if get_namespace(slug) is not None:
|
|
223
|
+
await _send_json(
|
|
224
|
+
send, status=409, payload={"error": f"namespace {slug!r} already exists"}
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
profile = DeploymentProfile.from_dict(
|
|
228
|
+
{
|
|
229
|
+
"tier": payload.get("tier", "local"),
|
|
230
|
+
"storage_url": payload.get("storage_url"),
|
|
231
|
+
"visibility": payload.get("visibility", "private"),
|
|
232
|
+
"anchor_chain": payload.get("anchor_chain"),
|
|
233
|
+
"anchor_contract": payload.get("anchor_contract"),
|
|
234
|
+
"anchor_rpc_url": payload.get("anchor_rpc_url"),
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
ns = Namespace(
|
|
238
|
+
slug=slug,
|
|
239
|
+
label=payload.get("label", slug),
|
|
240
|
+
profile=profile,
|
|
241
|
+
created_at_ms=int(time.time() * 1000),
|
|
242
|
+
)
|
|
243
|
+
save_namespace(ns)
|
|
244
|
+
await _send_json(send, status=201, payload=ns.to_dict())
|
|
245
|
+
except ValueError as e:
|
|
246
|
+
await _send_json(send, status=422, payload={"error": str(e)})
|
|
247
|
+
except Exception as e:
|
|
248
|
+
await _send_json(send, status=500, payload={"error": str(e)})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---- /v1/modes handler -----------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _handle_list_modes(send: _Send) -> None:
|
|
255
|
+
"""GET /v1/modes — return the registered deliberation modes.
|
|
256
|
+
|
|
257
|
+
Powers the schema-driven SimNew form: the SPA reads this on page
|
|
258
|
+
load, renders a dropdown, and (for modes with input_schema != null)
|
|
259
|
+
renders a dynamic form based on the schema.
|
|
260
|
+
"""
|
|
261
|
+
from .modes import list_modes
|
|
262
|
+
|
|
263
|
+
payload = [m.to_dict() for m in list_modes()]
|
|
264
|
+
await _send_json(send, status=200, payload=payload)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---- /v1/sims handlers -----------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _handle_list_sims(send: _Send) -> None:
|
|
271
|
+
sims = [s.to_dict() for s in list_sims(limit=1000)]
|
|
272
|
+
await _send_json(send, status=200, payload=sims)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _handle_create_sim(body: bytes, send: _Send) -> None:
|
|
276
|
+
try:
|
|
277
|
+
payload = json.loads(body) if body else {}
|
|
278
|
+
except json.JSONDecodeError as exc:
|
|
279
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
280
|
+
return
|
|
281
|
+
if not isinstance(payload, dict):
|
|
282
|
+
await _err(send, 400, "expected JSON object")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Topic-adaptive configuration: if the caller supplied only a topic
|
|
286
|
+
# (or left numeric params at sentinel defaults), infer the right
|
|
287
|
+
# n_questions / personas / replicas / deliberation_rounds from the topic.
|
|
288
|
+
topic = (payload.get("topic") or "").strip()
|
|
289
|
+
if topic:
|
|
290
|
+
from .topic_adapter import apply_profile_to_config, infer_topic_profile
|
|
291
|
+
from .topic_questions import generate_questions_for_topic
|
|
292
|
+
model = payload.get("model") or SimConfig.model
|
|
293
|
+
no_llm = bool(payload.get("no_llm", False))
|
|
294
|
+
n_questions_hint = int(payload.get("n_questions") or 8)
|
|
295
|
+
try:
|
|
296
|
+
# Run profile inference and question generation in parallel
|
|
297
|
+
# so both LLM round-trips overlap instead of serialising.
|
|
298
|
+
import asyncio as _asyncio
|
|
299
|
+
profile, pregenerated = await _asyncio.gather(
|
|
300
|
+
infer_topic_profile(topic, model=model, no_llm=no_llm),
|
|
301
|
+
generate_questions_for_topic(topic, n_questions_hint, model=model, no_llm=no_llm),
|
|
302
|
+
return_exceptions=True,
|
|
303
|
+
)
|
|
304
|
+
if isinstance(profile, BaseException):
|
|
305
|
+
profile = None
|
|
306
|
+
if isinstance(pregenerated, BaseException):
|
|
307
|
+
pregenerated = None
|
|
308
|
+
if profile is not None:
|
|
309
|
+
payload = apply_profile_to_config(payload, profile)
|
|
310
|
+
if pregenerated:
|
|
311
|
+
payload["_pregenerated_questions"] = pregenerated
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # never block a sim because adaptation failed
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
cfg = SimConfig.from_dict(payload)
|
|
317
|
+
except ValueError as exc:
|
|
318
|
+
await _err(send, 400, str(exc))
|
|
319
|
+
return
|
|
320
|
+
runner = get_runner()
|
|
321
|
+
if runner.queue_size >= MAX_QUEUE:
|
|
322
|
+
body = json.dumps({"error": "queue_full", "message": "Too many sims queued"}).encode(
|
|
323
|
+
"utf-8"
|
|
324
|
+
)
|
|
325
|
+
await send(
|
|
326
|
+
{
|
|
327
|
+
"type": "http.response.start",
|
|
328
|
+
"status": 503,
|
|
329
|
+
"headers": _add_cors(
|
|
330
|
+
[
|
|
331
|
+
(b"content-type", b"application/json"),
|
|
332
|
+
(b"content-length", str(len(body)).encode()),
|
|
333
|
+
(b"retry-after", b"30"),
|
|
334
|
+
]
|
|
335
|
+
),
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
339
|
+
return
|
|
340
|
+
try:
|
|
341
|
+
sim_id = await runner.submit(cfg)
|
|
342
|
+
except asyncio.QueueFull:
|
|
343
|
+
body = json.dumps({"error": "queue_full", "message": "Too many sims queued"}).encode(
|
|
344
|
+
"utf-8"
|
|
345
|
+
)
|
|
346
|
+
await send(
|
|
347
|
+
{
|
|
348
|
+
"type": "http.response.start",
|
|
349
|
+
"status": 503,
|
|
350
|
+
"headers": _add_cors(
|
|
351
|
+
[
|
|
352
|
+
(b"content-type", b"application/json"),
|
|
353
|
+
(b"content-length", str(len(body)).encode()),
|
|
354
|
+
(b"retry-after", b"30"),
|
|
355
|
+
]
|
|
356
|
+
),
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
360
|
+
return
|
|
361
|
+
await _send_json(send, status=202, payload={"sim_id": sim_id})
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _handle_get_status(sim_id: str, send: _Send) -> None:
|
|
365
|
+
if not sim_dir(sim_id).exists():
|
|
366
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
367
|
+
return
|
|
368
|
+
status = load_status(sim_id)
|
|
369
|
+
# Enrich with the topic the user entered (so the live view can show
|
|
370
|
+
# "Watching: <topic>" without a second request to /sims).
|
|
371
|
+
try:
|
|
372
|
+
from .registry import load_config as _load_cfg
|
|
373
|
+
|
|
374
|
+
cfg = _load_cfg(sim_id)
|
|
375
|
+
if cfg.get("topic"):
|
|
376
|
+
status["topic"] = cfg["topic"]
|
|
377
|
+
if cfg.get("questions"):
|
|
378
|
+
status["questions_set"] = cfg["questions"]
|
|
379
|
+
status["model"] = cfg.get("model")
|
|
380
|
+
status["no_llm"] = cfg.get("no_llm")
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
await _send_json(send, status=200, payload=status)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def _handle_get_stderr(sim_id: str, send: _Send) -> None:
|
|
387
|
+
"""GET /v1/sims/{id}/stderr — returns the captured stderr log
|
|
388
|
+
(last ~64KB) so a failed sim's detail page can show what went wrong.
|
|
389
|
+
"""
|
|
390
|
+
if not sim_dir(sim_id).exists():
|
|
391
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
392
|
+
return
|
|
393
|
+
log_path = sim_dir(sim_id) / "stderr.log"
|
|
394
|
+
if not log_path.exists():
|
|
395
|
+
await _send_json(send, status=200, payload={"stderr": "", "size": 0})
|
|
396
|
+
return
|
|
397
|
+
# Read last 64KB only — these can grow
|
|
398
|
+
size = log_path.stat().st_size
|
|
399
|
+
with log_path.open("rb") as f:
|
|
400
|
+
if size > 65536:
|
|
401
|
+
f.seek(-65536, 2)
|
|
402
|
+
f.readline() # discard partial line
|
|
403
|
+
data = f.read().decode("utf-8", errors="replace")
|
|
404
|
+
await _send_json(
|
|
405
|
+
send, status=200, payload={"stderr": data, "size": size, "truncated": size > 65536}
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def _handle_get_trace(sim_id: str, send: _Send) -> None:
|
|
410
|
+
if not sim_dir(sim_id).exists():
|
|
411
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
412
|
+
return
|
|
413
|
+
status = load_status(sim_id)
|
|
414
|
+
state = status.get("state")
|
|
415
|
+
if state == "failed":
|
|
416
|
+
await _err(send, 410, status.get("error", "sim failed"))
|
|
417
|
+
return
|
|
418
|
+
if state != "done":
|
|
419
|
+
await _err(send, 404, f"sim not done yet (state={state})")
|
|
420
|
+
return
|
|
421
|
+
trace = load_trace(sim_id)
|
|
422
|
+
if trace is None:
|
|
423
|
+
await _err(send, 404, "trace.json missing")
|
|
424
|
+
return
|
|
425
|
+
await _send_json(send, status=200, payload=trace)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ---- /v1/knowledge handlers (federation memory across all sims) ----
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _build_knowledge_index_for_namespace(namespace_slug: str) -> KnowledgeIndex:
|
|
432
|
+
"""Return a KnowledgeIndex appropriate for *namespace_slug*.
|
|
433
|
+
|
|
434
|
+
- If the namespace has tier=="server" and a storage_url is set:
|
|
435
|
+
reads sim IDs + traces from the ServerBackend (SQLite).
|
|
436
|
+
- Otherwise (tier=="local" or namespace not found):
|
|
437
|
+
delegates to the standard disk-walk ``build_knowledge_index()``.
|
|
438
|
+
|
|
439
|
+
The local path is 100% unchanged — no disk reads are altered.
|
|
440
|
+
"""
|
|
441
|
+
from .namespace import get_namespace
|
|
442
|
+
|
|
443
|
+
ns = get_namespace(namespace_slug) if namespace_slug else None
|
|
444
|
+
if ns is not None and ns.profile.tier == "server" and ns.profile.storage_url:
|
|
445
|
+
import time as _time
|
|
446
|
+
|
|
447
|
+
from .backends.server import ServerBackend
|
|
448
|
+
|
|
449
|
+
backend = ServerBackend(ns.profile.storage_url)
|
|
450
|
+
idx = KnowledgeIndex()
|
|
451
|
+
for sim_id in backend.list_sim_ids():
|
|
452
|
+
trace = backend.read_trace(sim_id)
|
|
453
|
+
if trace is None:
|
|
454
|
+
continue
|
|
455
|
+
try:
|
|
456
|
+
_ingest_trace(idx, sim_id, trace)
|
|
457
|
+
idx.n_sims += 1
|
|
458
|
+
except Exception:
|
|
459
|
+
continue
|
|
460
|
+
idx.last_built_ts_ms = int(_time.time() * 1000)
|
|
461
|
+
return idx
|
|
462
|
+
# Default: use the existing cached disk-walk index
|
|
463
|
+
return build_knowledge_index()
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
async def _handle_knowledge_overview(send: _Send) -> None:
|
|
467
|
+
idx = build_knowledge_index()
|
|
468
|
+
await _send_json(send, status=200, payload=idx.overview())
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _qs_param(qs: bytes, key: str, default: str = "") -> str:
|
|
472
|
+
"""Tiny query-string getter; mirrors api/app.py:_parse_qs but
|
|
473
|
+
avoids a circular import."""
|
|
474
|
+
for pair in qs.split(b"&"):
|
|
475
|
+
if not pair or b"=" not in pair:
|
|
476
|
+
continue
|
|
477
|
+
k, v = pair.split(b"=", 1)
|
|
478
|
+
if k.decode() == key:
|
|
479
|
+
from urllib.parse import unquote_plus
|
|
480
|
+
|
|
481
|
+
return unquote_plus(v.decode())
|
|
482
|
+
return default
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def _handle_knowledge_claims(qs: bytes, send: _Send) -> None:
|
|
486
|
+
namespace_slug = _qs_param(qs, "namespace", "default")
|
|
487
|
+
idx = _build_knowledge_index_for_namespace(namespace_slug)
|
|
488
|
+
try:
|
|
489
|
+
limit = max(1, min(2000, int(_qs_param(qs, "limit", "100"))))
|
|
490
|
+
offset = max(0, int(_qs_param(qs, "offset", "0")))
|
|
491
|
+
except ValueError:
|
|
492
|
+
await _err(send, 400, "limit/offset must be ints")
|
|
493
|
+
return
|
|
494
|
+
payload = idx.list_claims(
|
|
495
|
+
q=_qs_param(qs, "q"),
|
|
496
|
+
persona=_qs_param(qs, "persona"),
|
|
497
|
+
topic=_qs_param(qs, "topic"),
|
|
498
|
+
sim_id=_qs_param(qs, "sim_id"),
|
|
499
|
+
limit=limit,
|
|
500
|
+
offset=offset,
|
|
501
|
+
)
|
|
502
|
+
await _send_json(send, status=200, payload=payload)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def _handle_knowledge_agents(send: _Send) -> None:
|
|
506
|
+
idx = build_knowledge_index()
|
|
507
|
+
await _send_json(send, status=200, payload=idx.list_agents())
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
async def _handle_knowledge_topics(send: _Send) -> None:
|
|
511
|
+
idx = build_knowledge_index()
|
|
512
|
+
await _send_json(send, status=200, payload=idx.list_topics())
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
async def _handle_knowledge_ask(qs: bytes, send: _Send) -> None:
|
|
516
|
+
"""GET /v1/knowledge/ask?q=… — federation Q&A across all claims."""
|
|
517
|
+
idx = build_knowledge_index()
|
|
518
|
+
q = _qs_param(qs, "q", "")
|
|
519
|
+
try:
|
|
520
|
+
top_k = max(1, min(500, int(_qs_param(qs, "top_k", "20"))))
|
|
521
|
+
except ValueError:
|
|
522
|
+
top_k = 20
|
|
523
|
+
payload = idx.ask(q, top_k=top_k)
|
|
524
|
+
await _send_json(send, status=200, payload=payload)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---- /v1/personas + /v1/swarms ---------------------------------------------
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
async def _handle_list_personas(send: _Send) -> None:
|
|
531
|
+
from .personas import load_personas
|
|
532
|
+
|
|
533
|
+
payload = [p.to_dict() for p in load_personas()]
|
|
534
|
+
await _send_json(send, status=200, payload=payload)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
async def _handle_get_persona(slug: str, send: _Send) -> None:
|
|
538
|
+
from .personas import get_persona
|
|
539
|
+
|
|
540
|
+
p = get_persona(slug)
|
|
541
|
+
if p is None:
|
|
542
|
+
await _err(send, 404, f"unknown persona: {slug}")
|
|
543
|
+
return
|
|
544
|
+
await _send_json(send, status=200, payload=p.to_dict())
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def _handle_create_persona(body: bytes, send: _Send) -> None:
|
|
548
|
+
from .personas import PersonaSpec, is_builtin, save_persona
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
data = json.loads(body) if body else {}
|
|
552
|
+
except json.JSONDecodeError as exc:
|
|
553
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
554
|
+
return
|
|
555
|
+
if not isinstance(data, dict):
|
|
556
|
+
await _err(send, 400, "expected JSON object")
|
|
557
|
+
return
|
|
558
|
+
try:
|
|
559
|
+
spec = PersonaSpec.from_dict(data)
|
|
560
|
+
if is_builtin(spec.slug):
|
|
561
|
+
await _err(send, 422, f"slug {spec.slug!r} is a built-in; pick another")
|
|
562
|
+
return
|
|
563
|
+
save_persona(spec)
|
|
564
|
+
except ValueError as exc:
|
|
565
|
+
await _err(send, 400, str(exc))
|
|
566
|
+
return
|
|
567
|
+
await _send_json(send, status=201, payload=spec.to_dict())
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
async def _handle_update_persona(slug: str, body: bytes, send: _Send) -> None:
|
|
571
|
+
from .personas import PersonaSpec, get_persona, is_builtin, save_persona
|
|
572
|
+
|
|
573
|
+
if is_builtin(slug):
|
|
574
|
+
await _err(send, 422, f"persona {slug!r} is built-in and immutable")
|
|
575
|
+
return
|
|
576
|
+
try:
|
|
577
|
+
data = json.loads(body) if body else {}
|
|
578
|
+
except json.JSONDecodeError as exc:
|
|
579
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
580
|
+
return
|
|
581
|
+
existing = get_persona(slug)
|
|
582
|
+
if existing is None:
|
|
583
|
+
await _err(send, 404, f"unknown persona: {slug}")
|
|
584
|
+
return
|
|
585
|
+
merged = {**existing.to_dict(), **data, "slug": slug}
|
|
586
|
+
try:
|
|
587
|
+
spec = PersonaSpec.from_dict(merged)
|
|
588
|
+
save_persona(spec)
|
|
589
|
+
except ValueError as exc:
|
|
590
|
+
await _err(send, 400, str(exc))
|
|
591
|
+
return
|
|
592
|
+
await _send_json(send, status=200, payload=spec.to_dict())
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
async def _handle_delete_persona(slug: str, send: _Send) -> None:
|
|
596
|
+
from .personas import delete_persona, is_builtin
|
|
597
|
+
|
|
598
|
+
if is_builtin(slug):
|
|
599
|
+
await _err(send, 422, f"cannot delete built-in persona {slug!r}")
|
|
600
|
+
return
|
|
601
|
+
delete_persona(slug)
|
|
602
|
+
await send(
|
|
603
|
+
{
|
|
604
|
+
"type": "http.response.start",
|
|
605
|
+
"status": 204,
|
|
606
|
+
"headers": [(b"content-length", b"0")],
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
async def _handle_batch_generate_personas(body: bytes, send: _Send) -> None:
|
|
613
|
+
"""POST /v1/personas/batch-generate — generate a population of personas."""
|
|
614
|
+
from .personas import get_persona, is_builtin, save_persona
|
|
615
|
+
from .prompt_generator import generate_persona
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
data = json.loads(body) if body else {}
|
|
619
|
+
except json.JSONDecodeError as exc:
|
|
620
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
621
|
+
return
|
|
622
|
+
if not isinstance(data, dict):
|
|
623
|
+
await _err(send, 400, "expected JSON object")
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
population = data.get("population", [])
|
|
627
|
+
if not isinstance(population, list) or not population:
|
|
628
|
+
await _err(send, 400, "population must be a non-empty list")
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
model = data.get("model")
|
|
632
|
+
no_llm = bool(data.get("no_llm", False))
|
|
633
|
+
|
|
634
|
+
# Build the flat list of (description, idx_within_group) tuples
|
|
635
|
+
tasks: list[tuple[str, str]] = [] # (varied_description, base_description)
|
|
636
|
+
for entry in population:
|
|
637
|
+
if not isinstance(entry, dict):
|
|
638
|
+
continue
|
|
639
|
+
raw_desc = str(entry.get("description", "")).strip()
|
|
640
|
+
if not raw_desc:
|
|
641
|
+
continue
|
|
642
|
+
try:
|
|
643
|
+
count = max(1, min(10, int(entry.get("count", 1))))
|
|
644
|
+
except (TypeError, ValueError):
|
|
645
|
+
count = 1
|
|
646
|
+
for i in range(count):
|
|
647
|
+
if i == 0:
|
|
648
|
+
varied = raw_desc
|
|
649
|
+
elif i == 1:
|
|
650
|
+
varied = f"{raw_desc} (variant 2: slightly more optimistic)"
|
|
651
|
+
elif i == 2:
|
|
652
|
+
varied = f"{raw_desc} (variant 3: more conservative on macro)"
|
|
653
|
+
else:
|
|
654
|
+
varied = f"{raw_desc} (variant {i + 1}: distinct perspective)"
|
|
655
|
+
tasks.append((varied, raw_desc))
|
|
656
|
+
|
|
657
|
+
total = len(tasks)
|
|
658
|
+
if total == 0:
|
|
659
|
+
await _err(send, 400, "no valid population entries")
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
# Generate all personas in parallel
|
|
663
|
+
async def _gen(desc: str) -> Any:
|
|
664
|
+
try:
|
|
665
|
+
return await generate_persona(desc, model=model, no_llm=no_llm)
|
|
666
|
+
except Exception as exc:
|
|
667
|
+
return exc
|
|
668
|
+
|
|
669
|
+
results = await asyncio.gather(*[_gen(desc) for desc, _ in tasks])
|
|
670
|
+
|
|
671
|
+
saved_specs: list[dict] = []
|
|
672
|
+
errors: list[str] = []
|
|
673
|
+
|
|
674
|
+
for spec_or_exc in results:
|
|
675
|
+
if isinstance(spec_or_exc, Exception):
|
|
676
|
+
errors.append(str(spec_or_exc))
|
|
677
|
+
continue
|
|
678
|
+
spec = spec_or_exc
|
|
679
|
+
# Resolve slug collisions by appending -01, -02, etc.
|
|
680
|
+
base_slug = spec.slug
|
|
681
|
+
candidate = base_slug
|
|
682
|
+
if is_builtin(candidate) or get_persona(candidate) is not None:
|
|
683
|
+
for n in range(1, 100):
|
|
684
|
+
candidate = f"{base_slug}-{n:02d}"
|
|
685
|
+
if not is_builtin(candidate) and get_persona(candidate) is None:
|
|
686
|
+
break
|
|
687
|
+
spec.slug = candidate
|
|
688
|
+
try:
|
|
689
|
+
save_persona(spec)
|
|
690
|
+
saved_specs.append(spec.to_dict())
|
|
691
|
+
except Exception as exc:
|
|
692
|
+
errors.append(f"{spec.slug}: {exc}")
|
|
693
|
+
|
|
694
|
+
await _send_json(
|
|
695
|
+
send,
|
|
696
|
+
status=200,
|
|
697
|
+
payload={
|
|
698
|
+
"saved": len(saved_specs),
|
|
699
|
+
"personas": saved_specs,
|
|
700
|
+
"errors": errors,
|
|
701
|
+
},
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
async def _handle_generate_persona(body: bytes, send: _Send) -> None:
|
|
706
|
+
from .prompt_generator import generate_persona
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
data = json.loads(body) if body else {}
|
|
710
|
+
except json.JSONDecodeError as exc:
|
|
711
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
712
|
+
return
|
|
713
|
+
description = str(data.get("description", "")).strip()
|
|
714
|
+
if not description:
|
|
715
|
+
await _err(send, 400, "description is required")
|
|
716
|
+
return
|
|
717
|
+
model = data.get("model")
|
|
718
|
+
no_llm = bool(data.get("no_llm", False))
|
|
719
|
+
try:
|
|
720
|
+
spec = await generate_persona(description, model=model, no_llm=no_llm)
|
|
721
|
+
except Exception as exc:
|
|
722
|
+
await _err(send, 502, f"generator failed: {exc}")
|
|
723
|
+
return
|
|
724
|
+
await _send_json(send, status=200, payload=spec.to_dict())
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
# Tool library handlers
|
|
729
|
+
# ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
async def _handle_list_tool_catalog(send: _Send) -> None:
|
|
733
|
+
from .tool_registry import list_catalog
|
|
734
|
+
|
|
735
|
+
await _send_json(send, status=200, payload=[t.to_dict() for t in list_catalog()])
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
async def _handle_get_tool_def(slug: str, send: _Send) -> None:
|
|
739
|
+
from .tool_registry import get_tool_def
|
|
740
|
+
|
|
741
|
+
t = get_tool_def(slug)
|
|
742
|
+
if t is None:
|
|
743
|
+
await _err(send, 404, f"unknown tool: {slug}")
|
|
744
|
+
return
|
|
745
|
+
await _send_json(send, status=200, payload=t.to_dict())
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
async def _handle_list_activated_tools(send: _Send) -> None:
|
|
749
|
+
from .tool_registry import load_activated_tools
|
|
750
|
+
|
|
751
|
+
await _send_json(send, status=200, payload=[t.to_api_dict() for t in load_activated_tools()])
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
async def _handle_activate_tool(body: bytes, send: _Send) -> None:
|
|
755
|
+
import time as _time
|
|
756
|
+
|
|
757
|
+
from .tool_registry import (
|
|
758
|
+
ActivatedTool,
|
|
759
|
+
encode_credential,
|
|
760
|
+
get_activated_tool,
|
|
761
|
+
get_tool_def,
|
|
762
|
+
save_activated_tool,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
data = json.loads(body or b"{}")
|
|
767
|
+
except Exception:
|
|
768
|
+
await _err(send, 400, "invalid JSON")
|
|
769
|
+
return
|
|
770
|
+
tool_slug = str(data.get("tool_slug", ""))
|
|
771
|
+
if not get_tool_def(tool_slug):
|
|
772
|
+
await _err(send, 422, f"unknown tool slug: {tool_slug}")
|
|
773
|
+
return
|
|
774
|
+
existing = get_activated_tool(tool_slug)
|
|
775
|
+
if existing and existing.status != "disabled":
|
|
776
|
+
await _err(send, 409, f"tool already activated: {tool_slug} (status={existing.status})")
|
|
777
|
+
return
|
|
778
|
+
raw_creds: dict = data.get("credentials", {})
|
|
779
|
+
encoded_creds = {k: encode_credential(str(v)) for k, v in raw_creds.items()}
|
|
780
|
+
t = ActivatedTool(
|
|
781
|
+
tool_slug=tool_slug,
|
|
782
|
+
status="pending",
|
|
783
|
+
credentials=encoded_creds,
|
|
784
|
+
label=str(data.get("label", "") or tool_slug),
|
|
785
|
+
activated_at_ms=int(_time.time() * 1000),
|
|
786
|
+
)
|
|
787
|
+
save_activated_tool(t)
|
|
788
|
+
await _send_json(send, status=201, payload=t.to_api_dict())
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
async def _handle_test_tool(slug: str, send: _Send) -> None:
|
|
792
|
+
import importlib
|
|
793
|
+
import time as _time
|
|
794
|
+
|
|
795
|
+
from .tool_registry import (
|
|
796
|
+
decode_credential,
|
|
797
|
+
get_activated_tool,
|
|
798
|
+
get_tool_def,
|
|
799
|
+
save_activated_tool,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
tool_def = get_tool_def(slug)
|
|
803
|
+
if tool_def is None:
|
|
804
|
+
await _err(send, 404, f"unknown tool: {slug}")
|
|
805
|
+
return
|
|
806
|
+
activated = get_activated_tool(slug)
|
|
807
|
+
if activated is None:
|
|
808
|
+
await _err(send, 404, f"tool not activated: {slug}")
|
|
809
|
+
return
|
|
810
|
+
# No-key tools don't need a live network test — auto-verify immediately.
|
|
811
|
+
if tool_def.auth_type == "none":
|
|
812
|
+
ok, message = True, "No API key required — auto-verified"
|
|
813
|
+
else:
|
|
814
|
+
creds = {k: decode_credential(v) for k, v in activated.credentials.items()}
|
|
815
|
+
try:
|
|
816
|
+
mod = importlib.import_module(tool_def.handler_module)
|
|
817
|
+
ok, message = await mod.test_connection(creds)
|
|
818
|
+
except Exception as e:
|
|
819
|
+
ok, message = False, str(e)
|
|
820
|
+
activated.last_tested_ms = int(_time.time() * 1000)
|
|
821
|
+
activated.error_msg = None if ok else message
|
|
822
|
+
if ok and activated.status == "pending":
|
|
823
|
+
activated.status = "verified"
|
|
824
|
+
save_activated_tool(activated)
|
|
825
|
+
await _send_json(
|
|
826
|
+
send, status=200, payload={"ok": ok, "message": message, "status": activated.status}
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
async def _handle_set_tool_active(slug: str, send: _Send) -> None:
|
|
831
|
+
import time as _time
|
|
832
|
+
|
|
833
|
+
from .tool_registry import get_activated_tool, get_tool_def, save_activated_tool
|
|
834
|
+
|
|
835
|
+
tool_def = get_tool_def(slug)
|
|
836
|
+
activated = get_activated_tool(slug)
|
|
837
|
+
if activated is None:
|
|
838
|
+
await _err(send, 404, f"tool not activated: {slug}")
|
|
839
|
+
return
|
|
840
|
+
# No-key tools skip the verify step — promote from pending or verified.
|
|
841
|
+
no_key = tool_def is not None and tool_def.auth_type == "none"
|
|
842
|
+
if activated.status not in ("verified", "pending" if no_key else "__never__"):
|
|
843
|
+
await _err(
|
|
844
|
+
send,
|
|
845
|
+
422,
|
|
846
|
+
f"tool must be verified before activating; current status: {activated.status}",
|
|
847
|
+
)
|
|
848
|
+
return
|
|
849
|
+
if activated.status == "pending" and no_key:
|
|
850
|
+
activated.last_tested_ms = int(_time.time() * 1000)
|
|
851
|
+
activated.status = "active"
|
|
852
|
+
save_activated_tool(activated)
|
|
853
|
+
await _send_json(send, status=200, payload=activated.to_api_dict())
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
async def _handle_update_tool_credentials(slug: str, body: bytes, send: _Send) -> None:
|
|
857
|
+
from .tool_registry import encode_credential, get_activated_tool, save_activated_tool
|
|
858
|
+
|
|
859
|
+
activated = get_activated_tool(slug)
|
|
860
|
+
if activated is None:
|
|
861
|
+
await _err(send, 404, f"tool not activated: {slug}")
|
|
862
|
+
return
|
|
863
|
+
try:
|
|
864
|
+
data = json.loads(body or b"{}")
|
|
865
|
+
except Exception:
|
|
866
|
+
await _err(send, 400, "invalid JSON")
|
|
867
|
+
return
|
|
868
|
+
raw_creds: dict = data.get("credentials", {})
|
|
869
|
+
activated.credentials = {k: encode_credential(str(v)) for k, v in raw_creds.items()}
|
|
870
|
+
activated.status = "pending"
|
|
871
|
+
activated.error_msg = None
|
|
872
|
+
save_activated_tool(activated)
|
|
873
|
+
await _send_json(send, status=200, payload=activated.to_api_dict())
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
async def _handle_deactivate_tool(slug: str, send: _Send) -> None:
|
|
877
|
+
from .tool_registry import get_activated_tool, save_activated_tool
|
|
878
|
+
|
|
879
|
+
activated = get_activated_tool(slug)
|
|
880
|
+
if activated is None:
|
|
881
|
+
await _err(send, 404, f"tool not activated: {slug}")
|
|
882
|
+
return
|
|
883
|
+
activated.status = "disabled"
|
|
884
|
+
save_activated_tool(activated)
|
|
885
|
+
await _send_json(send, status=200, payload={"ok": True, "status": "disabled"})
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
# Swarm handlers
|
|
890
|
+
# ---------------------------------------------------------------------------
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
async def _handle_list_swarms(send: _Send) -> None:
|
|
894
|
+
from .swarms import load_swarms
|
|
895
|
+
|
|
896
|
+
payload = [s.to_dict() for s in load_swarms()]
|
|
897
|
+
await _send_json(send, status=200, payload=payload)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
async def _handle_get_swarm(slug: str, send: _Send) -> None:
|
|
901
|
+
from .swarms import get_swarm
|
|
902
|
+
|
|
903
|
+
s = get_swarm(slug)
|
|
904
|
+
if s is None:
|
|
905
|
+
await _err(send, 404, f"unknown swarm: {slug}")
|
|
906
|
+
return
|
|
907
|
+
await _send_json(send, status=200, payload=s.to_dict())
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
async def _handle_create_swarm(body: bytes, send: _Send) -> None:
|
|
911
|
+
from .swarms import Swarm, save_swarm
|
|
912
|
+
|
|
913
|
+
try:
|
|
914
|
+
data = json.loads(body) if body else {}
|
|
915
|
+
except json.JSONDecodeError as exc:
|
|
916
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
917
|
+
return
|
|
918
|
+
try:
|
|
919
|
+
s = Swarm.from_dict(data)
|
|
920
|
+
save_swarm(s)
|
|
921
|
+
except (TypeError, ValueError) as exc:
|
|
922
|
+
await _err(send, 400, str(exc))
|
|
923
|
+
return
|
|
924
|
+
await _send_json(send, status=201, payload=s.to_dict())
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
async def _handle_update_swarm(slug: str, body: bytes, send: _Send) -> None:
|
|
928
|
+
from .swarms import Swarm, get_swarm, save_swarm
|
|
929
|
+
|
|
930
|
+
existing = get_swarm(slug)
|
|
931
|
+
if existing is None:
|
|
932
|
+
await _err(send, 404, f"unknown swarm: {slug}")
|
|
933
|
+
return
|
|
934
|
+
try:
|
|
935
|
+
data = json.loads(body) if body else {}
|
|
936
|
+
except json.JSONDecodeError as exc:
|
|
937
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
938
|
+
return
|
|
939
|
+
merged = {**existing.to_dict(), **data, "slug": slug}
|
|
940
|
+
try:
|
|
941
|
+
s = Swarm.from_dict(merged)
|
|
942
|
+
save_swarm(s)
|
|
943
|
+
except (TypeError, ValueError) as exc:
|
|
944
|
+
await _err(send, 400, str(exc))
|
|
945
|
+
return
|
|
946
|
+
await _send_json(send, status=200, payload=s.to_dict())
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
async def _handle_delete_swarm(slug: str, send: _Send) -> None:
|
|
950
|
+
from .swarms import delete_swarm
|
|
951
|
+
|
|
952
|
+
delete_swarm(slug)
|
|
953
|
+
await send(
|
|
954
|
+
{
|
|
955
|
+
"type": "http.response.start",
|
|
956
|
+
"status": 204,
|
|
957
|
+
"headers": [(b"content-length", b"0")],
|
|
958
|
+
}
|
|
959
|
+
)
|
|
960
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
async def _handle_knowledge_claim(kid: str, send: _Send) -> None:
|
|
964
|
+
idx = build_knowledge_index()
|
|
965
|
+
payload = idx.claim(kid)
|
|
966
|
+
if payload is None:
|
|
967
|
+
await _err(send, 404, f"unknown claim kid: {kid}")
|
|
968
|
+
return
|
|
969
|
+
await _send_json(send, status=200, payload=payload)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
async def _handle_lineage(send: _Send) -> None:
|
|
973
|
+
"""S4 — Cross-sim KernelStore lineage summary.
|
|
974
|
+
|
|
975
|
+
Returns one row per persona that has accumulated Brier observations
|
|
976
|
+
across sims. Powers the Lineage section on SimDetail Trust tab and
|
|
977
|
+
the Home page lifetime-Brier headline.
|
|
978
|
+
"""
|
|
979
|
+
from hypermind.knowledge.kernel_store import KernelStore
|
|
980
|
+
|
|
981
|
+
rows = []
|
|
982
|
+
for persona in KernelStore.list_personas():
|
|
983
|
+
s = KernelStore.lifetime_summary(persona)
|
|
984
|
+
rows.append(s)
|
|
985
|
+
rows.sort(key=lambda r: r.get("mean_brier") if r.get("mean_brier") is not None else 1.0)
|
|
986
|
+
await _send_json(send, status=200, payload={"lineage": rows})
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
async def _handle_knowledge_scoreboard(send: _Send) -> None:
|
|
990
|
+
"""Per-persona-replica calibration scoreboard.
|
|
991
|
+
|
|
992
|
+
Returns a list of agents with their accuracy, Brier score, and
|
|
993
|
+
claim count, sorted by Brier (lower = better).
|
|
994
|
+
"""
|
|
995
|
+
idx = build_knowledge_index()
|
|
996
|
+
rows = []
|
|
997
|
+
for ag in idx.agents.values():
|
|
998
|
+
if ag.oracle_total == 0:
|
|
999
|
+
continue # only score agents that have resolved claims
|
|
1000
|
+
# Brier across resolved claims for this agent
|
|
1001
|
+
briers = []
|
|
1002
|
+
for c in idx.claims.values():
|
|
1003
|
+
if c.agent != ag.name or c.resolved_outcome is None:
|
|
1004
|
+
continue
|
|
1005
|
+
target = 1.0 if c.resolved_outcome else 0.0
|
|
1006
|
+
briers.append((c.confidence - target) ** 2)
|
|
1007
|
+
if not briers:
|
|
1008
|
+
continue
|
|
1009
|
+
brier = sum(briers) / len(briers)
|
|
1010
|
+
rows.append(
|
|
1011
|
+
{
|
|
1012
|
+
"name": ag.name,
|
|
1013
|
+
"persona": ag.persona,
|
|
1014
|
+
"accuracy": ag.accuracy,
|
|
1015
|
+
"brier": brier,
|
|
1016
|
+
"n_resolved": len(briers),
|
|
1017
|
+
"n_claims": ag.claims_published,
|
|
1018
|
+
"citations_received": ag.citations_received,
|
|
1019
|
+
"last_reputation": ag.last_reputation,
|
|
1020
|
+
"runs": ag.runs_participated,
|
|
1021
|
+
}
|
|
1022
|
+
)
|
|
1023
|
+
rows.sort(key=lambda r: r["brier"])
|
|
1024
|
+
await _send_json(send, status=200, payload=rows)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
async def _handle_knowledge_similar(kid: str, qs: bytes, send: _Send) -> None:
|
|
1028
|
+
"""Find claims with similar query text to the one identified by kid.
|
|
1029
|
+
|
|
1030
|
+
Useful for cross-sim synthesis: an analyst opens a claim and wants
|
|
1031
|
+
to see "what else has the federation said on this question?"
|
|
1032
|
+
"""
|
|
1033
|
+
idx = build_knowledge_index()
|
|
1034
|
+
target = idx.claim(kid)
|
|
1035
|
+
if target is None:
|
|
1036
|
+
await _err(send, 404, f"unknown claim kid: {kid}")
|
|
1037
|
+
return
|
|
1038
|
+
try:
|
|
1039
|
+
top_k = max(1, min(50, int(_qs_param(qs, "top_k", "10"))))
|
|
1040
|
+
except ValueError:
|
|
1041
|
+
top_k = 10
|
|
1042
|
+
payload = idx.ask(target["claim"]["query"], top_k=top_k + 1) # +1 to drop self
|
|
1043
|
+
# Remove self from results
|
|
1044
|
+
payload["items"] = [c for c in payload["items"] if c["kid"] != target["claim"]["kid"]][:top_k]
|
|
1045
|
+
payload["seed_kid"] = target["claim"]["kid"]
|
|
1046
|
+
payload["seed_query"] = target["claim"]["query"]
|
|
1047
|
+
await _send_json(send, status=200, payload=payload)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
async def _send_bytes(
|
|
1051
|
+
send: _Send,
|
|
1052
|
+
data: bytes,
|
|
1053
|
+
*,
|
|
1054
|
+
content_type: str = "application/octet-stream",
|
|
1055
|
+
filename: str = "file",
|
|
1056
|
+
) -> None:
|
|
1057
|
+
"""Send a raw bytes response with Content-Disposition."""
|
|
1058
|
+
await send(
|
|
1059
|
+
{
|
|
1060
|
+
"type": "http.response.start",
|
|
1061
|
+
"status": 200,
|
|
1062
|
+
"headers": [
|
|
1063
|
+
[b"content-type", content_type.encode()],
|
|
1064
|
+
[b"content-length", str(len(data)).encode()],
|
|
1065
|
+
[b"content-disposition", f'attachment; filename="{filename}"'.encode()],
|
|
1066
|
+
[b"access-control-allow-origin", b"*"],
|
|
1067
|
+
],
|
|
1068
|
+
}
|
|
1069
|
+
)
|
|
1070
|
+
await send({"type": "http.response.body", "body": data, "more_body": False})
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
async def _handle_knowledge_synthesis(qs: bytes, send: _Send) -> None:
|
|
1074
|
+
"""GET /v1/knowledge/synthesis — cross-sim consensus synthesis per question."""
|
|
1075
|
+
from collections import defaultdict
|
|
1076
|
+
|
|
1077
|
+
idx = build_knowledge_index()
|
|
1078
|
+
topic = _qs_param(qs, "topic", "")
|
|
1079
|
+
q = _qs_param(qs, "q", "")
|
|
1080
|
+
try:
|
|
1081
|
+
top_k = max(1, min(100, int(_qs_param(qs, "top_k", "20"))))
|
|
1082
|
+
except ValueError:
|
|
1083
|
+
top_k = 20
|
|
1084
|
+
|
|
1085
|
+
all_claims_resp = idx.list_claims(
|
|
1086
|
+
q=q or "",
|
|
1087
|
+
topic=topic or "",
|
|
1088
|
+
sim_id="",
|
|
1089
|
+
limit=2000,
|
|
1090
|
+
offset=0,
|
|
1091
|
+
)
|
|
1092
|
+
all_claims = all_claims_resp.get("items", [])
|
|
1093
|
+
|
|
1094
|
+
if not all_claims:
|
|
1095
|
+
await _send_json(
|
|
1096
|
+
send,
|
|
1097
|
+
status=200,
|
|
1098
|
+
payload={
|
|
1099
|
+
"topic": topic,
|
|
1100
|
+
"n_sims_contributing": 0,
|
|
1101
|
+
"questions": [],
|
|
1102
|
+
"contradiction_map": [],
|
|
1103
|
+
},
|
|
1104
|
+
)
|
|
1105
|
+
return
|
|
1106
|
+
|
|
1107
|
+
# Group claims by query (first 80 chars, lowercased)
|
|
1108
|
+
query_groups: dict[str, list] = defaultdict(list)
|
|
1109
|
+
for claim in all_claims:
|
|
1110
|
+
key = claim.get("query", "")[:80].lower().strip()
|
|
1111
|
+
query_groups[key].append(claim)
|
|
1112
|
+
|
|
1113
|
+
questions = []
|
|
1114
|
+
for query_key, claims in sorted(query_groups.items(), key=lambda x: -len(x[1])):
|
|
1115
|
+
if not claims:
|
|
1116
|
+
continue
|
|
1117
|
+
confs = [c.get("confidence", 0.5) for c in claims if c.get("confidence") is not None]
|
|
1118
|
+
if not confs:
|
|
1119
|
+
continue
|
|
1120
|
+
|
|
1121
|
+
mean_conf = sum(confs) / len(confs)
|
|
1122
|
+
spread = max(confs) - min(confs) if len(confs) > 1 else 0.0
|
|
1123
|
+
|
|
1124
|
+
sim_map: dict[str, list] = defaultdict(list)
|
|
1125
|
+
for c in claims:
|
|
1126
|
+
sim_map[c.get("sim_id", "unknown")].append(c)
|
|
1127
|
+
|
|
1128
|
+
confidence_history = []
|
|
1129
|
+
for sim_id_key, sim_claims in sim_map.items():
|
|
1130
|
+
sim_confs = [c.get("confidence", 0.5) for c in sim_claims]
|
|
1131
|
+
sim_ts = min(c.get("ts_wall_ms", 0) for c in sim_claims)
|
|
1132
|
+
confidence_history.append(
|
|
1133
|
+
{
|
|
1134
|
+
"sim_id": sim_id_key,
|
|
1135
|
+
"ts": sim_ts / 1000.0,
|
|
1136
|
+
"mean_conf": sum(sim_confs) / len(sim_confs),
|
|
1137
|
+
"spread": max(sim_confs) - min(sim_confs) if len(sim_confs) > 1 else 0.0,
|
|
1138
|
+
"n_claims": len(sim_claims),
|
|
1139
|
+
}
|
|
1140
|
+
)
|
|
1141
|
+
confidence_history.sort(key=lambda x: x["ts"])
|
|
1142
|
+
|
|
1143
|
+
n_sims = len(sim_map)
|
|
1144
|
+
if n_sims >= 3 and spread < 0.10:
|
|
1145
|
+
status_val = "settled"
|
|
1146
|
+
elif spread >= 0.30:
|
|
1147
|
+
status_val = "contested"
|
|
1148
|
+
else:
|
|
1149
|
+
status_val = "emerging"
|
|
1150
|
+
|
|
1151
|
+
cited_claims = sorted(claims, key=lambda c: c.get("citations_received", 0), reverse=True)
|
|
1152
|
+
best_reasoning = [
|
|
1153
|
+
c.get("reasoning", "")[:400] for c in cited_claims[:3] if c.get("reasoning", "")
|
|
1154
|
+
]
|
|
1155
|
+
|
|
1156
|
+
n_supporting = sum(1 for c in claims if c.get("confidence", 0.5) >= mean_conf + 0.15)
|
|
1157
|
+
n_contradicting = sum(1 for c in claims if c.get("confidence", 0.5) <= mean_conf - 0.15)
|
|
1158
|
+
|
|
1159
|
+
questions.append(
|
|
1160
|
+
{
|
|
1161
|
+
"query": claims[0].get("query", query_key),
|
|
1162
|
+
"status": status_val,
|
|
1163
|
+
"confidence_history": confidence_history,
|
|
1164
|
+
"current_consensus": round(mean_conf, 4),
|
|
1165
|
+
"current_spread": round(spread, 4),
|
|
1166
|
+
"n_sims": n_sims,
|
|
1167
|
+
"n_claims": len(claims),
|
|
1168
|
+
"n_supporting_claims": n_supporting,
|
|
1169
|
+
"n_contradicting_claims": n_contradicting,
|
|
1170
|
+
"best_reasoning": best_reasoning,
|
|
1171
|
+
}
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
questions.sort(key=lambda qt: -qt["n_claims"])
|
|
1175
|
+
questions = questions[:top_k]
|
|
1176
|
+
|
|
1177
|
+
# Build contradiction map
|
|
1178
|
+
contradiction_map = []
|
|
1179
|
+
for qt in questions:
|
|
1180
|
+
query_claims = [
|
|
1181
|
+
c
|
|
1182
|
+
for c in all_claims
|
|
1183
|
+
if c.get("query", "")[:80].lower().strip() == qt["query"][:80].lower().strip()
|
|
1184
|
+
]
|
|
1185
|
+
persona_confs: dict[str, list[float]] = defaultdict(list)
|
|
1186
|
+
for c in query_claims:
|
|
1187
|
+
p = c.get("persona", "unknown")
|
|
1188
|
+
persona_confs[p].append(c.get("confidence", 0.5))
|
|
1189
|
+
|
|
1190
|
+
persona_means = {p: sum(v) / len(v) for p, v in persona_confs.items()}
|
|
1191
|
+
sorted_personas = sorted(persona_means.items(), key=lambda x: x[1])
|
|
1192
|
+
|
|
1193
|
+
if len(sorted_personas) >= 2:
|
|
1194
|
+
low_p, low_v = sorted_personas[0]
|
|
1195
|
+
high_p, high_v = sorted_personas[-1]
|
|
1196
|
+
delta = high_v - low_v
|
|
1197
|
+
if delta >= 0.20:
|
|
1198
|
+
contradiction_map.append(
|
|
1199
|
+
{
|
|
1200
|
+
"query": qt["query"],
|
|
1201
|
+
"high_persona": high_p,
|
|
1202
|
+
"high_conf": round(high_v, 4),
|
|
1203
|
+
"low_persona": low_p,
|
|
1204
|
+
"low_conf": round(low_v, 4),
|
|
1205
|
+
"delta": round(delta, 4),
|
|
1206
|
+
}
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
contradiction_map.sort(key=lambda x: -x["delta"])
|
|
1210
|
+
|
|
1211
|
+
n_sims_contributing = len({c.get("sim_id") for c in all_claims})
|
|
1212
|
+
|
|
1213
|
+
await _send_json(
|
|
1214
|
+
send,
|
|
1215
|
+
status=200,
|
|
1216
|
+
payload={
|
|
1217
|
+
"topic": topic,
|
|
1218
|
+
"n_sims_contributing": n_sims_contributing,
|
|
1219
|
+
"questions": questions,
|
|
1220
|
+
"contradiction_map": contradiction_map[:20],
|
|
1221
|
+
},
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
async def _handle_knowledge_followup(qs: bytes, send: _Send) -> None:
|
|
1226
|
+
"""GET /v1/knowledge/followup — autonomous follow-up question generation."""
|
|
1227
|
+
from collections import defaultdict
|
|
1228
|
+
|
|
1229
|
+
from hypermind.simlab._scenario_impl import generate_followup_questions
|
|
1230
|
+
|
|
1231
|
+
idx = build_knowledge_index()
|
|
1232
|
+
sim_id = _qs_param(qs, "sim_id", "") or ""
|
|
1233
|
+
topic = _qs_param(qs, "topic", "") or ""
|
|
1234
|
+
try:
|
|
1235
|
+
top_k = max(1, min(50, int(_qs_param(qs, "top_k", "10"))))
|
|
1236
|
+
except ValueError:
|
|
1237
|
+
top_k = 10
|
|
1238
|
+
|
|
1239
|
+
claims_resp = idx.list_claims(
|
|
1240
|
+
topic=topic,
|
|
1241
|
+
sim_id=sim_id,
|
|
1242
|
+
limit=500,
|
|
1243
|
+
offset=0,
|
|
1244
|
+
)
|
|
1245
|
+
claims = claims_resp.get("items", [])
|
|
1246
|
+
|
|
1247
|
+
if not claims:
|
|
1248
|
+
await _send_json(send, status=200, payload={"questions": []})
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
query_claims: dict[str, list] = defaultdict(list)
|
|
1252
|
+
for c in claims:
|
|
1253
|
+
query_claims[c.get("query", "")].append(c)
|
|
1254
|
+
|
|
1255
|
+
panel_records = []
|
|
1256
|
+
for query, qclaims in query_claims.items():
|
|
1257
|
+
confs = [c.get("confidence", 0.5) for c in qclaims if c.get("confidence") is not None]
|
|
1258
|
+
if not confs:
|
|
1259
|
+
continue
|
|
1260
|
+
disputed = [c for c in qclaims if c.get("disputes_received", 0) > 0]
|
|
1261
|
+
panel_records.append(
|
|
1262
|
+
{
|
|
1263
|
+
"query": query,
|
|
1264
|
+
"mean_confidence": sum(confs) / len(confs),
|
|
1265
|
+
"spread": max(confs) - min(confs) if len(confs) > 1 else 0.0,
|
|
1266
|
+
"disputes": [
|
|
1267
|
+
{"from_persona": c.get("persona", "unknown"), "delta": 0.35} for c in disputed
|
|
1268
|
+
],
|
|
1269
|
+
"per_agent": [
|
|
1270
|
+
{
|
|
1271
|
+
"persona": c.get("persona", ""),
|
|
1272
|
+
"reasoning": c.get("reasoning", ""),
|
|
1273
|
+
"confidence": c.get("confidence", 0.5),
|
|
1274
|
+
}
|
|
1275
|
+
for c in qclaims
|
|
1276
|
+
],
|
|
1277
|
+
}
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
followups = generate_followup_questions(panel_records, top_k=top_k)
|
|
1281
|
+
await _send_json(send, status=200, payload={"questions": followups})
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _render_report_markdown(report: dict) -> str:
|
|
1285
|
+
"""Render a report data dict to a Markdown string."""
|
|
1286
|
+
import datetime
|
|
1287
|
+
|
|
1288
|
+
lines = []
|
|
1289
|
+
dt = datetime.datetime.fromtimestamp(report.get("generated_at", 0)).strftime(
|
|
1290
|
+
"%Y-%m-%d %H:%M UTC"
|
|
1291
|
+
)
|
|
1292
|
+
topic = report.get("topic") or "All Topics"
|
|
1293
|
+
|
|
1294
|
+
lines.append("# Swarm Intelligence Report")
|
|
1295
|
+
lines.append("")
|
|
1296
|
+
lines.append(
|
|
1297
|
+
f"**Topic:** {topic} | **Generated:** {dt} | "
|
|
1298
|
+
f"**Simulations:** {report.get('n_sims', 0)} | **Agents:** {report.get('n_agents', 0)}"
|
|
1299
|
+
)
|
|
1300
|
+
lines.append("")
|
|
1301
|
+
lines.append("---")
|
|
1302
|
+
lines.append("")
|
|
1303
|
+
lines.append("## Abstract")
|
|
1304
|
+
lines.append("")
|
|
1305
|
+
lines.append(report.get("abstract", ""))
|
|
1306
|
+
lines.append("")
|
|
1307
|
+
lines.append("---")
|
|
1308
|
+
lines.append("")
|
|
1309
|
+
lines.append("## Key Findings")
|
|
1310
|
+
lines.append("")
|
|
1311
|
+
lines.append("| # | Question | Confidence | Spread | Verdict | Sims | Calibration |")
|
|
1312
|
+
lines.append("|---|----------|-----------|--------|---------|------|-------------|")
|
|
1313
|
+
|
|
1314
|
+
verdict_emoji = {"YES": "✅", "LEAN YES": "🟢", "SPLIT": "🟡", "LEAN NO": "🟠", "NO": "❌"}
|
|
1315
|
+
|
|
1316
|
+
for i, kf in enumerate(report.get("key_findings", []), 1):
|
|
1317
|
+
ve = verdict_emoji.get(kf.get("verdict", ""), "")
|
|
1318
|
+
q_short = kf.get("query", "")[:80] + ("…" if len(kf.get("query", "")) > 80 else "")
|
|
1319
|
+
cal = f"{kf['calibration_error']:.3f}" if kf.get("calibration_error") is not None else "—"
|
|
1320
|
+
lines.append(
|
|
1321
|
+
f"| {i} | {q_short} | {kf.get('mean_conf', 0):.1%} | ±{kf.get('spread', 0):.1%} "
|
|
1322
|
+
f"| {ve} {kf.get('verdict', '')} | {kf.get('n_sims', 0)} | {cal} |"
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
lines.append("")
|
|
1326
|
+
lines.append("---")
|
|
1327
|
+
lines.append("")
|
|
1328
|
+
lines.append("## Per-Question Analysis")
|
|
1329
|
+
lines.append("")
|
|
1330
|
+
|
|
1331
|
+
for i, pq in enumerate(report.get("per_question", []), 1):
|
|
1332
|
+
lines.append(f"### Q{i}: {pq.get('query', '')}")
|
|
1333
|
+
lines.append("")
|
|
1334
|
+
lines.append(
|
|
1335
|
+
f"**Panel consensus:** {pq.get('mean_conf', 0):.1%} "
|
|
1336
|
+
f"(spread: ±{pq.get('spread', 0):.1%}) | **Verdict:** {pq.get('verdict', '')}"
|
|
1337
|
+
)
|
|
1338
|
+
lines.append("")
|
|
1339
|
+
lines.append(
|
|
1340
|
+
f"**Disputes:** {pq.get('n_disputes', 0)} | **Citations:** {pq.get('n_citations', 0)}"
|
|
1341
|
+
)
|
|
1342
|
+
lines.append("")
|
|
1343
|
+
for pr in pq.get("per_persona_reasoning", []):
|
|
1344
|
+
lines.append(f"**{pr.get('persona', '')}** ({pr.get('mean_confidence', 0):.1%})")
|
|
1345
|
+
reasoning = pr.get("reasoning", "").strip()
|
|
1346
|
+
if reasoning:
|
|
1347
|
+
lines.append(f"> {reasoning[:500]}")
|
|
1348
|
+
lines.append("")
|
|
1349
|
+
|
|
1350
|
+
lines.append("---")
|
|
1351
|
+
lines.append("")
|
|
1352
|
+
lines.append("## Persona Forensics")
|
|
1353
|
+
lines.append("")
|
|
1354
|
+
lines.append("| Persona | Brier Score | Claims | Citations | Resolved | Runs |")
|
|
1355
|
+
lines.append("|---------|------------|--------|-----------|----------|------|")
|
|
1356
|
+
|
|
1357
|
+
for pf in report.get("persona_forensics", []):
|
|
1358
|
+
lines.append(
|
|
1359
|
+
f"| {pf.get('emoji', '🤖')} {pf.get('label', pf.get('persona', ''))} "
|
|
1360
|
+
f"| {pf.get('brier', 0):.4f} | {pf.get('n_claims', 0)} "
|
|
1361
|
+
f"| {pf.get('citations_received', 0)} | {pf.get('n_resolved', 0)} "
|
|
1362
|
+
f"| {pf.get('runs', 0)} |"
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
lines.append("")
|
|
1366
|
+
lines.append("---")
|
|
1367
|
+
lines.append("")
|
|
1368
|
+
lines.append("## Autonomous Follow-Up Questions")
|
|
1369
|
+
lines.append("")
|
|
1370
|
+
lines.append(
|
|
1371
|
+
"*The following questions were autonomously identified by the swarm "
|
|
1372
|
+
"as highest-priority for further investigation.*"
|
|
1373
|
+
)
|
|
1374
|
+
lines.append("")
|
|
1375
|
+
|
|
1376
|
+
trigger_labels = {
|
|
1377
|
+
"high_spread": "⚡ HIGH UNCERTAINTY",
|
|
1378
|
+
"dispute": "⚔️ DISPUTED",
|
|
1379
|
+
"low_confidence": "🌫️ AMBIGUOUS",
|
|
1380
|
+
"gap": "🔭 KNOWLEDGE GAP",
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
for i, fq in enumerate(report.get("follow_up_questions", []), 1):
|
|
1384
|
+
label = trigger_labels.get(fq.get("trigger", ""), "❓")
|
|
1385
|
+
lines.append(f"{i}. **[{label}]** {fq.get('text', '')}")
|
|
1386
|
+
lines.append(f" > *{fq.get('rationale', '')[:200]}*")
|
|
1387
|
+
lines.append("")
|
|
1388
|
+
|
|
1389
|
+
return "\n".join(lines)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
async def _handle_report(body_bytes: bytes, send: _Send, *, force_markdown: bool = False) -> None:
|
|
1393
|
+
"""POST /v1/report — generate a JSON (or Markdown) synthesis report."""
|
|
1394
|
+
import time as _time
|
|
1395
|
+
from collections import defaultdict
|
|
1396
|
+
|
|
1397
|
+
from hypermind.simlab._scenario_impl import generate_followup_questions
|
|
1398
|
+
|
|
1399
|
+
try:
|
|
1400
|
+
body = json.loads(body_bytes) if body_bytes else {}
|
|
1401
|
+
except Exception:
|
|
1402
|
+
body = {}
|
|
1403
|
+
|
|
1404
|
+
sim_ids = body.get("sim_ids")
|
|
1405
|
+
topic = body.get("topic") or ""
|
|
1406
|
+
persona_filter = body.get("persona") or ""
|
|
1407
|
+
fmt = "markdown" if force_markdown else body.get("format", "json")
|
|
1408
|
+
|
|
1409
|
+
idx = build_knowledge_index()
|
|
1410
|
+
|
|
1411
|
+
sim_id_filter = sim_ids[0] if sim_ids and len(sim_ids) == 1 else ""
|
|
1412
|
+
|
|
1413
|
+
overview = idx.overview()
|
|
1414
|
+
claims_resp = idx.list_claims(
|
|
1415
|
+
persona=persona_filter,
|
|
1416
|
+
topic=topic or "",
|
|
1417
|
+
sim_id=sim_id_filter,
|
|
1418
|
+
limit=2000,
|
|
1419
|
+
offset=0,
|
|
1420
|
+
)
|
|
1421
|
+
claims = claims_resp.get("items", [])
|
|
1422
|
+
|
|
1423
|
+
# Build scoreboard inline (mirrors _handle_knowledge_scoreboard)
|
|
1424
|
+
scoreboard_rows: list[dict] = []
|
|
1425
|
+
for ag in idx.agents.values():
|
|
1426
|
+
briers = []
|
|
1427
|
+
for c in idx.claims.values():
|
|
1428
|
+
if c.agent != ag.name or c.resolved_outcome is None:
|
|
1429
|
+
continue
|
|
1430
|
+
target = 1.0 if c.resolved_outcome else 0.0
|
|
1431
|
+
briers.append((c.confidence - target) ** 2)
|
|
1432
|
+
brier = sum(briers) / len(briers) if briers else 0.0
|
|
1433
|
+
scoreboard_rows.append(
|
|
1434
|
+
{
|
|
1435
|
+
"name": ag.name,
|
|
1436
|
+
"persona": ag.persona,
|
|
1437
|
+
"accuracy": ag.accuracy,
|
|
1438
|
+
"brier": brier,
|
|
1439
|
+
"n_resolved": len(briers),
|
|
1440
|
+
"n_claims": ag.claims_published,
|
|
1441
|
+
"citations_received": ag.citations_received,
|
|
1442
|
+
"last_reputation": ag.last_reputation,
|
|
1443
|
+
"runs": ag.runs_participated,
|
|
1444
|
+
}
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
query_map: dict[str, list] = defaultdict(list)
|
|
1448
|
+
for c in claims:
|
|
1449
|
+
query_map[c.get("query", "")].append(c)
|
|
1450
|
+
|
|
1451
|
+
key_findings = []
|
|
1452
|
+
per_question = []
|
|
1453
|
+
|
|
1454
|
+
for query, qclaims in sorted(query_map.items(), key=lambda x: -len(x[1])):
|
|
1455
|
+
confs = [c.get("confidence", 0.5) for c in qclaims if c.get("confidence") is not None]
|
|
1456
|
+
if not confs:
|
|
1457
|
+
continue
|
|
1458
|
+
mean_conf = sum(confs) / len(confs)
|
|
1459
|
+
spread = max(confs) - min(confs) if len(confs) > 1 else 0.0
|
|
1460
|
+
|
|
1461
|
+
if mean_conf >= 0.70:
|
|
1462
|
+
verdict = "YES"
|
|
1463
|
+
elif mean_conf >= 0.55:
|
|
1464
|
+
verdict = "LEAN YES"
|
|
1465
|
+
elif mean_conf >= 0.45:
|
|
1466
|
+
verdict = "SPLIT"
|
|
1467
|
+
elif mean_conf >= 0.30:
|
|
1468
|
+
verdict = "LEAN NO"
|
|
1469
|
+
else:
|
|
1470
|
+
verdict = "NO"
|
|
1471
|
+
|
|
1472
|
+
gt_vals = [c.get("ground_truth") for c in qclaims if c.get("ground_truth") is not None]
|
|
1473
|
+
gt = gt_vals[0] if gt_vals else None
|
|
1474
|
+
cal_error = abs(mean_conf - gt) if gt is not None else None
|
|
1475
|
+
|
|
1476
|
+
resolved = [c for c in qclaims if c.get("resolved_outcome") is not None]
|
|
1477
|
+
n_sims_q = len({c.get("sim_id") for c in qclaims})
|
|
1478
|
+
|
|
1479
|
+
key_findings.append(
|
|
1480
|
+
{
|
|
1481
|
+
"query": query,
|
|
1482
|
+
"mean_conf": round(mean_conf, 4),
|
|
1483
|
+
"spread": round(spread, 4),
|
|
1484
|
+
"verdict": verdict,
|
|
1485
|
+
"n_sims": n_sims_q,
|
|
1486
|
+
"n_claims": len(qclaims),
|
|
1487
|
+
"calibration_error": round(cal_error, 4) if cal_error is not None else None,
|
|
1488
|
+
"ground_truth": gt,
|
|
1489
|
+
"n_resolved": len(resolved),
|
|
1490
|
+
}
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
persona_map_q: dict[str, list] = defaultdict(list)
|
|
1494
|
+
for c in qclaims:
|
|
1495
|
+
persona_map_q[c.get("persona", "unknown")].append(c)
|
|
1496
|
+
|
|
1497
|
+
persona_reasoning: list[dict] = []
|
|
1498
|
+
for p, pclaims in persona_map_q.items():
|
|
1499
|
+
pconfs = [c.get("confidence", 0.5) for c in pclaims]
|
|
1500
|
+
per_persona_mean = sum(pconfs) / len(pconfs)
|
|
1501
|
+
best = max(pclaims, key=lambda c: c.get("citations_received", 0))
|
|
1502
|
+
persona_reasoning.append(
|
|
1503
|
+
{
|
|
1504
|
+
"persona": p,
|
|
1505
|
+
"mean_confidence": round(per_persona_mean, 4),
|
|
1506
|
+
"n_claims": len(pclaims),
|
|
1507
|
+
"reasoning": best.get("reasoning", "")[:1000],
|
|
1508
|
+
}
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
disputes_q = [c for c in qclaims if c.get("disputes_received", 0) > 0]
|
|
1512
|
+
citations_q = sum(c.get("citations_received", 0) for c in qclaims)
|
|
1513
|
+
|
|
1514
|
+
per_question.append(
|
|
1515
|
+
{
|
|
1516
|
+
"query": query,
|
|
1517
|
+
"mean_conf": round(mean_conf, 4),
|
|
1518
|
+
"spread": round(spread, 4),
|
|
1519
|
+
"verdict": verdict,
|
|
1520
|
+
"per_persona_reasoning": persona_reasoning,
|
|
1521
|
+
"n_disputes": len(disputes_q),
|
|
1522
|
+
"n_citations": citations_q,
|
|
1523
|
+
"convergence_curve": [],
|
|
1524
|
+
}
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
key_findings.sort(key=lambda x: x["n_claims"], reverse=True)
|
|
1528
|
+
|
|
1529
|
+
# Persona forensics: aggregate by slug from scoreboard
|
|
1530
|
+
persona_agg: dict[str, dict] = {}
|
|
1531
|
+
for ag_row in scoreboard_rows:
|
|
1532
|
+
slug = ag_row.get("persona", "")
|
|
1533
|
+
if not slug:
|
|
1534
|
+
continue
|
|
1535
|
+
if slug not in persona_agg:
|
|
1536
|
+
persona_agg[slug] = {
|
|
1537
|
+
"persona": slug,
|
|
1538
|
+
"label": slug,
|
|
1539
|
+
"emoji": "🤖",
|
|
1540
|
+
"total_brier": 0.0,
|
|
1541
|
+
"n_brier": 0,
|
|
1542
|
+
"n_claims": 0,
|
|
1543
|
+
"citations_received": 0,
|
|
1544
|
+
"n_resolved": 0,
|
|
1545
|
+
"runs": 0,
|
|
1546
|
+
}
|
|
1547
|
+
pa = persona_agg[slug]
|
|
1548
|
+
if ag_row["brier"] > 0:
|
|
1549
|
+
pa["total_brier"] += ag_row["brier"]
|
|
1550
|
+
pa["n_brier"] += 1
|
|
1551
|
+
pa["n_claims"] += ag_row.get("n_claims", 0)
|
|
1552
|
+
pa["citations_received"] += ag_row.get("citations_received", 0)
|
|
1553
|
+
pa["n_resolved"] += ag_row.get("n_resolved", 0)
|
|
1554
|
+
pa["runs"] += ag_row.get("runs", 0)
|
|
1555
|
+
|
|
1556
|
+
try:
|
|
1557
|
+
from hypermind.simlab._scenario_impl import PERSONAS as _PERSONAS
|
|
1558
|
+
|
|
1559
|
+
for slug, pa in persona_agg.items():
|
|
1560
|
+
if slug in _PERSONAS:
|
|
1561
|
+
pa["label"] = _PERSONAS[slug].get("label", slug)
|
|
1562
|
+
pa["emoji"] = _PERSONAS[slug].get("emoji", "🤖")
|
|
1563
|
+
except Exception:
|
|
1564
|
+
pass
|
|
1565
|
+
|
|
1566
|
+
forensics_list: list[dict] = []
|
|
1567
|
+
for slug, pa in persona_agg.items():
|
|
1568
|
+
mean_brier = pa["total_brier"] / pa["n_brier"] if pa["n_brier"] > 0 else 0.0
|
|
1569
|
+
forensics_list.append(
|
|
1570
|
+
{
|
|
1571
|
+
"persona": slug,
|
|
1572
|
+
"label": pa["label"],
|
|
1573
|
+
"emoji": pa["emoji"],
|
|
1574
|
+
"brier": round(mean_brier, 4),
|
|
1575
|
+
"n_claims": pa["n_claims"],
|
|
1576
|
+
"citations_received": pa["citations_received"],
|
|
1577
|
+
"n_resolved": pa["n_resolved"],
|
|
1578
|
+
"runs": pa["runs"],
|
|
1579
|
+
}
|
|
1580
|
+
)
|
|
1581
|
+
forensics_list.sort(key=lambda x: x["brier"])
|
|
1582
|
+
|
|
1583
|
+
n_sims = overview.get("n_sims", 0)
|
|
1584
|
+
n_agents = overview.get("n_agents", 0)
|
|
1585
|
+
n_claims_total = len(claims)
|
|
1586
|
+
n_questions_total = len(key_findings)
|
|
1587
|
+
panel_accuracy = overview.get("panel_accuracy")
|
|
1588
|
+
|
|
1589
|
+
yes_count = sum(1 for kf in key_findings if kf["verdict"] in ("YES", "LEAN YES"))
|
|
1590
|
+
no_count = sum(1 for kf in key_findings if kf["verdict"] in ("NO", "LEAN NO"))
|
|
1591
|
+
split_count = sum(1 for kf in key_findings if kf["verdict"] == "SPLIT")
|
|
1592
|
+
|
|
1593
|
+
topic_str = f" on the topic of '{topic}'" if topic else ""
|
|
1594
|
+
accuracy_str = f" Panel calibration accuracy: {panel_accuracy:.1%}." if panel_accuracy else ""
|
|
1595
|
+
verdict_str = (
|
|
1596
|
+
f"The swarm reached affirmative verdicts (YES or LEAN YES) on {yes_count} of "
|
|
1597
|
+
f"{n_questions_total} questions, negative verdicts on {no_count}, "
|
|
1598
|
+
f"and split verdicts on {split_count}."
|
|
1599
|
+
if n_questions_total > 0
|
|
1600
|
+
else ""
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
abstract = (
|
|
1604
|
+
f"This report synthesizes the deliberations of a {n_agents}-agent intelligence swarm "
|
|
1605
|
+
f"across {n_sims} simulation(s){topic_str}. "
|
|
1606
|
+
f"The swarm evaluated {n_questions_total} distinct question(s), producing {n_claims_total} total claims "
|
|
1607
|
+
f"with {overview.get('total_citations', 0)} citations and {overview.get('total_disputes', 0)} disputes. "
|
|
1608
|
+
f"{verdict_str}"
|
|
1609
|
+
f"{accuracy_str} "
|
|
1610
|
+
f"Personas contributing to this report: "
|
|
1611
|
+
f"{', '.join(pa['label'] for pa in forensics_list[:6])}."
|
|
1612
|
+
).strip()
|
|
1613
|
+
|
|
1614
|
+
panel_records_for_followup = []
|
|
1615
|
+
for kf in key_findings:
|
|
1616
|
+
q_claims = query_map.get(kf["query"], [])
|
|
1617
|
+
disputed = [c for c in q_claims if c.get("disputes_received", 0) > 0]
|
|
1618
|
+
panel_records_for_followup.append(
|
|
1619
|
+
{
|
|
1620
|
+
"query": kf["query"],
|
|
1621
|
+
"mean_confidence": kf["mean_conf"],
|
|
1622
|
+
"spread": kf["spread"],
|
|
1623
|
+
"disputes": [
|
|
1624
|
+
{"from_persona": c.get("persona", ""), "delta": 0.35} for c in disputed
|
|
1625
|
+
],
|
|
1626
|
+
"per_agent": [],
|
|
1627
|
+
}
|
|
1628
|
+
)
|
|
1629
|
+
followup_questions = generate_followup_questions(panel_records_for_followup, top_k=10)
|
|
1630
|
+
|
|
1631
|
+
report_data = {
|
|
1632
|
+
"generated_at": _time.time(),
|
|
1633
|
+
"topic": topic,
|
|
1634
|
+
"n_sims": n_sims,
|
|
1635
|
+
"n_agents": n_agents,
|
|
1636
|
+
"n_claims": n_claims_total,
|
|
1637
|
+
"n_questions": n_questions_total,
|
|
1638
|
+
"abstract": abstract,
|
|
1639
|
+
"key_findings": key_findings,
|
|
1640
|
+
"per_question": per_question,
|
|
1641
|
+
"persona_forensics": forensics_list,
|
|
1642
|
+
"follow_up_questions": followup_questions,
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
if fmt == "markdown":
|
|
1646
|
+
md = _render_report_markdown(report_data)
|
|
1647
|
+
await _send_bytes(
|
|
1648
|
+
send,
|
|
1649
|
+
md.encode("utf-8"),
|
|
1650
|
+
content_type="text/markdown; charset=utf-8",
|
|
1651
|
+
filename="swarm-intelligence-report.md",
|
|
1652
|
+
)
|
|
1653
|
+
return
|
|
1654
|
+
|
|
1655
|
+
await _send_json(send, status=200, payload=report_data)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
async def _handle_stats(send: _Send) -> None:
|
|
1659
|
+
"""GET /v1/stats — simlab-level federation summary.
|
|
1660
|
+
|
|
1661
|
+
Aggregates stats across all namespaces found in the sim registry.
|
|
1662
|
+
If all sims share a single namespace the response reflects that
|
|
1663
|
+
namespace; otherwise ``namespace`` is ``"all"``.
|
|
1664
|
+
"""
|
|
1665
|
+
import hashlib
|
|
1666
|
+
|
|
1667
|
+
from .registry import load_config as _load_cfg
|
|
1668
|
+
|
|
1669
|
+
idx = build_knowledge_index()
|
|
1670
|
+
sealed_count = len(idx.claims)
|
|
1671
|
+
dispute_count = sum(len(v) for v in idx.disputes_to.values())
|
|
1672
|
+
|
|
1673
|
+
# Collect distinct namespaces from sim configs; fall back gracefully.
|
|
1674
|
+
namespaces: set[str] = set()
|
|
1675
|
+
rooms: set[str] = set()
|
|
1676
|
+
for sim in list_sims(limit=500):
|
|
1677
|
+
try:
|
|
1678
|
+
cfg = _load_cfg(sim.sim_id)
|
|
1679
|
+
ns = cfg.get("namespace")
|
|
1680
|
+
if ns:
|
|
1681
|
+
namespaces.add(ns)
|
|
1682
|
+
room = cfg.get("room")
|
|
1683
|
+
if room:
|
|
1684
|
+
rooms.add(room)
|
|
1685
|
+
except Exception:
|
|
1686
|
+
pass
|
|
1687
|
+
|
|
1688
|
+
if not namespaces:
|
|
1689
|
+
# Fall back to the default scenario namespace when no sim configs exist.
|
|
1690
|
+
try:
|
|
1691
|
+
from ._scenario_impl import NAMESPACE as _NS
|
|
1692
|
+
from ._scenario_impl import ROOM as _ROOM
|
|
1693
|
+
|
|
1694
|
+
namespaces = {_NS}
|
|
1695
|
+
rooms = {_ROOM}
|
|
1696
|
+
except Exception:
|
|
1697
|
+
namespaces = {"default"}
|
|
1698
|
+
|
|
1699
|
+
namespace = next(iter(namespaces)) if len(namespaces) == 1 else "all"
|
|
1700
|
+
ns_id = hashlib.sha256(namespace.encode()).hexdigest()[:32]
|
|
1701
|
+
payload = {
|
|
1702
|
+
"agent_kid": ns_id,
|
|
1703
|
+
"did_key": f"did:key:z{ns_id[:24]}",
|
|
1704
|
+
"namespace": namespace,
|
|
1705
|
+
"rooms": sorted(rooms) if rooms else [],
|
|
1706
|
+
"role": "operator",
|
|
1707
|
+
"ramp_paused": False,
|
|
1708
|
+
"world": {
|
|
1709
|
+
"namespace_id": ns_id,
|
|
1710
|
+
"sealed_count": sealed_count,
|
|
1711
|
+
"dispute_count": dispute_count,
|
|
1712
|
+
"tree_size": sealed_count,
|
|
1713
|
+
},
|
|
1714
|
+
}
|
|
1715
|
+
await _send_json(send, status=200, payload=payload)
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
async def _handle_disputes(send: _Send) -> None:
|
|
1719
|
+
"""GET /v1/disputes — collect dispute records across all completed sims."""
|
|
1720
|
+
all_sims = list_sims()
|
|
1721
|
+
disputes: list[dict] = []
|
|
1722
|
+
for sim in all_sims:
|
|
1723
|
+
if sim.state != "done":
|
|
1724
|
+
continue
|
|
1725
|
+
try:
|
|
1726
|
+
trace = load_trace(sim.sim_id)
|
|
1727
|
+
if trace is None:
|
|
1728
|
+
continue
|
|
1729
|
+
raw = trace if isinstance(trace, dict) else {}
|
|
1730
|
+
for pr in raw.get("panel_records", []):
|
|
1731
|
+
for d in pr.get("disputes", []):
|
|
1732
|
+
disputes.append(
|
|
1733
|
+
{
|
|
1734
|
+
"kid": d.get("dispute_kid", ""),
|
|
1735
|
+
"initiator": d.get("initiator_kid", d.get("kid", "")),
|
|
1736
|
+
"state": d.get("state", "CLOSED"),
|
|
1737
|
+
"bond_topic_scope": d.get("topic", pr.get("query", "")[:60]),
|
|
1738
|
+
"bond_amount": d.get("bond_amount", 0),
|
|
1739
|
+
"transitions": d.get("transitions", []),
|
|
1740
|
+
"sim_id": sim.sim_id,
|
|
1741
|
+
}
|
|
1742
|
+
)
|
|
1743
|
+
except Exception:
|
|
1744
|
+
continue
|
|
1745
|
+
await _send_json(send, status=200, payload={"count": len(disputes), "disputes": disputes})
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
async def _handle_sim_agents(sim_id: str, send: _Send) -> None:
|
|
1749
|
+
"""Return the agent roster for a sim (live or completed).
|
|
1750
|
+
|
|
1751
|
+
Sourced from the in-process persona registry populated when the
|
|
1752
|
+
runner builds profiles. Lets the live Activity canvas seed bubbles
|
|
1753
|
+
before the first event arrives.
|
|
1754
|
+
"""
|
|
1755
|
+
if not sim_dir(sim_id).exists():
|
|
1756
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
1757
|
+
return
|
|
1758
|
+
from . import _persona_registry
|
|
1759
|
+
|
|
1760
|
+
seen: dict[str, dict[str, str]] = {}
|
|
1761
|
+
# Walk the registry, emitting one row per unique display_name.
|
|
1762
|
+
with _persona_registry._LOCK:
|
|
1763
|
+
bucket = _persona_registry._REGISTRY.get(sim_id, {})
|
|
1764
|
+
for key, meta in bucket.items():
|
|
1765
|
+
name = meta.get("display_name") or key
|
|
1766
|
+
if name in seen:
|
|
1767
|
+
continue
|
|
1768
|
+
seen[name] = {
|
|
1769
|
+
"name": name,
|
|
1770
|
+
"persona": meta.get("persona", ""),
|
|
1771
|
+
"persona_emoji": meta.get("persona_emoji", ""),
|
|
1772
|
+
"role": meta.get("role", ""), # S5
|
|
1773
|
+
}
|
|
1774
|
+
await _send_json(send, status=200, payload={"agents": list(seen.values())})
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
async def _handle_resolve_outcomes(sim_id: str, body_bytes: bytes, send: _Send) -> None:
|
|
1778
|
+
"""POST /v1/sims/{id}/resolve — apply ground-truth resolutions, recompute EIG/Q + Brier."""
|
|
1779
|
+
import math
|
|
1780
|
+
import time as _time
|
|
1781
|
+
|
|
1782
|
+
from hypermind.eval.calibration import brier_score
|
|
1783
|
+
from hypermind.eval.swarm_iq import effective_information_gain_per_question
|
|
1784
|
+
|
|
1785
|
+
sd = sim_dir(sim_id)
|
|
1786
|
+
if not sd.exists():
|
|
1787
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
1788
|
+
return
|
|
1789
|
+
status = load_status(sim_id)
|
|
1790
|
+
if status.get("state") == "unknown":
|
|
1791
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
1792
|
+
return
|
|
1793
|
+
if status.get("state") != "done":
|
|
1794
|
+
await _err(send, 409, f"sim {sim_id!r} is not done (state={status.get('state')!r})")
|
|
1795
|
+
return
|
|
1796
|
+
trace = load_trace(sim_id)
|
|
1797
|
+
if trace is None:
|
|
1798
|
+
await _err(send, 404, f"no trace for sim: {sim_id}")
|
|
1799
|
+
return
|
|
1800
|
+
|
|
1801
|
+
try:
|
|
1802
|
+
payload = json.loads(body_bytes or b"{}")
|
|
1803
|
+
except json.JSONDecodeError as exc:
|
|
1804
|
+
await _err(send, 400, f"invalid JSON: {exc}")
|
|
1805
|
+
return
|
|
1806
|
+
resolutions = payload.get("resolutions")
|
|
1807
|
+
if not isinstance(resolutions, list) or not resolutions:
|
|
1808
|
+
await _err(send, 400, "body must have non-empty 'resolutions' array")
|
|
1809
|
+
return
|
|
1810
|
+
|
|
1811
|
+
panel_records: list[dict] = trace.get("panel_records") or []
|
|
1812
|
+
qid_to_idx: dict[str, int] = {}
|
|
1813
|
+
for i, pr in enumerate(panel_records):
|
|
1814
|
+
stored_qid = pr.get("query_id", "")
|
|
1815
|
+
if stored_qid:
|
|
1816
|
+
qid_to_idx[stored_qid] = i
|
|
1817
|
+
qid_to_idx[f"q-{i}"] = i
|
|
1818
|
+
|
|
1819
|
+
ts_now = int(_time.time() * 1000)
|
|
1820
|
+
applied: list[dict] = []
|
|
1821
|
+
for res in resolutions:
|
|
1822
|
+
qid = res.get("question_id")
|
|
1823
|
+
if qid is None:
|
|
1824
|
+
await _err(send, 400, "each resolution must have 'question_id'")
|
|
1825
|
+
return
|
|
1826
|
+
if qid not in qid_to_idx:
|
|
1827
|
+
await _err(send, 400, f"unknown question_id: {qid!r}")
|
|
1828
|
+
return
|
|
1829
|
+
outcome_raw = res.get("outcome")
|
|
1830
|
+
if outcome_raw is None or not isinstance(outcome_raw, bool):
|
|
1831
|
+
await _err(send, 400, f"resolution for {qid!r} must have boolean 'outcome'")
|
|
1832
|
+
return
|
|
1833
|
+
gt_raw = res.get("ground_truth")
|
|
1834
|
+
if gt_raw is None:
|
|
1835
|
+
gt_raw = 1.0 if outcome_raw else 0.0
|
|
1836
|
+
try:
|
|
1837
|
+
gt_f = float(gt_raw)
|
|
1838
|
+
except (TypeError, ValueError):
|
|
1839
|
+
await _err(send, 400, f"ground_truth for {qid!r} must be a float 0-1")
|
|
1840
|
+
return
|
|
1841
|
+
if not (0.0 <= gt_f <= 1.0):
|
|
1842
|
+
await _err(send, 400, f"ground_truth for {qid!r} must be in [0, 1]")
|
|
1843
|
+
return
|
|
1844
|
+
|
|
1845
|
+
idx = qid_to_idx[qid]
|
|
1846
|
+
panel_records[idx]["outcome"] = outcome_raw
|
|
1847
|
+
panel_records[idx]["ground_truth"] = gt_f
|
|
1848
|
+
panel_records[idx]["resolved_by"] = res.get("resolved_by")
|
|
1849
|
+
panel_records[idx]["source_url"] = res.get("source_url")
|
|
1850
|
+
panel_records[idx]["resolved_at_ms"] = res.get("resolved_at_ms") or ts_now
|
|
1851
|
+
applied.append({"question_id": qid, "idx": idx, "outcome": outcome_raw, "ground_truth": gt_f})
|
|
1852
|
+
|
|
1853
|
+
delib_records: list[dict] = trace.get("deliberation_records") or []
|
|
1854
|
+
eig_mean: float | None = None
|
|
1855
|
+
try:
|
|
1856
|
+
eig_result = effective_information_gain_per_question(panel_records, delib_records)
|
|
1857
|
+
raw = eig_result.get("eig_mean")
|
|
1858
|
+
eig_mean = None if (raw is None or (isinstance(raw, float) and math.isnan(raw))) else raw
|
|
1859
|
+
if not trace.get("collective_iq"):
|
|
1860
|
+
trace["collective_iq"] = {}
|
|
1861
|
+
trace["collective_iq"]["eig_mean"] = eig_mean
|
|
1862
|
+
trace["collective_iq"]["eig_per_q"] = [
|
|
1863
|
+
None if (isinstance(v, float) and math.isnan(v)) else v
|
|
1864
|
+
for v in (eig_result.get("eig_per_q") or [])
|
|
1865
|
+
]
|
|
1866
|
+
trace["collective_iq"]["eig_n_scored"] = eig_result.get("n_scored")
|
|
1867
|
+
except Exception:
|
|
1868
|
+
pass
|
|
1869
|
+
|
|
1870
|
+
agent_brier_acc: dict[str, dict] = {}
|
|
1871
|
+
for pr in panel_records:
|
|
1872
|
+
gt_f = pr.get("ground_truth")
|
|
1873
|
+
outcome_b = pr.get("outcome")
|
|
1874
|
+
if gt_f is None or outcome_b is None:
|
|
1875
|
+
continue
|
|
1876
|
+
stored_qid = pr.get("query_id", "")
|
|
1877
|
+
for claim in pr.get("claims") or []:
|
|
1878
|
+
agent = claim.get("agent", "unknown")
|
|
1879
|
+
conf = claim.get("confidence")
|
|
1880
|
+
if conf is None:
|
|
1881
|
+
continue
|
|
1882
|
+
try:
|
|
1883
|
+
conf = float(conf)
|
|
1884
|
+
except (TypeError, ValueError):
|
|
1885
|
+
continue
|
|
1886
|
+
bs = brier_score(conf, gt_f)
|
|
1887
|
+
gap = abs(conf - float(outcome_b))
|
|
1888
|
+
if agent not in agent_brier_acc:
|
|
1889
|
+
agent_brier_acc[agent] = {"sum_sq": 0.0, "n": 0, "cal_gap_sum": 0.0, "hallucinations": []}
|
|
1890
|
+
agent_brier_acc[agent]["sum_sq"] += bs
|
|
1891
|
+
agent_brier_acc[agent]["n"] += 1
|
|
1892
|
+
agent_brier_acc[agent]["cal_gap_sum"] += gap
|
|
1893
|
+
if gap > 0.4:
|
|
1894
|
+
agent_brier_acc[agent]["hallucinations"].append({
|
|
1895
|
+
"question_id": stored_qid,
|
|
1896
|
+
"confidence": conf,
|
|
1897
|
+
"outcome": outcome_b,
|
|
1898
|
+
"gap": round(gap, 4),
|
|
1899
|
+
})
|
|
1900
|
+
|
|
1901
|
+
agent_brier_scores: dict[str, dict] = {}
|
|
1902
|
+
total_brier_sum = 0.0
|
|
1903
|
+
total_brier_n = 0
|
|
1904
|
+
hallucination_count = 0
|
|
1905
|
+
for agent, acc in agent_brier_acc.items():
|
|
1906
|
+
n = acc["n"]
|
|
1907
|
+
mean_b = acc["sum_sq"] / n if n > 0 else None
|
|
1908
|
+
cal_gap_mean = acc["cal_gap_sum"] / n if n > 0 else None
|
|
1909
|
+
agent_brier_scores[agent] = {
|
|
1910
|
+
"mean_brier": round(mean_b, 6) if mean_b is not None else None,
|
|
1911
|
+
"n": n,
|
|
1912
|
+
"calibration_gap_mean": round(cal_gap_mean, 4) if cal_gap_mean is not None else None,
|
|
1913
|
+
"hallucinations": acc["hallucinations"],
|
|
1914
|
+
}
|
|
1915
|
+
if mean_b is not None:
|
|
1916
|
+
total_brier_sum += mean_b
|
|
1917
|
+
total_brier_n += 1
|
|
1918
|
+
hallucination_count += len(acc["hallucinations"])
|
|
1919
|
+
|
|
1920
|
+
mean_brier = round(total_brier_sum / total_brier_n, 6) if total_brier_n > 0 else None
|
|
1921
|
+
trace["agent_brier_scores"] = agent_brier_scores
|
|
1922
|
+
|
|
1923
|
+
existing: list[dict] = trace.get("oracle_resolutions") or []
|
|
1924
|
+
existing_by_qid: dict[str, int] = {r.get("question_id", ""): i for i, r in enumerate(existing)}
|
|
1925
|
+
for a in applied:
|
|
1926
|
+
pr = panel_records[a["idx"]]
|
|
1927
|
+
rec = {
|
|
1928
|
+
"question_id": a["question_id"],
|
|
1929
|
+
"outcome": a["outcome"],
|
|
1930
|
+
"ground_truth": a["ground_truth"],
|
|
1931
|
+
"source_url": pr.get("source_url"),
|
|
1932
|
+
"resolved_by": pr.get("resolved_by"),
|
|
1933
|
+
"resolved_at_ms": pr.get("resolved_at_ms"),
|
|
1934
|
+
"ts_wall_ms": ts_now,
|
|
1935
|
+
}
|
|
1936
|
+
if a["question_id"] in existing_by_qid:
|
|
1937
|
+
existing[existing_by_qid[a["question_id"]]] = rec
|
|
1938
|
+
else:
|
|
1939
|
+
existing.append(rec)
|
|
1940
|
+
trace["oracle_resolutions"] = existing
|
|
1941
|
+
|
|
1942
|
+
_write_json_atomic(sim_dir(sim_id) / "trace.json", trace)
|
|
1943
|
+
n_resolved = len([r for r in existing if r.get("outcome") is not None])
|
|
1944
|
+
update_status(sim_id, n_resolved=n_resolved, mean_brier=mean_brier, hallucination_count=hallucination_count)
|
|
1945
|
+
|
|
1946
|
+
await _send_json(
|
|
1947
|
+
send,
|
|
1948
|
+
status=200,
|
|
1949
|
+
payload={
|
|
1950
|
+
"sim_id": sim_id,
|
|
1951
|
+
"n_resolved": n_resolved,
|
|
1952
|
+
"eig_mean": eig_mean,
|
|
1953
|
+
"mean_brier": mean_brier,
|
|
1954
|
+
"agent_brier_scores": agent_brier_scores,
|
|
1955
|
+
"hallucination_count": hallucination_count,
|
|
1956
|
+
},
|
|
1957
|
+
)
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
async def _handle_get_outcomes(sim_id: str, send: _Send) -> None:
|
|
1961
|
+
"""GET /v1/sims/{id}/outcomes — current resolution state for all questions."""
|
|
1962
|
+
sd = sim_dir(sim_id)
|
|
1963
|
+
if not sd.exists():
|
|
1964
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
1965
|
+
return
|
|
1966
|
+
status = load_status(sim_id)
|
|
1967
|
+
if status.get("state") == "unknown":
|
|
1968
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
1969
|
+
return
|
|
1970
|
+
if status.get("state") != "done":
|
|
1971
|
+
await _err(send, 409, f"sim {sim_id!r} is not done (state={status.get('state')!r})")
|
|
1972
|
+
return
|
|
1973
|
+
trace = load_trace(sim_id)
|
|
1974
|
+
if trace is None:
|
|
1975
|
+
await _err(send, 404, f"no trace for sim: {sim_id}")
|
|
1976
|
+
return
|
|
1977
|
+
|
|
1978
|
+
panel_records: list[dict] = trace.get("panel_records") or []
|
|
1979
|
+
items = []
|
|
1980
|
+
for i, pr in enumerate(panel_records):
|
|
1981
|
+
resolved = pr.get("outcome") is not None
|
|
1982
|
+
items.append({
|
|
1983
|
+
"question_id": pr.get("query_id") or f"q-{i}",
|
|
1984
|
+
"query": pr.get("query", ""),
|
|
1985
|
+
"resolved": resolved,
|
|
1986
|
+
"outcome": pr.get("outcome"),
|
|
1987
|
+
"ground_truth": pr.get("ground_truth"),
|
|
1988
|
+
"resolved_by": pr.get("resolved_by"),
|
|
1989
|
+
"source_url": pr.get("source_url"),
|
|
1990
|
+
"resolved_at_ms": pr.get("resolved_at_ms"),
|
|
1991
|
+
})
|
|
1992
|
+
|
|
1993
|
+
n_resolved = sum(1 for it in items if it["resolved"])
|
|
1994
|
+
await _send_json(
|
|
1995
|
+
send,
|
|
1996
|
+
status=200,
|
|
1997
|
+
payload={
|
|
1998
|
+
"sim_id": sim_id,
|
|
1999
|
+
"n_total": len(items),
|
|
2000
|
+
"n_resolved": n_resolved,
|
|
2001
|
+
"n_pending": len(items) - n_resolved,
|
|
2002
|
+
"items": items,
|
|
2003
|
+
"agent_brier_scores": trace.get("agent_brier_scores") or {},
|
|
2004
|
+
},
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
async def _handle_adapt_topic(qs: bytes, send: _Send) -> None:
|
|
2009
|
+
"""GET /v1/sims/adapt?topic=… — preview inferred config for a topic."""
|
|
2010
|
+
from .topic_adapter import infer_topic_profile
|
|
2011
|
+
|
|
2012
|
+
topic = _qs_param(qs, "topic", "")
|
|
2013
|
+
if not topic:
|
|
2014
|
+
await _err(send, 400, "topic query parameter is required")
|
|
2015
|
+
return
|
|
2016
|
+
no_llm = _qs_param(qs, "no_llm", "false").lower() in ("1", "true", "yes")
|
|
2017
|
+
model = _qs_param(qs, "model", "") or "openai/gpt-4o-mini"
|
|
2018
|
+
profile = await infer_topic_profile(topic, model=model, no_llm=no_llm)
|
|
2019
|
+
await _send_json(send, status=200, payload={
|
|
2020
|
+
"topic": topic,
|
|
2021
|
+
"profile": profile.profile_name,
|
|
2022
|
+
"rationale": profile.rationale,
|
|
2023
|
+
"n_questions": profile.n_questions,
|
|
2024
|
+
"personas": profile.personas,
|
|
2025
|
+
"replicas": profile.replicas,
|
|
2026
|
+
"deliberation_rounds": profile.deliberation_rounds,
|
|
2027
|
+
"n_agents": profile.personas * profile.replicas,
|
|
2028
|
+
"suggested_personas": profile.suggested_personas,
|
|
2029
|
+
})
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
async def _handle_related_sims(qs: bytes, send: _Send) -> None:
|
|
2033
|
+
"""GET /v1/sims/related?topic=… — find related sims by topic similarity."""
|
|
2034
|
+
from .learning import find_related_sims
|
|
2035
|
+
|
|
2036
|
+
topic = _qs_param(qs, "topic", "")
|
|
2037
|
+
if not topic:
|
|
2038
|
+
await _err(send, 400, "topic query parameter is required")
|
|
2039
|
+
return
|
|
2040
|
+
try:
|
|
2041
|
+
limit = max(1, min(20, int(_qs_param(qs, "limit", "5"))))
|
|
2042
|
+
except ValueError:
|
|
2043
|
+
limit = 5
|
|
2044
|
+
results = find_related_sims(topic, limit=limit)
|
|
2045
|
+
await _send_json(send, status=200, payload={"topic": topic, "sims": results})
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
async def _handle_knowledge_reuse(sim_id: str, send: _Send) -> None:
|
|
2049
|
+
"""GET /v1/sims/{id}/knowledge-reuse — cross-sim learning efficiency stats."""
|
|
2050
|
+
from .learning import compute_knowledge_reuse
|
|
2051
|
+
|
|
2052
|
+
if not sim_dir(sim_id).exists():
|
|
2053
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
2054
|
+
return
|
|
2055
|
+
result = compute_knowledge_reuse(sim_id)
|
|
2056
|
+
if result is None:
|
|
2057
|
+
await _err(send, 409, f"sim {sim_id!r} is not done or has no trace")
|
|
2058
|
+
return
|
|
2059
|
+
await _send_json(send, status=200, payload=result)
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
async def _handle_relata_recall(sim_id: str, qs: bytes, send: _Send) -> None:
|
|
2063
|
+
"""GET /v1/sims/{id}/relata-recall?q=…&agent=…&top_k=5
|
|
2064
|
+
|
|
2065
|
+
Query each agent's private Relata memory for the given search string.
|
|
2066
|
+
Returns ranked rows per agent. Requires RELATA_URL to be configured.
|
|
2067
|
+
"""
|
|
2068
|
+
import os
|
|
2069
|
+
import urllib.parse
|
|
2070
|
+
|
|
2071
|
+
url = os.getenv("RELATA_URL")
|
|
2072
|
+
if not url:
|
|
2073
|
+
await _send_json(send, 200, {"error": "RELATA_URL not set", "rows": []})
|
|
2074
|
+
return
|
|
2075
|
+
|
|
2076
|
+
params = dict(urllib.parse.parse_qsl(qs.decode()))
|
|
2077
|
+
q = params.get("q", "").strip()
|
|
2078
|
+
agent_filter = params.get("agent", "")
|
|
2079
|
+
top_k = max(1, min(20, int(params.get("top_k", "5"))))
|
|
2080
|
+
|
|
2081
|
+
trace_file = sim_dir(sim_id) / "trace.json"
|
|
2082
|
+
if not trace_file.exists():
|
|
2083
|
+
await _err(send, 404, f"sim {sim_id!r} not found or not complete")
|
|
2084
|
+
return
|
|
2085
|
+
|
|
2086
|
+
import json as _json
|
|
2087
|
+
trace = _json.loads(trace_file.read_text())
|
|
2088
|
+
|
|
2089
|
+
try:
|
|
2090
|
+
from hypermind.mind.memory_store import RelataMemoryStore
|
|
2091
|
+
except ImportError:
|
|
2092
|
+
await _send_json(send, 200, {"error": "hypermind[relata] not installed", "rows": []})
|
|
2093
|
+
return
|
|
2094
|
+
|
|
2095
|
+
results = []
|
|
2096
|
+
for agent in trace.get("agents", []):
|
|
2097
|
+
kid_hex = agent.get("kid", "")
|
|
2098
|
+
if agent_filter and kid_hex != agent_filter:
|
|
2099
|
+
continue
|
|
2100
|
+
try:
|
|
2101
|
+
kid_bytes = bytes.fromhex(kid_hex)
|
|
2102
|
+
store = RelataMemoryStore(
|
|
2103
|
+
url,
|
|
2104
|
+
namespace_id=f"simlab-{sim_id[:12]}",
|
|
2105
|
+
agent_kid=kid_bytes,
|
|
2106
|
+
purpose="agent_notes",
|
|
2107
|
+
)
|
|
2108
|
+
store.open()
|
|
2109
|
+
rows = store.recall(q, top_k=top_k) if q else []
|
|
2110
|
+
store.close()
|
|
2111
|
+
results.append({
|
|
2112
|
+
"agent": agent.get("persona", kid_hex[:12]),
|
|
2113
|
+
"kid": kid_hex[:16],
|
|
2114
|
+
"rows": rows,
|
|
2115
|
+
})
|
|
2116
|
+
except Exception as exc:
|
|
2117
|
+
results.append({"agent": agent.get("persona", kid_hex[:12]), "kid": kid_hex[:16], "rows": [], "error": str(exc)})
|
|
2118
|
+
|
|
2119
|
+
await _send_json(send, 200, {"sim_id": sim_id, "q": q, "results": results})
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
async def _handle_persona_trajectory(persona: str, send: _Send) -> None:
|
|
2123
|
+
"""GET /v1/personas/{slug}/trajectory — Brier history across sims."""
|
|
2124
|
+
from .learning import get_persona_trajectory
|
|
2125
|
+
|
|
2126
|
+
result = get_persona_trajectory(persona)
|
|
2127
|
+
await _send_json(send, status=200, payload=result)
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
async def _handle_sim_events_stream(sim_id: str, send: _Send) -> None:
|
|
2131
|
+
if not sim_dir(sim_id).exists():
|
|
2132
|
+
await _err(send, 404, f"unknown sim: {sim_id}")
|
|
2133
|
+
return
|
|
2134
|
+
# Per-sim recorder lookup. The runner created Recorder.scoped(sim_id);
|
|
2135
|
+
# we re-create the same scoped instance here — both will share state
|
|
2136
|
+
# because Recorder.scoped() returns a fresh recorder, but the runner's
|
|
2137
|
+
# recorder is what emit() calls land on. Instead, tail the global
|
|
2138
|
+
# recorder filtered by namespace_id.
|
|
2139
|
+
from hypermind.observability.recorder import get_recorder
|
|
2140
|
+
|
|
2141
|
+
recorder = get_recorder()
|
|
2142
|
+
|
|
2143
|
+
await send(
|
|
2144
|
+
{
|
|
2145
|
+
"type": "http.response.start",
|
|
2146
|
+
"status": 200,
|
|
2147
|
+
"headers": [
|
|
2148
|
+
(b"content-type", b"text/event-stream"),
|
|
2149
|
+
(b"cache-control", b"no-cache"),
|
|
2150
|
+
(b"connection", b"keep-alive"),
|
|
2151
|
+
(b"access-control-allow-origin", b"*"),
|
|
2152
|
+
],
|
|
2153
|
+
}
|
|
2154
|
+
)
|
|
2155
|
+
await send(
|
|
2156
|
+
{
|
|
2157
|
+
"type": "http.response.body",
|
|
2158
|
+
"body": b": connected\n\n",
|
|
2159
|
+
"more_body": True,
|
|
2160
|
+
}
|
|
2161
|
+
)
|
|
2162
|
+
|
|
2163
|
+
from . import _persona_registry
|
|
2164
|
+
|
|
2165
|
+
_HEARTBEAT_INTERVAL = 15.0 # seconds between SSE heartbeat comments
|
|
2166
|
+
|
|
2167
|
+
tail_iter = recorder.tail()
|
|
2168
|
+
try:
|
|
2169
|
+
while True:
|
|
2170
|
+
try:
|
|
2171
|
+
record = await asyncio.wait_for(
|
|
2172
|
+
tail_iter.__anext__(), # type: ignore[attr-defined]
|
|
2173
|
+
timeout=_HEARTBEAT_INTERVAL,
|
|
2174
|
+
)
|
|
2175
|
+
except TimeoutError:
|
|
2176
|
+
# No event in the last 15 s — send a heartbeat comment to
|
|
2177
|
+
# keep the connection alive through proxies/load balancers.
|
|
2178
|
+
await send(
|
|
2179
|
+
{
|
|
2180
|
+
"type": "http.response.body",
|
|
2181
|
+
"body": b": heartbeat\n\n",
|
|
2182
|
+
"more_body": True,
|
|
2183
|
+
}
|
|
2184
|
+
)
|
|
2185
|
+
continue
|
|
2186
|
+
except StopAsyncIteration:
|
|
2187
|
+
break
|
|
2188
|
+
# I0: per-sim isolation is now enforced at the source — the
|
|
2189
|
+
# runner constructs Recorder.scoped(sim_id) and passes it
|
|
2190
|
+
# through testbed_cohort → _NamespaceWorld → agent. Every
|
|
2191
|
+
# agent's emit() lands a record with namespace_id=sim_id, so
|
|
2192
|
+
# the only filtering we need is the cheap equality check.
|
|
2193
|
+
# Records with no namespace_id (legacy / direct-driver paths)
|
|
2194
|
+
# are kept for back-compat but excluded from concurrent sims.
|
|
2195
|
+
meta = _persona_registry.lookup(sim_id, record.agent_id)
|
|
2196
|
+
if record.namespace_id is None:
|
|
2197
|
+
# Legacy / unscoped event: only show it on a sim stream
|
|
2198
|
+
# if the agent is registered for this sim (back-compat
|
|
2199
|
+
# for non-runner code paths).
|
|
2200
|
+
if not meta:
|
|
2201
|
+
continue
|
|
2202
|
+
elif record.namespace_id != sim_id:
|
|
2203
|
+
continue
|
|
2204
|
+
display_name = (meta or {}).get("display_name") or record.agent_id
|
|
2205
|
+
payload = {
|
|
2206
|
+
"ts_wall_ms": record.ts_wall_ms,
|
|
2207
|
+
"namespace": record.namespace,
|
|
2208
|
+
"agent_id": display_name, # friendly name for UI
|
|
2209
|
+
"agent_kid": record.agent_id, # kid hex for joins
|
|
2210
|
+
"event_type": record.event_type,
|
|
2211
|
+
"severity": record.severity,
|
|
2212
|
+
"fields": record.fields,
|
|
2213
|
+
"sim_id": sim_id,
|
|
2214
|
+
"persona": (meta or {}).get("persona", ""),
|
|
2215
|
+
"persona_emoji": (meta or {}).get("persona_emoji", ""),
|
|
2216
|
+
"role": (meta or {}).get("role", ""), # S5
|
|
2217
|
+
}
|
|
2218
|
+
chunk = f"data: {json.dumps(payload, default=str)}\n\n".encode()
|
|
2219
|
+
await send(
|
|
2220
|
+
{
|
|
2221
|
+
"type": "http.response.body",
|
|
2222
|
+
"body": chunk,
|
|
2223
|
+
"more_body": True,
|
|
2224
|
+
}
|
|
2225
|
+
)
|
|
2226
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
2227
|
+
return
|
|
2228
|
+
except Exception:
|
|
2229
|
+
return
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
# ---- App factory -----------------------------------------------------------
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
def simlab_app() -> Callable[[_Scope, _Receive, _Send], Awaitable[None]]:
|
|
2236
|
+
"""Build the SimLab ASGI app. Idempotent — safe to call multiple times."""
|
|
2237
|
+
dist = _ui_dist_dir()
|
|
2238
|
+
|
|
2239
|
+
async def app(scope: _Scope, receive: _Receive, send: _Send) -> None:
|
|
2240
|
+
if scope["type"] == "lifespan":
|
|
2241
|
+
while True:
|
|
2242
|
+
msg = await receive()
|
|
2243
|
+
if msg["type"] == "lifespan.startup":
|
|
2244
|
+
# Lazy-start the runner on app startup so the queue
|
|
2245
|
+
# worker is alive before any POST arrives. The
|
|
2246
|
+
# worker's first tick reaps any orphan sims (left
|
|
2247
|
+
# "running"/"queued" by a previous process that died).
|
|
2248
|
+
get_runner().start()
|
|
2249
|
+
from .namespace import ensure_default
|
|
2250
|
+
|
|
2251
|
+
ensure_default()
|
|
2252
|
+
await send({"type": "lifespan.startup.complete"})
|
|
2253
|
+
elif msg["type"] == "lifespan.shutdown":
|
|
2254
|
+
await get_runner().stop()
|
|
2255
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
2256
|
+
return
|
|
2257
|
+
|
|
2258
|
+
if scope["type"] != "http":
|
|
2259
|
+
return
|
|
2260
|
+
|
|
2261
|
+
method: str = scope.get("method", "GET").upper()
|
|
2262
|
+
path: str = scope.get("path", "/")
|
|
2263
|
+
|
|
2264
|
+
# CORS preflight — accept any origin since this is a localhost app
|
|
2265
|
+
if method == "OPTIONS":
|
|
2266
|
+
await send(
|
|
2267
|
+
{
|
|
2268
|
+
"type": "http.response.start",
|
|
2269
|
+
"status": 204,
|
|
2270
|
+
"headers": [
|
|
2271
|
+
(b"access-control-allow-origin", b"*"),
|
|
2272
|
+
(b"access-control-allow-methods", b"GET, POST, PUT, DELETE, OPTIONS"),
|
|
2273
|
+
(b"access-control-allow-headers", b"content-type"),
|
|
2274
|
+
],
|
|
2275
|
+
}
|
|
2276
|
+
)
|
|
2277
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
2278
|
+
return
|
|
2279
|
+
|
|
2280
|
+
# Health
|
|
2281
|
+
if method == "GET" and path == "/v1/healthz":
|
|
2282
|
+
import os
|
|
2283
|
+
|
|
2284
|
+
llm_available = bool(os.environ.get("OPENROUTER_API_KEY", "").strip())
|
|
2285
|
+
await _send_json(
|
|
2286
|
+
send,
|
|
2287
|
+
status=200,
|
|
2288
|
+
payload={
|
|
2289
|
+
"ok": True,
|
|
2290
|
+
"version": __version__,
|
|
2291
|
+
"llm_available": llm_available,
|
|
2292
|
+
},
|
|
2293
|
+
)
|
|
2294
|
+
return
|
|
2295
|
+
|
|
2296
|
+
# /v1/modes — deliberation-mode registry (schema-driven SimNew form)
|
|
2297
|
+
if method == "GET" and path == "/v1/modes":
|
|
2298
|
+
await _handle_list_modes(send)
|
|
2299
|
+
return
|
|
2300
|
+
|
|
2301
|
+
# /v1/sims listing
|
|
2302
|
+
if method == "GET" and path == "/v1/sims":
|
|
2303
|
+
await _handle_list_sims(send)
|
|
2304
|
+
return
|
|
2305
|
+
|
|
2306
|
+
# /v1/sims POST (new)
|
|
2307
|
+
if method == "POST" and path == "/v1/sims":
|
|
2308
|
+
body = await _read_body(receive)
|
|
2309
|
+
await _handle_create_sim(body, send)
|
|
2310
|
+
return
|
|
2311
|
+
|
|
2312
|
+
# /v1/knowledge endpoints
|
|
2313
|
+
# /v1/personas/* + /v1/swarms/*
|
|
2314
|
+
if method == "GET" and path == "/v1/personas":
|
|
2315
|
+
await _handle_list_personas(send)
|
|
2316
|
+
return
|
|
2317
|
+
if method == "POST" and path == "/v1/personas":
|
|
2318
|
+
body = await _read_body(receive)
|
|
2319
|
+
await _handle_create_persona(body, send)
|
|
2320
|
+
return
|
|
2321
|
+
if method == "POST" and path == "/v1/personas/generate":
|
|
2322
|
+
body = await _read_body(receive)
|
|
2323
|
+
await _handle_generate_persona(body, send)
|
|
2324
|
+
return
|
|
2325
|
+
if method == "POST" and path == "/v1/personas/batch-generate":
|
|
2326
|
+
body = await _read_body(receive)
|
|
2327
|
+
await _handle_batch_generate_personas(body, send)
|
|
2328
|
+
return
|
|
2329
|
+
if path.startswith("/v1/personas/"):
|
|
2330
|
+
slug = path[len("/v1/personas/") :]
|
|
2331
|
+
# trajectory sub-route must be checked before bare slug GET
|
|
2332
|
+
if method == "GET" and slug.endswith("/trajectory"):
|
|
2333
|
+
persona_slug = slug[: -len("/trajectory")]
|
|
2334
|
+
await _handle_persona_trajectory(persona_slug, send)
|
|
2335
|
+
return
|
|
2336
|
+
if method == "GET":
|
|
2337
|
+
await _handle_get_persona(slug, send)
|
|
2338
|
+
return
|
|
2339
|
+
if method == "PUT":
|
|
2340
|
+
body = await _read_body(receive)
|
|
2341
|
+
await _handle_update_persona(slug, body, send)
|
|
2342
|
+
return
|
|
2343
|
+
if method == "DELETE":
|
|
2344
|
+
await _handle_delete_persona(slug, send)
|
|
2345
|
+
return
|
|
2346
|
+
|
|
2347
|
+
# ---- /v1/namespaces -------------------------------------------------------
|
|
2348
|
+
if path == "/v1/namespaces" and method == "GET":
|
|
2349
|
+
from .namespace import list_namespaces
|
|
2350
|
+
|
|
2351
|
+
await _send_json(send, status=200, payload=[n.to_dict() for n in list_namespaces()])
|
|
2352
|
+
return
|
|
2353
|
+
if path == "/v1/namespaces" and method == "POST":
|
|
2354
|
+
body = await _read_body(receive)
|
|
2355
|
+
await _handle_create_namespace(body, send)
|
|
2356
|
+
return
|
|
2357
|
+
if path.startswith("/v1/namespaces/") and method == "GET":
|
|
2358
|
+
slug = path[len("/v1/namespaces/") :]
|
|
2359
|
+
from .namespace import get_namespace
|
|
2360
|
+
|
|
2361
|
+
ns = get_namespace(slug)
|
|
2362
|
+
if ns is None:
|
|
2363
|
+
await _send_json(
|
|
2364
|
+
send, status=404, payload={"error": f"namespace {slug!r} not found"}
|
|
2365
|
+
)
|
|
2366
|
+
else:
|
|
2367
|
+
await _send_json(send, status=200, payload=ns.to_dict())
|
|
2368
|
+
return
|
|
2369
|
+
|
|
2370
|
+
# ---- Tool library (/v1/tools/*) -----------------------------------
|
|
2371
|
+
if method == "GET" and path == "/v1/tools/catalog":
|
|
2372
|
+
await _handle_list_tool_catalog(send)
|
|
2373
|
+
return
|
|
2374
|
+
if method == "GET" and path == "/v1/tools/activated":
|
|
2375
|
+
await _handle_list_activated_tools(send)
|
|
2376
|
+
return
|
|
2377
|
+
if method == "POST" and path == "/v1/tools/activate":
|
|
2378
|
+
body = await _read_body(receive)
|
|
2379
|
+
await _handle_activate_tool(body, send)
|
|
2380
|
+
return
|
|
2381
|
+
if path.startswith("/v1/tools/"):
|
|
2382
|
+
tool_tail = path[len("/v1/tools/") :]
|
|
2383
|
+
tool_parts = tool_tail.split("/", 1)
|
|
2384
|
+
tool_slug = tool_parts[0]
|
|
2385
|
+
tool_sub = tool_parts[1] if len(tool_parts) > 1 else ""
|
|
2386
|
+
if method == "GET" and tool_sub == "":
|
|
2387
|
+
await _handle_get_tool_def(tool_slug, send)
|
|
2388
|
+
return
|
|
2389
|
+
if method == "POST" and tool_sub == "test":
|
|
2390
|
+
body = await _read_body(receive)
|
|
2391
|
+
await _handle_test_tool(tool_slug, send)
|
|
2392
|
+
return
|
|
2393
|
+
if method == "POST" and tool_sub == "activate":
|
|
2394
|
+
body = await _read_body(receive)
|
|
2395
|
+
await _handle_set_tool_active(tool_slug, send)
|
|
2396
|
+
return
|
|
2397
|
+
if method == "PUT" and tool_sub == "credentials":
|
|
2398
|
+
body = await _read_body(receive)
|
|
2399
|
+
await _handle_update_tool_credentials(tool_slug, body, send)
|
|
2400
|
+
return
|
|
2401
|
+
if method == "DELETE" and tool_sub == "deactivate":
|
|
2402
|
+
await _handle_deactivate_tool(tool_slug, send)
|
|
2403
|
+
return
|
|
2404
|
+
|
|
2405
|
+
if method == "GET" and path == "/v1/swarms":
|
|
2406
|
+
await _handle_list_swarms(send)
|
|
2407
|
+
return
|
|
2408
|
+
if method == "POST" and path == "/v1/swarms":
|
|
2409
|
+
body = await _read_body(receive)
|
|
2410
|
+
await _handle_create_swarm(body, send)
|
|
2411
|
+
return
|
|
2412
|
+
if path.startswith("/v1/swarms/"):
|
|
2413
|
+
slug = path[len("/v1/swarms/") :]
|
|
2414
|
+
if method == "GET":
|
|
2415
|
+
await _handle_get_swarm(slug, send)
|
|
2416
|
+
return
|
|
2417
|
+
if method == "PUT":
|
|
2418
|
+
body = await _read_body(receive)
|
|
2419
|
+
await _handle_update_swarm(slug, body, send)
|
|
2420
|
+
return
|
|
2421
|
+
if method == "DELETE":
|
|
2422
|
+
await _handle_delete_swarm(slug, send)
|
|
2423
|
+
return
|
|
2424
|
+
|
|
2425
|
+
if method == "GET" and path == "/v1/knowledge":
|
|
2426
|
+
await _handle_knowledge_overview(send)
|
|
2427
|
+
return
|
|
2428
|
+
if method == "GET" and path == "/v1/knowledge/claims":
|
|
2429
|
+
await _handle_knowledge_claims(scope.get("query_string", b""), send)
|
|
2430
|
+
return
|
|
2431
|
+
if method == "GET" and path == "/v1/knowledge/agents":
|
|
2432
|
+
await _handle_knowledge_agents(send)
|
|
2433
|
+
return
|
|
2434
|
+
if method == "GET" and path == "/v1/knowledge/topics":
|
|
2435
|
+
await _handle_knowledge_topics(send)
|
|
2436
|
+
return
|
|
2437
|
+
if method == "GET" and path == "/v1/knowledge/ask":
|
|
2438
|
+
await _handle_knowledge_ask(scope.get("query_string", b""), send)
|
|
2439
|
+
return
|
|
2440
|
+
if method == "GET" and path == "/v1/knowledge/scoreboard":
|
|
2441
|
+
await _handle_knowledge_scoreboard(send)
|
|
2442
|
+
return
|
|
2443
|
+
if method == "GET" and path == "/v1/knowledge/synthesis":
|
|
2444
|
+
await _handle_knowledge_synthesis(scope.get("query_string", b""), send)
|
|
2445
|
+
return
|
|
2446
|
+
if method == "GET" and path == "/v1/knowledge/followup":
|
|
2447
|
+
await _handle_knowledge_followup(scope.get("query_string", b""), send)
|
|
2448
|
+
return
|
|
2449
|
+
if method == "POST" and path == "/v1/report":
|
|
2450
|
+
body = await _read_body(receive)
|
|
2451
|
+
await _handle_report(body, send)
|
|
2452
|
+
return
|
|
2453
|
+
if method == "GET" and path == "/v1/report/markdown":
|
|
2454
|
+
await _handle_report(b"", send, force_markdown=True)
|
|
2455
|
+
return
|
|
2456
|
+
if method == "GET" and path == "/v1/lineage":
|
|
2457
|
+
await _handle_lineage(send)
|
|
2458
|
+
return
|
|
2459
|
+
|
|
2460
|
+
if method == "GET" and path == "/v1/stats":
|
|
2461
|
+
await _handle_stats(send)
|
|
2462
|
+
return
|
|
2463
|
+
|
|
2464
|
+
if method == "GET" and path == "/v1/disputes":
|
|
2465
|
+
await _handle_disputes(send)
|
|
2466
|
+
return
|
|
2467
|
+
if method == "GET" and path.startswith("/v1/knowledge/similar/"):
|
|
2468
|
+
kid = path[len("/v1/knowledge/similar/") :]
|
|
2469
|
+
await _handle_knowledge_similar(kid, scope.get("query_string", b""), send)
|
|
2470
|
+
return
|
|
2471
|
+
if method == "GET" and path.startswith("/v1/knowledge/claim/"):
|
|
2472
|
+
kid = path[len("/v1/knowledge/claim/") :]
|
|
2473
|
+
await _handle_knowledge_claim(kid, send)
|
|
2474
|
+
return
|
|
2475
|
+
|
|
2476
|
+
# /v1/sims/related + /v1/sims/adapt — must be before the {id} block
|
|
2477
|
+
if method == "GET" and path == "/v1/sims/related":
|
|
2478
|
+
await _handle_related_sims(scope.get("query_string", b""), send)
|
|
2479
|
+
return
|
|
2480
|
+
if method == "GET" and path == "/v1/sims/adapt":
|
|
2481
|
+
await _handle_adapt_topic(scope.get("query_string", b""), send)
|
|
2482
|
+
return
|
|
2483
|
+
|
|
2484
|
+
# /v1/sims/{id}/resolve (POST) — oracle outcome resolution
|
|
2485
|
+
if path.startswith("/v1/sims/") and method == "POST":
|
|
2486
|
+
tail = path[len("/v1/sims/") :]
|
|
2487
|
+
parts = tail.split("/", 1)
|
|
2488
|
+
sim_id = parts[0]
|
|
2489
|
+
sub = parts[1] if len(parts) > 1 else ""
|
|
2490
|
+
if not _SIM_ID_RE.match(sim_id):
|
|
2491
|
+
await _err(send, 400, f"invalid sim_id: {sim_id!r}")
|
|
2492
|
+
return
|
|
2493
|
+
if sub == "resolve":
|
|
2494
|
+
body = await _read_body(receive)
|
|
2495
|
+
await _handle_resolve_outcomes(sim_id, body, send)
|
|
2496
|
+
return
|
|
2497
|
+
|
|
2498
|
+
# /v1/sims/{id} and friends
|
|
2499
|
+
if path.startswith("/v1/sims/") and method == "GET":
|
|
2500
|
+
tail = path[len("/v1/sims/") :]
|
|
2501
|
+
parts = tail.split("/", 1)
|
|
2502
|
+
sim_id = parts[0]
|
|
2503
|
+
sub = parts[1] if len(parts) > 1 else ""
|
|
2504
|
+
if not _SIM_ID_RE.match(sim_id):
|
|
2505
|
+
await _err(send, 400, f"invalid sim_id: {sim_id!r}")
|
|
2506
|
+
return
|
|
2507
|
+
if sub == "":
|
|
2508
|
+
await _handle_get_trace(sim_id, send)
|
|
2509
|
+
return
|
|
2510
|
+
if sub == "status":
|
|
2511
|
+
await _handle_get_status(sim_id, send)
|
|
2512
|
+
return
|
|
2513
|
+
if sub == "stderr":
|
|
2514
|
+
await _handle_get_stderr(sim_id, send)
|
|
2515
|
+
return
|
|
2516
|
+
if sub == "agents":
|
|
2517
|
+
await _handle_sim_agents(sim_id, send)
|
|
2518
|
+
return
|
|
2519
|
+
if sub == "outcomes":
|
|
2520
|
+
await _handle_get_outcomes(sim_id, send)
|
|
2521
|
+
return
|
|
2522
|
+
if sub == "knowledge-reuse":
|
|
2523
|
+
await _handle_knowledge_reuse(sim_id, send)
|
|
2524
|
+
return
|
|
2525
|
+
if sub == "relata-recall":
|
|
2526
|
+
await _handle_relata_recall(sim_id, qs, send)
|
|
2527
|
+
return
|
|
2528
|
+
if sub == "events/stream":
|
|
2529
|
+
await _handle_sim_events_stream(sim_id, send)
|
|
2530
|
+
return
|
|
2531
|
+
|
|
2532
|
+
# Static SPA — last resort
|
|
2533
|
+
if await _serve_static(scope, send, dist=dist):
|
|
2534
|
+
return
|
|
2535
|
+
|
|
2536
|
+
await _err(send, 404, f"unknown route: {method} {path}")
|
|
2537
|
+
|
|
2538
|
+
return app
|