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,832 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Locate and plan writes for each catalog kind. Edits always write where they were read."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from mas.lab.controller.path_safety import (
|
|
19
|
+
UnsafeNameError,
|
|
20
|
+
ensure_inside,
|
|
21
|
+
is_hidden_under,
|
|
22
|
+
validate_name,
|
|
23
|
+
)
|
|
24
|
+
from mas.lab.controller.scope_catalog import Scope
|
|
25
|
+
from mas.ctl.overlay.canvas import overlay_is_layout_only
|
|
26
|
+
from mas.runtime.spec.source import load_yaml_file
|
|
27
|
+
|
|
28
|
+
_VERSION_DIR = re.compile(r"^v\d+")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FileWrite:
|
|
33
|
+
path: Path
|
|
34
|
+
content: str = ""
|
|
35
|
+
delete: bool = False
|
|
36
|
+
source: Path | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ItemBundle:
|
|
41
|
+
item_id: str
|
|
42
|
+
kind: str
|
|
43
|
+
name: str
|
|
44
|
+
version: str | None
|
|
45
|
+
ref: str
|
|
46
|
+
revision: str
|
|
47
|
+
writable: bool
|
|
48
|
+
manifest: str
|
|
49
|
+
children: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
title: str = ""
|
|
51
|
+
summary: str = ""
|
|
52
|
+
versions: list[str] = field(default_factory=list)
|
|
53
|
+
latest: str | None = None
|
|
54
|
+
resolved_id: str = ""
|
|
55
|
+
# Internal only — never serialised to the API.
|
|
56
|
+
root: Path | None = None
|
|
57
|
+
manifest_path: Path | None = None
|
|
58
|
+
child_paths: dict[str, Path] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def public_dict(self) -> dict[str, Any]:
|
|
61
|
+
payload: dict[str, Any] = {
|
|
62
|
+
"id": self.item_id,
|
|
63
|
+
"kind": self.kind,
|
|
64
|
+
"name": self.name,
|
|
65
|
+
"version": self.version,
|
|
66
|
+
"ref": self.ref,
|
|
67
|
+
"revision": self.revision,
|
|
68
|
+
"writable": self.writable,
|
|
69
|
+
"manifest": self.manifest,
|
|
70
|
+
"children": dict(self.children),
|
|
71
|
+
"title": self.title,
|
|
72
|
+
"summary": self.summary,
|
|
73
|
+
"versions": list(self.versions),
|
|
74
|
+
"latest": self.latest,
|
|
75
|
+
"resolved_id": self.resolved_id or self.item_id,
|
|
76
|
+
}
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Located:
|
|
82
|
+
item_id: str
|
|
83
|
+
name: str
|
|
84
|
+
version: str | None
|
|
85
|
+
root: Path
|
|
86
|
+
manifest_path: Path
|
|
87
|
+
child_paths: dict[str, Path] = field(default_factory=dict)
|
|
88
|
+
title: str = ""
|
|
89
|
+
summary: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def item_ref(scope: Scope, item_id: str) -> str:
|
|
93
|
+
return f"{scope.name}:{item_id}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def revision_of(files: dict[Path, str]) -> str:
|
|
97
|
+
digest = hashlib.sha256()
|
|
98
|
+
for path in sorted(files, key=lambda p: str(p)):
|
|
99
|
+
digest.update(str(path.name).encode("utf-8"))
|
|
100
|
+
digest.update(b"\0")
|
|
101
|
+
digest.update(files[path].encode("utf-8"))
|
|
102
|
+
digest.update(b"\0")
|
|
103
|
+
return digest.hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_text(path: Path) -> str:
|
|
107
|
+
return path.read_text(encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _title_summary(doc: dict[str, Any], fallback: str) -> tuple[str, str]:
|
|
111
|
+
meta = doc.get("metadata") or {}
|
|
112
|
+
block = doc.get("experiment") if isinstance(doc.get("experiment"), dict) else None
|
|
113
|
+
name = (
|
|
114
|
+
meta.get("name")
|
|
115
|
+
or (block or {}).get("name")
|
|
116
|
+
or doc.get("name")
|
|
117
|
+
or fallback
|
|
118
|
+
)
|
|
119
|
+
summary = str(
|
|
120
|
+
meta.get("description")
|
|
121
|
+
or (block or {}).get("description")
|
|
122
|
+
or doc.get("description")
|
|
123
|
+
or ""
|
|
124
|
+
).strip()
|
|
125
|
+
return str(name), summary.splitlines()[0][:200] if summary else ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class KindLayout:
|
|
129
|
+
kind: str = ""
|
|
130
|
+
child_subdir = ""
|
|
131
|
+
|
|
132
|
+
def locate(self, scope: Scope, item_id: str) -> Located | None:
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
136
|
+
raise NotImplementedError
|
|
137
|
+
|
|
138
|
+
def read(self, scope: Scope, item_id: str) -> ItemBundle | None:
|
|
139
|
+
located = self.locate(scope, item_id)
|
|
140
|
+
if located is None:
|
|
141
|
+
return None
|
|
142
|
+
return self._bundle_from_located(scope, located)
|
|
143
|
+
|
|
144
|
+
def _bundle_from_located(self, scope: Scope, located: Located) -> ItemBundle:
|
|
145
|
+
files: dict[Path, str] = {}
|
|
146
|
+
manifest = _read_text(located.manifest_path) if located.manifest_path.is_file() else ""
|
|
147
|
+
files[located.manifest_path] = manifest
|
|
148
|
+
children: dict[str, str] = {}
|
|
149
|
+
for name, path in located.child_paths.items():
|
|
150
|
+
if path.is_file():
|
|
151
|
+
text = _read_text(path)
|
|
152
|
+
children[name] = text
|
|
153
|
+
files[path] = text
|
|
154
|
+
versions, latest = self.versions_for(scope, located.name)
|
|
155
|
+
resolved = located.item_id
|
|
156
|
+
if located.version is None and latest:
|
|
157
|
+
resolved = f"{located.name}@{latest}"
|
|
158
|
+
return ItemBundle(
|
|
159
|
+
item_id=located.item_id,
|
|
160
|
+
kind=self.kind,
|
|
161
|
+
name=located.name,
|
|
162
|
+
version=located.version,
|
|
163
|
+
ref=item_ref(scope, located.item_id),
|
|
164
|
+
revision=revision_of(files),
|
|
165
|
+
writable=scope.writable,
|
|
166
|
+
manifest=manifest,
|
|
167
|
+
children=children,
|
|
168
|
+
title=located.title or located.name,
|
|
169
|
+
summary=located.summary,
|
|
170
|
+
versions=versions,
|
|
171
|
+
latest=latest,
|
|
172
|
+
resolved_id=resolved,
|
|
173
|
+
root=located.root,
|
|
174
|
+
manifest_path=located.manifest_path,
|
|
175
|
+
child_paths=located.child_paths,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def versions_for(self, scope: Scope, name: str) -> tuple[list[str], str | None]:
|
|
179
|
+
return versions_of(item for item in self.list_located(scope) if item.name == name)
|
|
180
|
+
|
|
181
|
+
def child_path(self, located: Located, child_name: str) -> Path:
|
|
182
|
+
existing = located.child_paths.get(child_name)
|
|
183
|
+
if existing is not None:
|
|
184
|
+
return existing
|
|
185
|
+
if self.child_subdir:
|
|
186
|
+
return located.root / self.child_subdir / f"{child_name}.yaml"
|
|
187
|
+
return located.root / f"{child_name}.yaml"
|
|
188
|
+
|
|
189
|
+
def plan_create(
|
|
190
|
+
self,
|
|
191
|
+
scope: Scope,
|
|
192
|
+
*,
|
|
193
|
+
name: str,
|
|
194
|
+
version: str | None,
|
|
195
|
+
template: str | None,
|
|
196
|
+
parent: str | None,
|
|
197
|
+
default_model: str | None,
|
|
198
|
+
copy_from: ItemBundle | None = None,
|
|
199
|
+
) -> list[FileWrite]:
|
|
200
|
+
raise NotImplementedError
|
|
201
|
+
|
|
202
|
+
def plan_update(self, scope: Scope, bundle: ItemBundle, located: Located) -> list[FileWrite]:
|
|
203
|
+
writes: list[FileWrite] = []
|
|
204
|
+
if located.manifest_path is not None:
|
|
205
|
+
writes.append(FileWrite(located.manifest_path, bundle.manifest))
|
|
206
|
+
existing = dict(located.child_paths)
|
|
207
|
+
for child_name, content in bundle.children.items():
|
|
208
|
+
safe = validate_name(child_name)
|
|
209
|
+
path = existing.get(safe) or self.child_path(located, safe)
|
|
210
|
+
path = ensure_inside(scope.root, path)
|
|
211
|
+
writes.append(FileWrite(path, content))
|
|
212
|
+
for old_name, old_path in existing.items():
|
|
213
|
+
if old_name not in bundle.children:
|
|
214
|
+
writes.append(FileWrite(old_path, "", delete=True))
|
|
215
|
+
return writes
|
|
216
|
+
|
|
217
|
+
def plan_delete(self, scope: Scope, located: Located) -> list[FileWrite]:
|
|
218
|
+
writes = [FileWrite(located.manifest_path, "", delete=True)]
|
|
219
|
+
for path in located.child_paths.values():
|
|
220
|
+
writes.append(FileWrite(path, "", delete=True))
|
|
221
|
+
return writes
|
|
222
|
+
|
|
223
|
+
def default_create_root(self, scope: Scope, name: str, version: str | None) -> Path:
|
|
224
|
+
raise NotImplementedError
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _version_key(version: str) -> tuple:
|
|
228
|
+
from mas.version_spec import version_sort_key
|
|
229
|
+
|
|
230
|
+
return version_sort_key(version)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def versions_of(items: Iterable[Located]) -> tuple[list[str], str | None]:
|
|
234
|
+
"""Sorted unique versions from already-located items (no extra directory walk)."""
|
|
235
|
+
versions = sorted({item.version for item in items if item.version}, key=_version_key)
|
|
236
|
+
return versions, versions[-1] if versions else None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def apply_writes(scope: Scope, writes: list[FileWrite]) -> None:
|
|
240
|
+
"""Validate targets, write to a staging tree, then rename into place.
|
|
241
|
+
|
|
242
|
+
On failure nothing from this batch remains. Every path is computed by a
|
|
243
|
+
layout and checked against the scope root.
|
|
244
|
+
"""
|
|
245
|
+
if not writes:
|
|
246
|
+
return
|
|
247
|
+
checked: list[FileWrite] = []
|
|
248
|
+
for write in writes:
|
|
249
|
+
target = ensure_inside(scope.root, write.path)
|
|
250
|
+
checked.append(
|
|
251
|
+
FileWrite(target, write.content, delete=write.delete, source=write.source)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
created: list[Path] = []
|
|
255
|
+
replaced: list[tuple[Path, Path | None]] = []
|
|
256
|
+
staging: Path | None = None
|
|
257
|
+
try:
|
|
258
|
+
staging = Path(tempfile.mkdtemp(prefix=".mas-write-", dir=str(scope.root)))
|
|
259
|
+
ensure_inside(scope.root, staging)
|
|
260
|
+
staged: list[tuple[Path, Path]] = []
|
|
261
|
+
for write in checked:
|
|
262
|
+
if write.delete:
|
|
263
|
+
continue
|
|
264
|
+
rel = write.path.relative_to(scope.root.resolve())
|
|
265
|
+
staged_path = staging / rel
|
|
266
|
+
staged_path.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
if write.source is not None:
|
|
268
|
+
shutil.copyfile(write.source, staged_path)
|
|
269
|
+
else:
|
|
270
|
+
staged_path.write_text(write.content.rstrip() + "\n", encoding="utf-8")
|
|
271
|
+
staged.append((staged_path, write.path))
|
|
272
|
+
for staged_path, final_path in staged:
|
|
273
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
backup = None
|
|
275
|
+
if final_path.exists():
|
|
276
|
+
backup = final_path.with_name(final_path.name + ".bak")
|
|
277
|
+
os.replace(final_path, backup)
|
|
278
|
+
os.replace(staged_path, final_path)
|
|
279
|
+
created.append(final_path)
|
|
280
|
+
replaced.append((final_path, backup))
|
|
281
|
+
for write in checked:
|
|
282
|
+
if write.delete and write.path.exists():
|
|
283
|
+
backup = write.path.with_name(write.path.name + ".bak")
|
|
284
|
+
os.replace(write.path, backup)
|
|
285
|
+
replaced.append((write.path, backup))
|
|
286
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
287
|
+
for _final, backup in replaced:
|
|
288
|
+
if backup is not None and backup.exists():
|
|
289
|
+
backup.unlink()
|
|
290
|
+
except Exception:
|
|
291
|
+
if staging is not None:
|
|
292
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
293
|
+
for final, backup in reversed(replaced):
|
|
294
|
+
if backup is not None and backup.exists():
|
|
295
|
+
os.replace(backup, final)
|
|
296
|
+
elif final.exists() and final in created:
|
|
297
|
+
final.unlink()
|
|
298
|
+
raise
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class ApplicationLayout(KindLayout):
|
|
302
|
+
kind = "application"
|
|
303
|
+
child_subdir = "agents"
|
|
304
|
+
|
|
305
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
306
|
+
from mas.lab.controller.lab_registry import LabRegistry
|
|
307
|
+
|
|
308
|
+
resources = LabRegistry.apps_in_directory(scope.root)
|
|
309
|
+
return self._from_resources(scope, resources)
|
|
310
|
+
|
|
311
|
+
def _from_resources(self, scope: Scope, resources: dict[str, dict[str, Any]]) -> list[Located]:
|
|
312
|
+
located: list[Located] = []
|
|
313
|
+
seen: set[str] = set()
|
|
314
|
+
for catalog_id, entry in resources.items():
|
|
315
|
+
name, version = _split_optional_version(catalog_id)
|
|
316
|
+
rel = str(entry.get("path") or "")
|
|
317
|
+
# Internal path is used only to find the folder; never returned.
|
|
318
|
+
manifest_path = (scope.root / rel).resolve() if rel else None
|
|
319
|
+
if manifest_path is None or not manifest_path.is_file():
|
|
320
|
+
# Bare app directory or agent-only app.
|
|
321
|
+
continue
|
|
322
|
+
try:
|
|
323
|
+
ensure_inside(scope.root, manifest_path)
|
|
324
|
+
except UnsafeNameError:
|
|
325
|
+
continue
|
|
326
|
+
root = manifest_path.parent
|
|
327
|
+
child_paths: dict[str, Path] = {}
|
|
328
|
+
agents_dir = root / "agents"
|
|
329
|
+
if agents_dir.is_dir():
|
|
330
|
+
for agent_file in sorted(agents_dir.glob("*.yaml")):
|
|
331
|
+
child_paths[agent_file.stem] = agent_file
|
|
332
|
+
title, summary = name, ""
|
|
333
|
+
try:
|
|
334
|
+
doc = load_yaml_file(manifest_path)
|
|
335
|
+
title, summary = _title_summary(doc if isinstance(doc, dict) else {}, name)
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
item_id = catalog_id
|
|
339
|
+
if item_id in seen:
|
|
340
|
+
continue
|
|
341
|
+
seen.add(item_id)
|
|
342
|
+
located.append(
|
|
343
|
+
Located(
|
|
344
|
+
item_id=item_id,
|
|
345
|
+
name=name,
|
|
346
|
+
version=version,
|
|
347
|
+
root=root,
|
|
348
|
+
manifest_path=manifest_path,
|
|
349
|
+
child_paths=child_paths,
|
|
350
|
+
title=title,
|
|
351
|
+
summary=summary,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return located
|
|
355
|
+
|
|
356
|
+
def locate(self, scope: Scope, item_id: str) -> Located | None:
|
|
357
|
+
name, version = _split_optional_version(item_id)
|
|
358
|
+
matches = [loc for loc in self.list_located(scope) if loc.name == name]
|
|
359
|
+
if not matches:
|
|
360
|
+
return None
|
|
361
|
+
if version is None:
|
|
362
|
+
versioned = [m for m in matches if m.version]
|
|
363
|
+
if versioned:
|
|
364
|
+
latest = max(versioned, key=lambda m: _version_key(m.version or "v0"))
|
|
365
|
+
return latest
|
|
366
|
+
return matches[0]
|
|
367
|
+
for match in matches:
|
|
368
|
+
if match.version == version or match.item_id == item_id:
|
|
369
|
+
return match
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
def default_create_root(self, scope: Scope, name: str, version: str | None) -> Path:
|
|
373
|
+
if version:
|
|
374
|
+
return ensure_inside(scope.root, scope.root / "apps" / name / version)
|
|
375
|
+
return ensure_inside(scope.root, scope.root / "apps" / name)
|
|
376
|
+
|
|
377
|
+
def plan_create(
|
|
378
|
+
self,
|
|
379
|
+
scope: Scope,
|
|
380
|
+
*,
|
|
381
|
+
name: str,
|
|
382
|
+
version: str | None,
|
|
383
|
+
template: str | None,
|
|
384
|
+
parent: str | None,
|
|
385
|
+
default_model: str | None,
|
|
386
|
+
copy_from: ItemBundle | None = None,
|
|
387
|
+
) -> list[FileWrite]:
|
|
388
|
+
root = self.default_create_root(scope, name, version)
|
|
389
|
+
mas_path = root / "mas.yaml"
|
|
390
|
+
agent_path = root / "agents" / f"{name}.yaml"
|
|
391
|
+
if copy_from is not None:
|
|
392
|
+
writes = [FileWrite(mas_path, _rewrite_name(copy_from.manifest, name))]
|
|
393
|
+
for child_name, content in copy_from.children.items():
|
|
394
|
+
writes.append(
|
|
395
|
+
FileWrite(root / "agents" / f"{child_name}.yaml", content)
|
|
396
|
+
)
|
|
397
|
+
return writes
|
|
398
|
+
mas_yaml = _new_mas_yaml(name)
|
|
399
|
+
agent_yaml = _new_agent_yaml(name, default_model=default_model)
|
|
400
|
+
return [FileWrite(mas_path, mas_yaml), FileWrite(agent_path, agent_yaml)]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class AgentLayout(KindLayout):
|
|
404
|
+
kind = "agent"
|
|
405
|
+
|
|
406
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
407
|
+
located: list[Located] = []
|
|
408
|
+
for folder in (scope.root / "agents",):
|
|
409
|
+
if not folder.is_dir():
|
|
410
|
+
continue
|
|
411
|
+
for path in sorted(folder.glob("*.yaml")):
|
|
412
|
+
located.append(self._located_from_file(scope, path))
|
|
413
|
+
app_layout = ApplicationLayout()
|
|
414
|
+
for app in app_layout.list_located(scope):
|
|
415
|
+
for child_name, path in app.child_paths.items():
|
|
416
|
+
located.append(
|
|
417
|
+
Located(
|
|
418
|
+
item_id=child_name,
|
|
419
|
+
name=child_name,
|
|
420
|
+
version=None,
|
|
421
|
+
root=path.parent,
|
|
422
|
+
manifest_path=path,
|
|
423
|
+
title=child_name,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
# Deduplicate by item_id, first wins.
|
|
427
|
+
uniq: dict[str, Located] = {}
|
|
428
|
+
for item in located:
|
|
429
|
+
uniq.setdefault(item.item_id, item)
|
|
430
|
+
return list(uniq.values())
|
|
431
|
+
|
|
432
|
+
def _located_from_file(self, scope: Scope, path: Path) -> Located:
|
|
433
|
+
title, summary = path.stem, ""
|
|
434
|
+
try:
|
|
435
|
+
doc = load_yaml_file(path)
|
|
436
|
+
title, summary = _title_summary(doc if isinstance(doc, dict) else {}, path.stem)
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
return Located(
|
|
440
|
+
item_id=path.stem,
|
|
441
|
+
name=path.stem,
|
|
442
|
+
version=None,
|
|
443
|
+
root=path.parent,
|
|
444
|
+
manifest_path=path,
|
|
445
|
+
title=title,
|
|
446
|
+
summary=summary,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def locate(self, scope: Scope, item_id: str) -> Located | None:
|
|
450
|
+
name, _version = _split_optional_version(item_id)
|
|
451
|
+
for item in self.list_located(scope):
|
|
452
|
+
if item.name == name or item.item_id == item_id:
|
|
453
|
+
return item
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
def default_create_root(self, scope: Scope, name: str, version: str | None) -> Path:
|
|
457
|
+
return ensure_inside(scope.root, scope.root / "agents")
|
|
458
|
+
|
|
459
|
+
def plan_create(
|
|
460
|
+
self,
|
|
461
|
+
scope: Scope,
|
|
462
|
+
*,
|
|
463
|
+
name: str,
|
|
464
|
+
version: str | None,
|
|
465
|
+
template: str | None,
|
|
466
|
+
parent: str | None,
|
|
467
|
+
default_model: str | None,
|
|
468
|
+
copy_from: ItemBundle | None = None,
|
|
469
|
+
) -> list[FileWrite]:
|
|
470
|
+
if parent:
|
|
471
|
+
app = ApplicationLayout().locate(scope, parent)
|
|
472
|
+
if app is None:
|
|
473
|
+
raise KeyError(parent)
|
|
474
|
+
path = ensure_inside(scope.root, app.root / "agents" / f"{name}.yaml")
|
|
475
|
+
content = copy_from.manifest if copy_from else _new_agent_yaml(name, default_model=default_model)
|
|
476
|
+
writes = [FileWrite(path, content)]
|
|
477
|
+
# Add the agent to the MAS spec.agency.agents and workflow.
|
|
478
|
+
mas_text = _read_text(app.manifest_path)
|
|
479
|
+
mas_text = _add_agent_to_mas(mas_text, name)
|
|
480
|
+
writes.append(FileWrite(app.manifest_path, mas_text))
|
|
481
|
+
return writes
|
|
482
|
+
path = ensure_inside(scope.root, scope.root / "agents" / f"{name}.yaml")
|
|
483
|
+
content = copy_from.manifest if copy_from else _new_agent_yaml(name, default_model=default_model)
|
|
484
|
+
return [FileWrite(path, content)]
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class YamlDirLayout(KindLayout):
|
|
488
|
+
"""YAML files in a typed directory (overlays, pipelines, experiments, datasets)."""
|
|
489
|
+
|
|
490
|
+
directory: str = ""
|
|
491
|
+
suffix: str = ".yaml"
|
|
492
|
+
declared_kind: str = ""
|
|
493
|
+
|
|
494
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
495
|
+
folder = scope.root / self.directory
|
|
496
|
+
if not folder.is_dir():
|
|
497
|
+
# Fall back to kind-classified files already discovered by the registry.
|
|
498
|
+
return self._from_registry(scope)
|
|
499
|
+
located: list[Located] = []
|
|
500
|
+
for path in sorted(folder.rglob(f"*{self.suffix}")):
|
|
501
|
+
if not path.is_file() or is_hidden_under(scope.root, path):
|
|
502
|
+
continue
|
|
503
|
+
located.append(self._located_from_file(path))
|
|
504
|
+
if located:
|
|
505
|
+
return located
|
|
506
|
+
return self._from_registry(scope)
|
|
507
|
+
|
|
508
|
+
def _from_registry(self, scope: Scope) -> list[Located]:
|
|
509
|
+
from mas.lab.controller.lab_registry import _iter_manifest_files
|
|
510
|
+
|
|
511
|
+
expected = self.declared_kind or self.kind
|
|
512
|
+
parent = self.directory if self.directory else None
|
|
513
|
+
located: list[Located] = []
|
|
514
|
+
for path in _iter_manifest_files(scope.root, expected, parent_dir=parent):
|
|
515
|
+
located.append(self._located_from_file(path))
|
|
516
|
+
return located
|
|
517
|
+
|
|
518
|
+
def _located_from_file(self, path: Path) -> Located:
|
|
519
|
+
item_id = path.stem
|
|
520
|
+
title, summary = item_id, ""
|
|
521
|
+
try:
|
|
522
|
+
doc = load_yaml_file(path)
|
|
523
|
+
if isinstance(doc, dict):
|
|
524
|
+
title, summary = _title_summary(doc, item_id)
|
|
525
|
+
meta_name = (doc.get("metadata") or {}).get("name")
|
|
526
|
+
if meta_name:
|
|
527
|
+
item_id = str(meta_name)
|
|
528
|
+
exp = doc.get("experiment")
|
|
529
|
+
if isinstance(exp, dict) and exp.get("name"):
|
|
530
|
+
item_id = str(exp["name"])
|
|
531
|
+
except Exception:
|
|
532
|
+
pass
|
|
533
|
+
return Located(
|
|
534
|
+
item_id=item_id,
|
|
535
|
+
name=item_id,
|
|
536
|
+
version=None,
|
|
537
|
+
root=path.parent,
|
|
538
|
+
manifest_path=path,
|
|
539
|
+
title=title,
|
|
540
|
+
summary=summary,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def locate(self, scope: Scope, item_id: str) -> Located | None:
|
|
544
|
+
name, _version = _split_optional_version(item_id)
|
|
545
|
+
for item in self.list_located(scope):
|
|
546
|
+
if item.item_id == item_id or item.name == name or item.manifest_path.stem == name:
|
|
547
|
+
return item
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
def default_create_root(self, scope: Scope, name: str, version: str | None) -> Path:
|
|
551
|
+
return ensure_inside(scope.root, scope.root / self.directory)
|
|
552
|
+
|
|
553
|
+
def plan_create(
|
|
554
|
+
self,
|
|
555
|
+
scope: Scope,
|
|
556
|
+
*,
|
|
557
|
+
name: str,
|
|
558
|
+
version: str | None,
|
|
559
|
+
template: str | None,
|
|
560
|
+
parent: str | None,
|
|
561
|
+
default_model: str | None,
|
|
562
|
+
copy_from: ItemBundle | None = None,
|
|
563
|
+
) -> list[FileWrite]:
|
|
564
|
+
path = ensure_inside(scope.root, self.default_create_root(scope, name, version) / f"{name}{self.suffix}")
|
|
565
|
+
if copy_from is not None:
|
|
566
|
+
return [FileWrite(path, _rewrite_name(copy_from.manifest, name))]
|
|
567
|
+
return [FileWrite(path, self._blank(name))]
|
|
568
|
+
|
|
569
|
+
def _blank(self, name: str) -> str:
|
|
570
|
+
return f"metadata:\n name: {name}\n"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class OverlayLayout(YamlDirLayout):
|
|
574
|
+
kind = "overlay"
|
|
575
|
+
directory = "overlays"
|
|
576
|
+
declared_kind = "overlay"
|
|
577
|
+
|
|
578
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
579
|
+
located = []
|
|
580
|
+
for item in super().list_located(scope):
|
|
581
|
+
try:
|
|
582
|
+
doc = load_yaml_file(item.manifest_path)
|
|
583
|
+
except Exception:
|
|
584
|
+
located.append(item)
|
|
585
|
+
continue
|
|
586
|
+
if overlay_is_layout_only(doc):
|
|
587
|
+
continue
|
|
588
|
+
located.append(item)
|
|
589
|
+
return located
|
|
590
|
+
|
|
591
|
+
def _blank(self, name: str) -> str:
|
|
592
|
+
return (
|
|
593
|
+
"apiVersion: mas/v1\n"
|
|
594
|
+
"kind: Overlay\n"
|
|
595
|
+
f"metadata:\n name: {name}\n description: ''\n"
|
|
596
|
+
"spec:\n"
|
|
597
|
+
" target:\n"
|
|
598
|
+
" kind: MAS\n"
|
|
599
|
+
" patch: {}\n"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class PipelineLayout(YamlDirLayout):
|
|
604
|
+
kind = "pipeline"
|
|
605
|
+
directory = "pipelines"
|
|
606
|
+
declared_kind = "pipeline"
|
|
607
|
+
|
|
608
|
+
def _blank(self, name: str) -> str:
|
|
609
|
+
return (
|
|
610
|
+
"apiVersion: lab/v1\n"
|
|
611
|
+
"kind: Pipeline\n"
|
|
612
|
+
f"metadata:\n name: {name}\n description: ''\n"
|
|
613
|
+
"spec:\n steps: []\n"
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class ExperimentLayout(YamlDirLayout):
|
|
618
|
+
kind = "experiment"
|
|
619
|
+
directory = "experiments"
|
|
620
|
+
declared_kind = "experiment"
|
|
621
|
+
|
|
622
|
+
def _blank(self, name: str) -> str:
|
|
623
|
+
return (
|
|
624
|
+
"experiment:\n"
|
|
625
|
+
f" name: {name}\n"
|
|
626
|
+
" description: ''\n"
|
|
627
|
+
" application:\n"
|
|
628
|
+
" app: unset\n"
|
|
629
|
+
" dataset:\n"
|
|
630
|
+
" path: queries\n"
|
|
631
|
+
" run:\n"
|
|
632
|
+
" n_runs: 1\n"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class DatasetLayout(YamlDirLayout):
|
|
637
|
+
kind = "dataset"
|
|
638
|
+
directory = "datasets"
|
|
639
|
+
declared_kind = "dataset"
|
|
640
|
+
|
|
641
|
+
def _blank(self, name: str) -> str:
|
|
642
|
+
return (
|
|
643
|
+
"apiVersion: lab/v1\n"
|
|
644
|
+
"kind: Dataset\n"
|
|
645
|
+
f"metadata:\n name: {name}\n description: ''\n"
|
|
646
|
+
"spec:\n"
|
|
647
|
+
" items:\n"
|
|
648
|
+
" - id: item-1\n"
|
|
649
|
+
" inputs:\n"
|
|
650
|
+
" user: placeholder\n"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class ToolLayout(YamlDirLayout):
|
|
655
|
+
kind = "tool"
|
|
656
|
+
directory = "tools"
|
|
657
|
+
suffix = ".tool.yaml"
|
|
658
|
+
declared_kind = "tool"
|
|
659
|
+
|
|
660
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
661
|
+
folder = scope.root / self.directory
|
|
662
|
+
if not folder.is_dir():
|
|
663
|
+
return []
|
|
664
|
+
located: list[Located] = []
|
|
665
|
+
for path in sorted(folder.rglob("*.tool.yaml")):
|
|
666
|
+
if is_hidden_under(scope.root, path):
|
|
667
|
+
continue
|
|
668
|
+
located.append(self._located_from_file(path))
|
|
669
|
+
return located
|
|
670
|
+
|
|
671
|
+
def _blank(self, name: str) -> str:
|
|
672
|
+
return (
|
|
673
|
+
"apiVersion: mas/v1\n"
|
|
674
|
+
"kind: Tool\n"
|
|
675
|
+
f"metadata:\n name: {name}\n description: ''\n"
|
|
676
|
+
"spec:\n source:\n type: python\n"
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
class SkillLayout(KindLayout):
|
|
681
|
+
kind = "skill"
|
|
682
|
+
|
|
683
|
+
def list_located(self, scope: Scope) -> list[Located]:
|
|
684
|
+
folder = scope.root / "skills"
|
|
685
|
+
if not folder.is_dir():
|
|
686
|
+
return []
|
|
687
|
+
located: list[Located] = []
|
|
688
|
+
for skill_dir in sorted(folder.iterdir()):
|
|
689
|
+
if skill_dir.name.startswith("."):
|
|
690
|
+
continue
|
|
691
|
+
skill_md = skill_dir / "SKILL.md"
|
|
692
|
+
if skill_dir.is_dir() and skill_md.is_file():
|
|
693
|
+
located.append(
|
|
694
|
+
Located(
|
|
695
|
+
item_id=skill_dir.name,
|
|
696
|
+
name=skill_dir.name,
|
|
697
|
+
version=None,
|
|
698
|
+
root=skill_dir,
|
|
699
|
+
manifest_path=skill_md,
|
|
700
|
+
title=skill_dir.name,
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
return located
|
|
704
|
+
|
|
705
|
+
def locate(self, scope: Scope, item_id: str) -> Located | None:
|
|
706
|
+
name, _version = _split_optional_version(item_id)
|
|
707
|
+
for item in self.list_located(scope):
|
|
708
|
+
if item.name == name:
|
|
709
|
+
return item
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
def plan_create(self, scope: Scope, **kwargs: Any) -> list[FileWrite]:
|
|
713
|
+
raise UnsafeNameError("skills cannot be created through this API")
|
|
714
|
+
|
|
715
|
+
def default_create_root(self, scope: Scope, name: str, version: str | None) -> Path:
|
|
716
|
+
return ensure_inside(scope.root, scope.root / "skills" / name)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class ScenarioLayout(YamlDirLayout):
|
|
720
|
+
kind = "scenario"
|
|
721
|
+
directory = "scenarios"
|
|
722
|
+
declared_kind = "scenario"
|
|
723
|
+
|
|
724
|
+
def _blank(self, name: str) -> str:
|
|
725
|
+
return f"id: {name}\nlogic: []\n"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
_LAYOUTS: dict[str, KindLayout] = {
|
|
729
|
+
"application": ApplicationLayout(),
|
|
730
|
+
"agent": AgentLayout(),
|
|
731
|
+
"overlay": OverlayLayout(),
|
|
732
|
+
"pipeline": PipelineLayout(),
|
|
733
|
+
"experiment": ExperimentLayout(),
|
|
734
|
+
"dataset": DatasetLayout(),
|
|
735
|
+
"tool": ToolLayout(),
|
|
736
|
+
"skill": SkillLayout(),
|
|
737
|
+
"scenario": ScenarioLayout(),
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def layout_for(kind: str) -> KindLayout:
|
|
742
|
+
layout = _LAYOUTS.get(kind)
|
|
743
|
+
if layout is None:
|
|
744
|
+
raise KeyError(kind)
|
|
745
|
+
return layout
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _split_optional_version(item_id: str) -> tuple[str, str | None]:
|
|
749
|
+
text = str(item_id or "").strip()
|
|
750
|
+
if "@" not in text:
|
|
751
|
+
return text, None
|
|
752
|
+
name, _, version = text.rpartition("@")
|
|
753
|
+
version = version.strip()
|
|
754
|
+
if not version or version.lower() == "latest":
|
|
755
|
+
return name, None
|
|
756
|
+
if version.startswith("^") or version[0] in "<>=":
|
|
757
|
+
return name, version
|
|
758
|
+
return name, version
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _new_mas_yaml(name: str) -> str:
|
|
762
|
+
agent_ref = f"{name}.agent"
|
|
763
|
+
return (
|
|
764
|
+
"apiVersion: mas/v1\n"
|
|
765
|
+
"kind: MAS\n"
|
|
766
|
+
"metadata:\n"
|
|
767
|
+
f" name: {name}\n"
|
|
768
|
+
" description: ''\n"
|
|
769
|
+
"spec:\n"
|
|
770
|
+
" agency:\n"
|
|
771
|
+
" agents:\n"
|
|
772
|
+
f" - id: {name}\n"
|
|
773
|
+
f" ref: {agent_ref}\n"
|
|
774
|
+
" workflow:\n"
|
|
775
|
+
f" entry: {name}\n"
|
|
776
|
+
" nodes:\n"
|
|
777
|
+
f" - id: {name}\n"
|
|
778
|
+
" role: specialist\n"
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _new_agent_yaml(name: str, *, default_model: str | None) -> str:
|
|
783
|
+
model_block = ""
|
|
784
|
+
if default_model:
|
|
785
|
+
model_block = (
|
|
786
|
+
" models:\n"
|
|
787
|
+
f" - model: {default_model}\n"
|
|
788
|
+
" temperature: 0.3\n"
|
|
789
|
+
)
|
|
790
|
+
return (
|
|
791
|
+
"apiVersion: mas/v1\n"
|
|
792
|
+
"kind: Agent\n"
|
|
793
|
+
"metadata:\n"
|
|
794
|
+
f" name: {name}\n"
|
|
795
|
+
"spec:\n"
|
|
796
|
+
f" description: {name}\n"
|
|
797
|
+
" context:\n"
|
|
798
|
+
f" role: You are {name}.\n"
|
|
799
|
+
f"{model_block}"
|
|
800
|
+
" design_pattern:\n"
|
|
801
|
+
" type: react\n"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _rewrite_name(manifest: str, name: str) -> str:
|
|
806
|
+
try:
|
|
807
|
+
doc = yaml.safe_load(manifest) or {}
|
|
808
|
+
except yaml.YAMLError:
|
|
809
|
+
return manifest
|
|
810
|
+
if not isinstance(doc, dict):
|
|
811
|
+
return manifest
|
|
812
|
+
if isinstance(doc.get("metadata"), dict):
|
|
813
|
+
doc["metadata"]["name"] = name
|
|
814
|
+
elif "experiment" not in doc:
|
|
815
|
+
doc["metadata"] = {"name": name}
|
|
816
|
+
if isinstance(doc.get("experiment"), dict):
|
|
817
|
+
doc["experiment"]["name"] = name
|
|
818
|
+
return yaml.safe_dump(doc, sort_keys=False)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _add_agent_to_mas(mas_text: str, agent_id: str) -> str:
|
|
822
|
+
doc = yaml.safe_load(mas_text) or {}
|
|
823
|
+
spec = doc.setdefault("spec", {})
|
|
824
|
+
agency = spec.setdefault("agency", {})
|
|
825
|
+
agents = agency.setdefault("agents", [])
|
|
826
|
+
if not any(isinstance(a, dict) and a.get("id") == agent_id for a in agents):
|
|
827
|
+
agents.append({"id": agent_id, "ref": f"{agent_id}.agent"})
|
|
828
|
+
workflow = spec.setdefault("workflow", {})
|
|
829
|
+
nodes = workflow.setdefault("nodes", [])
|
|
830
|
+
if not any(isinstance(n, dict) and n.get("id") == agent_id for n in nodes):
|
|
831
|
+
nodes.append({"id": agent_id, "role": "specialist"})
|
|
832
|
+
return yaml.safe_dump(doc, sort_keys=False)
|