Files
bot-bottle/docs/prds/0066-separate-agent-bottle-selection.md
T
didericis cd9f023f3d
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / unit (pull_request) Successful in 59s
test / integration-docker (pull_request) Successful in 1m6s
test / coverage (push) Successful in 21s
test / image-input-builds (push) Successful in 44s
test / image-input-builds (pull_request) Successful in 1m15s
test / unit (push) Successful in 57s
test / coverage (pull_request) Successful in 20s
lint / lint (push) Successful in 1m3s
Update Quality Badges / update-badges (push) Successful in 1m12s
test / integration-docker (push) Successful in 1m0s
docs: name bot-bottle, not ./cli.py, in every instruction
`doctor` told users to run `./cli.py backend setup`. cli.py is a four-line
wrapper at the repo root that calls bot_bottle.cli:main, and it does not ship —
[tool.setuptools.packages.find] includes only bot_bottle*, so anyone who
installed rather than cloned has no such file. Confirmed against a real
install: the user's tree contains exactly one executable, `bot-bottle`, and no
cli.py anywhere, while doctor recommended `./cli.py` three times. Invisible in
development, where ./cli.py works fine from a checkout, which is why only a
clean-install test surfaced it.

The two entry points are the same code, so the fix is to name the one that
always exists. 141 replacements across 45 files: the runtime messages that
caused this, plus README, docs, PRDs, research notes and test prose, so nothing
teaches the invocation a user cannot run.

scripts/demo.sh is deliberately untouched — it *executes* ./cli.py from a
checkout, where that is the correct and available path.

Verified end to end: a sandbox install now reports
"Run: bot-bottle backend setup --backend=firecracker", and bot-bottle is on
that user's PATH. Unit suite unchanged against the pre-existing baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
2026-07-27 10:20:17 -04:00

8.2 KiB

PRD 0066: Separate agent and bottle selection

  • Status: Active
  • Author: claude
  • Created: 2026-06-25
  • Issue: #269

Summary

Agents and bottles are two separate concerns: agents carry a system prompt and skills; bottles carry infrastructure configuration (egress, git-gate, env, agent provider). Today an agent's manifest file hard-codes a single bottle: reference, which prevents the same agent prompt from being reused across projects that need different bottle configurations. This PRD decouples them: at launch time, after choosing the agent, the operator picks an ordered list of bottles via a multi-select picker. The selected bottles are merged in order (later entries override earlier ones) to produce the effective bottle for the session.

Problem

The current bottle: <name> field on an agent manifest file binds the agent permanently to one bottle. To use the same system prompt with a different bottle (e.g. claude-implementer at home vs. at a client site that needs a different egress policy), the operator must duplicate the agent file and change the bottle: field. Duplicate agent files drift out of sync.

Goals / Success Criteria

  1. bottle: in an agent's frontmatter becomes optional. Existing manifests with bottle: continue to work unchanged (backward compat).
  2. After selecting an agent (via the existing single-select picker), a new multi-select bottle picker appears showing all available bottles.
  3. The multi-select picker pre-populates with the agent's bottle: value when present.
  4. Confirming with one or more bottles selected uses those bottles, merged in selection order, as the effective bottle for the session.
  5. Confirming with an empty selection falls back to the agent's bottle: field. If neither is set, a ManifestError is raised pointing the operator at the fix.
  6. The ordered bottle list is stored in launch metadata so bot-bottle resume uses the same bottles.
  7. The preflight summary (y/N screen) shows the effective bottle name(s).
  8. The multi-select picker supports incremental filtering, Space/Enter to toggle selection, an ordered "Selected: ..." summary line, Ctrl-D to confirm, and Esc/q to cancel the whole start operation.
  9. Unit tests cover: multi-select widget (filter, toggle, confirm, cancel), the cmd_start bottle-picker step, and the manifest load_for_agent runtime-bottle-merge path.

Non-goals

  • Reordering the selection list from within the picker (order = insertion order; drag-and-drop is out of scope).
  • Storing bottle selection history / MRU.
  • Changes to bot-bottle edit, bot-bottle list, or bot-bottle info.
  • Removing the bottle: key from the agent schema (it stays, now optional).

Design

bot_bottle/cli/tui.pyfilter_multiselect

def filter_multiselect(
    items: list[str],
    *,
    title: str = "",
    initial: list[str] | None = None,
    tty_path: str = "/dev/tty",
) -> list[str] | None:
    """Multi-select variant of filter_select.

    Returns the ordered list of selected items, or None on cancel.
    Press Space/Enter to toggle the item under the cursor.
    Press Ctrl-D to confirm. Press Esc/q to cancel.
    """

Layout:

Select bottles
Filter: _
─────────────────────────────────────────
> [*] claude
  [ ] dev
  [ ] codex
─────────────────────────────────────────
Selected (in order): claude
─────────────────────────────────────────
[↑↓/jk] move  [Space] toggle  [Ctrl-D] done  [Esc] cancel

initial pre-populates the ordered selection. None means no pre-selection. Items added are appended in insertion order; items removed leave the remaining order unchanged.

bot_bottle/manifest_schema.py — optional bottle:

bottle moves from AGENT_KEYS_REQUIRED to AGENT_KEYS_OPTIONAL.

bot_bottle/manifest_agent.py — optional bottle:

ManifestAgent.bottle changes from str (required) to str = "". from_dict no longer requires the key to be present; the bottle-exists validation is skipped when the key is absent.

bot_bottle/manifest_loader.pyscan_bottle_names

def scan_bottle_names(bottles_dir: Path) -> list[str]:
    """Scan <bottles_dir>/*.md and return sorted bottle names."""

bot_bottle/manifest.pyManifestIndex changes

all_bottle_names property — analogous to all_agent_names; scans home_md / "bottles" in lazy mode, returns sorted(self.bottles.keys()) in eager mode.

load_for_agent(agent_name, bottle_names: tuple[str, ...] = ()) — new bottle_names parameter. When non-empty, the listed bottles are resolved and merged in order (index 0 is the base; each subsequent bottle is applied on top using the same field-merge rules as extends:). The result replaces the bottle that agent.bottle would have provided. When empty, falls back to agent.bottle. Raises ManifestError if neither bottle_names nor agent.bottle is set.

bot_bottle/manifest_extends.pymerge_bottles_runtime

def merge_bottles_runtime(bottles: list[ManifestBottle]) -> ManifestBottle:
    """Merge an ordered list of pre-resolved ManifestBottle objects.

    Index 0 is the base; each subsequent entry overrides the previous using
    the same rules as the file-based extends machinery:
      - env: dict merge, later wins
      - git_user: per-field overlay, later wins on non-empty
      - git (repos): union by name, later wins per-name
      - egress.routes: concatenate
      - agent_provider, supervise: later bottle's value replaces earlier
    """

This function operates on already-parsed ManifestBottle objects, so it does not need to touch the raw-dict path.

bot_bottle/backend/__init__.pyBottleSpec + _validate

BottleSpec gains bottle_names: tuple[str, ...] = ().

BottleBackend._validate passes spec.bottle_names to load_for_agent:

manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)

The preflight print updates info(f"bottle: {agent.bottle}") to display the effective bottle name(s). When spec.bottle_names is non-empty those are shown; when empty and agent.bottle is set, the agent's bottle: is shown.

bot_bottle/bottle_state.py — persist bottle names

BottleMetadata gains bottle_names: tuple[str, ...] = (). read_metadata reads this from JSON (default ()). write_launch_metadata passes spec.bottle_names through.

bot_bottle/cli/start.py — bottle multiselect step

After agent selection, before the name/color modal:

available_bottle_names = manifest.all_bottle_names
# Peek at agent's bottle default for pre-population
initial_bottle = _peek_agent_bottle(manifest, agent_name)
initial = [initial_bottle] if initial_bottle else []

bottle_names_list = tui.filter_multiselect(
    available_bottle_names,
    title="Select bottles",
    initial=initial,
)
if bottle_names_list is None:
    return 0  # user cancelled
bottle_names = tuple(bottle_names_list)

_peek_agent_bottle reads the agent file's frontmatter without full parsing, returning the bottle: value or "" when absent.

BottleSpec is built with bottle_names=bottle_names.

bot_bottle/cli/resume.py — bottle names from metadata

spec = BottleSpec(
    ...
    bottle_names=tuple(metadata.bottle_names),
)

Implementation chunks

  1. Schema + modelmanifest_schema.py, manifest_agent.py (optional bottle:), manifest_loader.py (scan_bottle_names), manifest.py (all_bottle_names, load_for_agent signature), manifest_extends.py (merge_bottles_runtime), bottle_state.py (bottle_names field), resolve_common.py (thread through).
  2. BackendBottleSpec.bottle_names, _validate, preflight print.
  3. TUIfilter_multiselect in tui.py + unit tests.
  4. CLI wiringstart.py bottle picker step, resume.py metadata load.
  5. Teststest_cli_start_selector.py bottle-picker cases, test_manifest_agent.py optional-bottle cases, new test_manifest_bottle_merge.py for merge_bottles_runtime.

Open questions

None.