refactor: scan filenames at resolve, parse only selected agent at preflight
Manifest.resolve() now returns an empty-dict manifest with only directory paths recorded (home_md, cwd_md). No content is read from any .md file until load_for_agent() is called for a specific agent at preflight. - Manifest.from_md_dirs: scan-only, no frontmatter parsing - Manifest.load_for_agent: parses the selected agent file and its bottle chain; works on eager (from_json_obj) manifests too by returning self - Manifest.all_agent_names: scans filenames in lazy mode - backend._validate: calls load_for_agent and propagates upgraded spec - cli/info.py, cli/list.py, cli/start.py: use load_for_agent / all_agent_names - manifest_extends.py: reverted to original (no partial-resolve helpers) - manifest_loader.py: only scan_agent_names + load_bottle_chain_from_dir - Tests updated to call load_for_agent before accessing agents/bottles; test_md_agent_repos_deferred renamed to test_md_agent_repos_fails_at_preflight
This commit is contained in:
@@ -8,14 +8,13 @@ from typing import TYPE_CHECKING
|
||||
from .log import warn
|
||||
from .manifest_schema import (
|
||||
entity_name_from_path,
|
||||
validate_agent_frontmatter_keys,
|
||||
validate_bottle_frontmatter_keys,
|
||||
)
|
||||
from .manifest_util import ManifestError
|
||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import ManifestAgent, ManifestBottle
|
||||
from .manifest import ManifestBottle
|
||||
|
||||
|
||||
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||
@@ -33,73 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def load_bottles_from_dir(
|
||||
bottles_dir: Path,
|
||||
) -> tuple[dict[str, ManifestBottle], dict[str, ManifestError]]:
|
||||
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
|
||||
`({name: Bottle}, {name: error})`. Missing dir returns empty dicts.
|
||||
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
|
||||
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
|
||||
|
||||
Per-file errors are collected in the second dict rather than raised,
|
||||
so an invalid bottle file does not block unrelated bottles or agents."""
|
||||
from .manifest_extends import resolve_bottles_partial
|
||||
|
||||
raws: dict[str, dict[str, object]] = {}
|
||||
broken: dict[str, ManifestError] = {}
|
||||
if not bottles_dir.is_dir():
|
||||
return {}, {}
|
||||
for path in sorted(bottles_dir.glob("*.md")):
|
||||
name = entity_name_from_path(path)
|
||||
if name is None:
|
||||
warn(
|
||||
f"skipping {path}: filename must match "
|
||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
fm, _body = parse_frontmatter(path.read_text())
|
||||
except OSError as e:
|
||||
broken[name] = ManifestError(f"could not read {path}: {e}")
|
||||
continue
|
||||
except YamlSubsetError as e:
|
||||
broken[name] = ManifestError(f"{path}: {e}")
|
||||
continue
|
||||
try:
|
||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||
except ManifestError as e:
|
||||
broken[name] = e
|
||||
continue
|
||||
raws[name] = fm
|
||||
|
||||
good, resolve_broken = resolve_bottles_partial(raws)
|
||||
broken.update(resolve_broken)
|
||||
return good, broken
|
||||
|
||||
|
||||
def load_agents_from_dir(
|
||||
agents_dir: Path,
|
||||
bottle_names: set[str],
|
||||
*,
|
||||
source: str, # noqa: F841 — unused, but required by interface
|
||||
broken_bottle_errors: dict[str, ManifestError] | None = None,
|
||||
) -> tuple[dict[str, ManifestAgent], dict[str, ManifestError]]:
|
||||
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
||||
`({name: Agent}, {name: error})`. The Markdown body becomes the
|
||||
agent's prompt. Missing dir returns empty dicts.
|
||||
|
||||
Per-file errors are collected in the second dict rather than raised.
|
||||
Agents referencing a broken bottle are also moved to the error dict
|
||||
so their error surfaces at preflight rather than manifest load time."""
|
||||
from .manifest import ManifestAgent
|
||||
|
||||
broken_bottles = broken_bottle_errors or {}
|
||||
# Agents may reference bottles that failed to resolve; accept those names
|
||||
# during structural parsing so we can detect the broken-bottle case below.
|
||||
all_known_bottles = bottle_names | set(broken_bottles.keys())
|
||||
|
||||
out: dict[str, ManifestAgent] = {}
|
||||
broken: dict[str, ManifestError] = {}
|
||||
No file content is read. Invalid filenames are skipped with a warning."""
|
||||
result: dict[str, Path] = {}
|
||||
if not agents_dir.is_dir():
|
||||
return out, broken
|
||||
return result
|
||||
for path in sorted(agents_dir.glob("*.md")):
|
||||
name = entity_name_from_path(path)
|
||||
if name is None:
|
||||
@@ -108,36 +47,45 @@ def load_agents_from_dir(
|
||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
fm, body = parse_frontmatter(path.read_text())
|
||||
except OSError as e:
|
||||
broken[name] = ManifestError(f"could not read {path}: {e}")
|
||||
continue
|
||||
except YamlSubsetError as e:
|
||||
broken[name] = ManifestError(f"{path}: {e}")
|
||||
continue
|
||||
try:
|
||||
validate_agent_frontmatter_keys(path, fm.keys())
|
||||
agent_dict: dict[str, object] = {
|
||||
"bottle": fm.get("bottle"),
|
||||
"skills": fm.get("skills", []),
|
||||
"prompt": body.strip(),
|
||||
}
|
||||
if "git-gate" in fm:
|
||||
agent_dict["git-gate"] = fm["git-gate"]
|
||||
agent = ManifestAgent.from_dict(name, agent_dict, all_known_bottles)
|
||||
except ManifestError as e:
|
||||
broken[name] = e
|
||||
continue
|
||||
result[name] = path
|
||||
return result
|
||||
|
||||
# Agent parsed fine but its bottle may have failed to resolve.
|
||||
bottle_ref = agent.bottle
|
||||
if bottle_ref in broken_bottles:
|
||||
broken[name] = ManifestError(
|
||||
f"agent '{name}' references bottle '{bottle_ref}' which "
|
||||
f"failed to load: {broken_bottles[bottle_ref]}"
|
||||
|
||||
def load_bottle_chain_from_dir(
|
||||
bottle_name: str, bottles_dir: Path
|
||||
) -> ManifestBottle:
|
||||
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
|
||||
returning the resolved ManifestBottle.
|
||||
|
||||
Only the files in the extends chain are read — unrelated bottle files
|
||||
are never touched. Raises ManifestError on parse or validation failure."""
|
||||
from .manifest_extends import resolve_bottles
|
||||
|
||||
raws: dict[str, dict[str, object]] = {}
|
||||
to_load = [bottle_name]
|
||||
while to_load:
|
||||
name = to_load.pop()
|
||||
if name in raws:
|
||||
continue
|
||||
path = bottles_dir / f"{name}.md"
|
||||
if not path.is_file():
|
||||
avail = ", ".join(
|
||||
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
|
||||
) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' not found at {path}. "
|
||||
f"Available: {avail}"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
fm, _body = parse_frontmatter(path.read_text())
|
||||
except OSError as e:
|
||||
raise ManifestError(f"could not read {path}: {e}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{path}: {e}") from e
|
||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||
raws[name] = dict(fm)
|
||||
parent = fm.get("extends")
|
||||
if isinstance(parent, str):
|
||||
to_load.append(parent)
|
||||
|
||||
out[name] = agent
|
||||
return out, broken
|
||||
return resolve_bottles(raws)[bottle_name]
|
||||
|
||||
Reference in New Issue
Block a user