From 44365ecf68c23c65660a7638394c5f27fa6d48ef Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 3 Jun 2026 21:18:59 +0000 Subject: [PATCH] refactor(agent_provider): introduce AgentProvider ABC + contrib plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the provider-specific blocks of agent_provision_plan into contrib/claude/agent_provider.py and contrib/codex/agent_provider.py, behind a new AgentProvider ABC and a lazy get_provider() registry (mirrors PRD 0048's contrib convention). agent_provision_plan and runtime_for stay as thin shims so existing callers in backend/{docker,smolmachines}/prepare.py and cli/start.py keep working without per-call edits — the shipping diff in this commit is purely 'who owns the producer'. Adds bot_bottle/_provision_apply.py — the backend-agnostic skills / prompt / declarative-plan apply loops the per-provider default methods will dispatch through in the next commit. --- bot_bottle/_provision_apply.py | 117 +++++++++ bot_bottle/agent_provider.py | 251 +++++++++----------- bot_bottle/contrib/claude/__init__.py | 0 bot_bottle/contrib/claude/agent_provider.py | 133 +++++++++++ bot_bottle/contrib/codex/__init__.py | 0 bot_bottle/contrib/codex/agent_provider.py | 175 ++++++++++++++ 6 files changed, 542 insertions(+), 134 deletions(-) create mode 100644 bot_bottle/_provision_apply.py create mode 100644 bot_bottle/contrib/claude/__init__.py create mode 100644 bot_bottle/contrib/claude/agent_provider.py create mode 100644 bot_bottle/contrib/codex/__init__.py create mode 100644 bot_bottle/contrib/codex/agent_provider.py diff --git a/bot_bottle/_provision_apply.py b/bot_bottle/_provision_apply.py new file mode 100644 index 0000000..be8210d --- /dev/null +++ b/bot_bottle/_provision_apply.py @@ -0,0 +1,117 @@ +"""Shared apply primitives used by every `AgentProvider` (PRD 0050). + +The skills/prompt/provision loops here are backend-agnostic — they +only touch the `Bottle.exec`/`Bottle.cp_in` primitives that every +backend implements. Each provider's `provision_skills` / +`provision_prompt` / `provision` defaults delegate here so the +business logic lives in exactly one place.""" + +from __future__ import annotations + +import os +import shlex +from typing import TYPE_CHECKING + +from .agent_provider import GUEST_HOME +from .log import die, info + + +if TYPE_CHECKING: + from .backend import Bottle, BottlePlan + + +# In-guest paths agents look at. These are baked into the bot-bottle +# Dockerfile and the smolmachines guest image; no env knob exposed, +# per the PRD 0050 issue feedback. +SKILLS_DIR = f"{GUEST_HOME}/.claude/skills" +PROMPT_PATH = f"{GUEST_HOME}/.bot-bottle-prompt.txt" + + +def apply_skills(plan: "BottlePlan", bottle: "Bottle") -> None: + """Copy each named skill tree from the host into the guest. + No-op when the agent has no skills. + + `cp_in` lands files as root in every backend; chown back to + node:node so the agent can read its own skills.""" + from .backend.util import host_skill_dir + + agent = plan.spec.manifest.agents[plan.spec.agent_name] + if not agent.skills: + return + + bottle.exec(f"mkdir -p {SKILLS_DIR}", user="root") + + for name in agent.skills: + src = host_skill_dir(name) + if not os.path.isdir(src): + die( + f"skill {name!r} disappeared from host between " + f"validation and copy at {src}." + ) + dst = f"{SKILLS_DIR}/{name}" + info(f"copying skill {name} into {bottle.name}:{dst}") + bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root") + bottle.cp_in(f"{src}/.", f"{dst}/") + bottle.exec(f"chown -R node:node {dst}", user="root") + + +def apply_prompt(plan: "BottlePlan", bottle: "Bottle") -> str | None: + """Copy the prompt file into the guest, fix ownership/mode. + Returns the in-guest path iff the agent has a non-empty prompt + (drives `--append-system-prompt-file`); the file is copied either + way so the path always exists.""" + bottle.cp_in(str(plan.prompt_file), PROMPT_PATH) + bottle.exec( + f"chown node:node {PROMPT_PATH} && chmod 600 {PROMPT_PATH}", + user="root", + ) + agent = plan.spec.manifest.agents[plan.spec.agent_name] + return PROMPT_PATH if agent.prompt else None + + +def apply_provision(plan: "BottlePlan", bottle: "Bottle") -> None: + """Apply the declarative `dirs`/`pre_copy`/`files`/`verify` steps + from the provider's `AgentProvisionPlan`. This is the loop that + used to live in + `backend//provision/provider_auth.py:provision_provider_auth` + pre-PRD-0050.""" + provision = plan.agent_provision + for d in provision.dirs: + path = shlex.quote(d.guest_path) + _exec(bottle, f"mkdir -p {path}", f"could not create {d.guest_path}") + _exec( + bottle, + f"chown {shlex.quote(d.owner)} {path}", + f"could not chown {d.guest_path}", + ) + _exec( + bottle, + f"chmod {shlex.quote(d.mode)} {path}", + f"could not chmod {d.guest_path}", + ) + for command in provision.pre_copy: + _exec(bottle, shlex.join(command.argv), command.error) + for f in provision.files: + bottle.cp_in(str(f.host_path), f.guest_path) + path = shlex.quote(f.guest_path) + _exec( + bottle, + f"chown {shlex.quote(f.owner)} {path}", + f"could not chown {f.guest_path}", + ) + _exec( + bottle, + f"chmod {shlex.quote(f.mode)} {path}", + f"could not chmod {f.guest_path}", + ) + for command in provision.verify: + _exec(bottle, shlex.join(command.argv), command.error) + + +def _exec(bottle: "Bottle", script: str, error: str) -> None: + result = bottle.exec(script, user="root") + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + if detail: + detail = f": {detail}" + die(f"agent provider provisioning: {error}{detail}") diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 28f380d..a323635 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -3,18 +3,32 @@ The manifest owns the user-facing AgentProvider shape. This module is the launch-time table that turns a provider template into an executable command, default image, and prompt/auth behavior. + +Per PRD 0050 the per-provider implementations live under +`bot_bottle/contrib/