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,307 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Benchmark run endpoints and global experiment results."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from fastapi import APIRouter, HTTPException, UploadFile
12
+ from fastapi.responses import FileResponse
13
+
14
+ from mas.lab.controller.deps import build_tree
15
+ from mas.lab.controller.models import (
16
+ BenchmarkAnalyzeRequest,
17
+ BenchmarkExportRequest,
18
+ BenchmarkImportRequest,
19
+ BenchmarkRunRequest,
20
+ )
21
+ from mas.lab.controller.routes._api import MAS_LAB_ROOT, deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ @router.post("/api/libraries/{library_name}/benchmark/run", tags=["Libraries"], status_code=202)
27
+ async def benchmark_run(library_name: str, req: BenchmarkRunRequest):
28
+ """Run a benchmark experiment. Returns a job_id for polling."""
29
+ lib_dir = deps.get_library_path(library_name)
30
+
31
+ cleanup_paths: list[Path] = []
32
+
33
+ # If the value looks like YAML content (has newlines), write to a temp file
34
+ if "\n" in req.experiment_yaml:
35
+ tmp = tempfile.NamedTemporaryFile(
36
+ mode="w", suffix=".yaml", prefix="experiment-", delete=False, dir=str(lib_dir)
37
+ )
38
+ tmp.write(req.experiment_yaml)
39
+ tmp.close()
40
+ experiment_path = Path(tmp.name).name
41
+ cleanup_paths.append(Path(tmp.name))
42
+ else:
43
+ experiment_path = req.experiment_yaml
44
+
45
+ cmd = ["mas-lab", "benchmark", "run", experiment_path]
46
+ if req.progress:
47
+ cmd.append("--progress")
48
+ if req.force:
49
+ cmd.append("--force")
50
+ if req.max_runs is not None:
51
+ cmd += ["--max-runs", str(req.max_runs)]
52
+ if req.flavour:
53
+ cmd += ["--flavour", req.flavour]
54
+ job = jobs.submit_job(
55
+ endpoint=f"/api/libraries/{library_name}/benchmark/run",
56
+ cmd=cmd,
57
+ cwd=lib_dir,
58
+ timeout=req.timeout,
59
+ request_body=req.model_dump(),
60
+ cleanup_paths=cleanup_paths,
61
+ )
62
+ return {"job_id": job.id, "status": job.status.value, "command": job.command}
63
+
64
+
65
+ @router.post("/api/libraries/{library_name}/benchmark/analyze", tags=["Libraries"], status_code=202)
66
+ async def benchmark_analyze(library_name: str, req: BenchmarkAnalyzeRequest):
67
+ """Regenerate statistics/plots for an existing benchmark run."""
68
+ lib_dir = deps.get_library_path(library_name)
69
+ cmd = ["mas-lab", "benchmark", "analyze", req.benchmark_id]
70
+ if req.experiment_yaml:
71
+ cmd += ["--experiment-yaml", req.experiment_yaml]
72
+ job = jobs.submit_job(
73
+ endpoint=f"/api/libraries/{library_name}/benchmark/analyze",
74
+ cmd=cmd,
75
+ cwd=lib_dir,
76
+ timeout=req.timeout,
77
+ request_body=req.model_dump(),
78
+ )
79
+ return {"job_id": job.id, "status": job.status.value, "command": job.command}
80
+
81
+
82
+ @router.post("/api/libraries/{library_name}/benchmark/export", tags=["Libraries"], status_code=202)
83
+ async def benchmark_export(library_name: str, req: BenchmarkExportRequest):
84
+ """Export a benchmark run to a portable .tar.gz archive."""
85
+ lib_dir = deps.get_library_path(library_name)
86
+ cmd = ["mas-lab", "benchmark", "export", req.benchmark_id]
87
+ if req.output:
88
+ cmd += ["--output", req.output]
89
+ if not req.include_trace_cache:
90
+ cmd.append("--no-trace-cache")
91
+ if req.dry_run:
92
+ cmd.append("--dry-run")
93
+ job = jobs.submit_job(
94
+ endpoint=f"/api/libraries/{library_name}/benchmark/export",
95
+ cmd=cmd,
96
+ cwd=lib_dir,
97
+ timeout=req.timeout,
98
+ request_body=req.model_dump(),
99
+ )
100
+ return {"job_id": job.id, "status": job.status.value, "command": job.command}
101
+
102
+
103
+ @router.get("/api/libraries/{library_name}/benchmark/download", tags=["Libraries"])
104
+ async def benchmark_download(library_name: str, benchmark_id: str):
105
+ """Export and download a benchmark run as a .tar.gz file."""
106
+ import subprocess
107
+
108
+ lib_dir = deps.get_library_path(library_name)
109
+ tmp_dir = Path(tempfile.mkdtemp())
110
+ out_path = tmp_dir / f"{benchmark_id}.tar.gz"
111
+
112
+ cmd = ["mas-lab", "benchmark", "export", benchmark_id, "--output", str(out_path)]
113
+ result = subprocess.run(cmd, cwd=lib_dir, capture_output=True, text=True, timeout=120)
114
+ if result.returncode != 0:
115
+ shutil.rmtree(tmp_dir, ignore_errors=True)
116
+ detail = result.stderr.strip().split("\n")[-1] if result.stderr else "Export failed"
117
+ raise HTTPException(status_code=400, detail=detail)
118
+
119
+ if not out_path.exists():
120
+ shutil.rmtree(tmp_dir, ignore_errors=True)
121
+ raise HTTPException(status_code=500, detail="Export produced no output file")
122
+
123
+ from starlette.background import BackgroundTask
124
+
125
+ return FileResponse(
126
+ out_path,
127
+ filename=out_path.name,
128
+ media_type="application/gzip",
129
+ background=BackgroundTask(shutil.rmtree, tmp_dir, True),
130
+ )
131
+
132
+
133
+ @router.post("/api/libraries/{library_name}/benchmark/import", tags=["Libraries"], status_code=202)
134
+ async def benchmark_import(library_name: str, req: BenchmarkImportRequest):
135
+ """Import a benchmark archive produced by benchmark export."""
136
+ lib_dir = deps.get_library_path(library_name)
137
+ cmd = ["mas-lab", "benchmark", "import", req.tarball]
138
+ if req.output_dir:
139
+ cmd += ["--output-dir", req.output_dir]
140
+ if req.trace_cache_dir:
141
+ cmd += ["--trace-cache", req.trace_cache_dir]
142
+ if req.dry_run:
143
+ cmd.append("--dry-run")
144
+ job = jobs.submit_job(
145
+ endpoint=f"/api/libraries/{library_name}/benchmark/import",
146
+ cmd=cmd,
147
+ cwd=lib_dir,
148
+ timeout=req.timeout,
149
+ request_body=req.model_dump(),
150
+ )
151
+ return {"job_id": job.id, "status": job.status.value, "command": job.command}
152
+
153
+
154
+ @router.post("/api/libraries/{library_name}/benchmark/upload-import", tags=["Libraries"], status_code=202)
155
+ async def benchmark_upload_import(library_name: str, file: UploadFile):
156
+ """Accept a .tar.gz upload and import it as a benchmark run."""
157
+ lib_dir = deps.get_library_path(library_name)
158
+
159
+ tmp = tempfile.NamedTemporaryFile(
160
+ suffix=".tar.gz", prefix="bench-import-", delete=False, dir=str(lib_dir)
161
+ )
162
+ try:
163
+ contents = await file.read()
164
+ tmp.write(contents)
165
+ tmp.close()
166
+
167
+ cmd = ["mas-lab", "benchmark", "import", tmp.name]
168
+ job = jobs.submit_job(
169
+ endpoint=f"/api/libraries/{library_name}/benchmark/upload-import",
170
+ cmd=cmd,
171
+ cwd=lib_dir,
172
+ timeout=120,
173
+ request_body={"filename": file.filename},
174
+ cleanup_paths=[Path(tmp.name)],
175
+ )
176
+ return {"job_id": job.id, "status": job.status.value, "command": job.command}
177
+ except Exception:
178
+ Path(tmp.name).unlink(missing_ok=True)
179
+ raise
180
+
181
+
182
+ @router.get("/api/experiments", tags=["Benchmark"])
183
+ async def list_experiments():
184
+ """List all experiment runs with metadata."""
185
+ mas_lab_root = MAS_LAB_ROOT
186
+ labs_dir = mas_lab_root / "labs"
187
+ if not labs_dir.exists():
188
+ return {"experiments": []}
189
+
190
+ experiments = []
191
+ for d in sorted(labs_dir.iterdir()):
192
+ if not d.is_dir():
193
+ continue
194
+ entry: dict = {"name": d.name}
195
+ meta_file = d / "metadata.yaml"
196
+ if meta_file.exists():
197
+ import yaml
198
+ meta = yaml.safe_load(meta_file.read_text(encoding="utf-8"))
199
+ entry["status"] = meta.get("status", "unknown")
200
+ entry["description"] = meta.get("experiment_description", "")
201
+ entry["started_at"] = meta.get("started_at")
202
+ entry["completed_at"] = meta.get("completed_at")
203
+ entry["duration_seconds"] = meta.get("duration_seconds")
204
+ entry["n_scenarios"] = meta.get("n_scenarios")
205
+ entry["completed_scenarios"] = meta.get("completed_scenarios")
206
+ entry["failed_scenarios"] = meta.get("failed_scenarios")
207
+ experiments.append(entry)
208
+ return {"experiments": experiments}
209
+
210
+
211
+ @router.get("/api/experiments/definitions", tags=["Libraries"])
212
+ async def list_all_experiment_definitions():
213
+ """All experiment definitions across discovered libraries."""
214
+ experiments = deps.get_manifest_store()._registry.list_all_experiments()
215
+ return {"experiments": experiments}
216
+
217
+
218
+ @router.get("/api/experiments/{experiment_name}", tags=["Benchmark"])
219
+ async def get_experiment(experiment_name: str):
220
+ """Return experiment metadata and file tree."""
221
+ mas_lab_root = MAS_LAB_ROOT
222
+ exp_dir = mas_lab_root / "labs" / experiment_name
223
+ if not exp_dir.exists() or not exp_dir.is_dir():
224
+ raise HTTPException(status_code=404, detail=f"Experiment '{experiment_name}' not found")
225
+
226
+ metadata = {}
227
+ meta_file = exp_dir / "metadata.yaml"
228
+ if meta_file.exists():
229
+ import yaml
230
+ metadata = yaml.safe_load(meta_file.read_text(encoding="utf-8"))
231
+
232
+ tree = build_tree(exp_dir, exp_dir)
233
+
234
+ return {
235
+ "name": experiment_name,
236
+ "metadata": metadata,
237
+ "tree": tree,
238
+ }
239
+
240
+
241
+ @router.get("/api/experiments/{experiment_name}/file", tags=["Benchmark"])
242
+ async def get_experiment_file(experiment_name: str, path: str):
243
+ """Read a file from an experiment's output directory.
244
+
245
+ Pass the relative path as a query parameter, e.g.
246
+ ``?path=metrics/answer_relevancy.csv``
247
+ """
248
+ mas_lab_root = MAS_LAB_ROOT
249
+ exp_dir = mas_lab_root / "labs" / experiment_name
250
+ if not exp_dir.exists():
251
+ raise HTTPException(status_code=404, detail=f"Experiment '{experiment_name}' not found")
252
+
253
+ file_path = (exp_dir / path).resolve()
254
+ # Allow paths inside the experiment dir or the trace/data cache (symlinked runs).
255
+ # Trace symlinks may point to the XDG cache dir (~/.cache/mas/) which is
256
+ # separate from MAS_LAB_ROOT (~/.local/share/mas/).
257
+ from mas.runtime.xdg import mas_cache_root
258
+ allowed_roots = [
259
+ str(exp_dir.resolve()),
260
+ str((mas_lab_root / "data").resolve()),
261
+ str(mas_cache_root().resolve()),
262
+ ]
263
+ if not any(str(file_path).startswith(root) for root in allowed_roots):
264
+ raise HTTPException(status_code=403, detail="Path traversal not allowed")
265
+ if not file_path.exists() or not file_path.is_file():
266
+ raise HTTPException(status_code=404, detail=f"File not found: {path}")
267
+
268
+ # For text files, return content inline; for others, return as download
269
+ text_suffixes = {".json", ".jsonl", ".yaml", ".yml", ".csv", ".txt", ".html", ".md", ".fingerprint", ".svg"}
270
+ if file_path.suffix in text_suffixes:
271
+ content = file_path.read_text(encoding="utf-8")
272
+ return {"path": path, "content": content}
273
+
274
+ # Raster images are inlined too: the UI fetches this endpoint as JSON and
275
+ # builds a `data:` URI, so return the bytes base64-encoded rather than as a
276
+ # binary FileResponse (which the JSON client cannot parse).
277
+ raster_suffixes = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
278
+ if file_path.suffix.lower() in raster_suffixes:
279
+ import base64
280
+
281
+ content = base64.b64encode(file_path.read_bytes()).decode("ascii")
282
+ return {"path": path, "content": content}
283
+
284
+ return FileResponse(file_path, filename=file_path.name)
285
+
286
+
287
+ @router.delete("/api/experiments/{experiment_name}", tags=["Benchmark"])
288
+ async def delete_experiment(experiment_name: str):
289
+ """Delete an experiment's results and clear the trace cache."""
290
+ mas_lab_root = MAS_LAB_ROOT
291
+ exp_dir = mas_lab_root / "labs" / experiment_name
292
+ if not exp_dir.exists() or not exp_dir.is_dir():
293
+ raise HTTPException(status_code=404, detail=f"Experiment '{experiment_name}' not found")
294
+
295
+ shutil.rmtree(exp_dir)
296
+
297
+ cache_dir = mas_lab_root / "data" / "trace-cache"
298
+ cache_cleared = False
299
+ if cache_dir.exists():
300
+ shutil.rmtree(cache_dir)
301
+ cache_cleared = True
302
+
303
+ return {
304
+ "deleted": experiment_name,
305
+ "experiment_path": str(exp_dir),
306
+ "trace_cache_cleared": cache_cleared,
307
+ }
@@ -0,0 +1,141 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Registry-driven picker lists for the UI (models, patterns, tools, …)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException, Query, Request
8
+ from mas.lab.controller.deps import store_from_request
9
+ from mas.lab.controller.manifest_layout import item_ref, layout_for
10
+ from mas.lab.controller.scope_catalog import get_scope_catalog
11
+
12
+ router = APIRouter()
13
+
14
+ _CHOICE_KINDS = (
15
+ "models",
16
+ "design-patterns",
17
+ "step-types",
18
+ "runners",
19
+ "tools",
20
+ "skills",
21
+ "overlays",
22
+ )
23
+
24
+ _CHOICE_KIND_TO_LAYOUT = {
25
+ "tools": "tool",
26
+ "skills": "skill",
27
+ "overlays": "overlay",
28
+ }
29
+
30
+
31
+ @router.get("/api/choices/{choice_kind}", tags=["Choices"])
32
+ async def get_choices(
33
+ choice_kind: str,
34
+ request: Request,
35
+ scope: str | None = Query(default=None),
36
+ ):
37
+ if choice_kind not in _CHOICE_KINDS:
38
+ raise HTTPException(status_code=404, detail=f"unknown choices {choice_kind!r}")
39
+ if choice_kind == "models":
40
+ return _models()
41
+ if choice_kind == "design-patterns":
42
+ return await _design_patterns(request)
43
+ if choice_kind == "step-types":
44
+ from mas.lab.controller.lab_registry import get_lab_registry
45
+
46
+ return get_lab_registry().pipeline_step_types()
47
+ if choice_kind == "runners":
48
+ from mas.lab.controller.api import ControllerAPI
49
+
50
+ return {"runners": ControllerAPI().list_runtime_runners()}
51
+ layout_kind = _CHOICE_KIND_TO_LAYOUT.get(choice_kind)
52
+ if layout_kind is not None:
53
+ return _scope_items(request, layout_kind, scope)
54
+ raise HTTPException(status_code=404, detail=f"unknown choices {choice_kind!r}")
55
+
56
+
57
+ def _models() -> dict:
58
+ from mas.lab.controller.lab_registry import get_lab_registry
59
+
60
+ reg = get_lab_registry()
61
+ default = reg.default_model()
62
+ aliases: list[dict] = []
63
+ seen: set[str] = set()
64
+ # Infra aliases from the active workspace plus the logical default.
65
+ try:
66
+ defaults = reg.agent_defaults()
67
+ model = (defaults.get("models") or [{}])
68
+ if isinstance(model, list):
69
+ for entry in model:
70
+ ident = str((entry or {}).get("model") or "")
71
+ if ident and ident not in seen:
72
+ aliases.append({"id": ident, "label": ident, "source": "defaults"})
73
+ seen.add(ident)
74
+ except Exception:
75
+ pass
76
+ if default and default not in seen:
77
+ aliases.insert(0, {"id": default, "label": default, "source": "default"})
78
+ seen.add(default)
79
+ # generic-model and other infra aliases if the runtime exposes them.
80
+ try:
81
+ from mas.runtime.registry import get_registry
82
+
83
+ for item in get_registry().list("llm") or []:
84
+ ident = str(item.get("urn") or item.get("name") or "")
85
+ if ident and ident not in seen:
86
+ aliases.append({"id": ident, "label": ident, "source": "infra"})
87
+ seen.add(ident)
88
+ except Exception:
89
+ pass
90
+ return {"models": aliases, "default": default}
91
+
92
+
93
+ async def _design_patterns(request: Request) -> dict:
94
+ from mas.lab.controller.routes.design_patterns import list_design_patterns
95
+
96
+ return await list_design_patterns()
97
+
98
+
99
+ def _scope_items(request: Request, kind: str, scope_id: str | None) -> dict:
100
+ store = store_from_request(request)
101
+ catalog = get_scope_catalog(store)
102
+ if scope_id:
103
+ try:
104
+ scopes = [catalog.get(scope_id)]
105
+ except KeyError as exc:
106
+ raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
107
+ else:
108
+ scopes = [s for s in catalog.all() if s.kind == "library"]
109
+ items: list[dict] = []
110
+ for scope in scopes:
111
+ try:
112
+ located = layout_for(kind).list_located(scope)
113
+ except KeyError:
114
+ continue
115
+ for item in located:
116
+ namespace = "global"
117
+ if kind == "overlay":
118
+ try:
119
+ from mas.runtime.spec.source import load_yaml_file
120
+
121
+ doc = load_yaml_file(item.manifest_path) or {}
122
+ namespace = str(
123
+ (doc.get("metadata") or {}).get("namespace")
124
+ or doc.get("x-namespace")
125
+ or "global"
126
+ )
127
+ except Exception:
128
+ namespace = "global"
129
+ items.append(
130
+ {
131
+ "id": item.item_id,
132
+ "name": item.name,
133
+ "title": item.title or item.name,
134
+ "summary": item.summary,
135
+ "ref": item_ref(scope, item.item_id),
136
+ "namespace": namespace,
137
+ "scope": scope.id,
138
+ }
139
+ )
140
+ key = {"tool": "tools", "skill": "skills", "overlay": "overlays"}[kind]
141
+ return {key: items}
@@ -0,0 +1,91 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Dataset CRUD endpoints."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+
9
+ from mas.lab.controller.deps import ensure_yaml_ext
10
+ from mas.lab.controller.models import SaveDatasetRequest
11
+ from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get("/api/libraries/{library_name}/datasets", tags=["Libraries"])
17
+ def list_datasets(library_name: str):
18
+ """List available dataset YAML files under datasets/."""
19
+ datasets = deps.get_manifest_store().list_datasets_meta(library_name)
20
+ return {"datasets": datasets}
21
+
22
+
23
+ @router.post("/api/libraries/{library_name}/datasets", tags=["Libraries"], status_code=201)
24
+ def create_dataset(library_name: str, req: SaveDatasetRequest):
25
+ """Create a new dataset file in the library's datasets/ directory."""
26
+ lib_dir = deps.get_library_path(library_name)
27
+ datasets_dir = lib_dir / "datasets"
28
+ datasets_dir.mkdir(parents=True, exist_ok=True)
29
+
30
+ filename = ensure_yaml_ext(req.name)
31
+ file_path = datasets_dir / filename
32
+
33
+ if file_path.exists():
34
+ raise HTTPException(status_code=409, detail=f"Dataset '{filename}' already exists")
35
+
36
+ file_path.write_text(req.content, encoding="utf-8")
37
+ deps.get_manifest_store().invalidate()
38
+ return {"name": filename}
39
+
40
+
41
+ @router.get("/api/libraries/{library_name}/datasets/{dataset_name:path}", tags=["Libraries"])
42
+ def get_dataset(library_name: str, dataset_name: str):
43
+ """Return the content of a specific dataset."""
44
+ lib_dir = deps.get_library_path(library_name)
45
+ file_path = lib_dir / "datasets" / dataset_name
46
+ if not file_path.exists() and not dataset_name.lower().endswith((".yaml", ".yml")):
47
+ file_path = lib_dir / "datasets" / f"{dataset_name}.yaml"
48
+
49
+ if not file_path.exists():
50
+ raise HTTPException(status_code=404, detail=f"Dataset '{dataset_name}' not found")
51
+
52
+ return {"name": file_path.name, "content": file_path.read_text(encoding="utf-8")}
53
+
54
+
55
+ @router.put("/api/libraries/{library_name}/datasets/{dataset_name:path}", tags=["Libraries"])
56
+ def update_dataset(library_name: str, dataset_name: str, req: SaveDatasetRequest):
57
+ """Update an existing dataset. Supports rename via req.name."""
58
+ lib_dir = deps.get_library_path(library_name)
59
+ datasets_dir = lib_dir / "datasets"
60
+
61
+ dataset_name = ensure_yaml_ext(dataset_name)
62
+ new_name = ensure_yaml_ext(req.name)
63
+
64
+ old_path = datasets_dir / dataset_name
65
+ if not old_path.exists():
66
+ raise HTTPException(status_code=404, detail=f"Dataset '{dataset_name}' not found")
67
+
68
+ new_path = datasets_dir / new_name
69
+ if new_name != dataset_name and new_path.exists():
70
+ raise HTTPException(status_code=409, detail=f"Dataset '{new_name}' already exists")
71
+
72
+ if old_path != new_path:
73
+ old_path.unlink()
74
+ new_path.write_text(req.content, encoding="utf-8")
75
+ deps.get_manifest_store().invalidate()
76
+ return {"name": new_name}
77
+
78
+
79
+ @router.delete("/api/libraries/{library_name}/datasets/{dataset_name:path}", tags=["Libraries"])
80
+ def delete_dataset(library_name: str, dataset_name: str):
81
+ """Delete a dataset file."""
82
+ lib_dir = deps.get_library_path(library_name)
83
+ dataset_name = ensure_yaml_ext(dataset_name)
84
+ file_path = lib_dir / "datasets" / dataset_name
85
+
86
+ if not file_path.exists():
87
+ raise HTTPException(status_code=404, detail=f"Dataset '{dataset_name}' not found")
88
+
89
+ file_path.unlink()
90
+ deps.get_manifest_store().invalidate()
91
+ return {"deleted": dataset_name}
@@ -0,0 +1,121 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Design-pattern catalog endpoint for the agent / overlay canvas builders."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ from fastapi import APIRouter
10
+ from mas.lab.controller.routes._api import deps
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+ # Config keys advertised to the UI. Sourced from the design-pattern-config
17
+ # fragment (``mas/v1#design-pattern-config``); the constant below is a fallback
18
+ # for deployments that don't ship the runtime schema docs.
19
+ _CONFIG_KEYS_FALLBACK = (
20
+ "max_steps",
21
+ "max_rounds",
22
+ "critique_turns",
23
+ "enable_peer_context",
24
+ "parallel",
25
+ )
26
+
27
+
28
+ def _config_keys() -> list[str]:
29
+ """Property names from the design-pattern-config fragment (best effort)."""
30
+ try:
31
+ from mas.lab.schemas.paths import runtime_schema_dir
32
+ from mas.runtime.spec.source import load_yaml_file
33
+
34
+ path = runtime_schema_dir() / "fragments" / "design-pattern-config.schema.yaml"
35
+ doc = load_yaml_file(path)
36
+ keys = list((doc.get("properties") or {}).keys())
37
+ if keys:
38
+ return keys
39
+ except Exception as exc: # pragma: no cover - schema is optional at runtime
40
+ logger.debug("design-pattern-config fragment unavailable: %s", exc)
41
+ return list(_CONFIG_KEYS_FALLBACK)
42
+
43
+
44
+ def _pattern_type(urn: str) -> str:
45
+ """Manifest name for ``spec.design_pattern.type`` — final URN segment.
46
+
47
+ e.g. ``mas.dp.deterministic_staged_debate`` -> ``deterministic_staged_debate``.
48
+ """
49
+ return urn.rsplit(".", 1)[-1] if "." in urn else urn
50
+
51
+
52
+ def _library_of(module: str) -> str:
53
+ """Grouping key derived from the plugin module (OSS vs experimental).
54
+
55
+ ``mas.library.standard.plugins...`` -> ``standard``
56
+ ``mas.library.lab.plugins...`` -> ``lab``
57
+ """
58
+ parts = module.split(".")
59
+ if len(parts) >= 3 and parts[0] == "mas" and parts[1] == "library":
60
+ return parts[2]
61
+ return "unknown"
62
+
63
+
64
+ def _label(type_name: str, description: str) -> str:
65
+ """Human label: registry description if present, else a titleized name."""
66
+ desc = (description or "").strip()
67
+ if desc:
68
+ return desc.splitlines()[0]
69
+ return type_name.replace("_", " ").title()
70
+
71
+
72
+ def _resolve_default(raw: str) -> str:
73
+ """Map the default binding (e.g. ``react@v1``) onto a pattern ``type``."""
74
+ if not raw:
75
+ return ""
76
+ try:
77
+ from mas.runtime.registry import get_registry
78
+
79
+ registry = get_registry()
80
+ urn = registry.urn_for(raw) or registry.urn_for(raw.split("@", 1)[0])
81
+ if urn:
82
+ return _pattern_type(urn)
83
+ except Exception as exc: # pragma: no cover - defensive
84
+ logger.debug("could not resolve default design pattern %r: %s", raw, exc)
85
+ return raw.split("@", 1)[0]
86
+
87
+
88
+ @router.get("/api/design-patterns", tags=["Registry"])
89
+ async def list_design_patterns():
90
+ """Design-pattern plugins for the agent / overlay canvas builders.
91
+
92
+ Reshapes ``LabRegistry.list("design_pattern")`` into a UI-friendly catalog:
93
+ each entry carries a manifest ``type``, a display ``label``, the source
94
+ ``library`` (so the UI can group OSS vs experimental patterns) and the
95
+ ``config_keys`` a pattern may accept.
96
+ """
97
+ from mas.lab.controller.lab_registry import get_lab_registry
98
+
99
+ ws = getattr(deps.get_manifest_store(), "_workspace", None)
100
+ reg = get_lab_registry(ws)
101
+
102
+ config_keys = _config_keys()
103
+ patterns = []
104
+ for entry in reg.list("design_pattern"):
105
+ urn = entry.get("urn", "")
106
+ type_name = _pattern_type(urn)
107
+ patterns.append(
108
+ {
109
+ "type": type_name,
110
+ "label": _label(type_name, entry.get("description", "")),
111
+ "description": (entry.get("description") or "").strip(),
112
+ "library": _library_of(entry.get("module", "")),
113
+ "config_keys": config_keys,
114
+ }
115
+ )
116
+
117
+ # Standard (OSS) patterns first, then everything else, alphabetically.
118
+ patterns.sort(key=lambda p: (p["library"] != "standard", p["type"]))
119
+
120
+ default_binding = (reg.agent_defaults().get("design_pattern") or {}).get("type", "")
121
+ return {"patterns": patterns, "default": _resolve_default(default_binding)}