Files
rutster/docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md
A.D.Lee c30a45232d
All checks were successful
CI / fmt (push) Successful in 1m40s
CI / clippy (push) Successful in 2m24s
CI / test (1.85) (push) Successful in 5m8s
CI / test (stable) (push) Successful in 5m20s
CI / deny (push) Successful in 1m34s
Slice 3 — OpenAI Realtime brain: swap echo for the brain (#4)
2026-07-01 22:25:09 +00:00

768 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Rutster slice 3 — Swap echo for OpenAI Realtime: the brain lands
- **Status:** Draft (pending review)
- **Date:** 2026-06-30
- **Spearhead step:** 3 of 6 (vision-revision §10 / PORT_PLAN "Phasing")
- **Origin:** brainstorming session 2026-06-30, resumed after the 2026-06-29
strategic pivot (ADR-0007 + ADR-0008).
- **Depends on:** [slice 2 — the agent tap](2026-06-28-slice-2-agent-tap-design.md)
(must be implemented + green; the tap protocol and `TapAudioPipe` seam are
the foundation this slice swaps content into).
- **Related:**
[ADR-0002](../../adr/0002-north-star-and-fused-core.md) (fused vertical),
[ADR-0004](../../adr/0004-license.md) (GPL-3.0-or-later),
[ADR-0008](../../adr/0008-fob-and-green-zone.md) (the brain is **green-zone**;
the reflex loop is **FOB** — load-bearing for the S4 turn-ownership
decision below),
[ARCHITECTURE.md §"Agent tap"](../../ARCHITECTURE.md).
---
## TL;DR
Stand up spearhead step 3: swap slice-2's echo brain for a real
**speech-to-speech brain — OpenAI Realtime** — reached through slice-2's
existing tap. The core dials out (core-as-client; brain-as-server; still
**no inbound tap port on the core**), slice-2's tap protocol carries
audio + new event types (additive, v1, forwards-compatible), and the
brain process translates between our protocol and OpenAI Realtime's event
taxonomy.
Slice 3 proves **agent integration**: the same WSS plumbing that today
echoes will, in step 4, carry VAD/barge-in signals back from the brain to
the core's reflex loop (the FOB). It deliberately defers real barge-in /
VAD-driven playout kill (step 4), the PSTN trunk (step 5), and spend
control (step 6) — but it *pre-paves* the `speech_started` /
`speech_stopped` event seam so step 4 lands cleanly.
The seam slice-2 pre-paved (`AudioSource` / `AudioSink` traits + the
`TapAudioPipe` shape + the `TapEngine` task) is the **test of this
slice**: `RtcSession`'s media-loop path changes by zero lines; the
TapEngine's spawn / reconnect / teardown logic is untouched; only the
*tap protocol module* (`rutster-tap/src/protocol.rs`) grows new event
types and a new tool-registry module (`crates/rutster/src/tool_registry.rs`)
lands in the binary.
---
## 1. Scope
### 1.1 In scope
- Implementation of spearhead step 3: WebRTC peer → core terminates
DTLS-SRTP, decodes Opus → canonical PCM @ 24 kHz mono, ships PCM
**over WSS** to an external OpenAI Realtime brain process, receives PCM
back, encodes + plays out via str0m. The user speaks and hears the AI
reply through an end-to-end speech-to-speech loop within ~700 ms
(slice-1's 200 ms + tap round-trip + OpenAI latency + 100 ms playout
buffer headroom).
- **`crates/rutster-brain-realtime`** — a new workspace member; library +
binary. Dual purpose like slice-2's `rutster-tap-echo`: a standalone
dev-loop binary (`cargo run -p rutster-brain-realtime`) and an in-process
`MockRealtimeBrain` for integration tests (no network calls to OpenAI).
Default port `ws://127.0.0.1:8082/realtime` (the slice-2 echo brain
defaults to `:8081/echo`; the two coexist).
- **Tap protocol extension (additive, v1, forwards-compatible):**
- `speech_started`, `speech_stopped` (brain → core, advisory).
- `function_call` (brain → core: tool name + args).
- `function_call_output` (core → brain: status + result).
- `tools.update` (brain → core: tool catalog so the core can validate).
Old echo brains ignore the new types per slice-2's "unknown type → log +
count + drop" rule (§3.4 of the slice-2 spec).
- **In-boundary tool registry** in the brown binary
(`crates/rutster/src/tool_registry.rs`). `hangup` is the only wired tool
— fires the existing `Channel: Connected → Closing` path. Other tool
names reply `status: "not_implemented"`. The brain's `tools.update`
event declares the catalog; the registry validates function_call events
against it before dispatch.
- **OpenAI Realtime translation layer** in `rutster-brain-realtime`: tap
events ↔ OpenAI Realtime events. Audio is 24 kHz mono PCM inside base64
LE i16 — **matching slice-1's canonical tap format exactly**, no resample.
- **S4 turn-ownership decision (load-bearing per ADR-0008):** OpenAI
Realtime's `session.update` is sent with `turn_detection: null`
(disabled). The core drives turn-taking through its own (FOB) reflex
loop in step 4; OpenAI is treated as a speech-to-speech transducer.
The `speech_started` / `speech_stopped` events are caught and forwarded
through the tap protocol as advisory signals so step 4 can use them,
but OpenAI does **not** auto-bar the brain's `audio_out` — the
core-authoritative playout buffer (slice 2 §4.1) is the only thing
that gates playout.
- **Two-source config** for the brain process: `OPENAI_API_KEY` env
default + `OPENAI_API_KEY_FILE` path override. `OPENAI_REALTIME_MODEL`
env (default: `gpt-4o-realtime` or current equivalent; documented).
- **`--features=mock` dev mode** on the brain binary: runs the brain
process with an in-process mocked Realtime (no API key, no network
calls to OpenAI) — for offline dev loop + integration tests.
- New workspace deps: `tokio-tungstenite` (already pulled by slice-2),
`reqwest` or `serde_json` (already pulled), and **no new** workspace
member-deps beyond what slice-2 already pinned. Reuse slice-2's
protocol types from `rutster-tap`.
### 1.2 Out of scope (with scheduled return)
| Deferred item | Returns in | Why deferred |
|---|---|---|
| Real barge-in / VAD-driven playout kill | Step 4 | Slice-3 pre-paves the `speech_started` / `speech_stopped` advisory event seam; step 4 wires the FOB reflex loop to act on them. The core-authoritative playout buffer from slice-2 §4.1 is already in place. |
| PSTN trunk / rented-transport integration | Step 5 | ADR-0007's rented CPaaS raw-media fork lands the phone number; the brain is unaffected. |
| Spend cap / abuse gate | Step 6 | The brain has no spend surface yet — OpenAI bills the operator directly. In-boundary spend pacing lands with `rutster-spend` in step 6. |
| Multi-brain routing / per-tenant brain selection | Step 6 | One tap URL per call in slice-3 (env + per-call override). The slice-3 brain process is OpenAI-Realtime-only. A Deepgram+LLM+TTS composite adapter or a self-hosted open-weights brain is a future-rung concern; the tap protocol is brain-agnostic by design. |
| API-key rotation / KMS integration | Step 6 + later | `OPENAI_API_KEY` (env) + `OPENAI_API_KEY_FILE` (path) is the dev posture. KMS / Vault integration lands with the real trust boundary (step 6). |
| TLS on the tap and on the OpenAI leg | Step 6 | Slice-2 rejects `wss://` URLs at session-create (deferred to step 6); the OpenAI Realtime leg is `wss://` and uses rustls-native roots (no cert pinning in slice-3 — defer to step 6). |
| Authentication / authorization on the `tap_url` override | Step 6 | Inherits slice-2's "no auth yet" posture. |
| Re-INVITE / session migration / resumability | Later | Refresh the page → new session, same as slice-1/2. OpenAI Realtime session is per-call; no in-flight call preservation across server restart. |
| CDR / event bus / OTel beyond per-Channel `tracing` spans | Step 5 | Single peer + single brain; no fanout yet. Tool-call events go to logs + counters only. |
| Quality dashboard (containment, escalation reasons) | Capability ladder rung 3 | The current build target proves agent integration, not analytics. |
| Audio resampling for brains using 16 kHz | Future | OpenAI Realtime uses 24 kHz mono, matching slice-1's canonical tap format. Will be needed if a future brain uses 16 kHz; the translator resamples at that time. |
| `response.audio.delta` batching optimization | Future | OpenAI sends many small delta events; the translator MAY batch into the slice-2 `audio_out` 20 ms frame. Optimization optional for v1; tracked. |
### 1.3 What this slice does NOT prove
It does **not** prove: a real barge-in reflex (only the event seam),
latency determinism under reflex timing, PSTN trunking, spending controls,
multi-tenancy on the tap URL, API-key rotation, or wss:// TLS posture in
production. It proves **only** agent integration: the OpenAI Realtime
adapter as green-zone (per ADR-0008), the tap protocol extension carrying
audio + interruption signals + function-call plumbing, the in-boundary
tool registry dispatching `hangup` cleanly, and the seam test (slice-2's
`RtcSession` accepts the new brain with zero internal change).
---
## 2. Workspace layout (delta on slice-2)
One new workspace member; one new module in the binary; additive
extension to `rutster-tap`'s protocol module.
```
rutster/
├── Cargo.toml # +rutster-brain-realtime in members
├── crates/
│ ├── rutster/ # binary: +tool_registry module
│ │ ├── src/main.rs
│ │ ├── src/session_map.rs # +tool-call side-channel drain
│ │ ├── src/routes.rs # unchanged shape
│ │ ├── src/tap_engine.rs # UNCHANGED — the seam test
│ │ ├── src/tool_registry.rs # NEW: Tool trait, hangup tool
│ │ └── static/index.html # minor: surface brain connection status
│ ├── rutster-media/ # UNCHANGED — the seam test
│ │ └── src/loop_driver.rs # UNCHANGED
│ ├── rutster-call-model/ # UNCHANGED
│ ├── rutster-tap/ # +additive protocol event types
│ │ └── src/protocol.rs # +speech_started/stopped, function_call/output, tools.update
│ ├── rutster-tap-echo/ # UNCHANGED (still works against extended protocol)
│ ├── rutster-brain-realtime/ # NEW crate
│ │ ├── Cargo.toml # deps: rutster-tap, tokio, tokio-tungstenite, serde_json, tracing, url
│ │ ├── src/lib.rs # lib + MockRealtimeBrain for tests
│ │ ├── src/main.rs # standalone binary: ws://127.0.0.1:8082/realtime + OpenAI WS client
│ │ ├── src/translator.rs # tap ⇄ OpenAI event translation
│ │ └── src/openai_client.rs # wss://api.openai.com/v1/realtime client
│ ├── rutster-trunk/ # STUB (unchanged)
│ └── rutster-spend/ # STUB (unchanged)
└── examples/
└── echo_brain/ # unchanged (Python reference; ignores new events)
```
### 2.1 Dependency direction
- `rutster-brain-realtime``rutster-tap` (for protocol types — same
re-export pattern slice-2 established for `rutster-tap-echo`).
- `rutster-brain-realtime` is its own workspace member that's both a
binary (dev loop) and a library (test fixture). The library re-exports
a `MockRealtimeBrain` for use by integration tests in the binary crate.
- `rutster` (binary) gains `tool_registry.rs` as a sibling of
`tap_engine.rs` — the new module dispatches tool-call events the
TapClient observes via the new side-channel mpsc.
- `rutster-tap`'s protocol module is the contract both the brown core
and the new brain process share; new event types added here are re-used
by `rutster-brain-realtime` (the contract test that the wire types are
reusable from outside the core, exactly as `rutster-tap-echo` did).
- `rutster-media` and `rutster-call-model` are untouched. `loop_driver.rs`
and `rtc_session.rs` are byte-identical to slice-2 (post-review-fix)
baseline.
### 2.2 Why one new crate for the OpenAI Realtime brain (not an `examples/` file)
Mirrors slice-2's dual-purpose pattern for `rutster-tap-echo`: a real
workspace member that runs `cargo fmt`, `cargo clippy -D warnings`,
`cargo test`, and `cargo deny check` like every other crate. It reuses
`rutster-tap`'s protocol types — the contract test that the wire types
are reusable from **outside the core**, which is what makes the tap a
real extension point and not a closed system.
A Python reference brain (`examples/openai_realtime_brain/openai_realtime_brain.py`)
is the canonical foreign-language brain demo for **OpenAI Realtime
specifically** (slice-2 already shipped the echo brain in Python). This
file is **not in CI** (Python would violate the zero-non-Rust-dev-deps
dev loop). README-documented runnable: `pip install websockets openai`
and run.
### 2.3 Why the brain is green-zone (per ADR-0008)
ADR-0008 classifies "the agent brain" explicitly under green-zone: it's
not hot-path inside the boundary (its round-trip is hundreds of ms; the
timing-critical work happens in the FOB reflex loop), not
security-constitutive (the core-authoritative playout buffer structurally
prevents the brain from flooding the wire), and not differentiating (the
tap-as-open-protocol is the differentiator; any brain speaking it works).
So the OpenAI Realtime adapter lives **outside** the FOB trust boundary —
its own process, its own API key, its own failure domain. The brain is
**reached by** the FOB; it does not live in it. The only FOB-side
additions in slice-3 are:
- `rutster-tap/src/protocol.rs` — additive event types (protocol
extension, not new behavior — the existing TapClient + TapAudioPipe
surface is unchanged).
- `crates/rutster/src/tool_registry.rs` — in-boundary tool dispatch (a
security-constitutive capability: the brain proposes tool calls; the
FOB disposes. Per ADR-0007's spend-gate posture: "rutster mediates both
the provider call-control API and the brain tap, so the brain never
holds the wire").
---
## 3. Tap wire protocol extension (`rutster-tap/src/protocol.rs`)
The slice-2 v1 protocol (envelope: `{v, type, seq, ts}`). The new event
types are additive — slice-2's §3.4 "unknown type → log + count + drop"
rule means an old brain (slice-2's `rutster-tap-echo`) ignores them.
Slice-2's envelope invariants (per-direction `seq`, advisory `ts`,
`samples: 480` for audio frames, explicit LE byte order) carry over
unchanged.
### 3.1 Envelope (slice-2's, unchanged)
```jsonc
{
"v": 1,
"type": "<event_name>",
"seq": <uint>,
"ts": <uint>
}
```
### 3.2 New events — brain → core (advisory)
| `type` | payload fields | when |
|---|---|---|
| `speech_started` | `{}` | brain detected user speech started (translated from OpenAI `input_audio_buffer.speech_started`; advisory — the cores FOB reflex loop, when wired in step 4, may use it for barge-in) |
| `speech_stopped` | `{}` | brain detected user speech stopped (translated from OpenAI `input_audio_buffer.speech_stopped`; advisory) |
| `tools.update` | `{ "tools": [{ "name": "<slug>", "description": "...", "parameters": {...} }] }` | brain declares its tool catalog (sent on `hello` ack, re-sent on tool catalog changes). The core's tool registry uses this to validate function_call events. |
| `function_call` | `{ "id": "<uuid>", "name": "<slug>", "args": { ... } }` | brain wants the core to execute a tool (translated from OpenAI `response.function_call_arguments.done`). The core dispatches via the tool registry and replies via `function_call_output`. |
### 3.3 New events — core → brain
| `type` | payload fields | when |
|---|---|---|
| `function_call_output` | `{ "id": "<uuid>", "status": "ok"\|"error"\|"not_implemented", "result": { ... } }` | core's tool-registry reply. Translated by the brain process into an OpenAI `conversation.item.create` with `type: function_call_output` so OpenAI continues the conversation appropriately. |
### 3.4 Invariants
- **Tool-call id:** `function_call` and `function_call_output` carry the
same `id` (a UUID minted by the brain; OpenAI calls it `call_id`, we
translate verbatim). Mismatches are logged + counted; the tool-registry
dispatch keys off the id.
- **Tool validation:** the core's tool registry validates `function_call`
events against the most recent `tools.update` catalog. An unknown tool
name → `function_call_output` with `status: "not_implemented"` (not
`error`; distinguishing "the FOB doesn't know this tool" from "the tool
failed"). The catalog is brain-authoritative — the brain declares it;
the core merely checks dispatch.
- **Advisory interruption events:** `speech_started` / `speech_stopped`
are **advisory only** in slice-3. The core logs + counts them; the FOB
reflex loop lands in step 4 and will use them. No slice-3 code path
acts on them in the hot media loop — `loop_driver.rs` is byte-identical
to slice-2 (post-review-fix) baseline.
- **Forward-compat:** unknown `type` values continue to be logged +
counted + dropped (slice-2 §3.4). The echo brain from slice-2 ignores
every new type this slice introduces; it echoes audio unchanged.
### 3.5 Byte-order + audio format (unchanged from slice-2)
PCM inside the base64 payload is explicit little-endian `i16` bytes (v1
wire contract, slice-2 §3.4). OpenAI Realtime's audio events also use
24 kHz mono PCM inside base64 LE i16 — **no transcoding, no resample,
no endianness swap.** The translator passes the audio payload through
to the WS frame verbatim. This is a happy convergence that simplifies
the translator; it is not a coincidence (slice-1's choice of 24 kHz mono
was made with this ecosystem in mind).
---
## 4. The OpenAI Realtime translation layer (`rutster-brain-realtime/src/translator.rs`)
Pure (no schema of its own beyond the new tap event types); maps event
names + payload shapes between the two wire protocols. Owns no call
state beyond the OpenAI Realtime session/connection state.
### 4.1 The two-leg brain process
```
WS server side WS client side
(core-as-client dials here) (brain-as-client dials OpenAI)
┌──────────────────┐ ┌──────────┐
tap protocol │ rutster-brain- │ translation layer │ OpenAI │
core ◄────────┤ realtime ├────────────────────────────► │ Realtime │
(audio_in/out, │ │ tap event ⇄ OpenAI event │ API WS │
speech_started, │ │ │ (wss://api.openai.com)│
func_call, ...) └──────────────────┘ └──────────┘
│ brain process also owns:
│ • OpenAI session.update (turn_detection: null — S4)
│ • interruption-signal forwarding (OpenAI → tap)
│ • function_call forwarding (bidirectional)
│ • tools.update on startup (catalog to core)
```
The translation layer is pure (no schema of its own beyond the new tap
event types); it maps event names + payload shapes between the two wire
protocols. It owns no call state beyond OpenAI's session/connection state.
### 4.2 Event mapping table
| Tap protocol | OpenAI Realtime | Notes |
|---|---|---|
| `hello` | `session.update` (modalities: ["audio", "text"], audio config, **turn_detection: null**) | Sent on handshake + reconnect; the S4 decision is encoded here |
| `audio_in` (base64 PCM LE i16) | `input_audio_buffer.append` (base64, same wire shape) | Pass-through; no transcoding |
| `audio_out` (from brain → core, advisory) | `response.audio.delta` events | The brain receives, formats, sends to core via tap_audio_out mpsc |
| `speech_started` (brain → core advisory) | `input_audio_buffer.speech_started` | Caught and forwarded as advisory |
| `speech_stopped` (brain → core advisory) | `input_audio_buffer.speech_stopped` | Caught and forwarded as advisory |
| `function_call` (brain → core) | `response.function_call_arguments.done` (OpenAI emits) → translator formats as tap function_call | Brain's translator extracts `call_id`, `name`, `arguments`; the core dispatches via tool registry |
| `function_call_output` (core → brain) | `conversation.item.create` with `item.type: function_call_output`, `call_id: <id>`, `output: <result-json>` | Closes the tool-call loop |
| `bye` / `session_end` | `session.delete` + WS close | Graceful teardown |
### 4.3 The S4 turn-ownership decision (load-bearing per ADR-0008)
> ARCHITECTURE.md is right that the core should be authoritative on
> VAD/barge-in ("the tap carries the *results* of reflexes, not the
> responsibility"; "`AudioOut` advisory / core-authoritative"). But the
> most likely first brain — OpenAI Realtime — does its own server-side
> VAD and turn detection by default. Integrating step 34 means either:
> disable the brain's turn detection and feed it clean, locally-detected
> turns (preserves the reflex-authoritative principle, but is more
> integration work and fights the API's grain), or
> accept split-brain turn-taking where local VAD and the brain's VAD can
> disagree (double-triggers, dropped barge-ins).
>
> **Decision implied:** make "who owns turn detection" an explicit decision
> in the step-3 (brain) design doc, defaulting to core-authoritative,
> brain VAD off, with the integration cost budgeted. Don't let it be
> discovered at wiring time.
> — Claude, vision-sanity-check S4
ADR-0008's FOB/green-zone split makes this decidable without ambiguity:
- **The reflex loop is FOB** (hot-path + differentiating — turn-taking,
VAD-driven barge-in, jitter, pacing).
- **The brain is green-zone** (per ADR-0008's explicit classification).
- **The tap is core-authoritative** (slice-2 §4.1 — the playout buffer
structurally prevents the brain from gating playout).
The only doctrine-consistent posture is: **OpenAI Realtime's server-side
turn detection is disabled (`session.update` with `turn_detection: null`),
and the FOB owns turn-taking.** The `speech_started` /
`speech_stopped` events are forwarded as advisory signals so step 4 can
use them as one input to the FOB reflex loop — but OpenAI does not gate
playout, ever. The core-authoritative playout buffer (slice-2 §4.1) is
the only thing that gates playout in slice-3 (and continues to be in
step 4).
**Why this is the right call now (rather than deferring to step 4):**
leaving OpenAI's turn detection on would mean slice-3's demo
"works by accident" — the slice-4 barge-in integration would then have to
disable it mid-flight, with unpredictable behavioral changes. By nailing
it down at slice-3's `session.update`, the brain process never wires up
the wrong posture and step 4's barge-in work composes cleanly on top.
### 4.4 Failure mode + reconnect
- **Tap-side WS reconnect (brain unreachable):** slice-2's
`TapEngine::spawn_tap_engine` already does bounded-backoff reconnect
(`250 ms → 500 ms → 1 s → 2 s → cap 5 s`, infinite retries,
re-`hello`s with the same `session_id`). The brain process being
unreachable looks identical to slice-2's echo brain being unreachable
— the slice-2 reconnect path is unchanged. The seam test (slice-2
§8.5 #6) holds: `tap_engine.rs` is byte-identical.
- **OpenAI-side WS failure:** the brain process's own concern — it enters
its own bounded-backoff reconnect to OpenAI, and forwards a tap `error`
event (`{ "code": "brain_upstream_disconnect", "message": "..." }`)
to the core as an FYI. The core logs + counts; the call stays up; the
tap-side playout goes silent (slice-2's underflow path) until OpenAI
reconnects. The slice-3 brain's OpenAI-side reconnect is independent
of the slice-2 tap-side reconnect — two distinct failure surfaces.
- **API-key invalid (OpenAI returns 401):** the brain process emits a tap
`error` event (`{ "code": "brain_auth_failed", "message": "..." }`)
and exits the OpenAI leg. The core's response is unchanged from the
upstream-disconnect case (logs + silence + reconnect path). Operator
intervention; the call stays up.
- **Tool-registry dispatch failure:** a tool-registry error (e.g.,
`hangup` fires but the channel state machine rejects the transition
because the call is already `Closing`) returns a `function_call_output`
with `status: "error"` and a result body explaining. The brain forwards
the error to OpenAI; OpenAI may or may not retry depending on its own
logic. No core-side retry; the model gets one chance.
---
## 5. Lifecycle & integration
### 5.1 Session lifecycle (slice-3 delta on slice-2)
1. `POST /v1/sessions` — body still *optionally* carries `tap_url`. If
absent, falls back to `RUTSTER_TAP_URL` env. Default now documented
as either `ws://127.0.0.1:8081/echo` (slice-2 Rust echo brain) or
`ws://127.0.0.1:8082/realtime` (slice-3 OpenAI Realtime brain); the
operator picks which brain to run.
2. `POST /v1/sessions/:id/offer` — unchanged from slice-1/2; SDP answer.
3. On `Connected`: the binary's poll task observes the state transition
+ spawns the `TapEngine` task (unchanged from slice-2). The
`TapEngine` connects to the brain process's WS server.
4. Brain process startup: connects to `wss://api.openai.com/v1/realtime`,
sends `session.update` with `turn_detection: null`, waits for OpenAI
`session.created` ack.
5. Brain process WS server accepts the core's tap WS; `hello` exchange;
brain process sends `tools.update` with the catalog (currently:
`hangup` only, plus any tool schemas the brain process's startup
config declares — see §6).
6. Audio flows: core decodes Opus → PCM → `audio_in` to brain → translator
formats as `input_audio_buffer.append` → OpenAI processes →
`response.audio.delta` → translator formats as `audio_out` → core's
playout ring → str0m encode.
7. Interruption signals: OpenAI `input_audio_buffer.speech_started` /
`.speech_stopped` → translator → tap `speech_started` / `speech_stopped`
→ core logs + counts (advisory). Step 4 will wire these into the FOB
reflex loop.
8. Tool calls: OpenAI `response.function_call_arguments.done` → translator
→ tap `function_call` → core's `tool_registry` dispatches (via the
TapClient's new side-channel mpsc) → `function_call_output` reply →
translator → OpenAI `conversation.item.create`.
9. `DELETE /v1/sessions/:id` or peer-close → `Closing`: slice-2's
unmodified teardown sequence fires — the TapEngine's `session_end`
brain process's `session.delete` to OpenAI → WS close on both legs.
### 5.2 The tool-call side-channel (the only brown-binary wiring)
Slice-2's `TapClient` runs the WSS pump loop with two mpsc channels
(`tx_pcm_in` for inbound audio to brain, `rx_audio_out` for outbound
audio from brain). Slice-3 adds a third:
- `tx_function_call: mpsc::Sender<FunctionCallEvent>` — the TapClient
emits a `FunctionCallEvent` whenever it observes a tap `function_call`
message on its inbound stream. The binary's poll task drains this in
the same cycle it drains the existing `flush_tx` side-channel
(slice-2 §5.3 step 4) — same pattern, one extra channel.
- `rx_function_call_output: mpsc::Receiver<FunctionCallOutputEvent>`
the binary writes `function_call_output` events through this channel;
the TapClient picks them up and sends them as tap WS frames.
The shape keeps the seam test (§7 #5 of done criteria below): all this
lives in the binary's `tap_engine.rs` callers + the new `tool_registry.rs`
module. `loop_driver.rs` and `rtc_session.rs` are untouched.
### 5.3 Brain process startup configuration
| Env var | Purpose | Default |
|---|---|---|
| `RUTSTER_TAP_BIND` | WS server bind addr for the brain process | `127.0.0.1:8082` |
| `OPENAI_API_KEY` | OpenAI Realtime API key (mutually exclusive with `_FILE`) | required unless `--features=mock` |
| `OPENAI_API_KEY_FILE` | Path to a file containing the API key | optional; overrides `_KEY` |
| `OPENAI_REALTIME_MODEL` | OpenAI Realtime model id | `gpt-4o-realtime` (or current equivalent) |
| `OPENAI_REALTIME_VOICE` | Voice for TTS output | `alloy` (OpenAI's default; documented) |
| `RUTSTER_BRAIN_TOOLS` | Comma-separated tool names the brain should advertise in `tools.update` | `hangup` (only tool the core wires; others cause `not_implemented` replies) |
The dev mode (`--features=mock`) doesn't read `OPENAI_API_KEY` and
instruments an in-process mock OpenAI Realtime WS server that generates
canned `response.audio.delta` events on `input_audio_buffer.append` and
asserts on the `session.update` having `turn_detection: null`. Used by:
the slice-3 integration test (no real OpenAI credentials, no network
calls), and the offline dev loop.
---
## 6. The tool registry (`crates/rutster/src/tool_registry.rs`)
A FOB-internal dispatch over the brain's proposed tool calls. The brain
proposes (via `function_call`); the FOB disposes (via `function_call_output`).
Per ADR-0007's spend-gate posture: the brain never holds the wire,
structurally.
### 6.1 Trait + registry shape
```rust
#[async_trait]
pub trait Tool: Send + Sync {
/// Tool name as the brain will reference it in function_call events.
fn name(&self) -> &str;
/// JSON-schema description the registry sends to the brain on tools.update.
fn schema(&self) -> serde_json::Value;
/// Execute the tool; return a result that the registry will serialize
/// into the function_call_output payload.
async fn call(&self, args: serde_json::Value) -> ToolResult;
}
pub enum ToolResult {
Ok(serde_json::Value),
Error(String),
NotImplemented,
}
pub struct ToolRegistry {
tools: Vec<Box<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self;
pub fn register(&mut self, tool: Box<dyn Tool>);
/// Dispatch by tool name; returns ToolResult::NotImplemented if unknown.
pub async fn dispatch(&self, name: &str, args: serde_json::Value) -> ToolResult;
/// Used on startup + tools.update: enumerate the registered catalog.
pub fn catalog(&self) -> Vec<serde_json::Value>;
}
```
### 6.2 The `hangup` tool (the only wired impl in slice-3)
A `Tool` impl that on `call`:
1. Looks up the `ChannelId` (passed via the dispatch context — the
`tool_registry` is keyed by `ChannelId`, one registry per active
channel).
2. Fires `AppState::close(channel_id).await` — the existing slice-2
teardown path (sets `Closing`, sends `session_end`, aborts the engine).
3. Returns `ToolResult::Ok({"channel_state": "Closing"})` so the brain
gets confirmation.
Other tool names (anything in `RUTSTER_BRAIN_TOOLS` the brain advertises
that isn't `hangup`) → the registry returns `ToolResult::NotImplemented`:
the brain process forwards the reply as an OpenAI
`conversation.item.create` with the not_implemented status, and the model
is free to continue.
### 6.3 What slice-3 does NOT wire
- No `transfer` tool (escalation rung 2 — separate slice).
- No `lookup` / CRM-integration tools (business-logic integrations are
green-zone + future-rung).
- No spend/abuse-triggered tools (step 6).
- No dynamic tool registration at runtime (the catalog is fixed at
startup from `RUTSTER_BRAIN_TOOLS`).
If a future brain proposes a tool not in the registry: `not_implemented`
reply; the model is free to retry with different arguments or give up.
The registry's job is to be the auditable dispatch boundary, not to be
extensible mid-call.
---
## 7. CI, dev loop, testing
### 7.1 New `[workspace.dependencies]` (Cargo.toml)
- `async-trait = "0.1"` (for the `Tool` trait's async fn).
- `tokio-tungstenite`, `serde_json`, `tracing`, `url` — already pulled
by slice-2; reused.
Member crate `rutster-brain-realtime` references these with
`dep.workspace = true`.
### 7.2 CI (`.github/workflows/ci.yml`)
Unchanged structure: `cargo fmt --check`, `cargo clippy -D warnings`,
`cargo test --all`, `cargo deny check`. The new `rutster-brain-realtime`
crate joins `--all`. **No Python in CI** — the OpenAI Realtime Python
reference brain is README-documented only. The Rust
`rutster-brain-realtime`'s in-process `MockRealtimeBrain` powers the
integration test; no real OpenAI credentials needed.
### 7.3 Dev loop
- `cargo run -p rutster-brain-realtime --features=mock` → starts the
brain process with an in-process mock OpenAI Realtime (on `:8082`):
no API key required, no network calls to OpenAI.
- `cargo run -p rutster-brain-realtime` (with `OPENAI_API_KEY` set) →
starts the brain process with the real OpenAI Realtime.
- `cargo run` (or `cargo run -p rutster`) → starts the axum signaling
server on `0.0.0.0:8080`, dials out to `$RUTSTER_TAP_URL` on each
session.
- Browser → `http://localhost:8080/` → click "Start call" → grant mic →
speak → hear AI reply through the brain process.
- `RUST_LOG=rutster=debug cargo run` for verbose tracing including
tap + tool-call events.
- `--features=echo` on the binary (slice-2 inherited) → bypasses the
brain entirely, routes audio through `EchoAudioPipe`.
### 7.4 Testing strategy
- **Unit tests in `rutster-brain-realtime`:**
- Protocol-event translation round-trips (tap `audio_in` ↔ OpenAI
`input_audio_buffer.append`; tap `function_call` ↔ OpenAI
`response.function_call_arguments.done`; tap `function_call_output`
↔ OpenAI `conversation.item.create`).
- `session.update` body asserts `turn_detection: null` (S4 decision
encoded as a test).
- `tools.update` serialization round-trips.
- Error path: OpenAI-side WS error → tap `error` event forwarding.
- **Unit tests in `rutster-tap`:**
- New event types (de)serialization round-trips with golden JSON
fixtures in `tests/fixtures/`.
- Slice-2's existing echo-brain tests stay green (the new event types
are ignored by the echo brain — forwards-compat).
- **Unit tests in `rutster` `tool_registry.rs`:**
- `hangup` tool fires `AppState::close` correctly.
- Unknown tool name → `ToolResult::NotImplemented`.
- Registry catalog serialization.
- **Integration test in `rutster` binary crate:** spin up the axum server
(ephemeral port) + the in-process `MockRealtimeBrain` (ephemeral port) +
set `RUTSTER_TAP_URL`. Drive a synthetic WebRTC peer (extending
slice-2's `tap_integration.rs` harness): push PCM into the core via
the WebRTC peer → assert `audio_out` flows back through the tap (the
mock brain generates canned `response.audio.delta` on
`input_audio_buffer.append`) → assert they're re-encoded + pushed to
str0m. Plus: function-call round-trip (mock brain emits a
`function_call` for `hangup` → core's `tool_registry.dispatch("hangup")`
fires → channel state `Closing``session_end` over the tap →
mock brain's `session.delete` recorded).
- **Manual e2e test plan (README):**
1. `cargo run -p rutster-brain-realtime --features=mock` (mock brain on `:8082`).
2. `cargo run` (core on `:8080`).
3. Browser → speak → hear mock-brain reply within ~250 ms (slice-2's
round-trip; no real OpenAI).
4. Repeat with the real brain (`cargo run -p rutster-brain-realtime`
with `OPENAI_API_KEY` set) → end-to-end speech-to-speech with real
OpenAI Realtime within ~700 ms.
5. Repeat with the Python brain (`python
examples/openai_realtime_brain/openai_realtime_brain.py`) → should
also work (proves the protocol is language-agnostic, same as
slice-2's Python echo brain).
6. Function-call: trigger a model behavior that emits a tool call
(e.g. a test prompt that says "please hang up") → assert the call
closes + `session_end` over the tap + `session.delete` to OpenAI.
7. `cargo test --all` green; `cargo fmt --check` /
`cargo clippy -D warnings` / `cargo deny check` green.
### 7.5 Slice 3 "done" criteria
The slice is complete when, on a clean checkout (+ open pivot / slice-1
review fixes / slice-2 plan-rebaseline PRs merged):
1. `cargo test --all` passes (unit + integration). The new
`rutster-brain-realtime` crate tests green alongside slice-1/2's suite.
2. `cargo fmt --check`, `cargo clippy -D warnings`, `cargo deny check`
all pass.
3. `cargo run -p rutster-brain-realtime --features=mock` + `cargo run` →
browser, speak, hear mock-brain reply within ~250 ms.
4. With `OPENAI_API_KEY` set: real OpenAI Realtime end-to-end within
~700 ms.
5. **Both** `rutster-brain-realtime` (Rust, with `--features=mock`) **and**
`examples/openai_realtime_brain/openai_realtime_brain.py` (Python)
successfully interop against the core — proves the extended protocol
is language-agnostic.
6. **The seam test (load-bearing):** `rutster-media`'s `loop_driver.rs`
and `rtc_session.rs` keep their media-loop trait-method call sites
**byte-identical** to slice-2 (post-review-fix) baseline. The only
brown-binary additions are: `crates/rutster/src/tool_registry.rs`
(new module) + a new side-channel drain in `session_map.rs`. The
`tap_engine.rs` and `TapClient` get only additive new mpsc channels
(one new sender + one new receiver); the WSS pump loop's structure
is unchanged. A `git diff v<slice-2-post-review-fix-tag> --
crates/rutster-media/src/loop_driver.rs
crates/rutster-media/src/rtc_session.rs` shows no behavior-changing
hunks (doc-comment or import changes permitted).
7. **S4 turn-ownership test:** the integration test asserts that the
`session.update` sent to OpenAI Realtime (or its mock equivalent)
contains `turn_detection: null`. The brain never auto-barges; the
core-authoritative playout buffer is the only playout gate.
8. `LEARNING.md` grows ≥3 new pointers: `async-trait` patterns →
`crates/rutster/src/tool_registry.rs`; OpenAI Realtime adapter →
`crates/rutster-brain-realtime/src/translator.rs`; tap protocol
extension + forwards-compat → `crates/rutster-tap/src/protocol.rs`.
---
## 8. Open decisions (tracked)
- **`response.audio.delta` batching.** OpenAI sends many small delta
events per response; the translator MAY batch into the slice-2
`audio_out` 20 ms frame, or pass each delta through immediately as
its own `audio_out`. Batching reduces WS round-trips but adds latency.
Track + revisit after latency measurement with the real brain.
- **Tool-registry extensibility.** Slice-3 fixes the catalog at startup
via `RUTSTER_BRAIN_TOOLS`. Runtime-extensible registration (new tools
added mid-call) is a future-rung concern — would need a tool-register
API event. Track.
- **API-key rotation.** Slice-3 reads the key once at startup; a key
rotation requires a brain-process restart. KMS / Vault integration
lands with the real trust boundary (step 6). Track.
- **Voice + persona selection.** `OPENAI_REALTIME_VOICE` is the only
persona knob now; future brain processes may carry full prompt /
system-message customization (rung 2+). Track.
- **Multi-brain routing.** Slice-3 ships one brain process: OpenAI
Realtime. A Deepgram+LLM+TTS composite adapter or a self-hosted
open-weights brain would be its own crate
(`rutster-brain-deepgram`, `rutster-brain-local`) — the tap protocol
is brain-agnostic by design. Track + revisit when a second brain
lands.
---
## 9. Out-of-scope re-check (against AGENTS.md + ADR-0007/0008)
| Item | Status |
|---|---|
| Dedicated timing thread for media loop | Still step 4. |
| TLS on the HTTP signaling surface | Still step 5. |
| Authn / authz / multi-tenancy on `/v1/sessions` | Still step 6. |
| Trickle ICE | Unchanged. |
| Barge-in / VAD-driven playout kill | **Step 4.** Slice-3 pre-paves the `speech_started` / `speech_stopped` advisory event seam so step 4 lands cleanly. |
| PSTN trunk / rented transport | Still step 5. |
| Spend cap / abuse gate | Still step 6. |
| CDR / event bus / OTel beyond per-Channel tracing | Still step 5. |
| Browser automation / Playwright | Still post-slice-1. |
| Docker / compose | Still later-rung. |
| Transfer / park / pickup / barge (call features) | Still escalation rung 2. |
If an agent proposes adding any of these in slice 3, the right answer is
"no, see the slice-3 spec §1.2."
---
## 10. Key design decisions (summary of the brainstorming session)
| Decision | Choice | Rejected alternatives | Why |
|---|---|---|---|
| Brain target | **OpenAI Realtime NOW; design for multi-brain later.** Adapter trait shape is the tap protocol itself, not a Rust trait. | Trait crate `rutster-brain` + impl crate; Deepgram+LLM+TTS now | Slice-2's protocol-as-contract framing already delivers "multi-brain later" without a speculative trait; YAGNI on abstraction. A Deepgram+LLM+TTS composite brain's friction will be in its own internal topology (composing three async WS/HTTP streams), not in `trait BrainAdapter` ergonomics — a trait wouldn't ease that anyway. |
| Cool scope | **Round-trip speech + interruption signals (advisory) + function-calling plumbing** | Speech only; speech + interruption; speech + interruption + function calling | Function calling is the gateway to escalation (rung 2 — `hangup` is the only wired tool; `transfer` lands later). Plumbing the FOB dispatch contract in slice-3 means step-4 barge-in composes on a clean foundation; deferring it would force step-4 to retrofit the seam. |
| Function-call handler | **Built-in tool registry in the core (FOB)** | Outbound tool-server URL (second egress); adapter handles function calls itself; plumbing only | Per ADR-0007: "rutster mediates both the provider call-control API and the brain tap, so the brain never holds the wire." The FOB disposes; the brain proposes. `hangup` is the only wired tool; others reply `not_implemented` honestly. The registry's job is the auditable dispatch boundary, not extensibility. |
| Turn-detection ownership (S4) | **Core-authoritative, brain VAD off** (`session.update` with `turn_detection: null`) | Accept split-brain (OpenAI VAD on; core-authoritative too → disagreement); defer the decision to step 4 | ADR-0008 makes the FOB/green-zone split mechanical: reflex loop is FOB, brain is green-zone, playout is core-authoritative. The only doctrine-consistent choice is to disable OpenAI's server-side VAD and let the FOB own turn-taking. Nailing it down at slice-3's `session.update` means step-4 barge-in composes cleanly. |
| API-key posture | **Env var + file-path override.** `OPENAI_API_KEY` env default; `OPENAI_API_KEY_FILE` overrides. | Env-only; KMS-required-now | Matches slice-2's "no auth yet" stance cleanly. KMS / Vault lands with the real trust boundary (step 6). The file-path override makes secret-manager injection (k8s secrets, Vault agent) trivial when that layer exists. |
| Dev mode | **`--features=mock` in-process mocked OpenAI Realtime** | No dev mock (always require real OpenAI); external mock server | The slice-3 dev loop must be zero-OpenAI-credentials + zero-network-calls-to-OpenAI. The integration test uses the same mock. Same pattern as slice-2's `EchoServer`. |
| Workspace shape | **One new crate `rutster-brain-realtime` (library + binary), mirroring `rutster-tap-echo`** | Put the adapter in `examples/`; trait crate + impl crate | Slices-2's precedent. Real workspace member runs fmt/clippy/test/deny like everyone else. Reuses `rutster-tap`'s protocol types — the contract test that the wire types are reusable from outside the core. A future second brain is its own crate, same shape. |
| Brain process port + protocol | **`ws://127.0.0.1:8082/realtime` (slice-2's echo brain defaults to `:8081/echo`)** | Single brain port with feature toggle | Two brain processes coexist (operator picks which to run via `RUTSTER_TAP_URL`). The default URL stays `ws://127.0.0.1:8081/echo` (slice-2 documented); slice-3 adds `ws://127.0.0.1:8082/realtime` as the documented OpenAI-brain default. |
| Tap protocol extension posture | **Additive v1 events** (not v2) | v2 protocol bump; OpenAI Realtime event-schema adoption | Slice-2 §3.4's "unknown type → log + count + drop" rule makes additive events free — old echo brains ignore them. No wire-format break, no version negotiation needed. A v2 binary mode (raw LE i16 over WS binary frames) remains a future-rung optimization per slice-2 §9. |
---
## 11. References
- [README.md](../../../README.md) — north star, capability ladder
- [ARCHITECTURE.md §"Agent tap"](../../ARCHITECTURE.md) — the presumptive
tap shape this slice hardens against a real brain
- [PORT_PLAN.md](../../PORT_PLAN.md) — capability checklist + thin-slice
phasing; §10 "WASM demoted, agent tap is the extension point"
- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — fused vertical
- [ADR-0007](../../adr/0007-trunk-rented-transport.md) — rent the trunk;
the brain is unaffected (PSTN reaches the reflex loop via media-leg
ingress, not via the tap)
- [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB / green-zone
doctrine; the brain is green-zone, the reflex loop is FOB, the tap is
core-authoritative (the S4 turn-ownership decision follows)
- [Slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md)
— this slice's foundation
- [Slice 2 — the agent tap](2026-06-28-slice-2-agent-tap-design.md) — the
tap interface + the `TapAudioPipe` seam this slice composes on
- [Vision-revision spec](2026-06-26-vision-revision-design.md) — the
pressure-test that produced the architecture
- [Default UI design](2026-06-29-default-ui-design.md) — the operator
console design record (later-rung, not slice-3's concern)
- [Vision sanity-check review S4](../reviews/2026-06-28-vision-sanity-check.md)
— the original finding that named the S4 turn-ownership decision;
slice-3 §4.3 resolves it under ADR-0008