Files
bot-bottle/docs/prds/prd-new-separate-agent-bottle-selection.md
T

8.2 KiB

PRD prd-new: 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 ./cli.py 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 ./cli.py edit, ./cli.py list, or ./cli.py 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.