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:
2026-06-20 02:45:33 +00:00
committed by didericis
parent 996a260a98
commit 3ccd09ed0d
8 changed files with 227 additions and 319 deletions
+45 -97
View File
@@ -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]