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,214 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Library-scoped YAML manifest storage for the UI."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
|
|
11
|
+
from mas.lab.controller.lab_registry import LabRegistry, _library_description
|
|
12
|
+
from mas.lab.controller.lab_registry import get_lab_registry
|
|
13
|
+
from mas.ctl.overlay.canvas import overlay_is_layout_only
|
|
14
|
+
from mas.runtime.spec.source import load_yaml_file
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_SUBDIRS = {
|
|
19
|
+
"experiments": "experiments",
|
|
20
|
+
"pipelines": "pipelines",
|
|
21
|
+
"overlays": "overlays",
|
|
22
|
+
"datasets": "datasets",
|
|
23
|
+
"apps": "apps",
|
|
24
|
+
"scenarios": "scenarios",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ManifestStore:
|
|
29
|
+
"""Read/write YAML resources under library roots."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, workspace: Any = None, *, registry: LabRegistry | None = None) -> None:
|
|
32
|
+
"""Create a store, optionally injecting a specific ``registry``.
|
|
33
|
+
|
|
34
|
+
By default the store uses the process-wide :func:`get_lab_registry`
|
|
35
|
+
singleton. Pass ``registry`` (e.g. a ``LabRegistry`` built with
|
|
36
|
+
``MAS_LAB_REGISTRY_EAGER_REFRESH=0`` and manually populated
|
|
37
|
+
``_libraries``) to use an isolated registry instead — this is how
|
|
38
|
+
tests avoid both the global singleton and expensive discovery,
|
|
39
|
+
without bypassing ``__init__`` via ``__new__``.
|
|
40
|
+
"""
|
|
41
|
+
self._workspace = workspace
|
|
42
|
+
self._registry = registry if registry is not None else get_lab_registry(workspace)
|
|
43
|
+
self._libraries: Dict[str, Path] = {}
|
|
44
|
+
self._refreshed = False
|
|
45
|
+
|
|
46
|
+
def _ensure_ready(self) -> None:
|
|
47
|
+
# Many tests override _libraries directly; avoid expensive discovery if already injected.
|
|
48
|
+
if self._libraries:
|
|
49
|
+
return
|
|
50
|
+
if not self._refreshed:
|
|
51
|
+
self.refresh()
|
|
52
|
+
|
|
53
|
+
def refresh(self) -> None:
|
|
54
|
+
self._registry.refresh()
|
|
55
|
+
self._libraries = self._registry.library_paths()
|
|
56
|
+
self._refreshed = True
|
|
57
|
+
|
|
58
|
+
def invalidate(self) -> None:
|
|
59
|
+
"""Drop cached manifest-file listings (see LabRegistry.invalidate_manifest_cache)."""
|
|
60
|
+
self._registry.invalidate_manifest_cache()
|
|
61
|
+
|
|
62
|
+
def libraries(self) -> List[Dict[str, str]]:
|
|
63
|
+
self._ensure_ready()
|
|
64
|
+
items: List[Dict[str, str]] = []
|
|
65
|
+
for slug, path in sorted(self._libraries.items()):
|
|
66
|
+
items.append(
|
|
67
|
+
{
|
|
68
|
+
"dir": slug,
|
|
69
|
+
"name": slug.replace("-", " ").replace("_", " ").title(),
|
|
70
|
+
"description": _library_description(path),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return items
|
|
74
|
+
|
|
75
|
+
def library_root(self, library: str) -> Path:
|
|
76
|
+
self._ensure_ready()
|
|
77
|
+
root = self._libraries.get(library)
|
|
78
|
+
if root is not None:
|
|
79
|
+
return root
|
|
80
|
+
return self._registry.library_root(library)
|
|
81
|
+
|
|
82
|
+
def list_experiments(self, library: str) -> List[Dict[str, Any]]:
|
|
83
|
+
root = self.library_root(library)
|
|
84
|
+
return [
|
|
85
|
+
LabRegistry._parse_experiment_metadata(p, root)
|
|
86
|
+
for p in self._registry._iter_experiment_files(root)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def list_pipelines(self, library: str) -> List[Dict[str, Any]]:
|
|
90
|
+
root = self.library_root(library)
|
|
91
|
+
return [
|
|
92
|
+
LabRegistry._parse_pipeline_metadata(p, root)
|
|
93
|
+
for p in self._registry._iter_pipeline_files(root)
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def list_datasets_meta(self, library: str) -> List[Dict[str, str]]:
|
|
97
|
+
root = self.library_root(library)
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
"name": path.name,
|
|
101
|
+
"path": str(path.relative_to(root)),
|
|
102
|
+
"description": LabRegistry._dataset_description(path),
|
|
103
|
+
}
|
|
104
|
+
for path in self._registry._iter_dataset_files(root)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def list_overlays(self, library: str) -> List[Dict[str, Any]]:
|
|
108
|
+
root = self.library_root(library)
|
|
109
|
+
overlays: List[Dict[str, Any]] = []
|
|
110
|
+
for path in self._registry._iter_overlay_files(root):
|
|
111
|
+
rel = str(path.relative_to(root))
|
|
112
|
+
entry: Dict[str, Any] = {
|
|
113
|
+
"name": path.stem,
|
|
114
|
+
"filename": path.name,
|
|
115
|
+
"path": rel,
|
|
116
|
+
"ui_canvas": False,
|
|
117
|
+
}
|
|
118
|
+
try:
|
|
119
|
+
data = load_yaml_file(path)
|
|
120
|
+
meta = data.get("metadata", {})
|
|
121
|
+
entry["description"] = str(meta.get("description", "") or "")
|
|
122
|
+
entry["namespace"] = str((data.get("metadata") or {}).get("namespace", "global") or "global")
|
|
123
|
+
entry["ui_canvas"] = overlay_is_layout_only(data)
|
|
124
|
+
except Exception:
|
|
125
|
+
entry["description"] = ""
|
|
126
|
+
entry["namespace"] = "global"
|
|
127
|
+
overlays.append(entry)
|
|
128
|
+
return overlays
|
|
129
|
+
|
|
130
|
+
def collect_mas_resources(self, library: str) -> Dict[str, Dict[str, Any]]:
|
|
131
|
+
root = self.library_root(library)
|
|
132
|
+
return self._registry._collect_mas_resources(root)
|
|
133
|
+
|
|
134
|
+
def list_yaml_resources(self, library: str, resource: str) -> List[Dict[str, Any]]:
|
|
135
|
+
sub = _SUBDIRS.get(resource, resource)
|
|
136
|
+
root = self.library_root(library) / sub
|
|
137
|
+
if not root.exists():
|
|
138
|
+
return []
|
|
139
|
+
items: List[Dict[str, Any]] = []
|
|
140
|
+
for path in sorted(root.rglob("*.yaml")):
|
|
141
|
+
rel = path.relative_to(root)
|
|
142
|
+
name = path.stem if rel.parent == Path(".") else str(rel.with_suffix(""))
|
|
143
|
+
items.append({"name": name, "path": str(rel), "file": str(path)})
|
|
144
|
+
return items
|
|
145
|
+
|
|
146
|
+
def read_text(self, library: str, resource: str, name: str) -> str:
|
|
147
|
+
path = self._resolve_resource_path(library, resource, name)
|
|
148
|
+
return path.read_text(encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
def write_text(self, library: str, resource: str, name: str, content: str) -> Path:
|
|
151
|
+
path = self._resolve_resource_path(library, resource, name, create=True)
|
|
152
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
path.write_text(content.rstrip() + "\n", encoding="utf-8")
|
|
154
|
+
return path
|
|
155
|
+
|
|
156
|
+
def delete_resource(self, library: str, resource: str, name: str) -> None:
|
|
157
|
+
path = self._resolve_resource_path(library, resource, name)
|
|
158
|
+
if path.exists():
|
|
159
|
+
path.unlink()
|
|
160
|
+
|
|
161
|
+
def config_files(self, library: str) -> Dict[str, Dict[str, str]]:
|
|
162
|
+
"""Infra, flavours, and workspace YAML for the control panel."""
|
|
163
|
+
root = self.library_root(library)
|
|
164
|
+
flavours: Dict[str, str] = {}
|
|
165
|
+
infra: Dict[str, str] = {}
|
|
166
|
+
|
|
167
|
+
for path in sorted(p for p in root.rglob("flavours/*") if p.suffix in (".yaml", ".yml")):
|
|
168
|
+
if path.is_file():
|
|
169
|
+
flavours[str(path.relative_to(root))] = path.read_text(encoding="utf-8")
|
|
170
|
+
|
|
171
|
+
for path in sorted(p for p in root.rglob("infra/*") if p.suffix in (".yaml", ".yml")):
|
|
172
|
+
if path.is_file():
|
|
173
|
+
infra[str(path.relative_to(root))] = path.read_text(encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
ws_root = self._registry._workspace_root()
|
|
176
|
+
if ws_root is not None:
|
|
177
|
+
infra.update(LabRegistry.discover_workspace_infra(ws_root))
|
|
178
|
+
infra.update(LabRegistry.discover_bundled_infra())
|
|
179
|
+
|
|
180
|
+
workspace: Dict[str, str] = {}
|
|
181
|
+
if ws_root is not None:
|
|
182
|
+
ws_file = ws_root / WORKSPACE_CONFIG_FILENAME
|
|
183
|
+
if ws_file.is_file():
|
|
184
|
+
workspace[WORKSPACE_CONFIG_FILENAME] = ws_file.read_text(encoding="utf-8")
|
|
185
|
+
if not workspace:
|
|
186
|
+
local_ws = root / WORKSPACE_CONFIG_FILENAME
|
|
187
|
+
if local_ws.is_file():
|
|
188
|
+
workspace[WORKSPACE_CONFIG_FILENAME] = local_ws.read_text(encoding="utf-8")
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"infra": infra,
|
|
192
|
+
"flavours": flavours,
|
|
193
|
+
"workspace": workspace,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _resolve_resource_path(
|
|
197
|
+
self,
|
|
198
|
+
library: str,
|
|
199
|
+
resource: str,
|
|
200
|
+
name: str,
|
|
201
|
+
*,
|
|
202
|
+
create: bool = False,
|
|
203
|
+
) -> Path:
|
|
204
|
+
sub = _SUBDIRS.get(resource, resource)
|
|
205
|
+
root = self.library_root(library) / sub
|
|
206
|
+
candidate = (root / name).with_suffix(".yaml")
|
|
207
|
+
if candidate.exists() or create:
|
|
208
|
+
return candidate
|
|
209
|
+
for path in root.rglob("*.yaml"):
|
|
210
|
+
if path.stem == name or str(path.relative_to(root).with_suffix("")) == name:
|
|
211
|
+
return path
|
|
212
|
+
if create:
|
|
213
|
+
return candidate
|
|
214
|
+
raise FileNotFoundError(f"{resource}/{name} not found in library {library!r}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""In-process manifest validation for controller HTTP routes (no CLI subprocess)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from mas.ctl.validate.schemas import declared_kind, schema_path_for_kind
|
|
13
|
+
from mas.ctl.validate.validator import validate_data
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_manifest_yaml_content(
|
|
17
|
+
manifest_yaml: str,
|
|
18
|
+
*,
|
|
19
|
+
base_dir: Path,
|
|
20
|
+
kind: str | None = None,
|
|
21
|
+
resolve_refs: bool = True,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Validate inline YAML; return legacy CLI-shaped dict for API compatibility."""
|
|
24
|
+
try:
|
|
25
|
+
data = yaml.safe_load(manifest_yaml)
|
|
26
|
+
except yaml.YAMLError as exc:
|
|
27
|
+
return {
|
|
28
|
+
"exit_code": 1,
|
|
29
|
+
"stdout": "",
|
|
30
|
+
"stderr": f"YAML parse error: {exc}",
|
|
31
|
+
"command": "validate_data",
|
|
32
|
+
"valid": False,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
return {
|
|
37
|
+
"exit_code": 1,
|
|
38
|
+
"stdout": "",
|
|
39
|
+
"stderr": "manifest must be a YAML mapping",
|
|
40
|
+
"command": "validate_data",
|
|
41
|
+
"valid": False,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
resolved_kind = kind or declared_kind(data)
|
|
45
|
+
if resolved_kind is None or schema_path_for_kind(resolved_kind) is None:
|
|
46
|
+
return {
|
|
47
|
+
"exit_code": 0,
|
|
48
|
+
"stdout": "",
|
|
49
|
+
"stderr": "",
|
|
50
|
+
"command": "validate_data",
|
|
51
|
+
"valid": True,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
result = validate_data(
|
|
55
|
+
data,
|
|
56
|
+
source="<inline>",
|
|
57
|
+
kind=resolved_kind,
|
|
58
|
+
base_dir=base_dir,
|
|
59
|
+
resolve_refs=resolve_refs,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
errors = [i for i in result.issues if i.level == "error"]
|
|
63
|
+
warnings = [i for i in result.issues if i.level == "warning"]
|
|
64
|
+
lines = [f"[{i.level}] {i.message}" for i in errors + warnings]
|
|
65
|
+
stderr = "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"exit_code": 0 if result.ok else 1,
|
|
69
|
+
"stdout": "",
|
|
70
|
+
"stderr": stderr,
|
|
71
|
+
"command": "validate_data",
|
|
72
|
+
"valid": result.ok,
|
|
73
|
+
"issues": [{"level": i.level, "message": i.message, "path": i.path} for i in result.issues],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_overlay_yaml_content(content: str, *, base_dir: Path) -> list[str] | None:
|
|
78
|
+
"""Validate overlay YAML; return error strings or None when valid."""
|
|
79
|
+
outcome = validate_manifest_yaml_content(
|
|
80
|
+
content,
|
|
81
|
+
base_dir=base_dir,
|
|
82
|
+
kind="overlay",
|
|
83
|
+
resolve_refs=False,
|
|
84
|
+
)
|
|
85
|
+
if outcome.get("valid"):
|
|
86
|
+
return None
|
|
87
|
+
stderr = str(outcome.get("stderr") or "").strip()
|
|
88
|
+
if stderr:
|
|
89
|
+
return [line.strip() for line in stderr.splitlines() if line.strip()]
|
|
90
|
+
return ["Validation failed"]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Pydantic request/response models for the FastAPI controller API."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import AliasChoices, BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.constants import MAX_TIMEOUT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidateRequest(BaseModel):
|
|
15
|
+
manifest_yaml: str = Field(
|
|
16
|
+
...,
|
|
17
|
+
description="Full YAML manifest content to validate (agent or MAS)",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SaveOverlayRequest(BaseModel):
|
|
22
|
+
name: str = Field(..., description="Overlay name (used as filename, without .yaml)")
|
|
23
|
+
content: str = Field(..., description="Full overlay YAML content")
|
|
24
|
+
run_validation: bool = Field(default=True, description="Validate against schema before saving")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunAgentRequest(BaseModel):
|
|
28
|
+
manifest_yaml: str = Field(..., description="Full agent YAML manifest content to run")
|
|
29
|
+
query: str = Field(..., description="The question to send to the agent")
|
|
30
|
+
flavour: Optional[str] = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Flavour name (e.g., 'local', 'local-benchmark')",
|
|
33
|
+
)
|
|
34
|
+
session_id: Optional[str] = Field(
|
|
35
|
+
default=None,
|
|
36
|
+
description="Stable session id for multi-turn chat memory",
|
|
37
|
+
)
|
|
38
|
+
verbose: bool = Field(
|
|
39
|
+
default=False,
|
|
40
|
+
description="Deprecated: chat runs in-process without CLI stdout",
|
|
41
|
+
)
|
|
42
|
+
no_cache: bool = Field(default=True, description="Clear web search cache before running")
|
|
43
|
+
timeout: int = Field(default=60, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MultiTurnRequest(BaseModel):
|
|
47
|
+
manifest_yaml: str = Field(..., description="Full agent YAML manifest content to run")
|
|
48
|
+
queries: list[str] = Field(
|
|
49
|
+
...,
|
|
50
|
+
min_length=1,
|
|
51
|
+
description="Ordered list of queries for a multi-turn conversation",
|
|
52
|
+
)
|
|
53
|
+
overlays: list[str] = Field(
|
|
54
|
+
default_factory=list,
|
|
55
|
+
description="Overlay filenames to apply (relative to library dir)",
|
|
56
|
+
)
|
|
57
|
+
flavour: Optional[str] = Field(default=None, description="Flavour name")
|
|
58
|
+
verbose: bool = Field(default=True, description="Enable verbose output")
|
|
59
|
+
timeout: int = Field(default=90, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunMASRequest(BaseModel):
|
|
63
|
+
manifest_yaml: str = Field(
|
|
64
|
+
default="",
|
|
65
|
+
description="Full MAS YAML. Ignored when mas_path is set.",
|
|
66
|
+
)
|
|
67
|
+
mas_path: Optional[str] = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="MAS file relative to the library root (preferred: keeps agent refs valid).",
|
|
70
|
+
)
|
|
71
|
+
query: str = Field(..., description="The question to send to the MAS")
|
|
72
|
+
overlays: list[str] = Field(
|
|
73
|
+
default_factory=list,
|
|
74
|
+
description="Overlay names or paths relative to the library dir",
|
|
75
|
+
)
|
|
76
|
+
infra_refs: list[str] = Field(
|
|
77
|
+
default_factory=list,
|
|
78
|
+
description="If set, only these infra files are passed to mas-ctl (no directory glob).",
|
|
79
|
+
)
|
|
80
|
+
pre: list[dict] = Field(
|
|
81
|
+
default_factory=list,
|
|
82
|
+
description="Inline pipeline-style steps run in the library cwd before mas-ctl (type: shell).",
|
|
83
|
+
)
|
|
84
|
+
demo_ui_ingest_url: Optional[str] = Field(
|
|
85
|
+
default=None,
|
|
86
|
+
description="When set, mas-ctl POSTs live native records to this demo-ui ingest URL.",
|
|
87
|
+
)
|
|
88
|
+
demo_ui_ref: Optional[dict] = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
description="Data-source ref stamped onto live ingest records.",
|
|
91
|
+
)
|
|
92
|
+
flavour: Optional[str] = Field(default=None, description="Flavour name")
|
|
93
|
+
verbose: bool = Field(default=True, description="Enable verbose output")
|
|
94
|
+
timeout: int = Field(default=90, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SaveDatasetRequest(BaseModel):
|
|
98
|
+
name: str = Field(..., description="Dataset filename (e.g. my-dataset.yaml)")
|
|
99
|
+
content: str = Field(..., description="Full dataset YAML content")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ExperimentRunRequest(BaseModel):
|
|
103
|
+
experiment_yaml: str = Field(
|
|
104
|
+
...,
|
|
105
|
+
description="Experiment filename or path relative to the library root",
|
|
106
|
+
)
|
|
107
|
+
query: str = Field(
|
|
108
|
+
default="",
|
|
109
|
+
description="Optional prompt override shown in the demo-ui selector",
|
|
110
|
+
)
|
|
111
|
+
infra_refs: list[str] = Field(
|
|
112
|
+
default_factory=list,
|
|
113
|
+
description="Extra infra files relative to the library root (LLM cache, model pin)",
|
|
114
|
+
)
|
|
115
|
+
pre: list[dict] = Field(
|
|
116
|
+
default_factory=list,
|
|
117
|
+
description="Inline pipeline-style steps run in the library cwd before the worker starts",
|
|
118
|
+
)
|
|
119
|
+
demo_ui_ingest_url: Optional[str] = Field(
|
|
120
|
+
default=None,
|
|
121
|
+
description="When set, the in-process worker POSTs live native records here",
|
|
122
|
+
)
|
|
123
|
+
demo_ui_ref: Optional[dict] = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
description="Data-source ref stamped onto live ingest records",
|
|
126
|
+
)
|
|
127
|
+
force: bool = Field(default=True, description="Force a new run even if a previous exists")
|
|
128
|
+
max_runs: Optional[int] = Field(default=1, description="Cap n_runs for a live experiment")
|
|
129
|
+
timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BenchmarkRunRequest(BaseModel):
|
|
133
|
+
experiment_yaml: str = Field(
|
|
134
|
+
...,
|
|
135
|
+
description="Full experiment YAML content, or a filename relative to the library dir",
|
|
136
|
+
)
|
|
137
|
+
progress: bool = Field(default=True, description="Show progress bar")
|
|
138
|
+
max_runs: Optional[int] = Field(
|
|
139
|
+
default=None,
|
|
140
|
+
validation_alias=AliasChoices("max_runs", "n_runs"),
|
|
141
|
+
description="Override n_runs from config",
|
|
142
|
+
)
|
|
143
|
+
force: bool = Field(default=True, description="Force a new run even if a previous exists")
|
|
144
|
+
flavour: Optional[str] = Field(default=None, description="Flavour name")
|
|
145
|
+
timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class BenchmarkAnalyzeRequest(BaseModel):
|
|
149
|
+
benchmark_id: str = Field(..., description="Benchmark run id (short or full)")
|
|
150
|
+
experiment_yaml: Optional[str] = Field(
|
|
151
|
+
default=None,
|
|
152
|
+
description="Optional experiment YAML path when auto-detect fails",
|
|
153
|
+
)
|
|
154
|
+
timeout: int = Field(default=120, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class BenchmarkExportRequest(BaseModel):
|
|
158
|
+
benchmark_id: str = Field(..., description="Benchmark run id (short, full, last, or latest)")
|
|
159
|
+
output: Optional[str] = Field(default=None, description="Output .tar.gz path (default: cwd)")
|
|
160
|
+
include_trace_cache: bool = Field(default=True, description="Pack trace-cache entries")
|
|
161
|
+
dry_run: bool = Field(default=False, description="List archive contents without writing")
|
|
162
|
+
timeout: int = Field(default=300, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class BenchmarkImportRequest(BaseModel):
|
|
166
|
+
tarball: str = Field(..., description="Path to .tar.gz produced by benchmark export")
|
|
167
|
+
output_dir: Optional[str] = Field(default=None, description="Restore benchmark output here")
|
|
168
|
+
trace_cache_dir: Optional[str] = Field(default=None, description="Restore trace-cache entries here")
|
|
169
|
+
dry_run: bool = Field(default=False, description="Inspect archive without writing")
|
|
170
|
+
timeout: int = Field(default=300, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class PipelineRunRequest(BaseModel):
|
|
174
|
+
pipeline_yaml: str = Field(
|
|
175
|
+
default="pipelines/full-pipeline-topology-comparison.yaml",
|
|
176
|
+
description="Pipeline YAML path (relative to library dir)",
|
|
177
|
+
)
|
|
178
|
+
only: list[str] = Field(
|
|
179
|
+
default_factory=list,
|
|
180
|
+
description="Run only these steps (e.g., ['extract', 'evaluate'])",
|
|
181
|
+
)
|
|
182
|
+
timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class SavePipelineRequest(BaseModel):
|
|
186
|
+
name: str = Field(..., description="Pipeline name (used as filename, without .yaml)")
|
|
187
|
+
content: str = Field(..., description="Full pipeline YAML content")
|
|
188
|
+
run_validation: bool = Field(default=True, description="Validate against schema before saving")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SaveExperimentRequest(BaseModel):
|
|
192
|
+
name: str = Field(..., description="Experiment name (used as filename, without .yaml)")
|
|
193
|
+
content: str = Field(..., description="Full experiment YAML content")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class EvalOutputRequest(BaseModel):
|
|
197
|
+
events_file: str = Field(
|
|
198
|
+
...,
|
|
199
|
+
description="Path to events.jsonl (relative to library dir or absolute)",
|
|
200
|
+
)
|
|
201
|
+
metrics: list[str] = Field(
|
|
202
|
+
default=["answer_relevancy"],
|
|
203
|
+
description="Metric names to compute",
|
|
204
|
+
)
|
|
205
|
+
model: str = Field(default="azure/gpt-4o", description="LLM judge model")
|
|
206
|
+
timeout: int = Field(default=90, ge=1, le=MAX_TIMEOUT, description="Timeout in seconds")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class SaveMASResourceRequest(BaseModel):
|
|
210
|
+
mas_name: str = Field(..., description="MAS name (becomes the app folder name under apps/)")
|
|
211
|
+
mas_yaml: str = Field(..., description="MAS manifest YAML content")
|
|
212
|
+
agents: dict[str, str] = Field(
|
|
213
|
+
default_factory=dict,
|
|
214
|
+
description="Map of agent name → agent YAML content (stored in apps/<mas_name>/agents/)",
|
|
215
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Name and path guards for every controller write."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mas.lab.controller.kinds import NAME_PATTERN, VERSION_PATTERN
|
|
11
|
+
|
|
12
|
+
_NAME_RE = re.compile(NAME_PATTERN)
|
|
13
|
+
_VERSION_RE = re.compile(VERSION_PATTERN)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsafeNameError(ValueError):
|
|
17
|
+
"""A user-supplied name, version, or item id failed the allow-list."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_name(name: str) -> str:
|
|
21
|
+
text = str(name or "").strip()
|
|
22
|
+
if not text or not _NAME_RE.fullmatch(text):
|
|
23
|
+
raise UnsafeNameError(f"invalid name {name!r}")
|
|
24
|
+
if text.startswith("."):
|
|
25
|
+
raise UnsafeNameError(f"hidden name {name!r} is not allowed")
|
|
26
|
+
if ".." in text or "/" in text or "\\" in text:
|
|
27
|
+
raise UnsafeNameError(f"path-like name {name!r} is not allowed")
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_version(version: str | None) -> str | None:
|
|
32
|
+
if version is None or str(version).strip() == "":
|
|
33
|
+
return None
|
|
34
|
+
text = str(version).strip()
|
|
35
|
+
if not _VERSION_RE.fullmatch(text):
|
|
36
|
+
raise UnsafeNameError(f"invalid version {version!r}")
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_item_id(item_id: str) -> tuple[str, str | None]:
|
|
41
|
+
"""Split ``name`` or ``name@version``. Reject path-like ids."""
|
|
42
|
+
text = str(item_id or "").strip()
|
|
43
|
+
if not text or text.startswith(".") or ".." in text or "/" in text or "\\" in text:
|
|
44
|
+
raise UnsafeNameError(f"invalid item id {item_id!r}")
|
|
45
|
+
if "@" not in text:
|
|
46
|
+
return validate_name(text), None
|
|
47
|
+
name, _, version = text.rpartition("@")
|
|
48
|
+
return validate_name(name), validate_version(version)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_inside(root: Path, target: Path) -> Path:
|
|
52
|
+
"""Resolve *target* and require it to stay under *root* (no symlink escape)."""
|
|
53
|
+
root_resolved = root.resolve()
|
|
54
|
+
target_resolved = target.resolve()
|
|
55
|
+
try:
|
|
56
|
+
target_resolved.relative_to(root_resolved)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise UnsafeNameError("path is outside the scope root") from exc
|
|
59
|
+
return target_resolved
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_hidden_under(root: Path, target: Path) -> bool:
|
|
63
|
+
"""True when any relative part is a dot-directory (staging leftovers, ``.git``)."""
|
|
64
|
+
try:
|
|
65
|
+
rel = target.resolve().relative_to(root.resolve())
|
|
66
|
+
except ValueError:
|
|
67
|
+
return True
|
|
68
|
+
return any(part.startswith(".") for part in rel.parts)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Pipeline YAML validation helpers."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_pipeline_yaml(content: str) -> dict:
|
|
9
|
+
"""Validate pipeline YAML content against the JSON schema and check DAG integrity.
|
|
10
|
+
|
|
11
|
+
Returns {"valid": True} or {"valid": False, "errors": [...]}.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
import jsonschema
|
|
16
|
+
import yaml as _yaml
|
|
17
|
+
|
|
18
|
+
errors: list[str] = []
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
doc = _yaml.safe_load(content)
|
|
22
|
+
except _yaml.YAMLError as exc:
|
|
23
|
+
return {"valid": False, "errors": [f"YAML parse error: {exc}"]}
|
|
24
|
+
|
|
25
|
+
if not isinstance(doc, dict):
|
|
26
|
+
return {"valid": False, "errors": ["Document must be a YAML mapping"]}
|
|
27
|
+
|
|
28
|
+
# JSON Schema validation (bench package: pipeline-manifest.schema.json)
|
|
29
|
+
try:
|
|
30
|
+
from mas.lab.controller.schema_registry import read_schema_text
|
|
31
|
+
|
|
32
|
+
_, schema_text = read_schema_text("pipeline")
|
|
33
|
+
schema = json.loads(schema_text)
|
|
34
|
+
jsonschema.validate(doc, schema)
|
|
35
|
+
except jsonschema.ValidationError as exc:
|
|
36
|
+
errors.append(f"Schema: {exc.message} (at {'/'.join(str(p) for p in exc.absolute_path)})")
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
errors.append(f"Schema validation error: {exc}")
|
|
39
|
+
|
|
40
|
+
# DAG integrity checks
|
|
41
|
+
steps = (doc.get("spec") or {}).get("steps") or []
|
|
42
|
+
step_names = {s["name"] for s in steps if "name" in s}
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
for s in steps:
|
|
45
|
+
name = s.get("name", "<unnamed>")
|
|
46
|
+
if name in seen:
|
|
47
|
+
errors.append(f"Duplicate step name: '{name}'")
|
|
48
|
+
seen.add(name)
|
|
49
|
+
for dep in s.get("depends_on", []):
|
|
50
|
+
if dep not in step_names:
|
|
51
|
+
errors.append(f"Step '{name}' depends on unknown step '{dep}'")
|
|
52
|
+
if dep == name:
|
|
53
|
+
errors.append(f"Step '{name}' depends on itself")
|
|
54
|
+
|
|
55
|
+
# Cycle detection (topological sort)
|
|
56
|
+
if not errors:
|
|
57
|
+
adj: dict[str, list[str]] = {s["name"]: s.get("depends_on", []) for s in steps}
|
|
58
|
+
visited: set[str] = set()
|
|
59
|
+
in_stack: set[str] = set()
|
|
60
|
+
|
|
61
|
+
def _has_cycle(node: str) -> bool:
|
|
62
|
+
if node in in_stack:
|
|
63
|
+
return True
|
|
64
|
+
if node in visited:
|
|
65
|
+
return False
|
|
66
|
+
visited.add(node)
|
|
67
|
+
in_stack.add(node)
|
|
68
|
+
for dep in adj.get(node, []):
|
|
69
|
+
if _has_cycle(dep):
|
|
70
|
+
return True
|
|
71
|
+
in_stack.discard(node)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
for step_name in adj:
|
|
75
|
+
if _has_cycle(step_name):
|
|
76
|
+
errors.append("Dependency cycle detected in pipeline steps")
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
return {"valid": len(errors) == 0, "errors": errors}
|