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