Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions lab/components/bench/src/mas/lab/benchmark/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@
from typing import Optional

from mas.lab.benchmark.cache import get_trace_cache_dir as _get_trace_cache_dir
from mas.library_roots import find_ancestor_with_file
from mas.runtime.constants import LAB_CONFIG_FILENAME

def _resolve_run_manager_dir(explicit: Optional[Path]) -> Path:
"""Return the primary benchmark output root from CLI or active config."""
if explicit is not None:
return explicit.resolve()
for _d in [Path.cwd().resolve(), *Path.cwd().resolve().parents]:
_cfg = _d / "lab-config.yaml"
if _cfg.exists():
try:
from mas.runtime.spec.source import load_yaml_file

_raw = load_yaml_file(_cfg)
_out = _raw.get("lab", {}).get("output_dir", "output")
return (_d / _out).resolve()
except Exception:
logger.debug('suppressed', exc_info=True)
break
_lab_dir = find_ancestor_with_file(
Path.cwd().resolve(), LAB_CONFIG_FILENAME, stop_at_suffix=".lab"
)
if _lab_dir is not None:
try:
from mas.runtime.spec.source import load_yaml_file

_raw = load_yaml_file(_lab_dir / LAB_CONFIG_FILENAME)
_out = _raw.get("lab", {}).get("output_dir", "output")
return (_lab_dir / _out).resolve()
except Exception:
logger.debug('suppressed', exc_info=True)
from mas.lab import paths as _paths

return _paths.labs_root()
Expand Down
38 changes: 20 additions & 18 deletions lab/components/bench/src/mas/lab/benchmark/pipeline/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
)
from mas.lab.benchmark.pipeline.schema_validation import validate_payload
from mas.lab import paths as _paths
from mas.library_roots import find_ancestor_with_file
from mas.runtime.constants import LAB_CONFIG_FILENAME


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -229,24 +231,24 @@ def _find_lab_meta(
if config_path is None:
return override_name, None

candidate = config_path.resolve().parent
for _ in range(6): # max 6 levels up
lab_yaml = candidate / "lab-config.yaml"
if lab_yaml.exists():
name = override_name
if not name and _yaml is not None:
try:
with open(lab_yaml, encoding="utf-8") as fh:
data = _yaml.safe_load(fh) or {}
name = (data.get("lab") or {}).get("name", "")
except Exception as exc:
logger.debug("Could not read lab name from %s: %s", lab_yaml, exc)
if not name:
# Fallback: strip .lab suffix from directory name
name = candidate.name.removesuffix(".lab")
data_dir = candidate / "data"
return name, (data_dir if data_dir.is_dir() else None)
candidate = candidate.parent
candidate = find_ancestor_with_file(
config_path.resolve().parent, LAB_CONFIG_FILENAME, stop_at_suffix=".lab"
)
if candidate is not None:
lab_yaml = candidate / LAB_CONFIG_FILENAME
name = override_name
if not name and _yaml is not None:
try:
with open(lab_yaml, encoding="utf-8") as fh:
data = _yaml.safe_load(fh) or {}
name = (data.get("lab") or {}).get("name", "")
except Exception as exc:
logger.debug("Could not read lab name from %s: %s", lab_yaml, exc)
if not name:
# Fallback: strip .lab suffix from directory name
name = candidate.name.removesuffix(".lab")
data_dir = candidate / "data"
return name, (data_dir if data_dir.is_dir() else None)

# Not found — best-effort from directory name
fallback_name = override_name or config_path.parent.parent.name.removesuffix(".lab")
Expand Down
41 changes: 28 additions & 13 deletions lab/components/bench/src/mas/lab/lab/config/lab_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,36 @@
from pathlib import Path
from typing import Any, List, Optional, Union

from mas.library_roots import find_ancestor_with_file
from mas.runtime.constants import LAB_CONFIG_FILENAME

logger = logging.getLogger(__name__)

PluginSpec = Union[str, dict[str, Any]]

_LAB_DIR_SUFFIX = ".lab"


def _discover_lab_name(yaml_path: Path) -> Optional[str]:
"""Infer the lab name for an experiment YAML from its surrounding context."""
lab_yaml = yaml_path.parent / "lab-config.yaml"
if lab_yaml.exists():
lab_dir = find_ancestor_with_file(
yaml_path, LAB_CONFIG_FILENAME, stop_at_suffix=_LAB_DIR_SUFFIX
)
if lab_dir is not None:
try:
from mas.runtime.spec.source import load_yaml_file

_data = load_yaml_file(lab_yaml)
_data = load_yaml_file(lab_dir / LAB_CONFIG_FILENAME)
_lab_section = _data.get("lab", _data) if isinstance(_data, dict) else {}
_name = _lab_section.get("name")
if _name:
return str(_name)
except Exception:
logger.debug('suppressed', exc_info=True)

parent = yaml_path.parent
if parent.name.endswith(".lab"):
return parent.name[:-4]

for parent in yaml_path.parents:
if parent.name.endswith(".lab"):
return parent.name[:-4]
for parent in [yaml_path.parent, *yaml_path.parents]:
if parent.name.endswith(_LAB_DIR_SUFFIX):
return parent.name[: -len(_LAB_DIR_SUFFIX)]

return None

Expand All @@ -52,9 +55,21 @@ class LabContext:


def discover_lab_context(yaml_path: Path) -> LabContext:
"""Find sibling ``lab-config.yaml`` and plugin specs for *yaml_path*."""
ctx = LabContext(lab_dir=yaml_path.parent)
lab_yaml = yaml_path.parent / "lab-config.yaml"
"""Find the enclosing ``lab-config.yaml`` (walking up to the lab root,
same as :func:`_discover_lab_name`) and its plugin specs for *yaml_path*.

Most labs nest experiment YAMLs several directories below the lab root
(e.g. ``<lab>.lab/experiments/<name>/experiment.yaml``), so checking only
the immediate parent directory -- as this used to do -- never finds the
lab's own ``lab-config.yaml``, and ``libraries:``/``plugins:`` entries
declared there are silently never applied.
"""
lab_dir = (
find_ancestor_with_file(yaml_path, LAB_CONFIG_FILENAME, stop_at_suffix=_LAB_DIR_SUFFIX)
or yaml_path.parent
)
ctx = LabContext(lab_dir=lab_dir)
lab_yaml = lab_dir / LAB_CONFIG_FILENAME
if lab_yaml.is_file():
ctx.lab_yaml = lab_yaml
try:
Expand Down
130 changes: 130 additions & 0 deletions lab/components/bench/tests/test_lab_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
"""Tests for mas.lab.lab.config.lab_context -- discover_lab_context() must find
a lab's own lab-config.yaml regardless of how many directories the experiment
YAML is nested below the lab root. It used to check only the experiment's own
directory, so libraries:/plugins: declarations in a root-level lab-config.yaml
were silently never applied for any experiment nested more than one level
deep (the common case: <lab>.lab/experiments/<name>/experiment.yaml).
"""

from __future__ import annotations

import sys

from mas.lab.lab.config.lab_context import (
_discover_lab_name,
discover_lab_context,
inject_lab_libraries,
)


def _write_lab_config(lab_dir, *, name="my-lab", libraries=None, plugins=None) -> None:
lines = ["lab:", f' name: "{name}"']
if libraries:
lines.append(" libraries:")
lines += [f" - {lib}" for lib in libraries]
if plugins:
lines.append(" plugins:")
for p in plugins:
lines.append(f" - path: {p['path']}")
lines.append(f" module: {p['module']}")
(lab_dir / "lab-config.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")


def test_discover_lab_context_finds_lab_config_one_level_up(tmp_path) -> None:
lab_dir = tmp_path / "some.lab"
exp_dir = lab_dir / "experiments" / "01-smoke"
exp_dir.mkdir(parents=True)
_write_lab_config(lab_dir, name="some-lab")

ctx = discover_lab_context(exp_dir / "experiment.yaml")

assert ctx.lab_dir == lab_dir.resolve()
assert ctx.lab_yaml == lab_dir.resolve() / "lab-config.yaml"
assert ctx.lab_name == "some-lab"


def test_discover_lab_context_finds_lab_config_several_levels_up(tmp_path) -> None:
"""The actual regression case: experiments/<hackathon>/<pattern>/pipelines/
is 4 levels below the lab root, not 1."""
lab_dir = tmp_path / "sre-triage.lab"
exp_dir = lab_dir / "experiments" / "hackathon-experiments" / "centralized-moderator"
pipelines_dir = exp_dir / "pipelines"
pipelines_dir.mkdir(parents=True)
_write_lab_config(lab_dir, name="sre-triage", libraries=["lib/"])

ctx = discover_lab_context(exp_dir / "experiment.yaml")

assert ctx.lab_dir == lab_dir.resolve()
assert ctx.lab_name == "sre-triage"
assert ctx.libraries == ["lib/"]


def test_discover_lab_context_does_not_cross_a_lab_boundary(tmp_path) -> None:
"""An experiment inside inner.lab must never pick up outer.lab's config."""
outer_lab = tmp_path / "outer.lab"
inner_lab = outer_lab / "nested" / "inner.lab"
exp_dir = inner_lab / "experiments" / "01-smoke"
exp_dir.mkdir(parents=True)
_write_lab_config(outer_lab, name="outer-lab")
# inner.lab deliberately has no lab-config.yaml of its own.

ctx = discover_lab_context(exp_dir / "experiment.yaml")

assert ctx.lab_yaml is None
assert ctx.lab_name == "inner" # falls back to the .lab dir name, not "outer-lab"


def test_discover_lab_context_falls_back_to_experiment_dir_when_no_lab_config(
tmp_path,
) -> None:
exp_dir = tmp_path / "experiments" / "01-smoke"
exp_dir.mkdir(parents=True)

ctx = discover_lab_context(exp_dir / "experiment.yaml")

assert ctx.lab_dir == exp_dir.resolve()
assert ctx.lab_yaml is None


def test_discover_lab_name_matches_discover_lab_context(tmp_path) -> None:
lab_dir = tmp_path / "cognitive-mas" / "sre-triage.lab"
exp_dir = lab_dir / "experiments" / "top1-smoke"
exp_dir.mkdir(parents=True)
_write_lab_config(lab_dir, name="sre-triage")

assert _discover_lab_name(exp_dir / "experiment.yaml") == "sre-triage"


def test_inject_lab_libraries_puts_the_lab_root_on_sys_path_not_the_experiment_dir(
tmp_path, monkeypatch
) -> None:
lab_dir = tmp_path / "some.lab"
exp_dir = lab_dir / "experiments" / "deeply" / "nested" / "example"
exp_dir.mkdir(parents=True)
_write_lab_config(lab_dir)

monkeypatch.setattr(sys, "path", list(sys.path))
ctx = discover_lab_context(exp_dir / "experiment.yaml")
inject_lab_libraries(ctx)

assert str(lab_dir.resolve()) in sys.path
assert str(exp_dir.resolve()) not in sys.path


def test_inject_lab_libraries_resolves_libraries_relative_to_the_lab_root(
tmp_path, monkeypatch
) -> None:
lab_dir = tmp_path / "some.lab"
exp_dir = lab_dir / "experiments" / "a" / "b" / "c"
exp_dir.mkdir(parents=True)
lib_dir = lab_dir / "lib"
lib_dir.mkdir()
_write_lab_config(lab_dir, libraries=["lib/"])

monkeypatch.setattr(sys, "path", list(sys.path))
ctx = discover_lab_context(exp_dir / "experiment.yaml")
inject_lab_libraries(ctx)

assert str(lib_dir.resolve()) in sys.path
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
from pathlib import Path
from typing import Any, Dict, List, Optional

from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
from mas.runtime.constants import LAB_CONFIG_FILENAME, WORKSPACE_CONFIG_FILENAME
from mas.runtime.spec.source import load_yaml_file

logger = logging.getLogger(__name__)


def _library_description(path: Path) -> str:
for candidate in (path / "lab-config.yaml", path / "mas.yaml", path / "README.md"):
for candidate in (path / LAB_CONFIG_FILENAME, path / "mas.yaml", path / "README.md"):
if candidate.exists():
try:
if candidate.suffix == ".yaml":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from fastapi import APIRouter

from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
from mas.runtime.constants import LIBRARY_MANIFEST_FILENAME

router = APIRouter()

Expand All @@ -27,7 +28,7 @@ async def list_libraries():
for lib_dir in sorted(libraries_dir.iterdir()):
if not lib_dir.is_dir() or not lib_dir.name.startswith("library-"):
continue
lib_yaml = lib_dir / "library.yaml"
lib_yaml = lib_dir / LIBRARY_MANIFEST_FILENAME
name = lib_dir.name
description = ""
if lib_yaml.exists():
Expand Down
4 changes: 2 additions & 2 deletions lab/components/core/src/mas/lab/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from pathlib import Path
from typing import Literal

from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
from mas.runtime.constants import LAB_CONFIG_FILENAME, WORKSPACE_CONFIG_FILENAME
from mas.runtime.workspace_config import (
RuntimeWorkspaceConfig,
find_workspace_file,
Expand Down Expand Up @@ -308,7 +308,7 @@ def source_tag(
) -> str:
"""Return a short human-readable string describing the path source."""
if lab_config:
return "lab-config.yaml"
return LAB_CONFIG_FILENAME
if specific_env and os.environ.get(specific_env):
return f"${specific_env}"
return resolve_path(key).source
Expand Down
15 changes: 7 additions & 8 deletions lab/src/mas/lab/cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import click

from mas.lab import paths as _paths
from mas.library_roots import find_ancestor_with_file
from mas.runtime.constants import LAB_CONFIG_FILENAME


def _lab_output_label(lab_output: Path, slug: str) -> str:
Expand All @@ -25,11 +27,8 @@ def _lab_output_label(lab_output: Path, slug: str) -> str:

def _find_lab_config(start: Path) -> Path | None:
"""Walk up from *start* looking for lab-config.yaml."""
for directory in [start, *start.parents]:
candidate = directory / "lab-config.yaml"
if candidate.exists():
return candidate
return None
lab_dir = find_ancestor_with_file(start, LAB_CONFIG_FILENAME, stop_at_suffix=".lab")
return (lab_dir / LAB_CONFIG_FILENAME) if lab_dir is not None else None


def _resolve_lab_output() -> Path:
Expand Down Expand Up @@ -72,7 +71,7 @@ def config_cmd(as_json: bool) -> None:
lab_slug = _raw_slug.removesuffix(".lab")
if lab_section.get("output_dir"):
lab_output = (lab_config_path.parent / lab_section["output_dir"]).resolve()
lout_source_override = "lab-config.yaml"
lout_source_override = LAB_CONFIG_FILENAME
except Exception:
pass # fall back to global default

Expand Down Expand Up @@ -120,7 +119,7 @@ def config_cmd(as_json: bool) -> None:
"lab_output": {
"path": str(lab_output),
"label": _lab_output_label(lab_output, lab_slug or lab_output.parent.name),
"source": "lab-config.yaml" if lout_source_override == "lab-config.yaml" else "default",
"source": LAB_CONFIG_FILENAME if lout_source_override == LAB_CONFIG_FILENAME else "default",
},
"lab_config": str(lab_config_path) if lab_config_path else None,
"workspace_config": str(workspace_config_path) if workspace_config_path else None,
Expand Down Expand Up @@ -158,7 +157,7 @@ def _tag(source: str) -> str:
runs_source = summary["runs_dir"].source
click.echo(f"\n {'runs root':<22} {summary['runs_dir'].path} {_tag(runs_source)}")

lout_source = "lab-config.yaml" if lout_source_override == "lab-config.yaml" else "default"
lout_source = LAB_CONFIG_FILENAME if lout_source_override == LAB_CONFIG_FILENAME else "default"
_lout_label = _lab_output_label(lab_output, lab_slug or lab_output.parent.name)
click.echo(f"\n {'lab output':<22} {click.style(_lout_label, bold=True)} {_tag(lout_source)}")
click.echo(f" {' path':<22} {click.style(str(lab_output), dim=True)}")
Expand Down
Loading
Loading