mas-lab-component-controller 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. mas/lab/controller/__init__.py +7 -0
  2. mas/lab/controller/__main__.py +5 -0
  3. mas/lab/controller/agent_chat.py +226 -0
  4. mas/lab/controller/api.py +364 -0
  5. mas/lab/controller/artifact_discovery.py +107 -0
  6. mas/lab/controller/artifacts.py +112 -0
  7. mas/lab/controller/cli.py +493 -0
  8. mas/lab/controller/client.py +271 -0
  9. mas/lab/controller/config.py +75 -0
  10. mas/lab/controller/constants.py +30 -0
  11. mas/lab/controller/daemon.py +190 -0
  12. mas/lab/controller/deps.py +172 -0
  13. mas/lab/controller/fastapi_app.py +166 -0
  14. mas/lab/controller/http_server.py +243 -0
  15. mas/lab/controller/io_capture.py +55 -0
  16. mas/lab/controller/item_service.py +625 -0
  17. mas/lab/controller/job_store.py +209 -0
  18. mas/lab/controller/jobs.py +375 -0
  19. mas/lab/controller/kinds.py +149 -0
  20. mas/lab/controller/lab_registry.py +1076 -0
  21. mas/lab/controller/manifest_layout.py +832 -0
  22. mas/lab/controller/manifest_store.py +214 -0
  23. mas/lab/controller/manifest_validation.py +90 -0
  24. mas/lab/controller/models.py +215 -0
  25. mas/lab/controller/path_safety.py +68 -0
  26. mas/lab/controller/pipeline_validation.py +79 -0
  27. mas/lab/controller/registry.py +83 -0
  28. mas/lab/controller/routes/__init__.py +82 -0
  29. mas/lab/controller/routes/_api.py +15 -0
  30. mas/lab/controller/routes/apps.py +190 -0
  31. mas/lab/controller/routes/artifacts.py +37 -0
  32. mas/lab/controller/routes/benchmark.py +307 -0
  33. mas/lab/controller/routes/choices.py +141 -0
  34. mas/lab/controller/routes/datasets.py +91 -0
  35. mas/lab/controller/routes/design_patterns.py +121 -0
  36. mas/lab/controller/routes/eval_output.py +31 -0
  37. mas/lab/controller/routes/experiments.py +194 -0
  38. mas/lab/controller/routes/health.py +178 -0
  39. mas/lab/controller/routes/items.py +187 -0
  40. mas/lab/controller/routes/jobs.py +107 -0
  41. mas/lab/controller/routes/libraries.py +144 -0
  42. mas/lab/controller/routes/overlays.py +164 -0
  43. mas/lab/controller/routes/pipelines.py +139 -0
  44. mas/lab/controller/routes/registry.py +40 -0
  45. mas/lab/controller/routes/run.py +215 -0
  46. mas/lab/controller/routes/runs.py +392 -0
  47. mas/lab/controller/routes/scopes.py +139 -0
  48. mas/lab/controller/routes/uploads.py +24 -0
  49. mas/lab/controller/routes/validate.py +36 -0
  50. mas/lab/controller/run_bridge.py +276 -0
  51. mas/lab/controller/run_store.py +190 -0
  52. mas/lab/controller/schema_registry.py +109 -0
  53. mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
  54. mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
  55. mas/lab/controller/scope_catalog.py +266 -0
  56. mas/lab/controller/scope_service.py +134 -0
  57. mas/lab/controller/sessions.py +63 -0
  58. mas/lab/controller/settings.py +65 -0
  59. mas/lab/controller/ui_stream/__init__.py +13 -0
  60. mas/lab/controller/ui_stream/app.py +2147 -0
  61. mas/lab/controller/ui_stream/artefact_bind.py +308 -0
  62. mas/lab/controller/ui_stream/auth.py +136 -0
  63. mas/lab/controller/ui_stream/bus.py +156 -0
  64. mas/lab/controller/ui_stream/export.py +400 -0
  65. mas/lab/controller/ui_stream/governance.py +141 -0
  66. mas/lab/controller/ui_stream/ingest.py +300 -0
  67. mas/lab/controller/ui_stream/inspect.py +55 -0
  68. mas/lab/controller/ui_stream/layouts.py +161 -0
  69. mas/lab/controller/ui_stream/metrics.py +274 -0
  70. mas/lab/controller/ui_stream/plot_folds.py +609 -0
  71. mas/lab/controller/ui_stream/plugin_registry.py +139 -0
  72. mas/lab/controller/ui_stream/refs.py +259 -0
  73. mas/lab/controller/ui_stream/replay.py +236 -0
  74. mas/lab/controller/ui_stream/replay_cli.py +707 -0
  75. mas/lab/controller/ui_stream/run_index.py +337 -0
  76. mas/lab/controller/ui_stream/spec_load.py +48 -0
  77. mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
  78. mas/lab/controller/ui_stream/stream.py +95 -0
  79. mas/lab/controller/ui_stream/streams.py +42 -0
  80. mas/lab/controller/ui_stream/timeline.py +183 -0
  81. mas/lab/controller/ui_stream/ui_trace.py +258 -0
  82. mas/lab/controller/uploads.py +61 -0
  83. mas/lab/controller/worker_model.py +137 -0
  84. mas/lab/controller/workers.py +296 -0
  85. mas/lab/controller/workspace_library.py +70 -0
  86. mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
  87. mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
  88. mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
  89. mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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)