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,337 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Resolve run artefacts from experiment/scenario/test/run (or session) ids."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.ui_stream.refs import DataSourceRef
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RunRecord:
|
|
16
|
+
ref: DataSourceRef
|
|
17
|
+
events: Path | None = None
|
|
18
|
+
artefacts: dict[str, Path] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
if not path.is_file():
|
|
25
|
+
return {}
|
|
26
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
27
|
+
return data if isinstance(data, dict) else {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_experiment_file(lib_dir: Path, experiment: str | None) -> Path | None:
|
|
31
|
+
if not experiment:
|
|
32
|
+
for name in ("experiment.yaml", "experiments/experiment.yaml"):
|
|
33
|
+
candidate = lib_dir / name
|
|
34
|
+
if candidate.is_file():
|
|
35
|
+
return candidate
|
|
36
|
+
return None
|
|
37
|
+
candidates = [
|
|
38
|
+
lib_dir / experiment,
|
|
39
|
+
lib_dir / "experiments" / experiment,
|
|
40
|
+
]
|
|
41
|
+
if not experiment.endswith((".yaml", ".yml")):
|
|
42
|
+
candidates.extend(
|
|
43
|
+
[
|
|
44
|
+
lib_dir / f"{experiment}.yaml",
|
|
45
|
+
lib_dir / "experiments" / f"{experiment}.yaml",
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
for path in candidates:
|
|
49
|
+
if path.is_file():
|
|
50
|
+
return path
|
|
51
|
+
extra = list(lib_dir.glob("*.yaml"))
|
|
52
|
+
experiments_dir = lib_dir / "experiments"
|
|
53
|
+
if experiments_dir.is_dir():
|
|
54
|
+
extra.extend(experiments_dir.glob("*.yaml"))
|
|
55
|
+
for path in extra:
|
|
56
|
+
doc = _read_yaml(path)
|
|
57
|
+
name = str((doc.get("experiment") or {}).get("name") or doc.get("name") or path.stem)
|
|
58
|
+
if name == experiment or path.stem == experiment:
|
|
59
|
+
return path
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _experiment_application(lib_dir: Path, experiment: str | None) -> dict[str, Any]:
|
|
64
|
+
path = find_experiment_file(lib_dir, experiment)
|
|
65
|
+
if path is None:
|
|
66
|
+
return {}
|
|
67
|
+
doc = _read_yaml(path)
|
|
68
|
+
exp = doc.get("experiment") if isinstance(doc.get("experiment"), dict) else doc
|
|
69
|
+
app = (exp or {}).get("application")
|
|
70
|
+
if isinstance(app, dict):
|
|
71
|
+
return app
|
|
72
|
+
apps = (exp or {}).get("applications") or []
|
|
73
|
+
if apps and isinstance(apps[0], dict):
|
|
74
|
+
return apps[0]
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def infer_mas_rel(lib_dir: Path, experiment: str | None) -> str | None:
|
|
79
|
+
binding = _experiment_application(lib_dir, experiment)
|
|
80
|
+
manifest = str(binding.get("manifest") or "").strip()
|
|
81
|
+
if manifest:
|
|
82
|
+
return manifest.lstrip("./")
|
|
83
|
+
for rel in ("mas/scenario-1.mas.yaml", "mas.yaml"):
|
|
84
|
+
if (lib_dir / rel).is_file():
|
|
85
|
+
return rel
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_mas_files(lib_dir: Path, experiment: str | None) -> tuple[Path, Path] | None:
|
|
90
|
+
"""Return ``(mas.yaml, MAS directory)`` for a catalog app or a relative manifest."""
|
|
91
|
+
binding = _experiment_application(lib_dir, experiment)
|
|
92
|
+
app_ref = str(binding.get("app") or "").strip()
|
|
93
|
+
if app_ref:
|
|
94
|
+
try:
|
|
95
|
+
from mas.apps import get_app, resolve_app_manifest
|
|
96
|
+
|
|
97
|
+
manifest = resolve_app_manifest(get_app(app_ref), app_id=app_ref)
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
return manifest, manifest.parent
|
|
101
|
+
rel = infer_mas_rel(lib_dir, experiment)
|
|
102
|
+
if not rel:
|
|
103
|
+
return None
|
|
104
|
+
manifest = Path(rel) if Path(rel).is_absolute() else lib_dir / rel
|
|
105
|
+
if not manifest.is_file():
|
|
106
|
+
return None
|
|
107
|
+
return manifest, manifest.parent
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def overlay_rel(lib_dir: Path, overlay: str | None) -> Path | None:
|
|
111
|
+
if not overlay:
|
|
112
|
+
return None
|
|
113
|
+
name = overlay if overlay.endswith((".yaml", ".yml")) else f"{overlay}.yaml"
|
|
114
|
+
for folder in (lib_dir / "mas" / "overlays", lib_dir / "overlays", lib_dir / "mas" / "configs"):
|
|
115
|
+
candidate = folder / name
|
|
116
|
+
if candidate.is_file():
|
|
117
|
+
return candidate
|
|
118
|
+
skip = {".cache", "versions", "node_modules", ".git"}
|
|
119
|
+
matches = [path for path in lib_dir.rglob(name) if not skip.intersection(path.parts)]
|
|
120
|
+
return matches[0] if matches else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _item_dir_name(test: str | None) -> str:
|
|
124
|
+
raw = str(test or "1").strip() or "1"
|
|
125
|
+
if raw.startswith("item"):
|
|
126
|
+
return raw
|
|
127
|
+
return f"item{raw}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_dir_name(run: str | None) -> str:
|
|
131
|
+
raw = str(run or "1").strip() or "1"
|
|
132
|
+
if raw.startswith("r") and raw[1:].isdigit():
|
|
133
|
+
return raw
|
|
134
|
+
if raw.isdigit():
|
|
135
|
+
return f"r{raw}"
|
|
136
|
+
return raw
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_run_index(run: str | None) -> bool:
|
|
140
|
+
raw = str(run or "").strip()
|
|
141
|
+
if not raw:
|
|
142
|
+
return False
|
|
143
|
+
if raw.isdigit():
|
|
144
|
+
return True
|
|
145
|
+
return raw.startswith("r") and raw[1:].isdigit()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _newest_scenario_run(
|
|
149
|
+
root: Path,
|
|
150
|
+
scenario: str,
|
|
151
|
+
item: str | None,
|
|
152
|
+
*,
|
|
153
|
+
glob_ok: bool,
|
|
154
|
+
experiment: str | None,
|
|
155
|
+
) -> Path | None:
|
|
156
|
+
item_pat = item or "item*"
|
|
157
|
+
patterns = [f"{scenario}/{item_pat}/r*"]
|
|
158
|
+
if experiment:
|
|
159
|
+
patterns.append(f"{experiment}/{scenario}/{item_pat}/r*")
|
|
160
|
+
if glob_ok:
|
|
161
|
+
patterns.append(f"**/{scenario}/{item_pat}/r*")
|
|
162
|
+
found: list[Path] = []
|
|
163
|
+
for pattern in patterns:
|
|
164
|
+
for path in root.glob(pattern):
|
|
165
|
+
if path.is_dir() and path.name.startswith("r") and path.name[1:].isdigit():
|
|
166
|
+
found.append(path)
|
|
167
|
+
if not found:
|
|
168
|
+
return None
|
|
169
|
+
return max(found, key=lambda path: path.stat().st_mtime)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _standard_run_dir(lib_dir: Path, ref: DataSourceRef) -> Path | None:
|
|
173
|
+
scenario = ref.scenario
|
|
174
|
+
if not scenario:
|
|
175
|
+
return None
|
|
176
|
+
item = _item_dir_name(ref.test) if ref.test else None
|
|
177
|
+
run = _run_dir_name(ref.run) if _is_run_index(ref.run) else None
|
|
178
|
+
local_roots: list[Path] = []
|
|
179
|
+
for name in ("output", "outputs", "runs"):
|
|
180
|
+
candidate = lib_dir / name
|
|
181
|
+
if candidate.is_dir():
|
|
182
|
+
local_roots.append(candidate)
|
|
183
|
+
extra_roots: list[Path] = []
|
|
184
|
+
try:
|
|
185
|
+
from mas.lab.paths import data_dir, labs_root
|
|
186
|
+
|
|
187
|
+
extra_roots.append(data_dir())
|
|
188
|
+
extra_roots.append(labs_root())
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
def _match(root: Path, *, glob_ok: bool) -> Path | None:
|
|
193
|
+
if run is not None:
|
|
194
|
+
item_name = item or _item_dir_name(None)
|
|
195
|
+
direct = root / scenario / item_name / run
|
|
196
|
+
if direct.is_dir():
|
|
197
|
+
return direct
|
|
198
|
+
if ref.experiment:
|
|
199
|
+
nested = root / ref.experiment / scenario / item_name / run
|
|
200
|
+
if nested.is_dir():
|
|
201
|
+
return nested
|
|
202
|
+
if glob_ok:
|
|
203
|
+
matches = list(root.glob(f"**/{scenario}/{item_name}/{run}"))
|
|
204
|
+
if matches:
|
|
205
|
+
return matches[0]
|
|
206
|
+
return None
|
|
207
|
+
if not glob_ok:
|
|
208
|
+
return None
|
|
209
|
+
return _newest_scenario_run(
|
|
210
|
+
root, scenario, item, glob_ok=True, experiment=ref.experiment
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
for root in local_roots:
|
|
214
|
+
found = _match(root, glob_ok=True)
|
|
215
|
+
if found is not None:
|
|
216
|
+
return found
|
|
217
|
+
for root in extra_roots:
|
|
218
|
+
found = _match(root, glob_ok=False)
|
|
219
|
+
if found is not None:
|
|
220
|
+
return found
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _events_in_run_dir(run_dir: Path) -> Path | None:
|
|
225
|
+
try:
|
|
226
|
+
from mas.lab.benchmark.cache.trace_store import resolve_run_events_path
|
|
227
|
+
|
|
228
|
+
found = resolve_run_events_path(run_dir)
|
|
229
|
+
if found is not None:
|
|
230
|
+
return found
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
inline = run_dir / "traces" / "events.jsonl"
|
|
234
|
+
return inline if inline.is_file() else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _gold_events(lib_dir: Path, scenario: str) -> Path | None:
|
|
238
|
+
cache = lib_dir / "infra" / ".cache"
|
|
239
|
+
if not cache.is_dir():
|
|
240
|
+
return None
|
|
241
|
+
exact = list(cache.glob(f"**/{scenario}.events.jsonl"))
|
|
242
|
+
if exact:
|
|
243
|
+
return sorted(exact)[-1]
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _gold_metrics(lib_dir: Path, scenario: str) -> Path | None:
|
|
248
|
+
cache = lib_dir / "infra" / ".cache"
|
|
249
|
+
if not cache.is_dir():
|
|
250
|
+
return None
|
|
251
|
+
exact = list(cache.glob(f"**/{scenario}.metrics.json"))
|
|
252
|
+
if exact:
|
|
253
|
+
return sorted(exact)[-1]
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _gold_progress(lib_dir: Path, scenario: str) -> Path | None:
|
|
258
|
+
cache = lib_dir / "infra" / ".cache"
|
|
259
|
+
if not cache.is_dir():
|
|
260
|
+
return None
|
|
261
|
+
exact = list(cache.glob(f"**/{scenario}.progress.jsonl"))
|
|
262
|
+
if exact:
|
|
263
|
+
return sorted(exact)[-1]
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _gold_custom_owners(lib_dir: Path, scenario: str) -> dict[str, Path]:
|
|
268
|
+
"""Gold-cache siblings for every ``custom.{owner}`` trace actually present,
|
|
269
|
+
e.g. ``infra/.cache/{scenario}.custom.topology.jsonl``, next to the
|
|
270
|
+
existing ``{scenario}.events.jsonl`` / ``{scenario}.metrics.json``
|
|
271
|
+
convention. Discovered by glob, not a hardcoded owner list."""
|
|
272
|
+
cache = lib_dir / "infra" / ".cache"
|
|
273
|
+
if not cache.is_dir():
|
|
274
|
+
return {}
|
|
275
|
+
found: dict[str, Path] = {}
|
|
276
|
+
prefix, suffix = f"{scenario}.custom.", ".jsonl"
|
|
277
|
+
for path in cache.glob(f"**/{scenario}.custom.*.jsonl"):
|
|
278
|
+
owner = path.name[len(prefix) : -len(suffix)]
|
|
279
|
+
if owner and (owner not in found or path.stat().st_mtime > found[owner].stat().st_mtime):
|
|
280
|
+
found[owner] = path
|
|
281
|
+
return found
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def resolve_run(lib_dir: Path, ref: DataSourceRef, *, indexed: dict[str, RunRecord] | None = None) -> RunRecord:
|
|
285
|
+
if indexed:
|
|
286
|
+
for key in (ref.run or "", ref.session or "", ref.coordinate()):
|
|
287
|
+
if key and key in indexed:
|
|
288
|
+
return indexed[key]
|
|
289
|
+
run_dir = _standard_run_dir(lib_dir, ref)
|
|
290
|
+
events = _events_in_run_dir(run_dir) if run_dir else None
|
|
291
|
+
if events is None and ref.scenario:
|
|
292
|
+
events = _gold_events(lib_dir, ref.scenario)
|
|
293
|
+
artefacts: dict[str, Path] = {}
|
|
294
|
+
if events:
|
|
295
|
+
artefacts["events"] = events
|
|
296
|
+
if run_dir:
|
|
297
|
+
for key, name in (
|
|
298
|
+
("metrics", "metrics.json"),
|
|
299
|
+
("progress", "progress.jsonl"),
|
|
300
|
+
("progress_latest", "progress.json"),
|
|
301
|
+
("protocol", "protocol.json"),
|
|
302
|
+
("report", "session_report.yaml"),
|
|
303
|
+
("dataframe", "data.csv"),
|
|
304
|
+
):
|
|
305
|
+
path = run_dir / name
|
|
306
|
+
if path.is_file():
|
|
307
|
+
artefacts[key] = path
|
|
308
|
+
if "report" not in artefacts:
|
|
309
|
+
json_report = run_dir / "session_report.json"
|
|
310
|
+
if json_report.is_file():
|
|
311
|
+
artefacts["report"] = json_report
|
|
312
|
+
# custom.{owner}.jsonl siblings (file_tail.append_custom_trace) --
|
|
313
|
+
# discovered by glob so any owner is picked up with no code change.
|
|
314
|
+
for path in run_dir.glob("custom.*.jsonl"):
|
|
315
|
+
owner = path.stem.split(".", 1)[1] if path.stem.count(".") >= 1 else ""
|
|
316
|
+
if owner:
|
|
317
|
+
artefacts[f"custom_{owner}"] = path
|
|
318
|
+
if "metrics" not in artefacts and events is not None:
|
|
319
|
+
sibling = events.with_name(events.name.replace(".events.jsonl", ".metrics.json"))
|
|
320
|
+
if sibling.is_file():
|
|
321
|
+
artefacts["metrics"] = sibling
|
|
322
|
+
if "progress" not in artefacts and events is not None:
|
|
323
|
+
sibling = events.with_name(events.name.replace(".events.jsonl", ".progress.jsonl"))
|
|
324
|
+
if sibling.is_file():
|
|
325
|
+
artefacts["progress"] = sibling
|
|
326
|
+
if "metrics" not in artefacts and ref.scenario:
|
|
327
|
+
gold = _gold_metrics(lib_dir, ref.scenario)
|
|
328
|
+
if gold is not None:
|
|
329
|
+
artefacts["metrics"] = gold
|
|
330
|
+
if "progress" not in artefacts and ref.scenario:
|
|
331
|
+
gold = _gold_progress(lib_dir, ref.scenario)
|
|
332
|
+
if gold is not None:
|
|
333
|
+
artefacts["progress"] = gold
|
|
334
|
+
if ref.scenario:
|
|
335
|
+
for owner, path in _gold_custom_owners(lib_dir, ref.scenario).items():
|
|
336
|
+
artefacts.setdefault(f"custom_{owner}", path)
|
|
337
|
+
return RunRecord(ref=ref, events=events, artefacts=artefacts)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Load a MAS topology snapshot from library files using catalog ids."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mas.lab.controller.ui_stream.run_index import overlay_rel, resolve_mas_files
|
|
11
|
+
from mas.lab.controller.ui_stream.spec_snapshot import load_yaml_text, snapshot_from_documents
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_path(path: Path) -> dict[str, Any]:
|
|
15
|
+
if not path.is_file():
|
|
16
|
+
return {}
|
|
17
|
+
return load_yaml_text(path.read_text(encoding="utf-8"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def snapshot_for_ref(lib_dir: Path, *, experiment: str | None, overlay: str | None) -> dict[str, Any]:
|
|
21
|
+
files = resolve_mas_files(lib_dir, experiment)
|
|
22
|
+
if files is None:
|
|
23
|
+
raise FileNotFoundError("could not infer MAS spec from experiment id")
|
|
24
|
+
manifest, mas_dir = files
|
|
25
|
+
mas_doc = _read_path(manifest)
|
|
26
|
+
spec = mas_doc.get("spec") if isinstance(mas_doc.get("spec"), dict) else mas_doc
|
|
27
|
+
agency = (spec or {}).get("agency") or {}
|
|
28
|
+
agent_docs: dict[str, dict[str, Any]] = {}
|
|
29
|
+
for item in agency.get("agents") or []:
|
|
30
|
+
if isinstance(item, str):
|
|
31
|
+
aid = Path(item).stem
|
|
32
|
+
rel = item
|
|
33
|
+
elif isinstance(item, dict):
|
|
34
|
+
aid = str(item.get("id") or "").strip()
|
|
35
|
+
rel = str(item.get("ref") or "").strip()
|
|
36
|
+
else:
|
|
37
|
+
continue
|
|
38
|
+
if not aid:
|
|
39
|
+
continue
|
|
40
|
+
if rel:
|
|
41
|
+
agent_docs[aid] = _read_path(mas_dir / rel)
|
|
42
|
+
else:
|
|
43
|
+
agent_docs[aid] = {}
|
|
44
|
+
overlay_doc = None
|
|
45
|
+
overlay_path = overlay_rel(lib_dir, overlay)
|
|
46
|
+
if overlay_path is not None:
|
|
47
|
+
overlay_doc = load_yaml_text(overlay_path.read_text(encoding="utf-8"))
|
|
48
|
+
return snapshot_from_documents(mas_doc, agent_docs, overlay_doc)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Build a MAS topology snapshot for the demo-ui spec stream."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from mas.runtime.boundary.delegation.policy import parse_delegate_tool_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _as_list(value: Any) -> list[Any]:
|
|
15
|
+
if value is None:
|
|
16
|
+
return []
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
return value
|
|
19
|
+
return [value]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _skill_name(item: Any) -> str:
|
|
23
|
+
if isinstance(item, str):
|
|
24
|
+
return item.strip()
|
|
25
|
+
if isinstance(item, dict):
|
|
26
|
+
return str(item.get("name") or item.get("id") or item.get("ref") or "").strip()
|
|
27
|
+
return str(item or "").strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _tool_name(item: Any, mas_dir: Path | None = None) -> str:
|
|
31
|
+
if isinstance(item, str):
|
|
32
|
+
text = item.strip()
|
|
33
|
+
if text.endswith(".yaml") or text.endswith(".yml"):
|
|
34
|
+
return Path(text).stem
|
|
35
|
+
return text
|
|
36
|
+
if isinstance(item, dict):
|
|
37
|
+
named = str(item.get("name") or item.get("id") or "").strip()
|
|
38
|
+
if named:
|
|
39
|
+
return named
|
|
40
|
+
ref = str(item.get("ref") or "").strip()
|
|
41
|
+
if ref:
|
|
42
|
+
return Path(ref).stem
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _agent_skills(doc: dict[str, Any]) -> list[str]:
|
|
47
|
+
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else doc
|
|
48
|
+
names: list[str] = []
|
|
49
|
+
for item in _as_list((spec or {}).get("skills")):
|
|
50
|
+
name = _skill_name(item)
|
|
51
|
+
if name:
|
|
52
|
+
names.append(name)
|
|
53
|
+
return names
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _agent_tools(doc: dict[str, Any]) -> list[str]:
|
|
57
|
+
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else doc
|
|
58
|
+
names: list[str] = []
|
|
59
|
+
for item in _as_list((spec or {}).get("tools")):
|
|
60
|
+
name = _tool_name(item)
|
|
61
|
+
if name:
|
|
62
|
+
names.append(name)
|
|
63
|
+
return names
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _overlay_skill_adds(overlay: dict[str, Any] | None) -> dict[str, list[str]]:
|
|
67
|
+
if not isinstance(overlay, dict):
|
|
68
|
+
return {}
|
|
69
|
+
patch = ((overlay.get("spec") or {}).get("patch") or {}).get("agents") or {}
|
|
70
|
+
if not isinstance(patch, dict):
|
|
71
|
+
return {}
|
|
72
|
+
out: dict[str, list[str]] = {}
|
|
73
|
+
for key, body in patch.items():
|
|
74
|
+
if not isinstance(body, dict):
|
|
75
|
+
continue
|
|
76
|
+
skills = body.get("skills") or {}
|
|
77
|
+
added = skills.get("$op", {}).get("add") if isinstance(skills, dict) else None
|
|
78
|
+
if added is None and isinstance(skills, dict):
|
|
79
|
+
added = skills.get("add")
|
|
80
|
+
names = [_skill_name(item) for item in _as_list(added)]
|
|
81
|
+
names = [name for name in names if name]
|
|
82
|
+
if names:
|
|
83
|
+
out[str(key)] = names
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_overlay_skills(
|
|
88
|
+
agents: list[dict[str, Any]],
|
|
89
|
+
overlay: dict[str, Any] | None,
|
|
90
|
+
entry_id: str,
|
|
91
|
+
) -> None:
|
|
92
|
+
adds = _overlay_skill_adds(overlay)
|
|
93
|
+
if not adds:
|
|
94
|
+
return
|
|
95
|
+
by_id = {str(item["id"]): item for item in agents}
|
|
96
|
+
for key, names in adds.items():
|
|
97
|
+
targets: list[str]
|
|
98
|
+
if key in {"$all", "all"}:
|
|
99
|
+
targets = list(by_id)
|
|
100
|
+
elif key in {"$entry", "entry"}:
|
|
101
|
+
targets = [entry_id] if entry_id in by_id else []
|
|
102
|
+
elif key in {"$not-entry", "not-entry"}:
|
|
103
|
+
targets = [aid for aid in by_id if aid != entry_id]
|
|
104
|
+
else:
|
|
105
|
+
targets = [key] if key in by_id else []
|
|
106
|
+
for aid in targets:
|
|
107
|
+
have = list(by_id[aid].get("skills") or [])
|
|
108
|
+
for name in names:
|
|
109
|
+
if name not in have:
|
|
110
|
+
have.append(name)
|
|
111
|
+
by_id[aid]["skills"] = have
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def snapshot_from_documents(
|
|
115
|
+
mas_doc: dict[str, Any],
|
|
116
|
+
agent_docs: dict[str, dict[str, Any]] | None = None,
|
|
117
|
+
overlay_doc: dict[str, Any] | None = None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Return a UI topology snapshot: entry, agents, tools, skills, workflow edges."""
|
|
120
|
+
spec = mas_doc.get("spec") if isinstance(mas_doc.get("spec"), dict) else mas_doc
|
|
121
|
+
agency = (spec or {}).get("agency") or {}
|
|
122
|
+
workflow = (spec or {}).get("workflow") or {}
|
|
123
|
+
entry = str(workflow.get("entry") or "").strip()
|
|
124
|
+
listed = _as_list(agency.get("agents"))
|
|
125
|
+
agent_docs = agent_docs or {}
|
|
126
|
+
agents: list[dict[str, Any]] = []
|
|
127
|
+
delegates: dict[str, list[str]] = {}
|
|
128
|
+
for item in listed:
|
|
129
|
+
if isinstance(item, str):
|
|
130
|
+
aid = item
|
|
131
|
+
elif isinstance(item, dict):
|
|
132
|
+
aid = str(item.get("id") or item.get("name") or "").strip()
|
|
133
|
+
else:
|
|
134
|
+
continue
|
|
135
|
+
if not aid:
|
|
136
|
+
continue
|
|
137
|
+
if not entry:
|
|
138
|
+
entry = aid
|
|
139
|
+
doc = agent_docs.get(aid) or {}
|
|
140
|
+
agents.append(
|
|
141
|
+
{
|
|
142
|
+
"id": aid,
|
|
143
|
+
"tools": _agent_tools(doc),
|
|
144
|
+
"skills": _agent_skills(doc),
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
for node in _as_list(workflow.get("nodes")):
|
|
148
|
+
if not isinstance(node, dict):
|
|
149
|
+
continue
|
|
150
|
+
nid = str(node.get("id") or "").strip()
|
|
151
|
+
peers = [str(p).strip() for p in _as_list(node.get("delegates_to")) if str(p).strip()]
|
|
152
|
+
if nid and peers:
|
|
153
|
+
delegates[nid] = peers
|
|
154
|
+
apply_overlay_skills(agents, overlay_doc, entry)
|
|
155
|
+
return {
|
|
156
|
+
"kind": "mas_topology",
|
|
157
|
+
"entry": entry,
|
|
158
|
+
"user": "user",
|
|
159
|
+
"agents": agents,
|
|
160
|
+
"delegates_to": delegates,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_SKIP_ENTRY = frozenset({"", "mas", "user", "agent"})
|
|
165
|
+
_HUB_HINTS = frozenset({"coordinator", "orchestrator", "planner"})
|
|
166
|
+
_HUB_TOOLS = frozenset({"activate_skill", "run_action"})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def infer_implicit_delegates(
|
|
170
|
+
agents: list[dict[str, Any]],
|
|
171
|
+
records: list[dict[str, Any]] | None = None,
|
|
172
|
+
hinted: str = "",
|
|
173
|
+
) -> tuple[str, dict[str, list[str]]]:
|
|
174
|
+
"""Star from the coordinator when the trace has no ``delegate_to_*`` tools."""
|
|
175
|
+
ids = [
|
|
176
|
+
str(agent.get("id") or "").strip()
|
|
177
|
+
for agent in agents
|
|
178
|
+
if isinstance(agent, dict) and str(agent.get("id") or "").strip() not in _SKIP_ENTRY
|
|
179
|
+
]
|
|
180
|
+
ids = [aid for aid in ids if aid]
|
|
181
|
+
if len(ids) < 2:
|
|
182
|
+
return "", {}
|
|
183
|
+
hub = next((aid for aid in ids if aid.lower() in _HUB_HINTS), "")
|
|
184
|
+
last_speaker = ""
|
|
185
|
+
for record in records or []:
|
|
186
|
+
if not isinstance(record, dict):
|
|
187
|
+
continue
|
|
188
|
+
aid = str(record.get("agent_id") or "").strip()
|
|
189
|
+
if aid not in ids:
|
|
190
|
+
continue
|
|
191
|
+
kind = str(record.get("kind") or "")
|
|
192
|
+
tool = str(record.get("tool_name") or "")
|
|
193
|
+
if kind == "tool_call_start" and tool in _HUB_TOOLS:
|
|
194
|
+
hub = aid
|
|
195
|
+
if kind in {"user_response", "llm_call_end", "client_response"}:
|
|
196
|
+
last_speaker = aid
|
|
197
|
+
if not hub:
|
|
198
|
+
hinted = str(hinted or "").strip()
|
|
199
|
+
hub = hinted if hinted in ids else last_speaker or ids[0]
|
|
200
|
+
others = [aid for aid in ids if aid != hub]
|
|
201
|
+
return hub, ({hub: others} if hub and others else {})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def pick_entry(
|
|
205
|
+
agents: list[dict[str, Any]],
|
|
206
|
+
delegates: dict[str, list[str]],
|
|
207
|
+
hinted: str = "",
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Entry is the unique delegate hub, not the first native agent_id (often ``mas``)."""
|
|
210
|
+
children = {str(peer) for peers in (delegates or {}).values() for peer in (peers or [])}
|
|
211
|
+
roots = [
|
|
212
|
+
str(src)
|
|
213
|
+
for src in (delegates or {})
|
|
214
|
+
if str(src) not in children and str(src) not in _SKIP_ENTRY
|
|
215
|
+
]
|
|
216
|
+
if len(roots) == 1:
|
|
217
|
+
return roots[0]
|
|
218
|
+
hinted = str(hinted or "").strip()
|
|
219
|
+
ids = {str(agent.get("id") or "") for agent in agents if isinstance(agent, dict)}
|
|
220
|
+
if hinted not in _SKIP_ENTRY and hinted in ids:
|
|
221
|
+
return hinted
|
|
222
|
+
if roots:
|
|
223
|
+
return roots[0]
|
|
224
|
+
for agent in agents:
|
|
225
|
+
aid = str((agent or {}).get("id") or "")
|
|
226
|
+
if aid not in _SKIP_ENTRY:
|
|
227
|
+
return aid
|
|
228
|
+
return ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def snapshot_from_records(records: list[dict[str, Any]]) -> dict[str, Any]:
|
|
232
|
+
"""Infer a topology snapshot from native events (no MAS YAML required)."""
|
|
233
|
+
agents: list[dict[str, Any]] = []
|
|
234
|
+
seen: set[str] = set()
|
|
235
|
+
hinted = ""
|
|
236
|
+
delegates: dict[str, list[str]] = {}
|
|
237
|
+
for record in records:
|
|
238
|
+
if not isinstance(record, dict):
|
|
239
|
+
continue
|
|
240
|
+
kind = str(record.get("kind") or "")
|
|
241
|
+
aid = str(record.get("agent_id") or "").strip()
|
|
242
|
+
if aid and aid not in {"mas", "user"} and aid not in seen:
|
|
243
|
+
seen.add(aid)
|
|
244
|
+
agents.append({"id": aid, "tools": [], "skills": []})
|
|
245
|
+
if not hinted and aid not in _SKIP_ENTRY and kind in {"execution_start", "mas_call_start"}:
|
|
246
|
+
hinted = aid
|
|
247
|
+
if kind != "tool_call_start":
|
|
248
|
+
continue
|
|
249
|
+
tool = str(record.get("tool_name") or "")
|
|
250
|
+
peer = parse_delegate_tool_name(tool)
|
|
251
|
+
if not peer or not aid:
|
|
252
|
+
continue
|
|
253
|
+
peers = delegates.setdefault(aid, [])
|
|
254
|
+
if peer and peer not in peers:
|
|
255
|
+
peers.append(peer)
|
|
256
|
+
if peer and peer not in seen:
|
|
257
|
+
seen.add(peer)
|
|
258
|
+
agents.append({"id": peer, "tools": [], "skills": []})
|
|
259
|
+
if not delegates and len(agents) > 1:
|
|
260
|
+
hub, delegates = infer_implicit_delegates(agents, records, hinted)
|
|
261
|
+
if hub:
|
|
262
|
+
hinted = hub
|
|
263
|
+
return {
|
|
264
|
+
"kind": "mas_topology",
|
|
265
|
+
"entry": pick_entry(agents, delegates, hinted),
|
|
266
|
+
"user": "user",
|
|
267
|
+
"agents": agents,
|
|
268
|
+
"delegates_to": delegates,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def merge_spec_snapshots(base: dict[str, Any] | None, extra: dict[str, Any] | None) -> dict[str, Any]:
|
|
273
|
+
"""Union agents and delegates_to so a sparse MAS YAML still shows event peers."""
|
|
274
|
+
out = dict(base or {})
|
|
275
|
+
extra = extra or {}
|
|
276
|
+
agents: list[dict[str, Any]] = []
|
|
277
|
+
seen: set[str] = set()
|
|
278
|
+
for src in (out.get("agents"), extra.get("agents")):
|
|
279
|
+
for agent in src or []:
|
|
280
|
+
if not isinstance(agent, dict):
|
|
281
|
+
continue
|
|
282
|
+
aid = str(agent.get("id") or "").strip()
|
|
283
|
+
if not aid or aid in seen:
|
|
284
|
+
continue
|
|
285
|
+
seen.add(aid)
|
|
286
|
+
agents.append(agent)
|
|
287
|
+
delegates: dict[str, list[str]] = {}
|
|
288
|
+
for src in (out.get("delegates_to"), extra.get("delegates_to")):
|
|
289
|
+
if not isinstance(src, dict):
|
|
290
|
+
continue
|
|
291
|
+
for key, peers in src.items():
|
|
292
|
+
have = delegates.setdefault(str(key), [])
|
|
293
|
+
for peer in peers or []:
|
|
294
|
+
name = str(peer).strip()
|
|
295
|
+
if name and name not in have:
|
|
296
|
+
have.append(name)
|
|
297
|
+
if name and name not in seen:
|
|
298
|
+
seen.add(name)
|
|
299
|
+
agents.append({"id": name, "tools": [], "skills": []})
|
|
300
|
+
if str(key) not in seen:
|
|
301
|
+
seen.add(str(key))
|
|
302
|
+
agents.append({"id": str(key), "tools": [], "skills": []})
|
|
303
|
+
out["agents"] = agents
|
|
304
|
+
out["delegates_to"] = delegates
|
|
305
|
+
out["entry"] = pick_entry(agents, delegates, str(out.get("entry") or extra.get("entry") or ""))
|
|
306
|
+
out.setdefault("user", extra.get("user") or out.get("user") or "user")
|
|
307
|
+
out.setdefault("kind", extra.get("kind") or "mas_topology")
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def load_yaml_text(text: str) -> dict[str, Any]:
|
|
312
|
+
data = yaml.safe_load(text) or {}
|
|
313
|
+
return data if isinstance(data, dict) else {}
|