hypermind 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,277 @@
1
+ """LLM cost tracking — token accounting + USD estimation.
2
+
3
+ Every OpenAI/Anthropic/OpenRouter call carries a ``usage`` payload with
4
+ ``prompt_tokens`` and ``completion_tokens``. The :class:`TokenCostTracker`
5
+ captures these, converts to USD via :data:`PRICE_TABLE`, and emits
6
+ Prometheus counters and recorder events so dashboards can watch spend in
7
+ real time.
8
+
9
+ Patterns:
10
+ * One :class:`TokenCostTracker` per :class:`_NamespaceWorld`.
11
+ * Each LLM responder (OpenAIResponder, AnthropicResponder,
12
+ ToolCallingResponder) accepts an optional ``cost_tracker``.
13
+ * The tracker emits ``llm_token_cost`` events with full per-call
14
+ detail (model, agent_kid, tokens, cost) and increments
15
+ ``hypermind_llm_tokens_total{model,direction}`` counters.
16
+
17
+ Pricing notes:
18
+ * Prices are USD per 1 million tokens, sourced from public model pages
19
+ on 2026-05-01. Prices update regularly — operators can override via
20
+ ``TokenCostTracker(price_table_override={...})``.
21
+ * Prices for OpenRouter pass-through models match the underlying
22
+ provider's quoted price. OpenRouter's small markup (~5%) is not
23
+ accounted for; use this as an estimate, not as billing input.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import threading
29
+ import time
30
+ from typing import Any
31
+
32
+ from hypermind.responders.structured import TokenUsage
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Price table — USD per 1M tokens (input, output)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ #: Update version stamp — bump when prices change so deployments can
39
+ #: detect stale tables.
40
+ PRICE_TABLE_VERSION = "2026-05-01"
41
+
42
+ #: ``model_id -> (input_per_1m_usd, output_per_1m_usd)``.
43
+ #:
44
+ #: The dict is keyed by *canonical* model ID. OpenRouter prefixes
45
+ #: (``openai/``, ``anthropic/``, ``meta-llama/``, …) are accepted in
46
+ #: addition to bare names so callers don't have to normalise.
47
+ PRICE_TABLE: dict[str, tuple[float, float]] = {
48
+ # OpenAI
49
+ "gpt-4o": (2.50, 10.00),
50
+ "gpt-4o-mini": (0.15, 0.60),
51
+ "gpt-4-turbo": (10.00, 30.00),
52
+ "openai/gpt-4o": (2.50, 10.00),
53
+ "openai/gpt-4o-mini": (0.15, 0.60),
54
+ "openai/gpt-4-turbo": (10.00, 30.00),
55
+ # Anthropic
56
+ "claude-opus-4-7": (15.00, 75.00),
57
+ "claude-sonnet-4-6": (3.00, 15.00),
58
+ "claude-sonnet-4": (3.00, 15.00),
59
+ "claude-haiku-4-5": (0.80, 4.00),
60
+ "claude-haiku-3-5": (0.80, 4.00),
61
+ "anthropic/claude-opus-4-7": (15.00, 75.00),
62
+ "anthropic/claude-sonnet-4-6": (3.00, 15.00),
63
+ "anthropic/claude-sonnet-4": (3.00, 15.00),
64
+ "anthropic/claude-haiku-4-5": (0.80, 4.00),
65
+ # Google
66
+ "google/gemini-2.0-flash": (0.075, 0.30),
67
+ "google/gemini-1.5-pro": (1.25, 5.00),
68
+ # Open-weights via OpenRouter
69
+ "meta-llama/llama-3.3-70b-instruct": (0.18, 0.18),
70
+ "meta-llama/llama-3.1-70b-instruct": (0.18, 0.18),
71
+ "deepseek/deepseek-chat": (0.27, 1.10),
72
+ "qwen/qwen-2.5-72b-instruct": (0.20, 0.20),
73
+ }
74
+
75
+
76
+ def cost_usd(
77
+ model: str,
78
+ prompt_tokens: int,
79
+ completion_tokens: int,
80
+ *,
81
+ price_table: dict[str, tuple[float, float]] | None = None,
82
+ ) -> float | None:
83
+ """Compute USD cost for one LLM call.
84
+
85
+ Returns None if the model isn't in the price table — operators see
86
+ the warning in the recorder rather than a wrong cost figure.
87
+ """
88
+ table = price_table if price_table is not None else PRICE_TABLE
89
+ prices = table.get(model)
90
+ if prices is None:
91
+ return None
92
+ input_per_1m, output_per_1m = prices
93
+ return (prompt_tokens / 1_000_000) * input_per_1m + (
94
+ completion_tokens / 1_000_000
95
+ ) * output_per_1m
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # TokenCostTracker
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ class TokenCostTracker:
104
+ """Aggregates LLM cost events across all responders attached to a world.
105
+
106
+ Thread-safe (used from coroutines under a single event loop, but
107
+ OpenAI / Anthropic SDKs may schedule retries on threads).
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ recorder: Any = None,
114
+ price_table_override: dict[str, tuple[float, float]] | None = None,
115
+ ) -> None:
116
+ self._records: list[dict[str, Any]] = []
117
+ self._lock = threading.Lock()
118
+ self.recorder = recorder
119
+ self.price_table = (
120
+ dict(price_table_override) if price_table_override is not None else PRICE_TABLE
121
+ )
122
+
123
+ # ------------------------------------------------------------------
124
+ # Write path
125
+ # ------------------------------------------------------------------
126
+
127
+ def record(
128
+ self,
129
+ *,
130
+ model: str,
131
+ prompt_tokens: int,
132
+ completion_tokens: int,
133
+ agent_kid: bytes | None = None,
134
+ topic: str | None = None,
135
+ ts_wall_ms: int | None = None,
136
+ ) -> TokenUsage:
137
+ """Record one LLM call and return its :class:`TokenUsage`."""
138
+ ts = int(time.time() * 1000) if ts_wall_ms is None else int(ts_wall_ms)
139
+ c = cost_usd(model, prompt_tokens, completion_tokens, price_table=self.price_table)
140
+ usage = TokenUsage(
141
+ model=model,
142
+ prompt_tokens=prompt_tokens,
143
+ completion_tokens=completion_tokens,
144
+ cost_usd=c,
145
+ )
146
+ rec = {
147
+ "ts_wall_ms": ts,
148
+ "model": model,
149
+ "prompt_tokens": prompt_tokens,
150
+ "completion_tokens": completion_tokens,
151
+ "total_tokens": prompt_tokens + completion_tokens,
152
+ "cost_usd": c,
153
+ "agent_kid_hex": agent_kid.hex() if agent_kid else None,
154
+ "topic": topic,
155
+ }
156
+ with self._lock:
157
+ self._records.append(rec)
158
+
159
+ # Fan-out to recorder (best-effort)
160
+ if self.recorder is not None:
161
+ try:
162
+ self.recorder.emit(
163
+ event_type="llm_token_cost",
164
+ severity="info",
165
+ fields=rec,
166
+ )
167
+ self.recorder.incr(
168
+ "hypermind_llm_tokens_total",
169
+ by=prompt_tokens + completion_tokens,
170
+ model=model,
171
+ )
172
+ if c is not None:
173
+ self.recorder.incr(
174
+ "hypermind_llm_cost_usd_total",
175
+ by=int(c * 100_000), # store milli-cents to keep integer counter
176
+ model=model,
177
+ )
178
+ else:
179
+ self.recorder.emit(
180
+ event_type="llm_unknown_model_pricing",
181
+ severity="warn",
182
+ fields={"model": model, "tokens": prompt_tokens + completion_tokens},
183
+ )
184
+ except Exception:
185
+ pass
186
+
187
+ return usage
188
+
189
+ # ------------------------------------------------------------------
190
+ # Read path
191
+ # ------------------------------------------------------------------
192
+
193
+ def total_usd(
194
+ self,
195
+ *,
196
+ agent_kid: bytes | None = None,
197
+ model: str | None = None,
198
+ ) -> float:
199
+ """Sum of cost_usd across matching records (None costs ignored)."""
200
+ with self._lock:
201
+ rows = list(self._records)
202
+ out = 0.0
203
+ for r in rows:
204
+ if r["cost_usd"] is None:
205
+ continue
206
+ if agent_kid is not None and r.get("agent_kid_hex") != agent_kid.hex():
207
+ continue
208
+ if model is not None and r.get("model") != model:
209
+ continue
210
+ out += r["cost_usd"]
211
+ return out
212
+
213
+ def total_tokens(
214
+ self,
215
+ *,
216
+ agent_kid: bytes | None = None,
217
+ model: str | None = None,
218
+ ) -> int:
219
+ with self._lock:
220
+ rows = list(self._records)
221
+ out = 0
222
+ for r in rows:
223
+ if agent_kid is not None and r.get("agent_kid_hex") != agent_kid.hex():
224
+ continue
225
+ if model is not None and r.get("model") != model:
226
+ continue
227
+ out += r["total_tokens"]
228
+ return out
229
+
230
+ def snapshot(self) -> list[dict[str, Any]]:
231
+ """Return a defensive copy of every recorded call."""
232
+ with self._lock:
233
+ return [dict(r) for r in self._records]
234
+
235
+ def to_dict(self) -> dict[str, Any]:
236
+ """Aggregate summary suitable for the /v1/cost endpoint."""
237
+ snap = self.snapshot()
238
+ # Per-model breakdown
239
+ by_model: dict[str, dict[str, Any]] = {}
240
+ total_usd = 0.0
241
+ total_tok = 0
242
+ for r in snap:
243
+ m = r["model"]
244
+ slot = by_model.setdefault(
245
+ m,
246
+ {
247
+ "calls": 0,
248
+ "total_tokens": 0,
249
+ "cost_usd": 0.0,
250
+ },
251
+ )
252
+ slot["calls"] += 1
253
+ slot["total_tokens"] += r["total_tokens"]
254
+ if r["cost_usd"] is not None:
255
+ slot["cost_usd"] += r["cost_usd"]
256
+ total_usd += r["cost_usd"]
257
+ total_tok += r["total_tokens"]
258
+ return {
259
+ "n_calls": len(snap),
260
+ "total_tokens": total_tok,
261
+ "total_usd": total_usd,
262
+ "by_model": by_model,
263
+ "price_table_version": PRICE_TABLE_VERSION,
264
+ }
265
+
266
+ def reset(self) -> None:
267
+ """Clear all records — for tests or per-session resets."""
268
+ with self._lock:
269
+ self._records.clear()
270
+
271
+
272
+ __all__ = [
273
+ "PRICE_TABLE",
274
+ "PRICE_TABLE_VERSION",
275
+ "TokenCostTracker",
276
+ "cost_usd",
277
+ ]
@@ -0,0 +1,200 @@
1
+ """OpenTelemetry exporter — real-time OTLP push of consult/dispute spans.
2
+
3
+ The :class:`OtelExporter` listens for recorder events and emits OTel
4
+ spans for the verbs that matter to operators:
5
+
6
+ * ``publish_claim`` → span "hypermind.publish"
7
+ * ``dispute`` → span "hypermind.dispute"
8
+ * ``consult_panel`` → parent span "hypermind.consult"
9
+ * ``llm_token_cost`` → child span "hypermind.llm_call"
10
+ * ``oracle_resolve`` → span "hypermind.oracle"
11
+ * ``tool_call`` → child span "hypermind.tool_call"
12
+
13
+ Soft-depends on ``opentelemetry-api`` + ``opentelemetry-sdk``. When
14
+ those packages aren't installed the exporter degrades gracefully — it
15
+ records nothing but never raises.
16
+
17
+ The exporter is *attached*, not constructed inline: callers build an
18
+ OTel ``TracerProvider`` (with whatever exporter they prefer — OTLP
19
+ gRPC, Jaeger, etc.) and pass it. We don't pin a specific transport
20
+ because operators have wildly different deployment shapes.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, ClassVar
26
+
27
+
28
+ class OtelExporter:
29
+ """Bridges :class:`Recorder` events to OpenTelemetry spans.
30
+
31
+ Usage::
32
+
33
+ from opentelemetry.sdk.trace import TracerProvider
34
+ from opentelemetry.sdk.trace.export import (
35
+ BatchSpanProcessor,
36
+ ConsoleSpanExporter,
37
+ )
38
+ from hypermind.observability.otel_export import OtelExporter
39
+
40
+ provider = TracerProvider()
41
+ provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
42
+
43
+ exporter = OtelExporter(tracer_provider=provider, service_name="hypermind")
44
+ exporter.attach(agent._world.recorder)
45
+
46
+ For an OTLP push (Jaeger/Tempo), substitute the OTLPSpanExporter::
47
+
48
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
49
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=...)))
50
+
51
+ The exporter never owns the TracerProvider — operators control its
52
+ lifecycle, including shutdown.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ tracer_provider: Any = None,
59
+ service_name: str = "hypermind",
60
+ ) -> None:
61
+ self.service_name = service_name
62
+ self._tracer = None
63
+ self._available = False
64
+
65
+ if tracer_provider is None:
66
+ return
67
+
68
+ try:
69
+ self._tracer = tracer_provider.get_tracer(service_name)
70
+ self._available = True
71
+ except Exception:
72
+ self._tracer = None
73
+ self._available = False
74
+
75
+ # ------------------------------------------------------------------
76
+ # Available — operators check this to know whether OTel is hot
77
+ # ------------------------------------------------------------------
78
+
79
+ @property
80
+ def available(self) -> bool:
81
+ return self._available
82
+
83
+ # ------------------------------------------------------------------
84
+ # Attach to a recorder — listen for events and emit spans
85
+ # ------------------------------------------------------------------
86
+
87
+ def attach(self, recorder: Any) -> None:
88
+ """Subscribe to a recorder so subsequent events emit OTel spans.
89
+
90
+ We patch the recorder's ``emit()`` method to also call our
91
+ :meth:`_handle_event`. This keeps the exporter optional — if
92
+ nobody attaches one, recorder behaviour is unchanged.
93
+ """
94
+ if not self._available or recorder is None:
95
+ return
96
+
97
+ original_emit = recorder.emit
98
+
99
+ def emit_with_otel(*args: Any, **kwargs: Any) -> None:
100
+ try:
101
+ original_emit(*args, **kwargs)
102
+ finally:
103
+ try:
104
+ self._handle_event(kwargs)
105
+ except Exception:
106
+ pass # never break the recorder
107
+
108
+ recorder.emit = emit_with_otel # type: ignore[method-assign]
109
+
110
+ # ------------------------------------------------------------------
111
+ # Event → span mapping
112
+ # ------------------------------------------------------------------
113
+
114
+ _EVENT_TO_SPAN: ClassVar[dict[str, str]] = {
115
+ "seal": "hypermind.publish",
116
+ "dispute_open": "hypermind.dispute",
117
+ "consult_panel": "hypermind.consult",
118
+ "consult_deliberation": "hypermind.deliberation",
119
+ "consult_responder_error": "hypermind.consult.responder_error",
120
+ "llm_token_cost": "hypermind.llm_call",
121
+ "tool_call": "hypermind.tool_call",
122
+ "router_fallback": "hypermind.router.fallback",
123
+ "oracle_resolve": "hypermind.oracle",
124
+ }
125
+
126
+ def _handle_event(self, emit_kwargs: dict[str, Any]) -> None:
127
+ if self._tracer is None:
128
+ return
129
+ event_type = emit_kwargs.get("event_type", "")
130
+ span_name = self._EVENT_TO_SPAN.get(event_type)
131
+ if span_name is None:
132
+ return # not an event we surface to OTel
133
+
134
+ # Span attributes — flatten the fields dict into "field.<k>=<v>"
135
+ # attribute keys so OTel UIs can filter on them.
136
+ attrs: dict[str, Any] = {
137
+ "service.name": self.service_name,
138
+ "hypermind.event_type": event_type,
139
+ }
140
+ ns = emit_kwargs.get("namespace")
141
+ if ns:
142
+ attrs["hypermind.namespace"] = ns
143
+ agent_id = emit_kwargs.get("agent_id")
144
+ if agent_id:
145
+ attrs["hypermind.agent_id"] = agent_id
146
+ fields = emit_kwargs.get("fields", {}) or {}
147
+ for k, v in fields.items():
148
+ if isinstance(v, (str, bool, int, float)):
149
+ attrs[f"hypermind.field.{k}"] = v
150
+ else:
151
+ attrs[f"hypermind.field.{k}"] = repr(v)[:240]
152
+
153
+ # We don't know the actual operation duration here, so we emit
154
+ # a zero-duration "instant" span. Production callers that want
155
+ # parent/child relationships should use the explicit
156
+ # span_consult() context manager instead.
157
+ with self._tracer.start_as_current_span(
158
+ name=span_name,
159
+ attributes=attrs,
160
+ ) as _:
161
+ # No-op — we just want the span recorded.
162
+ pass
163
+
164
+ # ------------------------------------------------------------------
165
+ # Explicit span context managers — for callers that want correct durations
166
+ # ------------------------------------------------------------------
167
+
168
+ def span_consult(self, query: str, *, panel_size: int) -> Any:
169
+ """Start a parent span for one consult call.
170
+
171
+ Usage::
172
+
173
+ with exporter.span_consult(query="...", panel_size=8):
174
+ result = await agent.consult(...)
175
+ """
176
+ if self._tracer is None:
177
+ return _NoopSpan()
178
+ return self._tracer.start_as_current_span(
179
+ name="hypermind.consult",
180
+ attributes={
181
+ "hypermind.consult.panel_size": int(panel_size),
182
+ "hypermind.consult.query_len": len(query),
183
+ },
184
+ )
185
+
186
+
187
+ class _NoopSpan:
188
+ """Stand-in for an OTel span when the exporter isn't available."""
189
+
190
+ def __enter__(self) -> _NoopSpan:
191
+ return self
192
+
193
+ def __exit__(self, *exc: Any) -> None:
194
+ return None
195
+
196
+ def set_attribute(self, *args: Any, **kwargs: Any) -> None:
197
+ return None
198
+
199
+
200
+ __all__ = ["OtelExporter"]