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.
- mas/lab/controller/__init__.py +7 -0
- mas/lab/controller/__main__.py +5 -0
- mas/lab/controller/agent_chat.py +226 -0
- mas/lab/controller/api.py +364 -0
- mas/lab/controller/artifact_discovery.py +107 -0
- mas/lab/controller/artifacts.py +112 -0
- mas/lab/controller/cli.py +493 -0
- mas/lab/controller/client.py +271 -0
- mas/lab/controller/config.py +75 -0
- mas/lab/controller/constants.py +30 -0
- mas/lab/controller/daemon.py +190 -0
- mas/lab/controller/deps.py +172 -0
- mas/lab/controller/fastapi_app.py +166 -0
- mas/lab/controller/http_server.py +243 -0
- mas/lab/controller/io_capture.py +55 -0
- mas/lab/controller/item_service.py +625 -0
- mas/lab/controller/job_store.py +209 -0
- mas/lab/controller/jobs.py +375 -0
- mas/lab/controller/kinds.py +149 -0
- mas/lab/controller/lab_registry.py +1076 -0
- mas/lab/controller/manifest_layout.py +832 -0
- mas/lab/controller/manifest_store.py +214 -0
- mas/lab/controller/manifest_validation.py +90 -0
- mas/lab/controller/models.py +215 -0
- mas/lab/controller/path_safety.py +68 -0
- mas/lab/controller/pipeline_validation.py +79 -0
- mas/lab/controller/registry.py +83 -0
- mas/lab/controller/routes/__init__.py +82 -0
- mas/lab/controller/routes/_api.py +15 -0
- mas/lab/controller/routes/apps.py +190 -0
- mas/lab/controller/routes/artifacts.py +37 -0
- mas/lab/controller/routes/benchmark.py +307 -0
- mas/lab/controller/routes/choices.py +141 -0
- mas/lab/controller/routes/datasets.py +91 -0
- mas/lab/controller/routes/design_patterns.py +121 -0
- mas/lab/controller/routes/eval_output.py +31 -0
- mas/lab/controller/routes/experiments.py +194 -0
- mas/lab/controller/routes/health.py +178 -0
- mas/lab/controller/routes/items.py +187 -0
- mas/lab/controller/routes/jobs.py +107 -0
- mas/lab/controller/routes/libraries.py +144 -0
- mas/lab/controller/routes/overlays.py +164 -0
- mas/lab/controller/routes/pipelines.py +139 -0
- mas/lab/controller/routes/registry.py +40 -0
- mas/lab/controller/routes/run.py +215 -0
- mas/lab/controller/routes/runs.py +392 -0
- mas/lab/controller/routes/scopes.py +139 -0
- mas/lab/controller/routes/uploads.py +24 -0
- mas/lab/controller/routes/validate.py +36 -0
- mas/lab/controller/run_bridge.py +276 -0
- mas/lab/controller/run_store.py +190 -0
- mas/lab/controller/schema_registry.py +109 -0
- mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
- mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
- mas/lab/controller/scope_catalog.py +266 -0
- mas/lab/controller/scope_service.py +134 -0
- mas/lab/controller/sessions.py +63 -0
- mas/lab/controller/settings.py +65 -0
- mas/lab/controller/ui_stream/__init__.py +13 -0
- mas/lab/controller/ui_stream/app.py +2147 -0
- mas/lab/controller/ui_stream/artefact_bind.py +308 -0
- mas/lab/controller/ui_stream/auth.py +136 -0
- mas/lab/controller/ui_stream/bus.py +156 -0
- mas/lab/controller/ui_stream/export.py +400 -0
- mas/lab/controller/ui_stream/governance.py +141 -0
- mas/lab/controller/ui_stream/ingest.py +300 -0
- mas/lab/controller/ui_stream/inspect.py +55 -0
- mas/lab/controller/ui_stream/layouts.py +161 -0
- mas/lab/controller/ui_stream/metrics.py +274 -0
- mas/lab/controller/ui_stream/plot_folds.py +609 -0
- mas/lab/controller/ui_stream/plugin_registry.py +139 -0
- mas/lab/controller/ui_stream/refs.py +259 -0
- mas/lab/controller/ui_stream/replay.py +236 -0
- mas/lab/controller/ui_stream/replay_cli.py +707 -0
- mas/lab/controller/ui_stream/run_index.py +337 -0
- mas/lab/controller/ui_stream/spec_load.py +48 -0
- mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
- mas/lab/controller/ui_stream/stream.py +95 -0
- mas/lab/controller/ui_stream/streams.py +42 -0
- mas/lab/controller/ui_stream/timeline.py +183 -0
- mas/lab/controller/ui_stream/ui_trace.py +258 -0
- mas/lab/controller/uploads.py +61 -0
- mas/lab/controller/worker_model.py +137 -0
- mas/lab/controller/workers.py +296 -0
- mas/lab/controller/workspace_library.py +70 -0
- mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
- mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
- mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
- 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"})
|