45 KiB
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
(must be implemented + green; the tap protocol and
TapAudioPipeseam are the foundation this slice swaps content into). - Related: ADR-0002 (fused vertical), ADR-0004 (GPL-3.0-or-later), ADR-0008 (the brain is green-zone; the reflex loop is FOB — load-bearing for the S4 turn-ownership decision below), ARCHITECTURE.md §"Agent tap".
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'srutster-tap-echo: a standalone dev-loop binary (cargo run -p rutster-brain-realtime) and an in-processMockRealtimeBrainfor integration tests (no network calls to OpenAI). Default portws://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).hangupis the only wired tool — fires the existingChannel: Connected → Closingpath. Other tool names replystatus: "not_implemented". The brain'stools.updateevent 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.updateis sent withturn_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. Thespeech_started/speech_stoppedevents 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'saudio_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_KEYenv default +OPENAI_API_KEY_FILEpath override.OPENAI_REALTIME_MODELenv (default:gpt-4o-realtimeor current equivalent; documented). --features=mockdev 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),reqwestorserde_json(already pulled), and no new workspace member-deps beyond what slice-2 already pinned. Reuse slice-2's protocol types fromrutster-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 forrutster-tap-echo).rutster-brain-realtimeis its own workspace member that's both a binary (dev loop) and a library (test fixture). The library re-exports aMockRealtimeBrainfor use by integration tests in the binary crate.rutster(binary) gainstool_registry.rsas a sibling oftap_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 byrutster-brain-realtime(the contract test that the wire types are reusable from outside the core, exactly asrutster-tap-echodid).rutster-mediaandrutster-call-modelare untouched.loop_driver.rsandrtc_session.rsare 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)
{
"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 core’s 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_callandfunction_call_outputcarry the sameid(a UUID minted by the brain; OpenAI calls itcall_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_callevents against the most recenttools.updatecatalog. An unknown tool name →function_call_outputwithstatus: "not_implemented"(noterror; 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_stoppedare 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.rsis byte-identical to slice-2 (post-review-fix) baseline. - Forward-compat: unknown
typevalues 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"; "
AudioOutadvisory / core-authoritative"). But the most likely first brain — OpenAI Realtime — does its own server-side VAD and turn detection by default. Integrating step 3–4 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_enginealready does bounded-backoff reconnect (250 ms → 500 ms → 1 s → 2 s → cap 5 s, infinite retries, re-hellos with the samesession_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.rsis 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
errorevent ({ "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
errorevent ({ "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.,
hangupfires but the channel state machine rejects the transition because the call is alreadyClosing) returns afunction_call_outputwithstatus: "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)
POST /v1/sessions— body still optionally carriestap_url. If absent, falls back toRUTSTER_TAP_URLenv. Default now documented as eitherws://127.0.0.1:8081/echo(slice-2 Rust echo brain) orws://127.0.0.1:8082/realtime(slice-3 OpenAI Realtime brain); the operator picks which brain to run.POST /v1/sessions/:id/offer— unchanged from slice-1/2; SDP answer.- On
Connected: the binary's poll task observes the state transition- spawns the
TapEnginetask (unchanged from slice-2). TheTapEngineconnects to the brain process's WS server.
- spawns the
- Brain process startup: connects to
wss://api.openai.com/v1/realtime, sendssession.updatewithturn_detection: null, waits for OpenAIsession.createdack. - Brain process WS server accepts the core's tap WS;
helloexchange; brain process sendstools.updatewith the catalog (currently:hanguponly, plus any tool schemas the brain process's startup config declares — see §6). - Audio flows: core decodes Opus → PCM →
audio_into brain → translator formats asinput_audio_buffer.append→ OpenAI processes →response.audio.delta→ translator formats asaudio_out→ core's playout ring → str0m encode. - Interruption signals: OpenAI
input_audio_buffer.speech_started/.speech_stopped→ translator → tapspeech_started/speech_stopped→ core logs + counts (advisory). Step 4 will wire these into the FOB reflex loop. - Tool calls: OpenAI
response.function_call_arguments.done→ translator → tapfunction_call→ core'stool_registrydispatches (via the TapClient's new side-channel mpsc) →function_call_outputreply → translator → OpenAIconversation.item.create. DELETE /v1/sessions/:idor peer-close →Closing: slice-2's unmodified teardown sequence fires — the TapEngine'ssession_end→ brain process'ssession.deleteto 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 aFunctionCallEventwhenever it observes a tapfunction_callmessage on its inbound stream. The binary's poll task drains this in the same cycle it drains the existingflush_txside-channel (slice-2 §5.3 step 4) — same pattern, one extra channel.rx_function_call_output: mpsc::Receiver<FunctionCallOutputEvent>— the binary writesfunction_call_outputevents 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
#[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:
- Looks up the
ChannelId(passed via the dispatch context — thetool_registryis keyed byChannelId, one registry per active channel). - Fires
AppState::close(channel_id).await— the existing slice-2 teardown path (setsClosing, sendssession_end, aborts the engine). - 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
transfertool (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 theTooltrait'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(withOPENAI_API_KEYset) → starts the brain process with the real OpenAI Realtime.cargo run(orcargo run -p rutster) → starts the axum signaling server on0.0.0.0:8080, dials out to$RUTSTER_TAP_URLon each session.- Browser →
http://localhost:8080/→ click "Start call" → grant mic → speak → hear AI reply through the brain process. RUST_LOG=rutster=debug cargo runfor verbose tracing including tap + tool-call events.--features=echoon the binary (slice-2 inherited) → bypasses the brain entirely, routes audio throughEchoAudioPipe.
7.4 Testing strategy
- Unit tests in
rutster-brain-realtime:- Protocol-event translation round-trips (tap
audio_in↔ OpenAIinput_audio_buffer.append; tapfunction_call↔ OpenAIresponse.function_call_arguments.done; tapfunction_call_output↔ OpenAIconversation.item.create). session.updatebody assertsturn_detection: null(S4 decision encoded as a test).tools.updateserialization round-trips.- Error path: OpenAI-side WS error → tap
errorevent forwarding.
- Protocol-event translation round-trips (tap
- 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).
- New event types (de)serialization round-trips with golden JSON
fixtures in
- Unit tests in
rutstertool_registry.rs:hanguptool firesAppState::closecorrectly.- Unknown tool name →
ToolResult::NotImplemented. - Registry catalog serialization.
- Integration test in
rutsterbinary crate: spin up the axum server (ephemeral port) + the in-processMockRealtimeBrain(ephemeral port) + setRUTSTER_TAP_URL. Drive a synthetic WebRTC peer (extending slice-2'stap_integration.rsharness): push PCM into the core via the WebRTC peer → assertaudio_outflows back through the tap (the mock brain generates cannedresponse.audio.deltaoninput_audio_buffer.append) → assert they're re-encoded + pushed to str0m. Plus: function-call round-trip (mock brain emits afunction_callforhangup→ core'stool_registry.dispatch("hangup")fires → channel stateClosing→session_endover the tap → mock brain'ssession.deleterecorded). - Manual e2e test plan (README):
cargo run -p rutster-brain-realtime --features=mock(mock brain on:8082).cargo run(core on:8080).- Browser → speak → hear mock-brain reply within ~250 ms (slice-2's round-trip; no real OpenAI).
- Repeat with the real brain (
cargo run -p rutster-brain-realtimewithOPENAI_API_KEYset) → end-to-end speech-to-speech with real OpenAI Realtime within ~700 ms. - 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). - 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_endover the tap +session.deleteto OpenAI. cargo test --allgreen;cargo fmt --check/cargo clippy -D warnings/cargo deny checkgreen.
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):
cargo test --allpasses (unit + integration). The newrutster-brain-realtimecrate tests green alongside slice-1/2's suite.cargo fmt --check,cargo clippy -D warnings,cargo deny checkall pass.cargo run -p rutster-brain-realtime --features=mock+cargo run→ browser, speak, hear mock-brain reply within ~250 ms.- With
OPENAI_API_KEYset: real OpenAI Realtime end-to-end within ~700 ms. - Both
rutster-brain-realtime(Rust, with--features=mock) andexamples/openai_realtime_brain/openai_realtime_brain.py(Python) successfully interop against the core — proves the extended protocol is language-agnostic. - The seam test (load-bearing):
rutster-media'sloop_driver.rsandrtc_session.rskeep 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 insession_map.rs. Thetap_engine.rsandTapClientget only additive new mpsc channels (one new sender + one new receiver); the WSS pump loop's structure is unchanged. Agit diff v<slice-2-post-review-fix-tag> -- crates/rutster-media/src/loop_driver.rs crates/rutster-media/src/rtc_session.rsshows no behavior-changing hunks (doc-comment or import changes permitted). - S4 turn-ownership test: the integration test asserts that the
session.updatesent to OpenAI Realtime (or its mock equivalent) containsturn_detection: null. The brain never auto-barges; the core-authoritative playout buffer is the only playout gate. LEARNING.mdgrows ≥3 new pointers:async-traitpatterns →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.deltabatching. OpenAI sends many small delta events per response; the translator MAY batch into the slice-2audio_out20 ms frame, or pass each delta through immediately as its ownaudio_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_VOICEis 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 — north star, capability ladder
- ARCHITECTURE.md §"Agent tap" — the presumptive tap shape this slice hardens against a real brain
- PORT_PLAN.md — capability checklist + thin-slice phasing; §10 "WASM demoted, agent tap is the extension point"
- ADR-0002 — fused vertical
- ADR-0007 — rent the trunk; the brain is unaffected (PSTN reaches the reflex loop via media-leg ingress, not via the tap)
- ADR-0008 — 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 — this slice's foundation
- Slice 2 — the agent tap — the
tap interface + the
TapAudioPipeseam this slice composes on - Vision-revision spec — the pressure-test that produced the architecture
- Default UI design — the operator console design record (later-rung, not slice-3's concern)
- Vision sanity-check review S4 — the original finding that named the S4 turn-ownership decision; slice-3 §4.3 resolves it under ADR-0008