Compare commits

...

3 Commits

Author SHA1 Message Date
didericis-claude 0609813ba0 refactor: scan filenames at resolve, parse only selected agent at preflight
lint / lint (push) Successful in 1m37s
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 17s
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
2026-06-22 19:42:49 +00:00
didericis-claude 09e04359e3 fix: resolve pyright reportUnusedImport in manifest_extends
Import ManifestError at module level from manifest_util (no circular
dep) and remove the redundant local imports from function bodies that
were shadowing it. ManifestBottle retains its local import pattern to
avoid the circular manifest ↔ manifest_extends dependency.
2026-06-22 19:42:49 +00:00
didericis-claude fe32f65fa4 feat: defer broken manifest parse errors to preflight
Broken bottle/agent files no longer block the agent selector or prevent
unrelated agents from loading. Per-file parse errors are collected in
`Manifest.broken_agents`; the CLI selector includes them via
`all_agent_names`, and the error surfaces only when the specific agent
is selected and launch is attempted (in `require_agent`/`bottle_for`).

Closes #236
2026-06-22 19:42:49 +00:00
9 changed files with 260 additions and 137 deletions
+14 -9
View File
@@ -37,7 +37,7 @@ import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
@@ -289,7 +289,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
write_launch_metadata,
)
self._validate(spec)
spec = self._validate(spec)
self._preflight()
@@ -355,16 +355,21 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""
pass
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists
and the named skills are present on the host. Subclasses with
additional preconditions should override and call
`super()._validate(spec)` first."""
manifest = spec.manifest
manifest.require_agent(spec.agent_name)
def _validate(self, spec: BottleSpec) -> BottleSpec:
"""Cross-backend pre-launch checks. Parses the selected agent and
its bottle (raising ManifestError on invalid content), confirms
skills are present on the host, and every git IdentityFile resolves.
Returns a new BottleSpec whose manifest is fully loaded for the
selected agent. Subclasses with additional preconditions should
override and call `super()._validate(spec)` first, using the
returned spec for further checks."""
manifest = spec.manifest.load_for_agent(spec.agent_name)
spec = replace(spec, manifest=manifest)
agent = manifest.agents[spec.agent_name]
self._validate_skills(agent.skills)
self._validate_agent_provider_dockerfile(spec)
return spec
def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's
+3 -2
View File
@@ -14,8 +14,9 @@ def cmd_info(argv: list[str]) -> int:
parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv)
manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(args.name)
names = Manifest.resolve(USER_CWD)
names.require_agent(args.name)
manifest = names.load_for_agent(args.name)
agent = manifest.agents[args.name]
bottle = manifest.bottle_for(args.name)
+1 -1
View File
@@ -41,7 +41,7 @@ def cmd_list(argv: list[str]) -> int:
if args.scope == "available":
manifest = Manifest.resolve(USER_CWD)
for name in manifest.agents.keys():
for name in manifest.all_agent_names:
print(name)
return 0
+1 -1
View File
@@ -67,7 +67,7 @@ def cmd_start(argv: list[str]) -> int:
agent_name: str | None = args.name
if agent_name is None:
agent_name = tui.filter_select(
sorted(manifest.agents.keys()),
manifest.all_agent_names,
title="Select agent",
)
if agent_name is None:
+112 -29
View File
@@ -193,6 +193,10 @@ class ManifestBottle:
class Manifest:
bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, ManifestAgent]
# Set by from_md_dirs; empty in from_json_obj (test/programmatic) mode.
# Stores the manifest root dirs so load_for_agent can locate files later.
home_md: Path | None = field(default=None)
cwd_md: Path | None = field(default=None)
@classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
@@ -249,24 +253,15 @@ class Manifest:
home_dir: Path,
cwd_dir: Path | None,
) -> "Manifest":
"""Programmatic entry point. Loads bottles from
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
name collision. A `bottles/` subdir under `cwd_dir` is
logged as a warning and ignored.
"""Return a names-only Manifest. No file content is read; only
filenames are scanned for the agent selector. Full parsing happens
later, per-agent, via `load_for_agent`.
A `bottles/` subdir under `cwd_dir` is logged as a warning and
ignored — the filesystem layout IS the trust boundary.
Used by tests to build a Manifest from fixture directories
without touching `os.environ`."""
bottles_dir = home_dir / "bottles"
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
bottles = load_bottles_from_dir(bottles_dir)
bottle_names = set(bottles.keys())
agents_dir = home_dir / "agents"
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
@@ -280,13 +275,7 @@ class Manifest:
f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete."
)
cwd_agents_dir = cwd_dir / "agents"
cwd_agents = load_agents_from_dir(
cwd_agents_dir, bottle_names, source="$CWD"
)
agents = {**agents, **cwd_agents}
return cls(bottles=bottles, agents=agents)
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
@classmethod
def from_json_obj(cls, obj: object) -> "Manifest":
@@ -311,18 +300,113 @@ class Manifest:
}
return cls(bottles=bottles, agents=agents)
@property
def all_agent_names(self) -> list[str]:
"""Sorted list of all discoverable agent names.
In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None:
from .manifest_loader import scan_agent_names
home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set()
if self.cwd_md is not None:
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
return sorted(home_names | cwd_names)
return sorted(self.agents.keys())
def load_for_agent(self, agent_name: str) -> "Manifest":
"""Parse and return a full Manifest for `agent_name` and its bottle.
Only the selected agent's file and the bottle files in its extends
chain are read. Raises ManifestError if the agent or bottle is
invalid. Must be called on a names-only manifest (from resolve).
Backends call this at preflight to upgrade the spec's manifest."""
if self.home_md is None:
# Eager manifest (from_json_obj): already fully loaded; just validate name.
if agent_name not in self.agents:
available = ", ".join(sorted(self.agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
return self
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
from .manifest_schema import validate_agent_frontmatter_keys
from .yaml_subset import YamlSubsetError, parse_frontmatter
# Locate the agent file; cwd wins over home on name collision.
home_agents = scan_agent_names(self.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if self.cwd_md is not None:
cwd_agents = scan_agent_names(self.cwd_md / "agents")
merged = {**home_agents, **cwd_agents}
if agent_name not in merged:
available = ", ".join(sorted(merged.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent_path = merged[agent_name]
try:
fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e:
raise ManifestError(f"could not read {agent_path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{agent_path}: {e}") from e
validate_agent_frontmatter_keys(agent_path, fm.keys())
bottle_name = fm.get("bottle")
if not isinstance(bottle_name, str) or not bottle_name:
raise ManifestError(
f"agent '{agent_name}' must declare a 'bottle' field "
f"naming a defined bottle"
)
# Load the bottle chain (may raise ManifestError).
bottles_dir = self.home_md / "bottles"
bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
# Build and validate the full ManifestAgent.
agent_dict: dict[str, object] = {
"bottle": bottle_name,
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
return Manifest(
bottles={bottle_name: bottle},
agents={agent_name: agent},
home_md=self.home_md,
cwd_md=self.cwd_md,
)
def has_agent(self, name: str) -> bool:
return name in self.agents
def require_agent(self, name: str) -> None:
"""Check that `name` is a discoverable agent. In names-only mode
this checks whether the .md file exists; in eager mode it checks
the pre-parsed agents dict. Does NOT parse file content."""
if self.has_agent(name):
return
available = ", ".join(self.agents.keys())
if available:
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
raise ManifestError(msg)
if self.home_md is not None:
# Names-only mode: check file existence without parsing.
home_path = self.home_md / "agents" / f"{name}.md"
cwd_path = (
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError(
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
f"agent '{name}' not defined. Available: {available}"
)
def has_bottle(self, name: str) -> bool:
@@ -356,8 +440,7 @@ class Manifest:
def bottle_for(self, agent_name: str) -> ManifestBottle:
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
agent's git.user overlaid on top.
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
+44 -58
View File
@@ -8,21 +8,19 @@ 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:
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
not. The manifest format changed in PRD 0011 and we do not want
to silently leave the JSON content unused."""
from .manifest import ManifestError
legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists():
raise ManifestError(
@@ -34,48 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
)
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
raws: dict[str, dict[str, object]] = {}
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:
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] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import ManifestAgent, ManifestError
out: dict[str, ManifestAgent] = {}
No file content is read. Invalid filenames are skipped with a warning."""
result: dict[str, Path] = {}
if not agents_dir.is_dir():
return out
return result
for path in sorted(agents_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
@@ -84,22 +47,45 @@ def load_agents_from_dir(
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
result[name] = path
return result
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}"
)
try:
fm, body = parse_frontmatter(path.read_text())
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_agent_frontmatter_keys(path, fm.keys())
# Build the dict Agent.from_dict expects. The body becomes
# prompt; Claude Code passthrough fields stay in fm and get
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
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"]
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = dict(fm)
parent = fm.get("extends")
if isinstance(parent, str):
to_load.append(parent)
return resolve_bottles(raws)[bottle_name]
+1
View File
@@ -20,6 +20,7 @@ from bot_bottle.backend import ActiveAgent
def _make_manifest(agent_names: list[str]):
manifest = MagicMock()
manifest.agents = {name: MagicMock() for name in agent_names}
manifest.all_agent_names = sorted(agent_names)
return manifest
+10 -3
View File
@@ -217,7 +217,7 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home))
m = Manifest.resolve(str(self.home)).load_for_agent("impl")
u = m.bottle_for("impl").git_user
self.assertEqual("agent-name", u.name)
self.assertEqual("bottle@example.com", u.email)
@@ -226,10 +226,17 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
m.git_identity_summary("impl"),
)
def test_md_agent_repos_dies(self):
def test_md_agent_repos_fails_at_preflight(self):
"""git-gate.repos on an agent is an error; resolve() still succeeds
so other agents remain accessible, but load_for_agent raises."""
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REPOS)
msg = _error_message(Manifest.resolve, str(self.home))
from bot_bottle.manifest import ManifestError
names = Manifest.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl")
msg = str(ctx.exception)
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
+74 -34
View File
@@ -77,12 +77,12 @@ class _ResolveCase(unittest.TestCase):
class TestBottleFileParses(_ResolveCase):
"""SC #1: a bottle file under $HOME/.bot-bottle/bottles/
parses into the expected Bottle shape."""
parses into the expected Bottle shape via load_for_agent."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
m = self.resolve().load_for_agent("implementer")
self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes
self.assertEqual(2, len(routes))
@@ -94,13 +94,13 @@ class TestBottleFileParses(_ResolveCase):
class TestAgentFileParses(_ResolveCase):
"""SC #2: an agent file under $HOME/.bot-bottle/agents/
parses, the body becomes the prompt, the frontmatter fields
map to Agent fields."""
parses via load_for_agent; the body becomes the prompt, the
frontmatter fields map to Agent fields."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
m = self.resolve().load_for_agent("implementer")
a = m.agents["implementer"]
self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills)
@@ -128,7 +128,7 @@ class TestCwdAgentOverridesHome(_ResolveCase):
CWD-OVERRIDE-PROMPT
""",
)
m = self.resolve()
m = self.resolve().load_for_agent("implementer")
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present
self.assertEqual(2, len(m.bottles["dev"].egress.routes))
@@ -155,7 +155,7 @@ class TestCwdBottlesIgnored(_ResolveCase):
---
""",
)
m = self.resolve()
m = self.resolve().load_for_agent("implementer")
# Home value wins because cwd bottles are ignored entirely.
self.assertEqual(
"api.anthropic.com",
@@ -215,7 +215,7 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
Agent prompt body.
""",
)
m = self.resolve()
m = self.resolve().load_for_agent("implementer")
self.assertEqual("dev", m.agents["implementer"].bottle)
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
@@ -228,7 +228,7 @@ class TestManifestEntryPointParity(_ResolveCase):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve()
md_manifest = self.resolve().load_for_agent("implementer")
json_manifest = Manifest.from_json_obj({
"bottles": {
"dev": {
@@ -294,34 +294,48 @@ class TestManifestEntryPointParity(_ResolveCase):
self.assertEqual("dev", manifest.agents["implementer"].bottle)
class TestUnknownAgentKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file dies
rather than silently ignoring."""
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file does NOT crash
resolve(). The agent appears in all_agent_names for the selector.
The error surfaces only when load_for_agent is called for that agent."""
def test_dies(self):
def test_resolve_succeeds_despite_broken_agent(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "implementer.md",
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
...
""",
)
with self.assertRaises(ManifestError):
self.resolve()
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Resolve itself does not raise; broken agent appears in the name list.
self.assertIn("bad", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
class TestUnknownBottleKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on a bottle file dies
rather than silently ignoring."""
def test_dies(self):
def test_load_for_agent_raises_for_broken_agent(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "bottles" / "dev.md",
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
""",
)
m = self.resolve()
with self.assertRaises(ManifestError):
m.load_for_agent("bad")
def test_broken_bottle_only_fails_at_preflight(self):
"""A broken bottle does not crash resolve; only load_for_agent for
an agent that references it raises. Unrelated agents still work."""
_write(
self.home_cb / "bottles" / "bad.md",
"""
---
credproxy:
@@ -329,9 +343,26 @@ class TestUnknownBottleKeyDies(_ResolveCase):
---
""",
)
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
_write(
self.home_cb / "agents" / "broken-agent.md",
"""
---
bottle: bad
---
""",
)
m = self.resolve()
# Both agents appear in the name list at resolve time.
self.assertIn("implementer", m.all_agent_names)
self.assertIn("broken-agent", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertIn("implementer", full.agents)
# Broken bottle's agent raises at preflight.
with self.assertRaises(ManifestError):
self.resolve()
m.load_for_agent("broken-agent")
class TestStaleJsonDies(_ResolveCase):
@@ -359,11 +390,11 @@ class TestNoManifestDies(_ResolveCase):
self.assertEqual({}, dict(m.agents))
class TestUnknownBottleReferenceDies(_ResolveCase):
"""An agent file naming a bottle that doesn't exist on disk
dies with the existing "bottle not defined" error."""
class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
"""An agent file naming a non-existent bottle appears in all_agent_names
at resolve time; the error only surfaces when load_for_agent is called."""
def test_dies(self):
def test_stray_bottle_reference_fails_at_preflight(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "stray.md",
@@ -373,8 +404,17 @@ class TestUnknownBottleReferenceDies(_ResolveCase):
---
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Both names visible at resolve time.
self.assertIn("stray", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertIn("implementer", full.agents)
# Stray agent fails at preflight.
with self.assertRaises(ManifestError):
self.resolve()
m.load_for_agent("stray")
class TestFilenameValidation(_ResolveCase):
@@ -388,9 +428,9 @@ class TestFilenameValidation(_ResolveCase):
# This file should be skipped — capital letters not allowed.
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("implementer", m.agents)
self.assertNotIn("BadName", m.agents)
self.assertNotIn("badname", m.agents)
self.assertIn("implementer", m.all_agent_names)
self.assertNotIn("BadName", m.all_agent_names)
self.assertNotIn("badname", m.all_agent_names)
if __name__ == "__main__":