Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cdedbb7ca | |||
| f787764364 |
+12
-122
@@ -62,15 +62,25 @@ from dataclasses import dataclass, field, replace
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .log import warn
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
from .manifest_agent import ManifestAgent, ManifestAgentProvider
|
from .manifest_agent import ManifestAgent, ManifestAgentProvider
|
||||||
|
from .manifest_bottle import ManifestBottle
|
||||||
from .manifest_egress import (
|
from .manifest_egress import (
|
||||||
EGRESS_AUTH_SCHEMES,
|
EGRESS_AUTH_SCHEMES,
|
||||||
ManifestEgressConfig,
|
ManifestEgressConfig,
|
||||||
ManifestEgressRoute,
|
ManifestEgressRoute,
|
||||||
)
|
)
|
||||||
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config
|
from .manifest_extends import merge_bottles_runtime, resolve_bottles
|
||||||
from .manifest_schema import BOTTLE_KEYS
|
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
|
||||||
|
from .manifest_loader import (
|
||||||
|
check_stale_json,
|
||||||
|
load_bottle_chain_from_dir,
|
||||||
|
scan_agent_names,
|
||||||
|
scan_bottle_names,
|
||||||
|
)
|
||||||
|
from .manifest_schema import validate_agent_frontmatter_keys
|
||||||
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
# Re-export everything that callers currently import from this module.
|
# Re-export everything that callers currently import from this module.
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -89,10 +99,6 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _empty_str_dict() -> dict[str, str]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _section_dict(value: object, label: str) -> dict[str, object]:
|
def _section_dict(value: object, label: str) -> dict[str, object]:
|
||||||
"""Like as_json_object but treats absent/null as an empty section."""
|
"""Like as_json_object but treats absent/null as an empty section."""
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -100,107 +106,6 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
|
|||||||
return as_json_object(value, label)
|
return as_json_object(value, label)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ManifestBottle:
|
|
||||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
|
||||||
agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
|
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
|
||||||
# Per-bottle git identity (issue #86). Empty default — bottles
|
|
||||||
# that don't set `git-gate.user:` in the manifest skip the
|
|
||||||
# `git config --global` step entirely. A bottle can declare a user
|
|
||||||
# identity without any git-gate.repos upstreams, and vice versa.
|
|
||||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
|
||||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
|
||||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
|
||||||
# default, issue #249), the launch step brings up a supervise
|
|
||||||
# sidecar that exposes egress MCP tools to the agent. Set
|
|
||||||
# `supervise: false` to skip the sidecar.
|
|
||||||
supervise: bool = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
|
||||||
d = as_json_object(raw, f"bottle '{name}'")
|
|
||||||
|
|
||||||
if "runtime" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has a 'runtime' field, which is no longer "
|
|
||||||
f"supported. gVisor (runsc) is now auto-detected by the "
|
|
||||||
f"backend; remove the 'runtime' field from the bottle "
|
|
||||||
f"definition."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "ssh" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
|
||||||
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
|
|
||||||
f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
|
|
||||||
f"holds the credential and gitleaks-scans pushes."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "git" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' uses 'git' which has been replaced by "
|
|
||||||
f"'git-gate' (PRD 0047). Move git.user → git-gate.user "
|
|
||||||
f"and git.remotes → git-gate.repos (fields: url, identity, host_key)."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "git_user" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has a 'git_user' field, which has been "
|
|
||||||
f"removed. Move it under 'git-gate.user'."
|
|
||||||
)
|
|
||||||
|
|
||||||
unknown = set(d.keys()) - BOTTLE_KEYS
|
|
||||||
if unknown:
|
|
||||||
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
|
|
||||||
f"allowed keys are {allowed}."
|
|
||||||
)
|
|
||||||
|
|
||||||
env: dict[str, str] = {}
|
|
||||||
env_raw = d.get("env")
|
|
||||||
if env_raw is not None:
|
|
||||||
env_dict = as_json_object(env_raw, f"bottle '{name}' env")
|
|
||||||
for var, value in env_dict.items():
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ManifestError(
|
|
||||||
f"env entry {var} in bottle '{name}' must be a JSON string "
|
|
||||||
f"(was {type(value).__name__}). Use \"?<message>\" for prompt-at-runtime."
|
|
||||||
)
|
|
||||||
env[var] = value
|
|
||||||
|
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
|
||||||
git_user = ManifestGitUser()
|
|
||||||
git_raw = d.get("git-gate")
|
|
||||||
if git_raw is not None:
|
|
||||||
git, git_user = parse_git_gate_config(name, git_raw)
|
|
||||||
|
|
||||||
agent_provider = (
|
|
||||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
|
||||||
if "agent_provider" in d
|
|
||||||
else ManifestAgentProvider()
|
|
||||||
)
|
|
||||||
|
|
||||||
egress = (
|
|
||||||
ManifestEgressConfig.from_dict(name, d["egress"])
|
|
||||||
if "egress" in d
|
|
||||||
else ManifestEgressConfig()
|
|
||||||
)
|
|
||||||
|
|
||||||
supervise_raw = d.get("supervise", True)
|
|
||||||
if not isinstance(supervise_raw, bool):
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' supervise must be a boolean "
|
|
||||||
f"(was {type(supervise_raw).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
env=env, agent_provider=agent_provider, git=git,
|
|
||||||
git_user=git_user, egress=egress, supervise=supervise_raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_git_user(
|
def _merge_git_user(
|
||||||
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
||||||
) -> ManifestGitUser:
|
) -> ManifestGitUser:
|
||||||
@@ -237,8 +142,6 @@ def _resolve_effective_bottle_eager(
|
|||||||
|
|
||||||
When bottle_names is non-empty they are merged in order. When empty, falls
|
When bottle_names is non-empty they are merged in order. When empty, falls
|
||||||
back to agent.bottle. Raises ManifestError when neither is set."""
|
back to agent.bottle. Raises ManifestError when neither is set."""
|
||||||
from .manifest_extends import merge_bottles_runtime
|
|
||||||
|
|
||||||
if bottle_names:
|
if bottle_names:
|
||||||
resolved: list[ManifestBottle] = []
|
resolved: list[ManifestBottle] = []
|
||||||
for bn in bottle_names:
|
for bn in bottle_names:
|
||||||
@@ -270,9 +173,6 @@ def _resolve_effective_bottle_lazy(
|
|||||||
When bottle_names is non-empty they are resolved from disk and merged in
|
When bottle_names is non-empty they are resolved from disk and merged in
|
||||||
order. When empty, falls back to agent_bottle. Raises ManifestError when
|
order. When empty, falls back to agent_bottle. Raises ManifestError when
|
||||||
neither is set."""
|
neither is set."""
|
||||||
from .manifest_extends import merge_bottles_runtime
|
|
||||||
from .manifest_loader import load_bottle_chain_from_dir
|
|
||||||
|
|
||||||
if bottle_names:
|
if bottle_names:
|
||||||
resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names]
|
resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names]
|
||||||
return merge_bottles_runtime(resolved)
|
return merge_bottles_runtime(resolved)
|
||||||
@@ -358,8 +258,6 @@ class ManifestIndex:
|
|||||||
home_md = home_dir / ".bot-bottle"
|
home_md = home_dir / ".bot-bottle"
|
||||||
cwd_md = cwd_dir / ".bot-bottle"
|
cwd_md = cwd_dir / ".bot-bottle"
|
||||||
|
|
||||||
from .manifest_loader import check_stale_json
|
|
||||||
|
|
||||||
check_stale_json(home_dir, home_md, "$HOME")
|
check_stale_json(home_dir, home_md, "$HOME")
|
||||||
if cwd_dir.resolve() != home_dir.resolve():
|
if cwd_dir.resolve() != home_dir.resolve():
|
||||||
check_stale_json(cwd_dir, cwd_md, "$CWD")
|
check_stale_json(cwd_dir, cwd_md, "$CWD")
|
||||||
@@ -399,7 +297,6 @@ class ManifestIndex:
|
|||||||
files = sorted(stale_bottles.glob("*.md"))
|
files = sorted(stale_bottles.glob("*.md"))
|
||||||
if files:
|
if files:
|
||||||
names = ", ".join(p.name for p in files)
|
names = ", ".join(p.name for p in files)
|
||||||
from .log import warn
|
|
||||||
warn(
|
warn(
|
||||||
f"ignoring bottle file(s) under "
|
f"ignoring bottle file(s) under "
|
||||||
f"{stale_bottles}: {names}. Bottles can only "
|
f"{stale_bottles}: {names}. Bottles can only "
|
||||||
@@ -421,7 +318,6 @@ class ManifestIndex:
|
|||||||
raw_bottles: dict[str, dict[str, object]] = {}
|
raw_bottles: dict[str, dict[str, object]] = {}
|
||||||
for n, b in raw_bottles_obj.items():
|
for n, b in raw_bottles_obj.items():
|
||||||
raw_bottles[n] = as_json_object(b, f"bottle '{n}'")
|
raw_bottles[n] = as_json_object(b, f"bottle '{n}'")
|
||||||
from .manifest_extends import resolve_bottles
|
|
||||||
|
|
||||||
bottles = resolve_bottles(raw_bottles)
|
bottles = resolve_bottles(raw_bottles)
|
||||||
|
|
||||||
@@ -439,7 +335,6 @@ class ManifestIndex:
|
|||||||
filenames without reading their content. In eager mode (from
|
filenames without reading their content. In eager mode (from
|
||||||
from_json_obj) it returns the pre-parsed bottles' names."""
|
from_json_obj) it returns the pre-parsed bottles' names."""
|
||||||
if self.home_md is not None:
|
if self.home_md is not None:
|
||||||
from .manifest_loader import scan_bottle_names
|
|
||||||
return scan_bottle_names(self.home_md / "bottles")
|
return scan_bottle_names(self.home_md / "bottles")
|
||||||
return sorted(self.bottles.keys())
|
return sorted(self.bottles.keys())
|
||||||
|
|
||||||
@@ -451,7 +346,6 @@ class ManifestIndex:
|
|||||||
filenames without reading their content. In eager mode (from
|
filenames without reading their content. In eager mode (from
|
||||||
from_json_obj) it returns the pre-parsed agents' names."""
|
from_json_obj) it returns the pre-parsed agents' names."""
|
||||||
if self.home_md is not None:
|
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())
|
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
||||||
cwd_names: set[str] = set()
|
cwd_names: set[str] = set()
|
||||||
if self.cwd_md is not None:
|
if self.cwd_md is not None:
|
||||||
@@ -509,10 +403,6 @@ class ManifestIndex:
|
|||||||
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
|
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
|
||||||
its bottle chain from disk for the first time here."""
|
its bottle chain from disk for the first time here."""
|
||||||
assert self.home_md is not None # guaranteed by load_for_agent dispatch
|
assert self.home_md is not None # guaranteed by load_for_agent dispatch
|
||||||
from .manifest_loader import 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.
|
# Locate the agent file; cwd wins over home on name collision.
|
||||||
home_agents = scan_agent_names(self.home_md / "agents")
|
home_agents = scan_agent_names(self.home_md / "agents")
|
||||||
cwd_agents: dict[str, Path] = {}
|
cwd_agents: dict[str, Path] = {}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""The `ManifestBottle` value type.
|
||||||
|
|
||||||
|
Split out of `manifest.py` so the `extends:`/loader resolvers can import it
|
||||||
|
without a circular dependency: `manifest.py` imports those resolvers, while
|
||||||
|
they only need this value type. Everything here depends on leaf modules
|
||||||
|
(`manifest_util`, `manifest_agent`, `manifest_egress`, `manifest_git`,
|
||||||
|
`manifest_schema`), so this module sits at the bottom of the manifest layer.
|
||||||
|
|
||||||
|
`manifest.py` re-exports `ManifestBottle`, so existing
|
||||||
|
`from .manifest import ManifestBottle` callers are unaffected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .manifest_util import ManifestError, as_json_object
|
||||||
|
from .manifest_agent import ManifestAgentProvider
|
||||||
|
from .manifest_egress import ManifestEgressConfig
|
||||||
|
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
|
||||||
|
from .manifest_schema import BOTTLE_KEYS
|
||||||
|
|
||||||
|
__all__ = ["ManifestBottle"]
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_str_dict() -> dict[str, str]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ManifestBottle:
|
||||||
|
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
|
agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
|
||||||
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
|
# Per-bottle git identity (issue #86). Empty default — bottles
|
||||||
|
# that don't set `git-gate.user:` in the manifest skip the
|
||||||
|
# `git config --global` step entirely. A bottle can declare a user
|
||||||
|
# identity without any git-gate.repos upstreams, and vice versa.
|
||||||
|
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||||
|
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||||
|
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
||||||
|
# default, issue #249), the launch step brings up a supervise
|
||||||
|
# sidecar that exposes egress MCP tools to the agent. Set
|
||||||
|
# `supervise: false` to skip the sidecar.
|
||||||
|
supervise: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||||
|
d = as_json_object(raw, f"bottle '{name}'")
|
||||||
|
|
||||||
|
if "runtime" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has a 'runtime' field, which is no longer "
|
||||||
|
f"supported. gVisor (runsc) is now auto-detected by the "
|
||||||
|
f"backend; remove the 'runtime' field from the bottle "
|
||||||
|
f"definition."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "ssh" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
||||||
|
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
|
||||||
|
f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
|
||||||
|
f"holds the credential and gitleaks-scans pushes."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "git" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' uses 'git' which has been replaced by "
|
||||||
|
f"'git-gate' (PRD 0047). Move git.user → git-gate.user "
|
||||||
|
f"and git.remotes → git-gate.repos (fields: url, identity, host_key)."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "git_user" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has a 'git_user' field, which has been "
|
||||||
|
f"removed. Move it under 'git-gate.user'."
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(d.keys()) - BOTTLE_KEYS
|
||||||
|
if unknown:
|
||||||
|
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
|
||||||
|
f"allowed keys are {allowed}."
|
||||||
|
)
|
||||||
|
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
env_raw = d.get("env")
|
||||||
|
if env_raw is not None:
|
||||||
|
env_dict = as_json_object(env_raw, f"bottle '{name}' env")
|
||||||
|
for var, value in env_dict.items():
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ManifestError(
|
||||||
|
f"env entry {var} in bottle '{name}' must be a JSON string "
|
||||||
|
f"(was {type(value).__name__}). Use \"?<message>\" for prompt-at-runtime."
|
||||||
|
)
|
||||||
|
env[var] = value
|
||||||
|
|
||||||
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
|
git_user = ManifestGitUser()
|
||||||
|
git_raw = d.get("git-gate")
|
||||||
|
if git_raw is not None:
|
||||||
|
git, git_user = parse_git_gate_config(name, git_raw)
|
||||||
|
|
||||||
|
agent_provider = (
|
||||||
|
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||||
|
if "agent_provider" in d
|
||||||
|
else ManifestAgentProvider()
|
||||||
|
)
|
||||||
|
|
||||||
|
egress = (
|
||||||
|
ManifestEgressConfig.from_dict(name, d["egress"])
|
||||||
|
if "egress" in d
|
||||||
|
else ManifestEgressConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
supervise_raw = d.get("supervise", True)
|
||||||
|
if not isinstance(supervise_raw, bool):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' supervise must be a boolean "
|
||||||
|
f"(was {type(supervise_raw).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
env=env, agent_provider=agent_provider, git=git,
|
||||||
|
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||||
|
)
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from .manifest_bottle import ManifestBottle
|
||||||
|
from .manifest_egress import ManifestEgressConfig, validate_egress_routes
|
||||||
if TYPE_CHECKING:
|
from .manifest_git import ManifestGitUser, parse_git_gate_config
|
||||||
from .manifest import ManifestBottle
|
from .manifest_util import ManifestError, as_json_object
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
|
|
||||||
|
|
||||||
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
||||||
@@ -27,9 +26,6 @@ def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
|||||||
|
|
||||||
|
|
||||||
def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle") -> "ManifestBottle":
|
def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle") -> "ManifestBottle":
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
|
|
||||||
merged_env = {**base.env, **override.env}
|
merged_env = {**base.env, **override.env}
|
||||||
|
|
||||||
merged_git_user = ManifestGitUser(
|
merged_git_user = ManifestGitUser(
|
||||||
@@ -81,8 +77,6 @@ def _resolve_one_bottle(
|
|||||||
repos_cache: dict[str, dict[str, object]],
|
repos_cache: dict[str, dict[str, object]],
|
||||||
seen: tuple[str, ...],
|
seen: tuple[str, ...],
|
||||||
) -> ManifestBottle:
|
) -> ManifestBottle:
|
||||||
from .manifest import ManifestBottle, ManifestError
|
|
||||||
|
|
||||||
if name in cache:
|
if name in cache:
|
||||||
return cache[name]
|
return cache[name]
|
||||||
if name in seen:
|
if name in seen:
|
||||||
@@ -174,11 +168,6 @@ def _fold_two_bottles(
|
|||||||
later_repos_raw: dict[str, object],
|
later_repos_raw: dict[str, object],
|
||||||
) -> tuple[ManifestBottle, dict[str, object]]:
|
) -> tuple[ManifestBottle, dict[str, object]]:
|
||||||
"""Combine two resolved parent bottles; later wins over earlier."""
|
"""Combine two resolved parent bottles; later wins over earlier."""
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
from .manifest_git import parse_git_gate_config
|
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
merged_env = {**earlier.env, **later.env}
|
merged_env = {**earlier.env, **later.env}
|
||||||
|
|
||||||
merged_git_user = ManifestGitUser(
|
merged_git_user = ManifestGitUser(
|
||||||
@@ -227,10 +216,6 @@ def _merge_bottles(
|
|||||||
name: str,
|
name: str,
|
||||||
) -> ManifestBottle:
|
) -> ManifestBottle:
|
||||||
"""Apply PRD 0025 merge rules."""
|
"""Apply PRD 0025 merge rules."""
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
|
||||||
from .manifest_egress import validate_egress_routes
|
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
# git-gate.repos: when the child declares repos, inject the already
|
# git-gate.repos: when the child declares repos, inject the already
|
||||||
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
||||||
# parses with the full inherited+overridden list (issue #237).
|
# parses with the full inherited+overridden list (issue #237).
|
||||||
@@ -303,8 +288,6 @@ def _resolve_repos_raw(
|
|||||||
inherits the parent's set verbatim; an explicit empty dict clears it.
|
inherits the parent's set verbatim; an explicit empty dict clears it.
|
||||||
Otherwise parent and child unite by name, with same-name entries
|
Otherwise parent and child unite by name, with same-name entries
|
||||||
field-merged (parent fields are defaults, child fields win)."""
|
field-merged (parent fields are defaults, child fields win)."""
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
if not _child_declares_git_gate_repos(child_raw):
|
if not _child_declares_git_gate_repos(child_raw):
|
||||||
return parent_repos
|
return parent_repos
|
||||||
child_repos = _declared_repos_raw(child_raw)
|
child_repos = _declared_repos_raw(child_raw)
|
||||||
@@ -324,8 +307,6 @@ def _resolve_repos_raw(
|
|||||||
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
||||||
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
||||||
or an empty dict when none are declared."""
|
or an empty dict when none are declared."""
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
if not _child_declares_git_gate_repos(child_raw):
|
if not _child_declares_git_gate_repos(child_raw):
|
||||||
return {}
|
return {}
|
||||||
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||||
@@ -333,8 +314,6 @@ def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
git_raw = child_raw.get("git-gate")
|
git_raw = child_raw.get("git-gate")
|
||||||
if git_raw is None:
|
if git_raw is None:
|
||||||
return False
|
return False
|
||||||
@@ -347,9 +326,6 @@ def _merge_egress(
|
|||||||
child: ManifestEgressConfig,
|
child: ManifestEgressConfig,
|
||||||
child_raw: dict[str, object],
|
child_raw: dict[str, object],
|
||||||
) -> ManifestEgressConfig:
|
) -> ManifestEgressConfig:
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
|
child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
|
||||||
routes = parent.routes + child.routes
|
routes = parent.routes + child.routes
|
||||||
log = child.Log if "log" in child_egress_raw else parent.Log
|
log = child.Log if "log" in child_egress_raw else parent.Log
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .log import warn
|
from .log import warn
|
||||||
|
from .manifest_bottle import ManifestBottle
|
||||||
|
from .manifest_extends import resolve_bottles
|
||||||
from .manifest_schema import (
|
from .manifest_schema import (
|
||||||
entity_name_from_path,
|
entity_name_from_path,
|
||||||
validate_bottle_frontmatter_keys,
|
validate_bottle_frontmatter_keys,
|
||||||
@@ -13,9 +14,6 @@ from .manifest_schema import (
|
|||||||
from .manifest_util import ManifestError
|
from .manifest_util import ManifestError
|
||||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .manifest import ManifestBottle
|
|
||||||
|
|
||||||
|
|
||||||
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
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
|
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||||
@@ -78,8 +76,6 @@ def load_bottle_chain_from_dir(
|
|||||||
|
|
||||||
Only the files in the extends chain are read — unrelated bottle files
|
Only the files in the extends chain are read — unrelated bottle files
|
||||||
are never touched. Raises ManifestError on parse or validation failure."""
|
are never touched. Raises ManifestError on parse or validation failure."""
|
||||||
from .manifest_extends import resolve_bottles
|
|
||||||
|
|
||||||
raws: dict[str, dict[str, object]] = {}
|
raws: dict[str, dict[str, object]] = {}
|
||||||
to_load = [bottle_name]
|
to_load = [bottle_name]
|
||||||
while to_load:
|
while to_load:
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# PRD prd-new: Egress control plane — metering, budgets, and forced cutoff
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-06-25
|
||||||
|
- **Issue:** #251
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an **out-of-band egress enforcement & observability plane**: meter every
|
||||||
|
agent's token usage at the egress proxy, decrement budgets without the agent's
|
||||||
|
cooperation, and forcibly cut a bottle's egress when a budget is exhausted —
|
||||||
|
either automatically or on command from a host-level dashboard. The trigger
|
||||||
|
(usage threshold) and the action (route-drop / freeze / kill) both live in the
|
||||||
|
egress plane and run with no agent in the loop. This is distinct from the
|
||||||
|
supervise sidecar (PRD 0013), which is agent-initiated and therefore cannot
|
||||||
|
enforce a cost cutoff on a runaway agent. State (usage ledger, budgets, audit)
|
||||||
|
moves into a host-level SQLite database behind a thin repository API, the first
|
||||||
|
SQL store in an otherwise flat-file repo.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
bot-bottle can't currently do two things the cost-overrun case demands:
|
||||||
|
|
||||||
|
1. **Forced egress shutdown on limit.** When an agent crosses a token
|
||||||
|
threshold, kill its egress automatically — no human in the loop.
|
||||||
|
2. **Remote (host-level) management.** Drive agents from a single surface:
|
||||||
|
see usage, cut egress, stop bottles, to prevent cost overruns.
|
||||||
|
|
||||||
|
The existing supervise sidecar (PRD 0013) is **entirely agent-initiated**: every
|
||||||
|
action begins with the agent voluntarily calling an MCP tool and an operator
|
||||||
|
approving it. A runaway or expensive agent — exactly the cost-overrun case —
|
||||||
|
will never call `egress-block` on itself. Supervision is therefore a
|
||||||
|
**collaborative recovery** mechanism, not an **enforcement** mechanism; making
|
||||||
|
it mandatory (#249) would not deliver forced cost-cutoff.
|
||||||
|
|
||||||
|
The requirement forces a distinction the current design blurs:
|
||||||
|
|
||||||
|
- **Plane A — enforcement / observability (this PRD).** System → infrastructure.
|
||||||
|
Meter usage, cut egress on threshold or command, account for cost.
|
||||||
|
Out-of-band; independent of the agent. **Unconditional** — an enforcement
|
||||||
|
plane you can opt out of isn't enforcement.
|
||||||
|
- **Plane B — agent-facing recovery (the existing supervise sidecar).**
|
||||||
|
Agent → operator, approval-gated. Useful interactively; meaningless for a
|
||||||
|
headless agent with no operator watching its queue. Remains optional.
|
||||||
|
|
||||||
|
This PRD builds Plane A. It reframes the "always-on control" invariant of #249
|
||||||
|
as "the egress control plane is always present" — a more defensible property
|
||||||
|
than "every agent runs the agent-facing supervisor." Unsupervised
|
||||||
|
(headless/CI/ephemeral) agents stay first-class: still subject to the mandatory
|
||||||
|
meter + kill switch, they simply lack the agent-facing proposal tools they
|
||||||
|
couldn't use anyway.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- The egress proxy meters every request to a metered API host (e.g.
|
||||||
|
`api.anthropic.com`) and records authoritative token usage per bottle and per
|
||||||
|
agent provider, with no agent cooperation.
|
||||||
|
- A budget can be set at four scopes with deterministic precedence
|
||||||
|
(**agent → bottle → parent bottle → global host budget**); the
|
||||||
|
most-specific applicable budget governs.
|
||||||
|
- When usage crosses a budget, the bottle's configured **cutoff policy**
|
||||||
|
(`cutoff` | `freeze` | `kill`) fires automatically, executed host-side on the
|
||||||
|
egress plane — never via the supervise queue.
|
||||||
|
- An operator can, from a single **host-level TUI dashboard**, see live per-bottle
|
||||||
|
usage against budget and command a cutoff/stop on demand.
|
||||||
|
- Host budgets, default cutoff policy, and per-provider limits are declared in a
|
||||||
|
new host-level `~/.bot-bottle/settings.yml`, parseable by `yaml_subset.py`.
|
||||||
|
- All usage, budget state, and enforcement actions persist in a host-level
|
||||||
|
SQLite DB behind a thin repository API, so the store can later be swapped for
|
||||||
|
a cross-host cloud service.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **Remote control / cross-host control plane.** Web + mobile remote control,
|
||||||
|
cross-host budgets, and the authn/transport they require are explicitly
|
||||||
|
deferred. v1 is a **host-only TUI** with no remote surface.
|
||||||
|
- **Dollar-denominated budgets.** Budgets are token counts keyed by agent
|
||||||
|
provider, not currency. Price tables are out of scope.
|
||||||
|
- **Migrating existing flat-file state into SQLite.** Resume `metadata.json`,
|
||||||
|
transcripts, Dockerfile overrides, the supervise queue, and audit logs stay on
|
||||||
|
the filesystem. Only the *new* metering/budget/enforcement ledger is SQL.
|
||||||
|
- **Making the supervise sidecar (Plane B) mandatory.** Out of scope here; this
|
||||||
|
PRD is the answer to "what should be unconditional" (Plane A), leaving #249's
|
||||||
|
Plane-B question open.
|
||||||
|
- **Per-request hard pre-send blocking as the primary mechanism.** The gate is
|
||||||
|
budget-crossing detected at/after metering; a pre-flight estimator (below) is a
|
||||||
|
refinement, not the core enforcement path.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Two measurements: gate vs. account
|
||||||
|
|
||||||
|
There are two distinct needs, and they want different signals:
|
||||||
|
|
||||||
|
- **Account (authoritative).** Decrement the real budget from the API
|
||||||
|
**response**, which already carries authoritative usage (Anthropic
|
||||||
|
`input_tokens` / `output_tokens`, OpenAI `usage`). The egress addon already
|
||||||
|
has a `response(flow)` hook (`bot_bottle/egress_addon.py:460`), so the real
|
||||||
|
number is available with no extra network call. **Caveat:** agent traffic is
|
||||||
|
mostly streaming SSE, so the response path must tail the stream for the final
|
||||||
|
usage event rather than parse a single JSON body — scoped explicitly as work.
|
||||||
|
- **Gate (estimate).** To block *before* sending, only the request is available,
|
||||||
|
so an estimator / provider `count_tokens` endpoint is the only option.
|
||||||
|
|
||||||
|
Calling `count_tokens` for accounting would be both less accurate *and* an extra
|
||||||
|
metered egress call per request, so accounting uses response `usage` and the
|
||||||
|
estimator is reserved for the optional pre-flight gate.
|
||||||
|
|
||||||
|
### `count_tokens` on agent providers
|
||||||
|
|
||||||
|
Add an abstract `count_tokens(request) -> int` to the `AgentProvider`
|
||||||
|
abstraction (`bot_bottle/agent_provider.py`):
|
||||||
|
|
||||||
|
- **Default** is a good-enough stdlib estimator. Prefer stdlib only; a small
|
||||||
|
pip dependency *for the sidecar* is acceptable for the fallback if stdlib
|
||||||
|
proves too inaccurate (this does not relax the package's stdlib-first stance —
|
||||||
|
it would be a sidecar-only dep, like the bundle already carries).
|
||||||
|
- **Built-in `claude`** uses Anthropic's token-counting endpoint;
|
||||||
|
**built-in `codex`** uses OpenAI's. These are exact for the gate but cost a
|
||||||
|
metered call, so they are gate-only; accounting still comes from the response.
|
||||||
|
|
||||||
|
### Budgets and precedence
|
||||||
|
|
||||||
|
Budgets are token counts keyed by **agent provider name** (the same names
|
||||||
|
bottles already use). Four scopes, most-specific wins:
|
||||||
|
|
||||||
|
```
|
||||||
|
agent → bottle → parent bottle → global (host)
|
||||||
|
```
|
||||||
|
|
||||||
|
The global host budget is the highest-priority feature to ship (the cross-host
|
||||||
|
control plane will eventually consume it); per-agent and per-bottle budgets
|
||||||
|
override it for finer control. A budget can also be supplied **at bottle
|
||||||
|
launch** (`--budget` or equivalent), overriding the settings.yml defaults for
|
||||||
|
that run. Enforcement evaluates the effective budget as the
|
||||||
|
nearest-defined scope at decrement time.
|
||||||
|
|
||||||
|
### `~/.bot-bottle/settings.yml`
|
||||||
|
|
||||||
|
New **host-level** settings file (the `~/.bot-bottle/` root, *not* the per-repo
|
||||||
|
`.bot-bottle/` — host budgets must not be committed per-repo). Parsed by
|
||||||
|
`yaml_subset.py`, so it must stay within that bounded subset (flat mappings,
|
||||||
|
scalars; no anchors, no multi-line block scalars). Shape:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
budget:
|
||||||
|
claude: 5000000 # token budget keyed by agent provider
|
||||||
|
codex: 2000000
|
||||||
|
shutdown: cutoff # default cutoff policy: cutoff | freeze | kill
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forced cutoff and cutoff policy
|
||||||
|
|
||||||
|
On budget exhaustion (or an operator command), the configured per-bottle cutoff
|
||||||
|
policy fires. The three policies map onto primitives that already exist:
|
||||||
|
|
||||||
|
- **`cutoff`** (default) — drop the bottle's `routes.yaml` to empty and reload
|
||||||
|
(or isolate the bottle from the egress network); the agent/bottle keeps
|
||||||
|
running but can no longer reach metered hosts. This is the route-drop already
|
||||||
|
available on the egress plane (`bot_bottle/backend/egress_apply.py`).
|
||||||
|
- **`freeze`** — commit/snapshot state, then kill the agent/bottle; resumable
|
||||||
|
later via `bot_bottle/backend/freeze.py`.
|
||||||
|
- **`kill`** — tear the bottle down without saving state (backend teardown).
|
||||||
|
|
||||||
|
The trigger lives in the metering path and the action in the egress/backend
|
||||||
|
plane; **neither touches the supervise proposal queue** (design constraint from
|
||||||
|
#251).
|
||||||
|
|
||||||
|
### Host-level SQLite store
|
||||||
|
|
||||||
|
**Decision: introduce SQLite now, narrowly.**
|
||||||
|
|
||||||
|
- **The dependency objection doesn't apply.** `sqlite3` is in the Python stdlib,
|
||||||
|
so it does not break the AGENTS.md stdlib-first / no-runtime-pip stance — same
|
||||||
|
category as the hand-rolled `yaml_subset.py`, except the stdlib already ships
|
||||||
|
the whole engine.
|
||||||
|
- **It fits the problem.** A *global* token budget decremented concurrently by N
|
||||||
|
egress sidecars (today `~/.bot-bottle/` already has `state/`, `audit/`,
|
||||||
|
`queue/` written by parallel bottles) is a read-modify-write race. Over JSON
|
||||||
|
that means hand-rolled file locking; SQLite gives atomic transactions + WAL for
|
||||||
|
free. The per-agent/per-bottle precedence rollup plus "sum across all bottles"
|
||||||
|
is a `GROUP BY`, not an N-directory rescan.
|
||||||
|
- **It rehearses the cloud swap.** "Wrap operations in an API so we can swap to a
|
||||||
|
cloud service" maps directly onto a thin repository/DAO over SQLite → Postgres
|
||||||
|
later. A JSON-file store is a worse rehearsal than SQL.
|
||||||
|
|
||||||
|
**Costs (real but bounded):** a new paradigm in a flat-file repo needs a
|
||||||
|
`schema_version` table + idempotent startup migrations; SQLite serializes
|
||||||
|
writers, so WAL mode + `busy_timeout` are required (a non-issue at a handful of
|
||||||
|
bottles); test fixtures need temp DBs.
|
||||||
|
|
||||||
|
**Scope of the store:** one DB at `~/.bot-bottle/bot-bottle.db` behind a thin
|
||||||
|
repository API. Only the **new** metering/budget/enforcement-audit ledger lives
|
||||||
|
there. Existing per-bottle blobs (resume `metadata.json`, transcripts,
|
||||||
|
Dockerfile overrides, supervise queue) stay on the filesystem — migrating them
|
||||||
|
now is churn for no benefit and they lack the concurrency/aggregation problem.
|
||||||
|
|
||||||
|
### Host-level controller + dashboard
|
||||||
|
|
||||||
|
A single **host-level controller** owns the meter, budget evaluation, and the
|
||||||
|
cutoff actions across all bottles (cf. `bot_bottle/cli/supervise.py`'s
|
||||||
|
cross-bottle view), rather than a per-bottle daemon. v1 ships one host-level
|
||||||
|
**TUI dashboard** that reads live usage-vs-budget from the SQLite store and
|
||||||
|
offers on-demand cutoff/stop. The existing supervisor UI should eventually fold
|
||||||
|
into this same dashboard; this PRD lays the host-level surface it will move to.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Ordered, individually mergeable:
|
||||||
|
|
||||||
|
1. **SQLite repository foundation.** `~/.bot-bottle/bot-bottle.db`, schema +
|
||||||
|
`schema_version` migrations, WAL + `busy_timeout`, thin repository API,
|
||||||
|
temp-DB test fixtures. No behavior wired yet.
|
||||||
|
2. **Metering at the egress proxy.** Parse authoritative response `usage`
|
||||||
|
(including SSE final-usage tailing) in the egress addon `response` hook;
|
||||||
|
write per-bottle / per-provider usage rows to the ledger.
|
||||||
|
3. **`settings.yml` + budget model.** Host-level `~/.bot-bottle/settings.yml`
|
||||||
|
parsed by `yaml_subset.py`; budget precedence (agent → bottle → parent →
|
||||||
|
global) and the `--budget` launch flag.
|
||||||
|
4. **Forced cutoff + cutoff policy.** Wire the threshold trigger to the
|
||||||
|
`cutoff` / `freeze` / `kill` primitives on the egress/backend plane; record
|
||||||
|
enforcement actions to the audit ledger.
|
||||||
|
5. **Host-level TUI dashboard.** Live usage-vs-budget view + on-demand
|
||||||
|
cutoff/stop, reading the store.
|
||||||
|
6. **`count_tokens` pre-flight gate (optional refinement).** Abstract method +
|
||||||
|
stdlib estimator default; Anthropic/OpenAI endpoints for built-in
|
||||||
|
claude/codex; optional pre-send block.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **SSE usage tailing robustness.** Buffering streamed responses to extract the
|
||||||
|
final usage event without breaking the agent's own stream consumption — how
|
||||||
|
much of the body must the addon hold, and what's the failure mode if the
|
||||||
|
stream is interrupted mid-flight?
|
||||||
|
- **Crossing mid-request.** A single response can push usage past budget only
|
||||||
|
*after* it's already been delivered. Is post-hoc cutoff (next request blocked)
|
||||||
|
sufficient, or is a pre-flight estimator gate (chunk 6) required for v1?
|
||||||
|
- **Provider name ↔ metered host mapping.** How does the proxy attribute a
|
||||||
|
flow to an agent-provider budget key — by destination host, by bottle
|
||||||
|
identity, or both?
|
||||||
|
- **Parent-bottle budget semantics.** For `bottle extends` (PRD 0025 / 0065)
|
||||||
|
chains, does "parent bottle" mean the manifest parent, the launching bottle,
|
||||||
|
or the full ancestry summed?
|
||||||
|
- **Dashboard ↔ controller transport (even host-only).** In-process, a local
|
||||||
|
socket, or polling the SQLite store directly? Picks the seam the future remote
|
||||||
|
control plane will extend.
|
||||||
Reference in New Issue
Block a user