217 lines
8.2 KiB
Markdown
217 lines
8.2 KiB
Markdown
# 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 `./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.py` — `filter_multiselect`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
spec = BottleSpec(
|
|
...
|
|
bottle_names=tuple(metadata.bottle_names),
|
|
)
|
|
```
|
|
|
|
## Implementation chunks
|
|
|
|
1. **Schema + model** — `manifest_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. **Backend** — `BottleSpec.bottle_names`, `_validate`, preflight print.
|
|
3. **TUI** — `filter_multiselect` in `tui.py` + unit tests.
|
|
4. **CLI wiring** — `start.py` bottle picker step, `resume.py` metadata load.
|
|
5. **Tests** — `test_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.
|