mas-lab-component-controller 0.2.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 (89) hide show
  1. mas/lab/controller/__init__.py +7 -0
  2. mas/lab/controller/__main__.py +5 -0
  3. mas/lab/controller/agent_chat.py +226 -0
  4. mas/lab/controller/api.py +364 -0
  5. mas/lab/controller/artifact_discovery.py +107 -0
  6. mas/lab/controller/artifacts.py +112 -0
  7. mas/lab/controller/cli.py +493 -0
  8. mas/lab/controller/client.py +271 -0
  9. mas/lab/controller/config.py +75 -0
  10. mas/lab/controller/constants.py +30 -0
  11. mas/lab/controller/daemon.py +190 -0
  12. mas/lab/controller/deps.py +172 -0
  13. mas/lab/controller/fastapi_app.py +166 -0
  14. mas/lab/controller/http_server.py +243 -0
  15. mas/lab/controller/io_capture.py +55 -0
  16. mas/lab/controller/item_service.py +625 -0
  17. mas/lab/controller/job_store.py +209 -0
  18. mas/lab/controller/jobs.py +375 -0
  19. mas/lab/controller/kinds.py +149 -0
  20. mas/lab/controller/lab_registry.py +1076 -0
  21. mas/lab/controller/manifest_layout.py +832 -0
  22. mas/lab/controller/manifest_store.py +214 -0
  23. mas/lab/controller/manifest_validation.py +90 -0
  24. mas/lab/controller/models.py +215 -0
  25. mas/lab/controller/path_safety.py +68 -0
  26. mas/lab/controller/pipeline_validation.py +79 -0
  27. mas/lab/controller/registry.py +83 -0
  28. mas/lab/controller/routes/__init__.py +82 -0
  29. mas/lab/controller/routes/_api.py +15 -0
  30. mas/lab/controller/routes/apps.py +190 -0
  31. mas/lab/controller/routes/artifacts.py +37 -0
  32. mas/lab/controller/routes/benchmark.py +307 -0
  33. mas/lab/controller/routes/choices.py +141 -0
  34. mas/lab/controller/routes/datasets.py +91 -0
  35. mas/lab/controller/routes/design_patterns.py +121 -0
  36. mas/lab/controller/routes/eval_output.py +31 -0
  37. mas/lab/controller/routes/experiments.py +194 -0
  38. mas/lab/controller/routes/health.py +178 -0
  39. mas/lab/controller/routes/items.py +187 -0
  40. mas/lab/controller/routes/jobs.py +107 -0
  41. mas/lab/controller/routes/libraries.py +144 -0
  42. mas/lab/controller/routes/overlays.py +164 -0
  43. mas/lab/controller/routes/pipelines.py +139 -0
  44. mas/lab/controller/routes/registry.py +40 -0
  45. mas/lab/controller/routes/run.py +215 -0
  46. mas/lab/controller/routes/runs.py +392 -0
  47. mas/lab/controller/routes/scopes.py +139 -0
  48. mas/lab/controller/routes/uploads.py +24 -0
  49. mas/lab/controller/routes/validate.py +36 -0
  50. mas/lab/controller/run_bridge.py +276 -0
  51. mas/lab/controller/run_store.py +190 -0
  52. mas/lab/controller/schema_registry.py +109 -0
  53. mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
  54. mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
  55. mas/lab/controller/scope_catalog.py +266 -0
  56. mas/lab/controller/scope_service.py +134 -0
  57. mas/lab/controller/sessions.py +63 -0
  58. mas/lab/controller/settings.py +65 -0
  59. mas/lab/controller/ui_stream/__init__.py +13 -0
  60. mas/lab/controller/ui_stream/app.py +2147 -0
  61. mas/lab/controller/ui_stream/artefact_bind.py +308 -0
  62. mas/lab/controller/ui_stream/auth.py +136 -0
  63. mas/lab/controller/ui_stream/bus.py +156 -0
  64. mas/lab/controller/ui_stream/export.py +400 -0
  65. mas/lab/controller/ui_stream/governance.py +141 -0
  66. mas/lab/controller/ui_stream/ingest.py +300 -0
  67. mas/lab/controller/ui_stream/inspect.py +55 -0
  68. mas/lab/controller/ui_stream/layouts.py +161 -0
  69. mas/lab/controller/ui_stream/metrics.py +274 -0
  70. mas/lab/controller/ui_stream/plot_folds.py +609 -0
  71. mas/lab/controller/ui_stream/plugin_registry.py +139 -0
  72. mas/lab/controller/ui_stream/refs.py +259 -0
  73. mas/lab/controller/ui_stream/replay.py +236 -0
  74. mas/lab/controller/ui_stream/replay_cli.py +707 -0
  75. mas/lab/controller/ui_stream/run_index.py +337 -0
  76. mas/lab/controller/ui_stream/spec_load.py +48 -0
  77. mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
  78. mas/lab/controller/ui_stream/stream.py +95 -0
  79. mas/lab/controller/ui_stream/streams.py +42 -0
  80. mas/lab/controller/ui_stream/timeline.py +183 -0
  81. mas/lab/controller/ui_stream/ui_trace.py +258 -0
  82. mas/lab/controller/uploads.py +61 -0
  83. mas/lab/controller/worker_model.py +137 -0
  84. mas/lab/controller/workers.py +296 -0
  85. mas/lab/controller/workspace_library.py +70 -0
  86. mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
  87. mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
  88. mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
  89. mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,609 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Fold native events into plot artefacts when no pipeline stream step is running.
4
+
5
+ Chat / topology snapshots feed the mas-lab ``plot`` host. Series, diracs, and
6
+ story ticks come from optional UI folds (entry point ``mas.lab.ui.folds`` or
7
+ ``MAS_UI_FOLDS=module:Class``). This module does not run detectors.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import json
14
+ import os
15
+ import re
16
+ from typing import Any, Callable
17
+
18
+ from mas.lab.controller.ui_stream.refs import DataSourceRef
19
+ from mas.lab.controller.ui_stream.spec_snapshot import _HUB_HINTS, _HUB_TOOLS, pick_entry
20
+ from mas.runtime.boundary.delegation.policy import parse_delegate_tool_name
21
+
22
+ _CHANNEL_SPLIT = re.compile(r"<\|?channel\|?>", re.I)
23
+
24
+
25
+ def _visible_text(raw: str) -> str:
26
+ """Drop Gemma/Harmony channel wrappers; keep the visible reply."""
27
+ text = str(raw or "").strip()
28
+ if not text:
29
+ return ""
30
+ if "<|channel>" in text or "<channel|>" in text:
31
+ parts = [p.strip() for p in _CHANNEL_SPLIT.split(text) if p.strip()]
32
+ visible = [p for p in parts if p.lower() not in {"thought", "commentary", "analysis"}]
33
+ if visible:
34
+ text = visible[-1]
35
+ return text.strip()
36
+
37
+
38
+ def _text(event: dict[str, Any]) -> str:
39
+ raw = event.get("content") or event.get("output") or event.get("text") or event.get("input") or ""
40
+ if isinstance(raw, dict):
41
+ raw = raw.get("content") or raw.get("output") or raw.get("text") or raw.get("input") or ""
42
+ return _visible_text(str(raw or ""))
43
+
44
+
45
+ def _delegate_peer(tool: str) -> str:
46
+ return parse_delegate_tool_name(str(tool or "").strip()) or ""
47
+
48
+
49
+ def _event_t(event: dict[str, Any]) -> float | None:
50
+ raw = event.get("timestamp", event.get("ts", event.get("t")))
51
+ if isinstance(raw, bool):
52
+ return None
53
+ if isinstance(raw, (int, float)):
54
+ value = float(raw)
55
+ return value / 1000.0 if value > 1e12 else value
56
+ if isinstance(raw, str) and raw.strip():
57
+ try:
58
+ value = float(raw)
59
+ except ValueError:
60
+ return None
61
+ return value / 1000.0 if value > 1e12 else value
62
+ return None
63
+
64
+
65
+ def _stamp_t0(state: dict[str, Any], t: float | None) -> None:
66
+ if t is None or t == 0.0:
67
+ return
68
+ prev = state.get("t0")
69
+ if prev is None:
70
+ state["t0"] = float(t)
71
+ return
72
+ prev_f = float(prev)
73
+ # Session / missing rows at epoch-0 must not pin t0 when the trace is unix time.
74
+ if prev_f > 1e9 and t < 1e7:
75
+ return
76
+ if t > 1e9 and prev_f < 1e7:
77
+ state["t0"] = float(t)
78
+ return
79
+ state["t0"] = min(prev_f, float(t))
80
+
81
+
82
+ def _args(event: dict[str, Any]) -> str:
83
+ raw = event.get("arguments") or event.get("args")
84
+ if raw is None:
85
+ return ""
86
+ if isinstance(raw, (dict, list)):
87
+ return json.dumps(raw, ensure_ascii=False)[:4000]
88
+ return str(raw).strip()[:4000]
89
+
90
+
91
+ def _is_human(event: dict[str, Any], agent: str) -> bool:
92
+ """True only for the human operator, not an LLM chat ``role: user``."""
93
+ who = str(event.get("agent_id") or agent)
94
+ return who in {"user", "human"}
95
+
96
+
97
+ def _looks_like_briefing(text: str) -> bool:
98
+ """Agent system/task markdown is not the opening user query."""
99
+ head = (text or "").lstrip()
100
+ if not head:
101
+ return False
102
+ if head.startswith("#") or head.startswith("**"):
103
+ return True
104
+ return "\n## " in head[:400]
105
+
106
+
107
+ # Same-wave parallel delegates share a timestamp; a later fork is a new turn.
108
+ _WAVE_GAP_S = 2.0
109
+
110
+
111
+ def _note_hub(state: dict[str, Any], event: dict[str, Any]) -> None:
112
+ agent = str(event.get("agent_id") or "").strip()
113
+ if agent.lower() in _HUB_HINTS:
114
+ state["_hub"] = agent
115
+ tool = str(event.get("tool_name") or "")
116
+ if _delegate_peer(tool):
117
+ state["_hub"] = agent
118
+ elif tool in _HUB_TOOLS and agent:
119
+ state.setdefault("_hub", agent)
120
+
121
+
122
+ def _retag_depths(state: dict[str, Any], cards: list[dict[str, Any]]) -> None:
123
+ hub = str(state.get("_hub") or "")
124
+ if not hub:
125
+ for row in cards:
126
+ who = str(row.get("from") or "")
127
+ if who.lower() in _HUB_HINTS:
128
+ hub = who
129
+ state["_hub"] = hub
130
+ break
131
+ if not hub:
132
+ return
133
+ for row in cards:
134
+ who = str(row.get("from") or "")
135
+ if row.get("role") == "user" or who in {"user", "human"}:
136
+ row["depth"] = 0
137
+ elif who == hub:
138
+ row["depth"] = 1
139
+ elif who:
140
+ row["depth"] = 2
141
+
142
+
143
+ def _bubble_depth(state: dict[str, Any], agent: str) -> int:
144
+ """User is 0, coordinator 1, specialists 2. Cards stay siblings."""
145
+ nested = state.get("_nested") or []
146
+ if agent in nested:
147
+ return 2
148
+ hub = str(state.get("_hub") or "")
149
+ if hub and agent == hub:
150
+ return 1
151
+ if hub and agent and agent != hub:
152
+ return 2
153
+ return 1
154
+
155
+
156
+ def _new_bubble(cards: list[dict[str, Any]], agent: str, *, depth: int, t: float | None) -> dict[str, Any]:
157
+ row = {
158
+ "role": "reply",
159
+ "from": agent,
160
+ "text": "",
161
+ "tools": [],
162
+ "t": t,
163
+ "ts": t,
164
+ "depth": depth,
165
+ }
166
+ cards.append(row)
167
+ return row
168
+
169
+
170
+ def _latest_reply(cards: list[dict[str, Any]], agent: str) -> dict[str, Any] | None:
171
+ for row in reversed(cards):
172
+ if row.get("role") == "user":
173
+ break
174
+ if row.get("from") == agent and row.get("role") == "reply":
175
+ return row
176
+ return None
177
+
178
+
179
+ def _later_speaker(cards: list[dict[str, Any]], row: dict[str, Any], agent: str) -> bool:
180
+ """True when another speaker already has a card after this turn."""
181
+ seen = False
182
+ for card in cards:
183
+ if card is row:
184
+ seen = True
185
+ continue
186
+ if not seen:
187
+ continue
188
+ who = str(card.get("from") or "")
189
+ if who and who != agent:
190
+ return True
191
+ return False
192
+
193
+
194
+ def _tool_start_t(tool: dict[str, Any]) -> float | None:
195
+ return _event_t({"timestamp": tool.get("t_start", tool.get("t", tool.get("ts")))})
196
+
197
+
198
+ def _last_tool_start(row: dict[str, Any]) -> float | None:
199
+ tools = row.get("tools") or []
200
+ if not tools:
201
+ return None
202
+ return _tool_start_t(tools[-1])
203
+
204
+
205
+ def _open_turn(
206
+ cards: list[dict[str, Any]],
207
+ agent: str,
208
+ *,
209
+ depth: int,
210
+ t: float | None,
211
+ for_tool: bool = False,
212
+ force: bool = False,
213
+ ) -> dict[str, Any]:
214
+ """One bubble per wave: later speaker, filled reply, time gap, or a new parallel group."""
215
+ row = _latest_reply(cards, agent)
216
+ if row is None or force:
217
+ return _new_bubble(cards, agent, depth=depth, t=t)
218
+ if _later_speaker(cards, row, agent):
219
+ return _new_bubble(cards, agent, depth=depth, t=t)
220
+ if for_tool and str(row.get("text") or "").strip():
221
+ return _new_bubble(cards, agent, depth=depth, t=t)
222
+ if for_tool and t is not None:
223
+ last = _last_tool_start(row)
224
+ if last is not None and abs(float(t) - last) > _WAVE_GAP_S:
225
+ return _new_bubble(cards, agent, depth=depth, t=t)
226
+ return row
227
+
228
+
229
+ def fold_chat_event(state: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
230
+ kind = str(event.get("kind") or "")
231
+ agent = str(event.get("agent_id") or "agent")
232
+ t = _event_t(event)
233
+ _note_hub(state, event)
234
+ _stamp_t0(state, t)
235
+ cards: list[dict[str, Any]] = list(state.get("cards") or [])
236
+ activity: list[dict[str, Any]] = list(state.get("activity") or [])
237
+ depth = int(state.get("_depth") or 0)
238
+ text = _text(event)
239
+ if _is_human(event, agent) and text:
240
+ if not any(row.get("role") == "user" for row in cards):
241
+ cards.append({"role": "user", "from": "user", "text": text[:4000], "t": t, "ts": t, "depth": 0})
242
+ elif (
243
+ kind == "execution_start"
244
+ and text
245
+ and depth == 0
246
+ and not _looks_like_briefing(text)
247
+ and not any(row.get("role") == "user" for row in cards)
248
+ ):
249
+ cards.append({"role": "user", "from": "user", "text": text[:4000], "t": t, "ts": t, "depth": 0})
250
+ elif kind in {"llm_call_end", "client_response", "user_response"} and text:
251
+ clipped = text[:4000]
252
+ nest = _bubble_depth(state, agent)
253
+ bubble = _open_turn(cards, agent, depth=nest, t=t)
254
+ existing = str(bubble.get("text") or "")
255
+ if existing and existing != clipped:
256
+ same_turn = kind == "user_response" and bubble.get("_from_kind") in {
257
+ "llm_call_end",
258
+ "client_response",
259
+ }
260
+ cid = event.get("call_id")
261
+ if same_turn and cid and bubble.get("call_id") and cid != bubble.get("call_id"):
262
+ same_turn = False
263
+ if same_turn and t is not None and bubble.get("t") is not None:
264
+ same_turn = abs(float(t) - float(bubble["t"])) <= 1.0
265
+ if not same_turn:
266
+ bubble = _new_bubble(cards, agent, depth=nest, t=t)
267
+ bubble["text"] = clipped
268
+ bubble["_from_kind"] = kind
269
+ if event.get("call_id"):
270
+ bubble["call_id"] = event.get("call_id")
271
+ bubble["t"] = t
272
+ bubble["ts"] = t
273
+ elif kind == "parallel_group_start":
274
+ state["_open_group"] = agent
275
+ elif kind == "tool_call_start":
276
+ tool = str(event.get("tool_name") or "")
277
+ delegate = bool(_delegate_peer(tool))
278
+ inflight = int(state.get("_delegates") or 0)
279
+ if delegate:
280
+ inflight += 1
281
+ depth = 1
282
+ peer = _delegate_peer(tool)
283
+ nested = list(state.get("_nested") or [])
284
+ if peer and peer not in nested:
285
+ nested.append(peer)
286
+ state["_nested"] = nested
287
+ force = False
288
+ if delegate and state.pop("_open_group", None) == agent:
289
+ prev = _latest_reply(cards, agent)
290
+ force = prev is not None and bool(prev.get("tools"))
291
+ bubble = _open_turn(
292
+ cards, agent, depth=_bubble_depth(state, agent), t=t, for_tool=True, force=force
293
+ )
294
+ state["_delegates"] = inflight
295
+ tools = list(bubble.get("tools") or [])
296
+ tools.append(
297
+ {
298
+ "role": "tool",
299
+ "from": agent,
300
+ "to": tool,
301
+ "text": text[:4000] if text else "",
302
+ "args": _args(event),
303
+ "tool": tool,
304
+ "kind": "delegate" if delegate else "tool",
305
+ "state": "running",
306
+ "call_id": event.get("call_id"),
307
+ "t_start": t,
308
+ "t": t,
309
+ "ts": t,
310
+ }
311
+ )
312
+ bubble["tools"] = tools
313
+ activity.append(
314
+ {
315
+ "call_id": event.get("call_id"),
316
+ "agent": agent,
317
+ "tool": tool,
318
+ "kind": "delegate" if delegate else "tool",
319
+ "state": "running",
320
+ "t": t,
321
+ "ts": t,
322
+ }
323
+ )
324
+ elif kind == "tool_call_end":
325
+ call_id = event.get("call_id")
326
+ found = False
327
+ for bubble in reversed(cards):
328
+ for row in reversed(list(bubble.get("tools") or [])):
329
+ if call_id and row.get("call_id") == call_id:
330
+ row["state"] = "done"
331
+ row["t"] = t
332
+ row["ts"] = t
333
+ if text:
334
+ row["text"] = text[:4000]
335
+ found = True
336
+ break
337
+ if found:
338
+ break
339
+ for row in reversed(activity):
340
+ if call_id and row.get("call_id") == call_id:
341
+ row["state"] = "done"
342
+ row["t"] = t
343
+ row["ts"] = t
344
+ if row.get("kind") == "delegate":
345
+ inflight = max(0, int(state.get("_delegates") or 0) - 1)
346
+ state["_delegates"] = inflight
347
+ depth = 1 if inflight or state.get("_nested") else 0
348
+ break
349
+ _retag_depths(state, cards)
350
+ state["cards"] = cards[-200:]
351
+ state["activity"] = activity[-200:]
352
+ state["_depth"] = depth
353
+ state["kind"] = "chat"
354
+ return state
355
+
356
+
357
+ def fold_topology_event(state: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
358
+ """Animate the spec graph. Do not invent nodes; spec.snapshot owns the roster."""
359
+ nodes: dict[str, dict[str, Any]] = {n["id"]: n for n in state.get("nodes") or [] if n.get("id")}
360
+ if not nodes:
361
+ return state
362
+ edges: list[dict[str, str]] = list(state.get("edges") or [])
363
+ activity: list[dict[str, Any]] = list(state.get("activity") or [])
364
+ kind = str(event.get("kind") or "")
365
+ agent = str(event.get("agent_id") or "")
366
+ tool = str(event.get("tool_name") or "")
367
+ t = _event_t(event)
368
+ _stamp_t0(state, t)
369
+ peer = parse_delegate_tool_name(tool)
370
+ if peer:
371
+ if agent in nodes and peer in nodes:
372
+ activity.append(
373
+ {
374
+ "from": agent,
375
+ "to": peer,
376
+ "state": "running" if kind.endswith("start") else "done",
377
+ "kind": kind,
378
+ "t": t,
379
+ "ts": t,
380
+ }
381
+ )
382
+ elif kind in {
383
+ "llm_call_start",
384
+ "llm_call_end",
385
+ "tool_call_start",
386
+ "tool_call_end",
387
+ "execution_start",
388
+ "execution_end",
389
+ "user_response",
390
+ "client_response",
391
+ }:
392
+ if agent in nodes:
393
+ parent = next(
394
+ (
395
+ str(row.get("from") or "")
396
+ for row in edges
397
+ if str(row.get("to") or "") == agent and str(row.get("from") or "") not in {"", agent}
398
+ ),
399
+ "",
400
+ )
401
+ hop_back = kind in {"user_response", "client_response"} and parent in nodes
402
+ activity.append(
403
+ {
404
+ "from": agent if hop_back else (agent or "user"),
405
+ "to": parent if hop_back else agent,
406
+ "state": "running" if kind.endswith("start") else "done",
407
+ "kind": kind,
408
+ "t": t,
409
+ "ts": t,
410
+ }
411
+ )
412
+ idle = bool(state.get("idle"))
413
+ if kind in {"mas_call_end", "run.terminal"}:
414
+ for row in activity:
415
+ if row.get("state") == "running":
416
+ row["state"] = "done"
417
+ row["t"] = t
418
+ row["ts"] = t
419
+ idle = True
420
+ elif kind.endswith("_start") or kind in {"user_response", "client_response"}:
421
+ idle = False
422
+ state["nodes"] = list(nodes.values())
423
+ state["edges"] = edges
424
+ state["activity"] = activity[-40:]
425
+ state["kind"] = "topology"
426
+ state["idle"] = idle
427
+ return state
428
+
429
+
430
+ def topology_from_spec(snap: dict[str, Any] | None) -> dict[str, Any]:
431
+ """Turn a ``spec.snapshot`` / ``mas_topology`` payload into plot nodes/edges."""
432
+ spec = snap if isinstance(snap, dict) else {}
433
+ user = str(spec.get("user") or "user")
434
+ agents = [agent for agent in (spec.get("agents") or []) if isinstance(agent, dict)]
435
+ delegates = spec.get("delegates_to") if isinstance(spec.get("delegates_to"), dict) else {}
436
+ entry = pick_entry(agents, delegates, str(spec.get("entry") or ""))
437
+ nodes: list[dict[str, Any]] = [{"id": user, "role": "user"}]
438
+ seen = {user}
439
+ for agent in spec.get("agents") or []:
440
+ if not isinstance(agent, dict):
441
+ continue
442
+ aid = str(agent.get("id") or agent.get("name") or "").strip()
443
+ if not aid or aid in seen:
444
+ continue
445
+ seen.add(aid)
446
+ nodes.append({"id": aid, "role": "entry" if aid == entry else "agent"})
447
+ edges: list[dict[str, str]] = []
448
+
449
+ def ensure(aid: str, role: str = "agent") -> None:
450
+ aid = str(aid or "").strip()
451
+ if not aid or aid in seen:
452
+ return
453
+ seen.add(aid)
454
+ nodes.append({"id": aid, "role": "entry" if aid == entry else role})
455
+
456
+ if entry:
457
+ ensure(entry, "entry")
458
+ edges.append({"from": user, "to": entry})
459
+ for src, peers in (spec.get("delegates_to") or {}).items():
460
+ ensure(str(src), "entry" if str(src) == entry else "agent")
461
+ for peer in peers or []:
462
+ if src and peer:
463
+ ensure(str(peer))
464
+ edges.append({"from": str(src), "to": str(peer)})
465
+ _annotate_depths(nodes, edges)
466
+ return {"kind": "topology", "nodes": nodes, "edges": edges, "activity": []}
467
+
468
+
469
+ def _annotate_depths(nodes: list[dict[str, Any]], edges: list[dict[str, str]]) -> None:
470
+ children: dict[str, list[str]] = {}
471
+ for edge in edges:
472
+ src = str(edge.get("from") or "")
473
+ dst = str(edge.get("to") or "")
474
+ if src and dst:
475
+ children.setdefault(src, []).append(dst)
476
+ depth: dict[str, int] = {}
477
+ roots = [str(node.get("id") or "") for node in nodes if node.get("role") == "user"]
478
+ if not roots and nodes:
479
+ roots = [str(nodes[0].get("id") or "")]
480
+ queue = [root for root in roots if root]
481
+ for root in queue:
482
+ depth[root] = 0
483
+ while queue:
484
+ cur = queue.pop(0)
485
+ for child in children.get(cur, []):
486
+ if child not in depth:
487
+ depth[child] = depth[cur] + 1
488
+ queue.append(child)
489
+ for node in nodes:
490
+ aid = str(node.get("id") or "")
491
+ node["depth"] = int(depth.get(aid, node.get("depth") or 1))
492
+
493
+
494
+ def load_ui_folds() -> list[Any]:
495
+ """Optional extra folds. mas-lab does not import a detector package here."""
496
+ folds: list[Any] = []
497
+ seen: set[str] = set()
498
+
499
+ def add(obj: Any, token: str) -> None:
500
+ if token in seen or obj is None:
501
+ return
502
+ seen.add(token)
503
+ folds.append(obj() if isinstance(obj, type) else obj)
504
+
505
+ for spec in str(os.environ.get("MAS_UI_FOLDS") or "").split(","):
506
+ spec = spec.strip()
507
+ if not spec:
508
+ continue
509
+ mod_name, _, attr = spec.partition(":")
510
+ try:
511
+ mod = importlib.import_module(mod_name)
512
+ add(getattr(mod, attr or "Fold"), spec)
513
+ except Exception:
514
+ continue
515
+ try:
516
+ from importlib.metadata import entry_points
517
+
518
+ eps = entry_points()
519
+ group = (
520
+ eps.select(group="mas.lab.ui.folds")
521
+ if hasattr(eps, "select")
522
+ else eps.get("mas.lab.ui.folds", [])
523
+ )
524
+ for ep in group or []:
525
+ try:
526
+ add(ep.load(), f"ep:{getattr(ep, 'name', ep)}")
527
+ except Exception:
528
+ continue
529
+ except Exception:
530
+ pass
531
+ return folds
532
+
533
+
534
+ def _blob(value: Any) -> str:
535
+ return json.dumps(value, sort_keys=True, default=str)
536
+
537
+
538
+ def _public_fold(state: dict[str, Any]) -> dict[str, Any]:
539
+ return {key: value for key, value in state.items() if not str(key).startswith("_")}
540
+
541
+
542
+ class LivePlotFolds:
543
+ """Per-coordinate chat / topology snapshots. Extra folds emit series."""
544
+
545
+ def __init__(self) -> None:
546
+ self._chat: dict[str, dict[str, Any]] = {}
547
+ self._topo: dict[str, dict[str, Any]] = {}
548
+ self._extra = load_ui_folds()
549
+
550
+ def apply(
551
+ self,
552
+ emit: Callable[[DataSourceRef, dict[str, Any]], None],
553
+ ref: DataSourceRef,
554
+ record: dict[str, Any],
555
+ *,
556
+ skip: set[str] | None = None,
557
+ ) -> None:
558
+ kind = str(record.get("kind") or "")
559
+ if kind.startswith("custom.") or kind.startswith("series."):
560
+ return
561
+ key = ref.coordinate()
562
+ held = skip or set()
563
+ # Reconstruct chat from native events even when a ui-trace snapshot exists.
564
+ self._fold_chat(emit, ref, key, record)
565
+ if "custom.topology" not in held:
566
+ self._fold_topo(emit, ref, key, record)
567
+ for fold in self._extra:
568
+ apply = getattr(fold, "apply", None)
569
+ if apply is None:
570
+ continue
571
+ try:
572
+ apply(emit, ref, record, skip=held)
573
+ except Exception:
574
+ continue
575
+
576
+ def seed_spec(self, ref: DataSourceRef, snap: dict[str, Any]) -> dict[str, Any]:
577
+ """Remember topology from a spec snapshot so the first GET is not empty."""
578
+ key = ref.coordinate()
579
+ topo = topology_from_spec(snap)
580
+ self._topo[key] = {**topo, "kind": "topology"}
581
+ return {**topo, "kind": "custom.topology.snapshot"}
582
+
583
+ def _fold_chat(
584
+ self,
585
+ emit: Callable[[DataSourceRef, dict[str, Any]], None],
586
+ ref: DataSourceRef,
587
+ key: str,
588
+ record: dict[str, Any],
589
+ ) -> None:
590
+ state = self._chat.setdefault(key, {"kind": "chat", "cards": [], "activity": []})
591
+ prev = _blob(state)
592
+ fold_chat_event(state, record)
593
+ if _blob(state) != prev:
594
+ emit(ref, {**_public_fold(state), "kind": "custom.chat.snapshot"})
595
+
596
+ def _fold_topo(
597
+ self,
598
+ emit: Callable[[DataSourceRef, dict[str, Any]], None],
599
+ ref: DataSourceRef,
600
+ key: str,
601
+ record: dict[str, Any],
602
+ ) -> None:
603
+ state = self._topo.setdefault(
604
+ key, {"kind": "topology", "nodes": [], "edges": [], "activity": []}
605
+ )
606
+ prev = _blob(state)
607
+ fold_topology_event(state, record)
608
+ if _blob(state) != prev:
609
+ emit(ref, {**_public_fold(state), "kind": "custom.topology.snapshot"})