Closes #269.
8.2 KiB
PRD prd-new: Separate agent and bottle selection
- Status: Draft
- 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
bottle:in an agent's frontmatter becomes optional. Existing manifests withbottle:continue to work unchanged (backward compat).- After selecting an agent (via the existing single-select picker), a new multi-select bottle picker appears showing all available bottles.
- The multi-select picker pre-populates with the agent's
bottle:value when present. - Confirming with one or more bottles selected uses those bottles, merged in selection order, as the effective bottle for the session.
- 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. - The ordered bottle list is stored in launch metadata so
./cli.py resumeuses the same bottles. - The preflight summary (
y/Nscreen) shows the effective bottle name(s). - 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.
- Unit tests cover: multi-select widget (filter, toggle, confirm, cancel),
the
cmd_startbottle-picker step, and the manifestload_for_agentruntime-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.py — filter_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.py — scan_bottle_names
def scan_bottle_names(bottles_dir: Path) -> list[str]:
"""Scan <bottles_dir>/*.md and return sorted bottle names."""
bot_bottle/manifest.py — ManifestIndex 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.py — merge_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__.py — BottleSpec + _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
- Schema + model —
manifest_schema.py,manifest_agent.py(optionalbottle:),manifest_loader.py(scan_bottle_names),manifest.py(all_bottle_names,load_for_agentsignature),manifest_extends.py(merge_bottles_runtime),bottle_state.py(bottle_namesfield),resolve_common.py(thread through). - Backend —
BottleSpec.bottle_names,_validate, preflight print. - TUI —
filter_multiselectintui.py+ unit tests. - CLI wiring —
start.pybottle picker step,resume.pymetadata load. - Tests —
test_cli_start_selector.pybottle-picker cases,test_manifest_agent.pyoptional-bottle cases, newtest_manifest_bottle_merge.pyformerge_bottles_runtime.
Open questions
None.