refactor: replace runtime.dockerfile with AgentProvider.dockerfile property
Drop the `dockerfile` field from `AgentProviderRuntime` and replace it with a convention-based `dockerfile` property on `AgentProvider`: the base class looks for a `Dockerfile` file next to the provider's own `agent_provider.py` module (via `inspect.getfile`), returning its path or None. Built-in providers inherit the default automatically; custom user providers work the same way by dropping a Dockerfile next to their plugin file; any provider needing a non-standard path can override. All callers (`docker/prepare.py`, `smolmachines/prepare.py`, `capability_apply.py`) now resolve the provider object once and call `.dockerfile` directly instead of reading `runtime.dockerfile`.
This commit is contained in:
@@ -20,6 +20,7 @@ Per PRD 0050 the per-provider implementations live under
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -51,7 +52,6 @@ class AgentProviderRuntime:
|
|||||||
template: str
|
template: str
|
||||||
command: str
|
command: str
|
||||||
image: str
|
image: str
|
||||||
dockerfile: str
|
|
||||||
prompt_mode: PromptMode
|
prompt_mode: PromptMode
|
||||||
bypass_args: tuple[str, ...]
|
bypass_args: tuple[str, ...]
|
||||||
resume_args: tuple[str, ...]
|
resume_args: tuple[str, ...]
|
||||||
@@ -127,6 +127,18 @@ class AgentProvider(ABC):
|
|||||||
"""The static command / image / prompt-mode table for this
|
"""The static command / image / prompt-mode table for this
|
||||||
template."""
|
template."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dockerfile(self) -> Path | None:
|
||||||
|
"""Path to the provider's Dockerfile, or None if no Dockerfile
|
||||||
|
is declared.
|
||||||
|
|
||||||
|
Default: looks for a `Dockerfile` file next to this provider's
|
||||||
|
`agent_provider.py` module. Override to point at a non-standard
|
||||||
|
path, or return None to signal that no Dockerfile exists (the
|
||||||
|
provider relies on a pre-built image)."""
|
||||||
|
path = Path(inspect.getfile(type(self))).parent / "Dockerfile"
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def provision_plan(
|
def provision_plan(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import get_provider
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
mark_preserved,
|
mark_preserved,
|
||||||
@@ -93,11 +94,11 @@ def fetch_current_dockerfile(slug: str) -> str:
|
|||||||
override = per_bottle_dockerfile(slug)
|
override = per_bottle_dockerfile(slug)
|
||||||
if override is not None:
|
if override is not None:
|
||||||
return override
|
return override
|
||||||
repo_dockerfile = _repo_dockerfile_path()
|
repo_dockerfile = get_provider("claude").dockerfile
|
||||||
if repo_dockerfile.is_file():
|
if repo_dockerfile is not None and repo_dockerfile.is_file():
|
||||||
return repo_dockerfile.read_text()
|
return repo_dockerfile.read_text()
|
||||||
raise CapabilityApplyError(
|
raise CapabilityApplyError(
|
||||||
f"no per-bottle Dockerfile for {slug} and no repo Dockerfile at "
|
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
|
||||||
f"{repo_dockerfile}"
|
f"{repo_dockerfile}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,12 +126,6 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
|||||||
# --- Internals -------------------------------------------------------------
|
# --- Internals -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _repo_dockerfile_path() -> Path:
|
|
||||||
"""Path to the Claude provider Dockerfile. Resolved at call time so
|
|
||||||
the path is correct regardless of where this module is imported from."""
|
|
||||||
# bot_bottle/backend/docker/ -> bot_bottle/ -> contrib/claude/Dockerfile
|
|
||||||
return Path(__file__).resolve().parent.parent.parent / "contrib" / "claude" / "Dockerfile"
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_transcript(slug: str) -> None:
|
def snapshot_transcript(slug: str) -> None:
|
||||||
"""`docker cp` /home/node/.claude out of the agent container into
|
"""`docker cp` /home/node/.claude out of the agent container into
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, runtime_for
|
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider
|
||||||
from ...egress import Egress
|
from ...egress import Egress
|
||||||
from ...env import ResolvedEnv, resolve_env
|
from ...env import ResolvedEnv, resolve_env
|
||||||
from ...git_gate import GitGate
|
from ...git_gate import GitGate
|
||||||
@@ -59,7 +59,8 @@ def resolve_plan(
|
|||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
provider = bottle.agent_provider
|
provider = bottle.agent_provider
|
||||||
provider_runtime = runtime_for(provider.template)
|
provider_obj = get_provider(provider.template)
|
||||||
|
provider_runtime = provider_obj.runtime
|
||||||
guest_home = "/home/node"
|
guest_home = "/home/node"
|
||||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||||
|
|
||||||
@@ -99,18 +100,14 @@ def resolve_plan(
|
|||||||
elif provider.dockerfile:
|
elif provider.dockerfile:
|
||||||
image_default = f"bot-bottle-{provider.template}:{slug}"
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||||
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||||
elif provider_runtime.dockerfile:
|
|
||||||
image_default = provider_runtime.image
|
|
||||||
dockerfile_path = provider_runtime.dockerfile
|
|
||||||
elif provider.template not in PROVIDER_TEMPLATES:
|
|
||||||
user_dockerfile = (
|
|
||||||
Path.home() / ".bot-bottle" / "contrib" / provider.template / "Dockerfile"
|
|
||||||
)
|
|
||||||
if user_dockerfile.is_file():
|
|
||||||
image_default = f"bot-bottle-{provider.template}:{slug}"
|
|
||||||
dockerfile_path = str(user_dockerfile)
|
|
||||||
else:
|
else:
|
||||||
|
p_dockerfile = provider_obj.dockerfile
|
||||||
|
if p_dockerfile is not None:
|
||||||
|
if provider.template in PROVIDER_TEMPLATES:
|
||||||
image_default = provider_runtime.image
|
image_default = provider_runtime.image
|
||||||
|
else:
|
||||||
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||||
|
dockerfile_path = str(p_dockerfile)
|
||||||
else:
|
else:
|
||||||
image_default = provider_runtime.image
|
image_default = provider_runtime.image
|
||||||
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
||||||
@@ -216,13 +213,11 @@ def resolve_plan(
|
|||||||
# moved it behind the `list-egress-routes` MCP tool so the
|
# moved it behind the `list-egress-routes` MCP tool so the
|
||||||
# agent gets live state rather than a launch-time snapshot.)
|
# agent gets live state rather than a launch-time snapshot.)
|
||||||
supervise_dockerfile_path = (
|
supervise_dockerfile_path = (
|
||||||
Path(dockerfile_path)
|
Path(dockerfile_path) if dockerfile_path else provider_obj.dockerfile
|
||||||
if dockerfile_path
|
|
||||||
else Path(__file__).resolve().parent.parent.parent / "contrib" / "claude" / "Dockerfile"
|
|
||||||
)
|
)
|
||||||
dockerfile_content = (
|
dockerfile_content = (
|
||||||
supervise_dockerfile_path.read_text(encoding="utf-8")
|
supervise_dockerfile_path.read_text(encoding="utf-8")
|
||||||
if supervise_dockerfile_path.is_file()
|
if supervise_dockerfile_path is not None and supervise_dockerfile_path.is_file()
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
supervise_dir = supervise_state_dir(slug)
|
supervise_dir = supervise_state_dir(slug)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...agent_provider import agent_provision_plan, runtime_for
|
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider
|
||||||
from ...backend import BottleSpec
|
from ...backend import BottleSpec
|
||||||
from ...backend.docker.bottle_state import (
|
from ...backend.docker.bottle_state import (
|
||||||
BottleMetadata,
|
BottleMetadata,
|
||||||
@@ -57,7 +57,8 @@ def resolve_plan(
|
|||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
provider = bottle.agent_provider
|
provider = bottle.agent_provider
|
||||||
provider_runtime = runtime_for(provider.template)
|
provider_obj = get_provider(provider.template)
|
||||||
|
provider_runtime = provider_obj.runtime
|
||||||
guest_home = "/home/node"
|
guest_home = "/home/node"
|
||||||
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
|
||||||
|
|
||||||
@@ -122,9 +123,14 @@ def resolve_plan(
|
|||||||
if provider.dockerfile:
|
if provider.dockerfile:
|
||||||
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
|
||||||
image_default = f"bot-bottle-{provider.template}:{slug}"
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||||
elif provider_runtime.dockerfile:
|
else:
|
||||||
agent_dockerfile_path = provider_runtime.dockerfile
|
p_dockerfile = provider_obj.dockerfile
|
||||||
|
if p_dockerfile is not None:
|
||||||
|
agent_dockerfile_path = str(p_dockerfile)
|
||||||
|
if provider.template in PROVIDER_TEMPLATES:
|
||||||
image_default = provider_runtime.image
|
image_default = provider_runtime.image
|
||||||
|
else:
|
||||||
|
image_default = f"bot-bottle-{provider.template}:{slug}"
|
||||||
else:
|
else:
|
||||||
image_default = provider_runtime.image
|
image_default = provider_runtime.image
|
||||||
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
template="claude",
|
template="claude",
|
||||||
command="claude",
|
command="claude",
|
||||||
image="bot-bottle-claude:latest",
|
image="bot-bottle-claude:latest",
|
||||||
dockerfile=str(Path(__file__).resolve().parent / "Dockerfile"),
|
|
||||||
prompt_mode="append_file",
|
prompt_mode="append_file",
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
resume_args=("--continue",),
|
resume_args=("--continue",),
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
template="codex",
|
template="codex",
|
||||||
command="codex",
|
command="codex",
|
||||||
image="bot-bottle-codex:latest",
|
image="bot-bottle-codex:latest",
|
||||||
dockerfile=str(Path(__file__).resolve().parent / "Dockerfile"),
|
|
||||||
prompt_mode="read_prompt_file",
|
prompt_mode="read_prompt_file",
|
||||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
resume_args=("resume", "--last"),
|
resume_args=("resume", "--last"),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class _Provider(AgentProvider):
|
|||||||
@property
|
@property
|
||||||
def runtime(self) -> AgentProviderRuntime:
|
def runtime(self) -> AgentProviderRuntime:
|
||||||
return AgentProviderRuntime(
|
return AgentProviderRuntime(
|
||||||
template="test", command="test", image="", dockerfile="",
|
template="test", command="test", image="",
|
||||||
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
||||||
remote_control_args=(),
|
remote_control_args=(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
|
|||||||
patch(
|
patch(
|
||||||
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
|
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
|
||||||
) as mock_app,
|
) as mock_app,
|
||||||
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
|
|
||||||
):
|
):
|
||||||
mock_gg.return_value.prepare.return_value = MagicMock()
|
mock_gg.return_value.prepare.return_value = MagicMock()
|
||||||
mock_eg.return_value.prepare.return_value = MagicMock()
|
mock_eg.return_value.prepare.return_value = MagicMock()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class _Provider(AgentProvider):
|
|||||||
@property
|
@property
|
||||||
def runtime(self) -> AgentProviderRuntime:
|
def runtime(self) -> AgentProviderRuntime:
|
||||||
return AgentProviderRuntime(
|
return AgentProviderRuntime(
|
||||||
template="test", command="test", image="", dockerfile="",
|
template="test", command="test", image="",
|
||||||
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
||||||
remote_control_args=(),
|
remote_control_args=(),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user