Compare commits

..

1 Commits

Author SHA1 Message Date
didericis 99ba532783 docs(prd): add PRD for egress control plane
Out-of-band egress enforcement & cost-control plane: meter token usage
at the egress proxy, evaluate budgets with agent→bottle→parent→global
precedence, and force cutoff/freeze/kill without the agent in the loop.
Introduces a host-level SQLite ledger behind a thin repository API and a
host-only TUI dashboard. Closes the design discussion on #251.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
2026-06-25 19:13:28 -04:00
2 changed files with 279 additions and 40 deletions
+32 -40
View File
@@ -301,44 +301,6 @@ def _run_multiselect(
return result
def _toggle_membership(items: list[str], item: str) -> None:
"""Add `item` if absent, remove it if present (in place)."""
if item in items:
items.remove(item)
else:
items.append(item)
def _handle_order_key(key: int, selected: list[str], order_cursor: int) -> int:
"""Apply a keypress in 'order' focus: navigate, reorder, or remove the
item at `order_cursor`. Mutates `selected` in place and returns the new
order cursor."""
if key in (curses.KEY_UP, ord("k")):
if order_cursor > 0:
order_cursor -= 1
elif key in (curses.KEY_DOWN, ord("j")):
if order_cursor < len(selected) - 1:
order_cursor += 1
elif key == ord("K"):
# Move selected item up (earlier in order).
if order_cursor > 0:
i = order_cursor
selected[i - 1], selected[i] = selected[i], selected[i - 1]
order_cursor -= 1
elif key == ord("J"):
# Move selected item down (later in order).
if order_cursor < len(selected) - 1:
i = order_cursor
selected[i], selected[i + 1] = selected[i + 1], selected[i]
order_cursor += 1
elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
# Remove item from selection while in order mode.
del selected[order_cursor]
if order_cursor >= len(selected) and order_cursor > 0:
order_cursor -= 1
return order_cursor
def _multiselect_loop(
screen: Any, items: list[str], *, title: str, initial: list[str]
) -> Optional[list[str]]:
@@ -400,7 +362,11 @@ def _multiselect_loop(
elif key == _KEY_SPACE:
if filtered:
_toggle_membership(selected, filtered[cursor])
item = filtered[cursor]
if item in selected:
selected.remove(item)
else:
selected.append(item)
elif key in (curses.KEY_UP, ord("k")):
if cursor > 0:
@@ -421,7 +387,33 @@ def _multiselect_loop(
cursor = 0
else: # focus == "order"
order_cursor = _handle_order_key(key, selected, order_cursor)
if key in (curses.KEY_UP, ord("k")):
if order_cursor > 0:
order_cursor -= 1
elif key in (curses.KEY_DOWN, ord("j")):
if order_cursor < len(selected) - 1:
order_cursor += 1
elif key == ord("K"):
# Move selected item up (earlier in order).
if order_cursor > 0:
i = order_cursor
selected[i - 1], selected[i] = selected[i], selected[i - 1]
order_cursor -= 1
elif key == ord("J"):
# Move selected item down (later in order).
if order_cursor < len(selected) - 1:
i = order_cursor
selected[i], selected[i + 1] = selected[i + 1], selected[i]
order_cursor += 1
elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
# Remove item from selection while in order mode.
del selected[order_cursor]
if order_cursor >= len(selected) and order_cursor > 0:
order_cursor -= 1
def _render_multiselect(
+247
View File
@@ -0,0 +1,247 @@
# PRD prd-new: Egress control plane — metering, budgets, and forced cutoff
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-06-25
- **Issue:** #251
## Summary
Add an **out-of-band egress enforcement & observability plane**: meter every
agent's token usage at the egress proxy, decrement budgets without the agent's
cooperation, and forcibly cut a bottle's egress when a budget is exhausted —
either automatically or on command from a host-level dashboard. The trigger
(usage threshold) and the action (route-drop / freeze / kill) both live in the
egress plane and run with no agent in the loop. This is distinct from the
supervise sidecar (PRD 0013), which is agent-initiated and therefore cannot
enforce a cost cutoff on a runaway agent. State (usage ledger, budgets, audit)
moves into a host-level SQLite database behind a thin repository API, the first
SQL store in an otherwise flat-file repo.
## Problem
bot-bottle can't currently do two things the cost-overrun case demands:
1. **Forced egress shutdown on limit.** When an agent crosses a token
threshold, kill its egress automatically — no human in the loop.
2. **Remote (host-level) management.** Drive agents from a single surface:
see usage, cut egress, stop bottles, to prevent cost overruns.
The existing supervise sidecar (PRD 0013) is **entirely agent-initiated**: every
action begins with the agent voluntarily calling an MCP tool and an operator
approving it. A runaway or expensive agent — exactly the cost-overrun case —
will never call `egress-block` on itself. Supervision is therefore a
**collaborative recovery** mechanism, not an **enforcement** mechanism; making
it mandatory (#249) would not deliver forced cost-cutoff.
The requirement forces a distinction the current design blurs:
- **Plane A — enforcement / observability (this PRD).** System → infrastructure.
Meter usage, cut egress on threshold or command, account for cost.
Out-of-band; independent of the agent. **Unconditional** — an enforcement
plane you can opt out of isn't enforcement.
- **Plane B — agent-facing recovery (the existing supervise sidecar).**
Agent → operator, approval-gated. Useful interactively; meaningless for a
headless agent with no operator watching its queue. Remains optional.
This PRD builds Plane A. It reframes the "always-on control" invariant of #249
as "the egress control plane is always present" — a more defensible property
than "every agent runs the agent-facing supervisor." Unsupervised
(headless/CI/ephemeral) agents stay first-class: still subject to the mandatory
meter + kill switch, they simply lack the agent-facing proposal tools they
couldn't use anyway.
## Goals / Success Criteria
- The egress proxy meters every request to a metered API host (e.g.
`api.anthropic.com`) and records authoritative token usage per bottle and per
agent provider, with no agent cooperation.
- A budget can be set at four scopes with deterministic precedence
(**agent → bottle → parent bottle → global host budget**); the
most-specific applicable budget governs.
- When usage crosses a budget, the bottle's configured **cutoff policy**
(`cutoff` | `freeze` | `kill`) fires automatically, executed host-side on the
egress plane — never via the supervise queue.
- An operator can, from a single **host-level TUI dashboard**, see live per-bottle
usage against budget and command a cutoff/stop on demand.
- Host budgets, default cutoff policy, and per-provider limits are declared in a
new host-level `~/.bot-bottle/settings.yml`, parseable by `yaml_subset.py`.
- All usage, budget state, and enforcement actions persist in a host-level
SQLite DB behind a thin repository API, so the store can later be swapped for
a cross-host cloud service.
## Non-goals
- **Remote control / cross-host control plane.** Web + mobile remote control,
cross-host budgets, and the authn/transport they require are explicitly
deferred. v1 is a **host-only TUI** with no remote surface.
- **Dollar-denominated budgets.** Budgets are token counts keyed by agent
provider, not currency. Price tables are out of scope.
- **Migrating existing flat-file state into SQLite.** Resume `metadata.json`,
transcripts, Dockerfile overrides, the supervise queue, and audit logs stay on
the filesystem. Only the *new* metering/budget/enforcement ledger is SQL.
- **Making the supervise sidecar (Plane B) mandatory.** Out of scope here; this
PRD is the answer to "what should be unconditional" (Plane A), leaving #249's
Plane-B question open.
- **Per-request hard pre-send blocking as the primary mechanism.** The gate is
budget-crossing detected at/after metering; a pre-flight estimator (below) is a
refinement, not the core enforcement path.
## Design
### Two measurements: gate vs. account
There are two distinct needs, and they want different signals:
- **Account (authoritative).** Decrement the real budget from the API
**response**, which already carries authoritative usage (Anthropic
`input_tokens` / `output_tokens`, OpenAI `usage`). The egress addon already
has a `response(flow)` hook (`bot_bottle/egress_addon.py:460`), so the real
number is available with no extra network call. **Caveat:** agent traffic is
mostly streaming SSE, so the response path must tail the stream for the final
usage event rather than parse a single JSON body — scoped explicitly as work.
- **Gate (estimate).** To block *before* sending, only the request is available,
so an estimator / provider `count_tokens` endpoint is the only option.
Calling `count_tokens` for accounting would be both less accurate *and* an extra
metered egress call per request, so accounting uses response `usage` and the
estimator is reserved for the optional pre-flight gate.
### `count_tokens` on agent providers
Add an abstract `count_tokens(request) -> int` to the `AgentProvider`
abstraction (`bot_bottle/agent_provider.py`):
- **Default** is a good-enough stdlib estimator. Prefer stdlib only; a small
pip dependency *for the sidecar* is acceptable for the fallback if stdlib
proves too inaccurate (this does not relax the package's stdlib-first stance —
it would be a sidecar-only dep, like the bundle already carries).
- **Built-in `claude`** uses Anthropic's token-counting endpoint;
**built-in `codex`** uses OpenAI's. These are exact for the gate but cost a
metered call, so they are gate-only; accounting still comes from the response.
### Budgets and precedence
Budgets are token counts keyed by **agent provider name** (the same names
bottles already use). Four scopes, most-specific wins:
```
agent → bottle → parent bottle → global (host)
```
The global host budget is the highest-priority feature to ship (the cross-host
control plane will eventually consume it); per-agent and per-bottle budgets
override it for finer control. A budget can also be supplied **at bottle
launch** (`--budget` or equivalent), overriding the settings.yml defaults for
that run. Enforcement evaluates the effective budget as the
nearest-defined scope at decrement time.
### `~/.bot-bottle/settings.yml`
New **host-level** settings file (the `~/.bot-bottle/` root, *not* the per-repo
`.bot-bottle/` — host budgets must not be committed per-repo). Parsed by
`yaml_subset.py`, so it must stay within that bounded subset (flat mappings,
scalars; no anchors, no multi-line block scalars). Shape:
```yaml
budget:
claude: 5000000 # token budget keyed by agent provider
codex: 2000000
shutdown: cutoff # default cutoff policy: cutoff | freeze | kill
```
### Forced cutoff and cutoff policy
On budget exhaustion (or an operator command), the configured per-bottle cutoff
policy fires. The three policies map onto primitives that already exist:
- **`cutoff`** (default) — drop the bottle's `routes.yaml` to empty and reload
(or isolate the bottle from the egress network); the agent/bottle keeps
running but can no longer reach metered hosts. This is the route-drop already
available on the egress plane (`bot_bottle/backend/egress_apply.py`).
- **`freeze`** — commit/snapshot state, then kill the agent/bottle; resumable
later via `bot_bottle/backend/freeze.py`.
- **`kill`** — tear the bottle down without saving state (backend teardown).
The trigger lives in the metering path and the action in the egress/backend
plane; **neither touches the supervise proposal queue** (design constraint from
#251).
### Host-level SQLite store
**Decision: introduce SQLite now, narrowly.**
- **The dependency objection doesn't apply.** `sqlite3` is in the Python stdlib,
so it does not break the AGENTS.md stdlib-first / no-runtime-pip stance — same
category as the hand-rolled `yaml_subset.py`, except the stdlib already ships
the whole engine.
- **It fits the problem.** A *global* token budget decremented concurrently by N
egress sidecars (today `~/.bot-bottle/` already has `state/`, `audit/`,
`queue/` written by parallel bottles) is a read-modify-write race. Over JSON
that means hand-rolled file locking; SQLite gives atomic transactions + WAL for
free. The per-agent/per-bottle precedence rollup plus "sum across all bottles"
is a `GROUP BY`, not an N-directory rescan.
- **It rehearses the cloud swap.** "Wrap operations in an API so we can swap to a
cloud service" maps directly onto a thin repository/DAO over SQLite → Postgres
later. A JSON-file store is a worse rehearsal than SQL.
**Costs (real but bounded):** a new paradigm in a flat-file repo needs a
`schema_version` table + idempotent startup migrations; SQLite serializes
writers, so WAL mode + `busy_timeout` are required (a non-issue at a handful of
bottles); test fixtures need temp DBs.
**Scope of the store:** one DB at `~/.bot-bottle/bot-bottle.db` behind a thin
repository API. Only the **new** metering/budget/enforcement-audit ledger lives
there. Existing per-bottle blobs (resume `metadata.json`, transcripts,
Dockerfile overrides, supervise queue) stay on the filesystem — migrating them
now is churn for no benefit and they lack the concurrency/aggregation problem.
### Host-level controller + dashboard
A single **host-level controller** owns the meter, budget evaluation, and the
cutoff actions across all bottles (cf. `bot_bottle/cli/supervise.py`'s
cross-bottle view), rather than a per-bottle daemon. v1 ships one host-level
**TUI dashboard** that reads live usage-vs-budget from the SQLite store and
offers on-demand cutoff/stop. The existing supervisor UI should eventually fold
into this same dashboard; this PRD lays the host-level surface it will move to.
## Implementation chunks
Ordered, individually mergeable:
1. **SQLite repository foundation.** `~/.bot-bottle/bot-bottle.db`, schema +
`schema_version` migrations, WAL + `busy_timeout`, thin repository API,
temp-DB test fixtures. No behavior wired yet.
2. **Metering at the egress proxy.** Parse authoritative response `usage`
(including SSE final-usage tailing) in the egress addon `response` hook;
write per-bottle / per-provider usage rows to the ledger.
3. **`settings.yml` + budget model.** Host-level `~/.bot-bottle/settings.yml`
parsed by `yaml_subset.py`; budget precedence (agent → bottle → parent →
global) and the `--budget` launch flag.
4. **Forced cutoff + cutoff policy.** Wire the threshold trigger to the
`cutoff` / `freeze` / `kill` primitives on the egress/backend plane; record
enforcement actions to the audit ledger.
5. **Host-level TUI dashboard.** Live usage-vs-budget view + on-demand
cutoff/stop, reading the store.
6. **`count_tokens` pre-flight gate (optional refinement).** Abstract method +
stdlib estimator default; Anthropic/OpenAI endpoints for built-in
claude/codex; optional pre-send block.
## Open questions
- **SSE usage tailing robustness.** Buffering streamed responses to extract the
final usage event without breaking the agent's own stream consumption — how
much of the body must the addon hold, and what's the failure mode if the
stream is interrupted mid-flight?
- **Crossing mid-request.** A single response can push usage past budget only
*after* it's already been delivered. Is post-hoc cutoff (next request blocked)
sufficient, or is a pre-flight estimator gate (chunk 6) required for v1?
- **Provider name ↔ metered host mapping.** How does the proxy attribute a
flow to an agent-provider budget key — by destination host, by bottle
identity, or both?
- **Parent-bottle budget semantics.** For `bottle extends` (PRD 0025 / 0065)
chains, does "parent bottle" mean the manifest parent, the launching bottle,
or the full ancestry summed?
- **Dashboard ↔ controller transport (even host-only).** In-process, a local
socket, or polling the SQLite store directly? Picks the seam the future remote
control plane will extend.