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,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 {}