118 KiB
Slice 3 — OpenAI Realtime Brain Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Swap slice-2's echo brain for a real OpenAI Realtime speech-to-speech brain, reached through slice-2's existing tap interface — proving agent integration end-to-end (browser speak → brain reply within ~700 ms).
Architecture: One new workspace member rutster-brain-realtime (library + binary, mirrors slice-2's rutster-tap-echo shape) holds the OpenAI Realtime translation layer. The brain process is a WS server (core-as-client dials it, unchanged from slice-2) that's simultaneously a WS client to wss://api.openai.com/v1/realtime (OpenAI is server on its leg). Three additive tap protocol event types (speech_started, speech_stopped, function_call, function_call_output, tools.update) extend slice-2's v1 protocol forwards-compatibly (old echo brains ignore them per slice-2 §3.4). An in-boundary tool registry (crates/rutster/src/tool_registry.rs, FOB) dispatches function-call events the brain proposes — hangup is the only wired tool; others reply not_implemented. loop_driver.rs and rtc_session.rs are byte-identical to slice-2's baseline (the seam test, §7.5 #6 of the spec).
Tech Stack: Rust 1.85 + edition 2024 · tokio (runtime + mpsc + oneshot, already pinned) · tokio-tungstenite 0.24 (WS client + server, already pinned, already has connect feature) · futures-util 0.3 · serde/serde_json · tracing · async-trait (new dep) · url 2. Brain process config: OPENAI_API_KEY/OPENAI_API_KEY_FILE env or --features=mock for an in-process mock OpenAI WS server (no real OpenAI calls).
Global Constraints
- License: every crate manifest sets
license = "GPL-3.0-or-later"(ADR-0004). The[workspace.package]already sets this; new crates inherit vialicense.workspace = true. - Workspace: root
Cargo.tomlis[workspace]with[workspace.dependencies](slice-1 §2.1). New deps go in[workspace.dependencies]in the root; member crates reference withdep.workspace = true. One new dep this slice:async-trait = "0.1". - Workspace members (delta on slice-2): slice-2's seven members (
crates/rutster,crates/rutster-media,crates/rutster-call-model,crates/rutster-trunk,crates/rutster-tap,crates/rutster-tap-echo,crates/rutster-spend) plus ONE new member:crates/rutster-brain-realtime. Total = 8 members. - PCM format (slice-1 §3.1, §3.9, ARCHITECTURE.md): 16-bit signed mono, 24 kHz, fixed 20 ms frame = 480 samples.
PcmFrame { samples: [i16; 480] }lives inrutster-media(single canonical home);rutster-tapre-exports it. OpenAI Realtime uses the same format (24 kHz mono PCM inside base64 LE i16) — pass-through, no resample. - Wire byte order (spec §3, §9): PCM inside the base64 payload is explicit little-endian (
i16::to_le_bytesencode /i16::from_le_bytesdecode). OpenAI Realtime's API also uses LE — no endianness swap. - Tap protocol version:
v: 1. Slice-3's new event types are additive — slice-2's#[serde(other)] FrameKind::Unknownfallback means old echo brains ignore them (slice-2 §3.4 forward-compat). - S4 turn-ownership decision (load-bearing per ADR-0008, spec §4.3): the brain process's
session.updateto OpenAI Realtime setsturn_detection: null. OpenAI's server-side VAD is disabled. The FOB reflex loop (step 4) owns turn-taking; tap playout stays core-authoritative (slice-2 §4.1). - API-key posture (spec §5.3):
OPENAI_API_KEYenv default +OPENAI_API_KEY_FILEpath override (mutually exclusive). KMS/Vault integration deferred to step 6.cargo run -p rutster-brain-realtimewithout either (and without--features=mock) fails fast at startup with a clear error. - Namespace / copy rules:
ws://127.0.0.1:8082/realtimefor the brain process's WS server (slice-2's echo brain uses:8081/echo; the two coexist).wss://api.openai.com/v1/realtimefor OpenAI's leg (inopenai_client.rs). - Test discipline: TDD — every code-bearing task writes a failing test first, watches it fail, implements minimal code to pass, verifies green, commits. Tests use the in-process
MockRealtimeBrain(no real OpenAI credentials, no network calls to OpenAI). The Python reference brain (examples/openai_realtime_brain/) is not in CI (violates the zero-non-Rust-dev-deps dev loop). - Code style: Verbatim from AGENTS.md —
snake_casefor fns/vars/modules/crates,PascalCasefor types; newtype wrappers for type-safety where two ids could be confused;cargo fmtis the single source of truth for whitespace;clippy -D warningsis the lint bar; learner-facing doc comments on every public item + explanatory inline comments on the mechanism (the project-wide policy AGENTS.md §"Code style (Rust)" ratifies). - Tabular inclusive language, per AGENTS.md: avoid "police/master/slave/blacklist." Protocol names from upstream specs (RFC, OpenAI's API) stay verbatim.
File structure (landed shape — delta on slice-2)
rutster/
├── Cargo.toml # +async-trait dep; +rutster-brain-realtime in members
├── crates/
│ ├── rutster/ # binary: +tool_registry module + side-channel drain
│ │ ├── src/main.rs # unchanged shape (calls AppState::new(default_tap_url))
│ │ ├── src/session_map.rs # +function_call side-channel drain in drive_all_sessions
│ │ ├── src/routes.rs # unchanged
│ │ ├── src/tap_engine.rs # +tx_function_call Sender / rx_function_call_output Receiver
│ │ ├── src/tool_registry.rs # NEW: Tool trait + hangup tool
│ │ └── static/index.html # minor: surface brain connection state in <pre>
│ ├── rutster-media/ # UNCHANGED — the seam test (spec §7.5 #6)
│ │ └── src/loop_driver.rs # UNCHANGED
│ ├── rutster-call-model/ # UNCHANGED
│ ├── rutster-tap/ # +additive protocol event types + TapClient pump arm
│ │ ├── src/protocol.rs # +SpeechStarted / SpeechStopped / FunctionCall / FunctionCallOutput / ToolsUpdate
│ │ ├── src/tap_client.rs # +new function_call & tools.update pump arms (forward to side-channel)
│ │ └── src/lib.rs # +re-exports for new event types
│ ├── rutster-tap-echo/ # UNCHANGED (still works against extended protocol)
│ ├── rutster-brain-realtime/ # NEW crate (library + binary)
│ │ ├── Cargo.toml # deps: rutster-tap, tokio, tokio-tungstenite, futures-util, serde_json, tracing, url, async-trait
│ │ ├── src/lib.rs # lib — re-exports + MockRealtimeBrain + mocks for tests
│ │ ├── src/main.rs # standalone binary: ws://127.0.0.1:8082/realtime server + wss:// OpenAI client
│ │ ├── src/translator.rs # tap ⇄ OpenAI Realtime event translation (pure functions)
│ │ ├── src/openai_client.rs # wss:// client to api.openai.com/v1/realtime
│ │ └── src/api_key.rs # OPENAI_API_KEY / OPENAI_API_KEY_FILE loader
│ ├── rutster-trunk/ # STUB (unchanged)
│ └── rutster-spend/ # STUB (unchanged)
└── examples/
├── echo_brain/ # unchanged (Python reference echo brain from slice-2)
└── openai_realtime_brain/ # NEW: Python reference OpenAI Realtime brain (not in CI)
├── README.md # how to run
├── openai_realtime_brain.py # ~120 lines (websockets + openai libs)
└── requirements.txt # websockets, openai
Task-to-file mapping (quick reference)
| Task | Files |
|---|---|
| 1: workspace deps | Cargo.toml, crates/rutster-brain-realtime/{Cargo.toml,src/lib.rs} |
| 2: tap protocol extensions | crates/rutster-tap/src/protocol.rs, crates/rutster-tap/src/lib.rs |
| 3: API-key loader | crates/rutster-brain-realtime/src/api_key.rs |
| 4: translator (tap ⇄ OpenAI) | crates/rutster-brain-realtime/src/translator.rs |
| 5: OpenAI wss client | crates/rutster-brain-realtime/src/openai_client.rs |
| 6: Tool trait + registry | crates/rutster/src/tool_registry.rs, crates/rutster/Cargo.toml |
| 7: tap_client function_call arms + TapConn | crates/rutster-tap/src/tap_client.rs, crates/rutster-tap/src/lib.rs, crates/rutster/src/tap_engine.rs |
| 8: session_map tool-call side-channel drain | crates/rutster/src/session_map.rs |
| 9: brain binary (ws server + OpenAI client glue) | crates/rutster-brain-realtime/src/main.rs |
| 10: MockRealtimeBrain + integration test | crates/rutster-brain-realtime/src/lib.rs, crates/rutster/tests/realtime_integration.rs |
| 11: Python reference brain + LEARNING.md | examples/openai_realtime_brain/, LEARNING.md, README dev-loop |
Task 1: Workspace deps + crate skeleton
Files:
- Create:
Cargo.toml(modify — addasync-traitto[workspace.dependencies]+crates/rutster-brain-realtimetomembers) - Create:
crates/rutster-brain-realtime/Cargo.toml - Create:
crates/rutster-brain-realtime/src/lib.rs - Test:
cargo build -p rutster-brain-realtime
Interfaces:
-
Consumes: nothing (workspace manifest + empty crate).
-
Produces: a new crate
rutster-brain-realtimethat compiles; later tasks add modules. -
Step 1: Modify root
Cargo.toml— add dep + member
Open Cargo.toml. Add to [workspace.dependencies] (alongside tokio-tungstenite, futures-util, etc.):
# async-trait 0.1: async fns in trait objects (Tool trait, slice-3 spec §6.1).
async-trait = "0.1"
Add to [workspace] members (alongside crates/rutster-tap-echo):
"crates/rutster-brain-realtime",
- Step 2: Create the member crate's
Cargo.toml
Create crates/rutster-brain-realtime/Cargo.toml:
# crates/rutster-brain-realtime/Cargo.toml
[package]
name = "rutster-brain-realtime"
version = "0.1.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "OpenAI Realtime speech-to-speech brain — translates slice-2's tap protocol to OpenAI Realtime's event schema (green-zone per ADR-0008; slice-3 spec §1.1, §4)."
[dependencies]
rutster-tap = { path = "../rutster-tap" }
tokio = { workspace = true, features = ["full"] }
tokio-tungstenite = { workspace = true, features = ["connect"] }
futures-util = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
[features]
default = []
# Mock mode: in-process fake OpenAI Realtime WS server (no real API calls).
# Used by the integration test + the offline dev loop (spec §7.3).
mock = []
- Step 3: Create the skeleton
src/lib.rs
Create crates/rutster-brain-realtime/src/lib.rs:
//! # rutster-brain-realtime
//!
//! **Slice-3 brain:** translates slice-2's tap protocol (the green-zone side of
//! the seam — core-as-client dials this brain's WS server; ADR-0008 classifies
//! the brain as green-zone) to OpenAI Realtime's event schema. The brain
//! process is a WS *server* (core-as-client dials it, unchanged from slice-2)
//! AND a WS *client* to `wss://api.openai.com/v1/realtime` (OpenAI is server
//! on *its* leg).
//!
//! See `docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md` for
//! the full design.
//!
//! ## Modules
//!
//! - [`api_key`] — load API key from env var or file (spec §5.3).
//! - [`translator`] — pure-function event translation (tap ⇄ OpenAI).
//! - [`openai_client`] — wss:// client to api.openai.com/v1/realtime.
//!
//! ## Dev mode (`--features=mock`)
//!
//! When built with `mock`, the binary uses in-process `MockRealtimeBrain`
//! (defined in `lib.rs`'s test-support module) instead of dialing OpenAI.
//! No API key required, no real OpenAI calls. Used by the integration test +
//! the offline dev loop (spec §7.3).
pub mod api_key;
pub mod openai_client;
pub mod translator;
- Step 4: Add
mockCargo feature placeholder modules so the crate compiles
The other modules don't exist yet (Tasks 3–5 add them). For now, create minimal stubs so cargo build -p rutster-brain-realtime passes. Create crates/rutster-brain-realtime/src/api_key.rs:
//! API-key loader (spec §5.3) — implements the env-var + file-path posture.
//! Filled in by Task 3.
Create crates/rutster-brain-realtime/src/translator.rs:
//! Tap ⇄ OpenAI Realtime event translation (spec §4). Filled in by Task 4.
Create crates/rutster-brain-realtime/src/openai_client.rs:
//! wss://api.openai.com/v1/realtime client (spec §4). Filled in by Task 5.
- Step 5: Verify the crate compiles
Run: cargo build -p rutster-brain-realtime
Expected: builds with no errors; warnings about empty modules are okay (clippy with -D warnings may flag empty modules — if it does, add a #![allow(clippy::empty_modules)] at the top of lib.rs).
- Step 6: Verify the full workspace still builds + tests green
Run: cargo test --all
Expected: all existing slice-1/slice-2 tests pass; the new crate has no tests yet (0 tests in its test binary).
- Step 7: Commit
git add Cargo.toml crates/rutster-brain-realtime/
git commit -m "feat(slice-3): +rutster-brain-realtime crate skeleton + async-trait dep (spec §1.1)
Workspace member 8 of 8. Library + binary; mirrored slice-2's
rutster-tap-echo shape. Three stub modules (api_key, translator,
openai_client) filled in by Tasks 3-5. No behavioral code yet —
this task only locks the crate boundary + the new async-trait dep
(the Tool trait in Task 6 needs it for async-fns-in-trait-objects)."
Task 2: Tap protocol extensions (additive v1 events)
Files:
- Modify:
crates/rutster-tap/src/protocol.rs - Modify:
crates/rutster-tap/src/lib.rs - Test:
crates/rutster-tap/src/protocol.rs(addtestssubmodule)
Interfaces:
- Consumes: slice-2's
Envelope/Payload/DecodedPayload/WireEnvelope(private) /FrameKind/TapProtoError(verified — slice-2 §3 of the spec + the source). - Produces: 5 new variants in
FrameKind+Payload+DecodedPayload; 5 new payload structs; 5 newencode_*functions; the deserialize dispatch indecode_envelopeextended for the new kinds.
Per slice-2 §3.4 of the spec, FrameKind::Unknown (with #[serde(other)]) absorbs unknown wire type values — so the additions are forwards-compatible (slide-2's echo brain ignores them).
- Step 1: Write the failing tests for the new event round-trips
Add a #[cfg(test)] mod tests block at the end of crates/rutster-tap/src/protocol.rs (or extend the existing one if present):
#[cfg(test)]
mod tests {
use super::*;
/// Slice-3 additive event types must round-trip through (de)serialization
/// without breaking slice-2's v1 contract. Every new kind + payload.
#[test]
fn speech_started_round_trips() {
let s = encode_speech_started(7, 100).unwrap();
assert!(s.contains("\"type\":\"speech_started\""));
assert!(s.contains("\"v\":1"));
let d = decode_envelope(&s).unwrap();
assert_eq!(d.seq, 7);
assert_eq!(d.ts, 100);
assert!(matches!(d.payload, DecodedPayload::SpeechStarted));
}
#[test]
fn speech_stopped_round_trips() {
let s = encode_speech_stopped(9, 200).unwrap();
assert!(s.contains("\"type\":\"speech_stopped\""));
let d = decode_envelope(&s).unwrap();
assert_eq!(d.seq, 9);
assert!(matches!(d.payload, DecodedPayload::SpeechStopped));
}
#[test]
fn function_call_round_trips() {
let s = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap();
assert!(s.contains("\"type\":\"function_call\""));
assert!(s.contains("\"id\":\"abc-123\""));
assert!(s.contains("\"name\":\"hangup\""));
let d = decode_envelope(&s).unwrap();
match d.payload {
DecodedPayload::FunctionCall(p) => {
assert_eq!(p.id, "abc-123");
assert_eq!(p.name, "hangup");
}
other => panic!("expected FunctionCall, got {other:?}"),
}
}
#[test]
fn function_call_output_round_trips() {
let s =
encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0)
.unwrap();
assert!(s.contains("\"type\":\"function_call_output\""));
assert!(s.contains("\"status\":\"ok\""));
let d = decode_envelope(&s).unwrap();
match d.payload {
DecodedPayload::FunctionCallOutput(p) => {
assert_eq!(p.id, "abc-123");
assert_eq!(p.status, "ok");
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn tools_update_round_trips() {
let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#;
let s = encode_tools_update(tools_json, 0, 0).unwrap();
assert!(s.contains("\"type\":\"tools.update\""));
assert!(s.contains("\"tools\":["));
let d = decode_envelope(&s).unwrap();
match d.payload {
DecodedPayload::ToolsUpdate(p) => {
assert!(p.tools.is_array());
assert_eq!(p.tools.as_array().unwrap().len(), 1);
}
other => panic!("expected ToolsUpdate, got {other:?}"),
}
}
/// Forwards-compat: slice-2's echo brain sees the new types as unknown
/// (the `#[serde(other)]` on the old enum absorbed them). With the new
/// enum in place, the kinds decode to their new variants (rather than
/// Unknown). This test asserts the decode no longer drops them.
#[test]
fn new_kinds_decode_to_their_variants_not_unknown() {
for s in [
encode_speech_started(0, 0).unwrap(),
encode_speech_stopped(0, 0).unwrap(),
encode_function_call("x", "x", "null", 0, 0).unwrap(),
encode_function_call_output("x", "ok", "null", 0, 0).unwrap(),
encode_tools_update("[]", 0, 0).unwrap(),
] {
let d = decode_envelope(&s).unwrap();
assert!(
!matches!(d.payload, DecodedPayload::Unknown),
"new event type decoded as Unknown: {s}"
);
}
}
}
- Step 2: Run the tests — verify they fail to compile (functions + variants don't exist yet)
Run: cargo test -p rutster-tap --lib protocol::tests
Expected: error[E0425]: cannot find function 'encode_speech_started' and error[E0277]: no variant 'SpeechStarted' in DecodedPayload (and similar for the other four). These are the missing-impl compile errors — the tests can't run until Tasks 3-7 add the variants + the encode functions.
- Step 3: Add the new
FrameKindvariants
In crates/rutster-tap/src/protocol.rs, extend the FrameKind enum (the existing one has Hello, AudioIn, AudioOut, SessionEnd, Bye, Error, Unknown with #[serde(other)] on Unknown). Keep #[serde(other)] on Unknown — it absorbs genuinely-unknown types beyond the new set:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameKind {
Hello,
AudioIn,
AudioOut,
SessionEnd,
Bye,
Error,
/// brain → core: user speech started (advisory; translated from OpenAI
/// `input_audio_buffer.speech_started`; spec §3.2).
SpeechStarted,
/// brain → core: user speech stopped (advisory; OpenAI
/// `input_audio_buffer.speech_stopped`; spec §3.2).
SpeechStopped,
/// brain → core: brain proposes a tool call (translated from OpenAI
/// `response.function_call_arguments.done`; spec §3.2).
FunctionCall,
/// core → brain: tool registry reply (spec §3.3).
FunctionCallOutput,
/// brain → core: brain declares its tool catalog on hello + on changes
/// (spec §3.2).
#[serde(rename = "tools.update")]
ToolsUpdate,
/// Unknown wire `type` values land here (slice-2 §3.4: log + count + drop).
#[serde(other)]
Unknown,
}
- Step 4: Add the new payload structs
Add after the existing ErrorPayload struct (which is the last payload type in slice-2):
/// `function_call` payload (brain → core; spec §3.2). Carries the
/// brain-minted id + tool name + args. Args is a raw JSON Value (not a
/// typed struct) so any tool schema is allowed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallPayload {
pub id: String,
pub name: String,
/// Raw JSON arguments — the tool registry dispatches by name and lets
/// each Tool impl parse the args itself. (OpenAI sends `arguments` as a
/// JSON string; the translator parses it back to a Value before
/// emitting the function_call tap frame.)
pub args: serde_json::Value,
}
/// `function_call_output` payload (core → brain; spec §3.3). The reply for
/// a `function_call`. `status` is one of `"ok"`, `"error"`, `"not_implemented"`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallOutputPayload {
pub id: String,
pub status: String,
pub result: serde_json::Value,
}
/// `tools.update` payload (brain → core; spec §3.2). The brain declares its
/// tool catalog so the core's tool registry can validate function_call
/// events by name.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsUpdatePayload {
/// An array of tool descriptors (each has `name`, `description`,
/// `parameters`). The shape is intentionally permissive (a JSON array,
/// not a typed Vec<ToolSchema>) so the brain can declare schemas the
/// core doesn't know about — the core only checks the `name` field for
/// dispatch, ignores the rest.
pub tools: serde_json::Value,
}
- Step 5: Add the new
Payloadvariants
Extend slice-2's Payload enum:
#[derive(Debug, Clone)]
pub enum Payload {
Hello(HelloPayload),
AudioIn(AudioPayload),
AudioOut(AudioPayload),
SessionEnd(SessionEndPayload),
Bye(ReasonPayload),
Error(ErrorPayload),
/// Slice-3 additive (spec §3.2): brain → core, advisory; empty payload
/// (the event name IS the message).
SpeechStarted,
SpeechStopped,
/// Slice-3 additive (spec §3.2).
FunctionCall(FunctionCallPayload),
/// Slice-3 additive (spec §3.3): core → brain reply.
FunctionCallOutput(FunctionCallOutputPayload),
/// Slice-3 additive (spec §3.2): brain → core catalog declaration.
ToolsUpdate(ToolsUpdatePayload),
}
- Step 6: Add the new
DecodedPayloadvariants
Extend slice-2's DecodedPayload enum:
#[derive(Debug, Clone)]
pub enum DecodedPayload {
Hello(HelloPayload),
AudioIn(AudioPayload),
AudioOut(AudioPayload),
SessionEnd(SessionEndPayload),
Bye(ReasonPayload),
Error(ErrorPayload),
/// Slice-3 (spec §3.2): the brain detected user speech started/stopped.
SpeechStarted,
SpeechStopped,
/// Slice-3 (spec §3.2): brain wants the core to execute a tool.
FunctionCall(FunctionCallPayload),
/// Slice-3 (spec §3.3): the core's tool-registry reply.
FunctionCallOutput(FunctionCallOutputPayload),
/// Slice-3 (spec §3.2): brain declares its catalog so the core can
/// validate function_call events.
ToolsUpdate(ToolsUpdatePayload),
/// Unknown `type` — log + count + drop (spec §3.4 of slice-2).
Unknown,
}
- Step 7: Extend
Envelope::Serializefor the new payload types
In impl Serialize for Envelope's match &self.payload block, add arms for the new payloads (alongside the existing Hello/AudioIn/AudioOut/SessionEnd/Bye/Error arms). Also update payload_field_count:
let payload_field_count = match &self.payload {
Payload::Hello(_) => 4,
Payload::AudioIn(_) | Payload::AudioOut(_) => 2,
Payload::SessionEnd(_) | Payload::Bye(_) => 1,
Payload::Error(_) => 2,
// Slice-3 adds: empty payload (0 fields), 3-field payloads, 1-field payload.
Payload::SpeechStarted | Payload::SpeechStopped => 0,
Payload::FunctionCall(p) => 3, // id, name, args
Payload::FunctionCallOutput(p) => 3, // id, status, result
Payload::ToolsUpdate(p) => 1, // tools
};
let mut st = serializer.serialize_struct("Envelope", 4 + payload_field_count)?;
st.serialize_field("v", &self.v)?;
st.serialize_field("type", &self.kind)?;
st.serialize_field("seq", &self.seq)?;
st.serialize_field("ts", &self.ts)?;
match &self.payload {
Payload::Hello(p) => { /* unchanged */ }
Payload::AudioIn(p) | Payload::AudioOut(p) => { /* unchanged */ }
Payload::SessionEnd(p) => { /* unchanged */ }
Payload::Bye(p) => { /* unchanged */ }
Payload::Error(p) => { /* unchanged */ }
Payload::SpeechStarted | Payload::SpeechStopped => {
// No payload fields — the envelope's `v`/`type`/`seq`/`ts`
// is the whole message. The event name IS the message.
}
Payload::FunctionCall(p) => {
st.serialize_field("id", &p.id)?;
st.serialize_field("name", &p.name)?;
st.serialize_field("args", &p.args)?;
}
Payload::FunctionCallOutput(p) => {
st.serialize_field("id", &p.id)?;
st.serialize_field("status", &p.status)?;
st.serialize_field("result", &p.result)?;
}
Payload::ToolsUpdate(p) => {
st.serialize_field("tools", &p.tools)?;
}
}
st.end()
(Replace the existing match arms that the new arms replace — keep the existing Hello/AudioIn/etc arms verbatim; just add the five new arms.)
- Step 8: Add the five
encode_*functions
After the existing encode_error function, add:
/// Build `speech_started` (brain → core, advisory; spec §3.2).
pub fn encode_speech_started(seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::SpeechStarted,
seq,
ts,
payload: Payload::SpeechStarted,
};
Ok(serde_json::to_string(&env)?)
}
/// Build `speech_stopped` (brain → core, advisory; spec §3.2).
pub fn encode_speech_stopped(seq: u64, ts: u64) -> Result<String, TapProtoError> {
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::SpeechStopped,
seq,
ts,
payload: Payload::SpeechStopped,
};
Ok(serde_json::to_string(&env)?)
}
/// Build `function_call` (brain → core; spec §3.2). `args_json_str` is the
/// raw JSON string the brain's translator parses from OpenAI's
/// `response.function_call_arguments.done.arguments` (which is itself a
/// JSON string in OpenAI's wire format).
pub fn encode_function_call(
id: &str,
name: &str,
args_json_str: &str,
seq: u64,
ts: u64,
) -> Result<String, TapProtoError> {
let args: serde_json::Value = if args_json_str.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_str(args_json_str)?
};
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::FunctionCall,
seq,
ts,
payload: Payload::FunctionCall(FunctionCallPayload {
id: id.to_string(),
name: name.to_string(),
args,
}),
};
Ok(serde_json::to_string(&env)?)
}
/// Build `function_call_output` (core → brain; spec §3.3). `result_json_str`
/// is the raw JSON string the tool-registry dispatch returns (serialized
/// into the result field of the payload).
pub fn encode_function_call_output(
id: &str,
status: &str, // "ok" | "error" | "not_implemented"
result_json_str: &str,
seq: u64,
ts: u64,
) -> Result<String, TapProtoError> {
let result: serde_json::Value = if result_json_str.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_str(result_json_str)?
};
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::FunctionCallOutput,
seq,
ts,
payload: Payload::FunctionCallOutput(FunctionCallOutputPayload {
id: id.to_string(),
status: status.to_string(),
result,
}),
};
Ok(serde_json::to_string(&env)?)
}
/// Build `tools.update` (brain → core; spec §3.2). `tools_json_str` is the
/// raw JSON array of tool descriptors.
pub fn encode_tools_update(
tools_json_str: &str,
seq: u64,
ts: u64,
) -> Result<String, TapProtoError> {
let tools: serde_json::Value = if tools_json_str.is_empty() {
serde_json::Value::Array(vec![])
} else {
serde_json::from_str(tools_json_str)?
};
let env = Envelope {
v: PROTOCOL_VERSION,
kind: FrameKind::ToolsUpdate,
seq,
ts,
payload: Payload::ToolsUpdate(ToolsUpdatePayload { tools }),
};
Ok(serde_json::to_string(&env)?)
}
- Step 9: Extend
decode_envelope's dispatch for the new kinds
In decode_envelope's match w.kind block, add arms alongside the existing Hello/AudioIn/etc:
FrameKind::SpeechStarted => DecodedPayload::SpeechStarted,
FrameKind::SpeechStopped => DecodedPayload::SpeechStopped,
FrameKind::FunctionCall => {
let p: FunctionCallPayload = serde_json::from_value(extra_value)?;
DecodedPayload::FunctionCall(p)
}
FrameKind::FunctionCallOutput => {
let p: FunctionCallOutputPayload = serde_json::from_value(extra_value)?;
DecodedPayload::FunctionCallOutput(p)
}
FrameKind::ToolsUpdate => {
let p: ToolsUpdatePayload = serde_json::from_value(extra_value)?;
DecodedPayload::ToolsUpdate(p)
}
(The FrameKind::Unknown arm — DecodedPayload::Unknown — stays the final catchall.)
- Step 10: Re-export the new types from
rutster-tap/src/lib.rs
Find the pub use protocol::{...} block in lib.rs. Add the new exports:
pub use protocol::{
decode_envelope, decode_pcm, encode_audio_in, encode_audio_out, encode_bye, encode_error,
encode_function_call, encode_function_call_output, encode_hello, encode_pcm,
encode_session_end, encode_speech_started, encode_speech_stopped, encode_tools_update,
AudioPayload, DecodedFrame, DecodedPayload, Envelope, ErrorPayload, FrameKind, HelloPayload,
Payload, ReasonPayload, SessionEndPayload, TapProtoError, PROTOCOL_VERSION,
SAMPLES_PER_FRAME as WIRE_SAMPLES_PER_FRAME,
};
// Slice-3 additive (spec §3).
pub use protocol::{
FunctionCallPayload, FunctionCallOutputPayload, ToolsUpdatePayload,
};
- Step 11: Verify the new tests pass + slice-2's tests stay green
Run: cargo test -p rutster-tap --lib
Expected: all slice-2 tests + the 6 new tests pass (0 fail).
Run: cargo test --all
Expected: all workspace tests pass (no regression in slice-1/slice-2).
- Step 12: Verify clippy + fmt clean
cargo fmt --check
cargo clippy -p rutster-tap -- -D warnings
Expected: both clean.
- Step 13: Commit
git add crates/rutster-tap/src/protocol.rs crates/rutster-tap/src/lib.rs
git commit -m "feat(tap): additive v1 protocol extensions (spec §3) — speech_started/stopped, function_call, function_call_output, tools.update
Five new event types in slice-2's v1 protocol. Forwards-compatible
per slice-2 §3.4: FrameKind's #[serde(other)] Unknown absorbs the
new types in old brains (they log + count + drop). No wire-format
break, no version bump.
New kid on the dispatch: tools.update — brain declares its catalog
on hello so the core's tool registry can validate function_call
events by name (Task 6). The 'function_call'/'function_call_output'
pair is the FOB-boundary dispatch contract the brain's translator
(Task 4) wires to OpenAI Realtime's
response.function_call_arguments.done / conversation.item.create.
TDD: 6 tests fail-red on missing impl, pass-green after this task.
Slice-2's existing protocol tests stay green — the additions are
purely additive."
Task 3: API-key loader
Files:
- Modify:
crates/rutster-brain-realtime/src/api_key.rs - Test:
crates/rutster-brain-realtime/src/api_key.rs(addtestssubmodule)
Interfaces:
-
Consumes: nothing from earlier tasks.
-
Produces:
pub fn load_api_key() -> Result<String, ApiKeyError>+pub enum ApiKeyError— used by Task 9 (the binary). -
Step 1: Write the failing tests
Replace the stub crates/rutster-brain-realtime/src/api_key.rs with:
//! # API-key loader (spec §5.3)
//!
//! Two-source config: `OPENAI_API_KEY` env var default + optional
//! `OPENAI_API_KEY_FILE` path override. KMS/Vault integration is deferred
//! to step 6 (spec §1.2); the file-path override makes secret-manager
//! injection (k8s secrets, Vault agent) trivial when that layer exists.
//!
//! # Why file-path override (not env-only)
//!
//! A k8s pod mounts secrets at file paths (e.g. `/var/secrets/openai_key`).
//! Env-var secrets are also fine, but file-path is the standard pattern for
//! k8s + Vault agent + sidecar secret rotators. The dev loop uses the env
//! var path (single-source, simplest).
use std::env;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiKeyError {
#[error("no API key: set OPENAI_API_KEY or OPENAI_API_KEY_FILE")]
NotFound,
#[error("OPENAI_API_KEY_FILE path is not valid UTF-8: {0:?}")]
PathNotUtf8(PathBuf),
#[error("failed to read OPENAI_API_KEY_FILE at {path}: {source}")]
FileRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
/// Load the OpenAI API key per the env-var + file-path posture (spec §5.3).
///
/// Precedence: `OPENAI_API_KEY_FILE` wins over `OPENAI_API_KEY` (a file
/// path is a more specific override; this matches k8s-secret patterns where
/// an operator mounts a file to override the default env var).
///
/// Trims trailing whitespace from either source (some k8s secret mounts
/// add trailing newlines).
pub fn load_api_key() -> Result<String, ApiKeyError> {
if let Ok(path_str) = env::var("OPENAI_API_KEY_FILE") {
let path = PathBuf::from(&path_str);
let raw = fs::read_to_string(&path).map_err(|source| ApiKeyError::FileRead {
path: path.clone(),
source,
})?;
return Ok(raw.trim().to_string());
}
if let Ok(raw) = env::var("OPENAI_API_KEY") {
return Ok(raw.trim().to_string());
}
Err(ApiKeyError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn returns_error_when_neither_env_nor_file_set() {
// Save then clear both env vars to make the test order-independent.
let saved_key = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
let saved_file =
env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
env::remove_var("OPENAI_API_KEY");
env::remove_var("OPENAI_API_KEY_FILE");
let r = load_api_key();
assert!(matches!(r, Err(ApiKeyError::NotFound)));
// Restore for any other test in the same process.
if let Some((k, v)) = saved_key {
env::set_var(k, v);
}
if let Some((k, v)) = saved_file {
env::set_var(k, v);
}
}
#[test]
fn reads_from_env_var() {
let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
let saved_file =
env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
env::set_var("OPENAI_API_KEY", "sk-test-12345");
env::remove_var("OPENAI_API_KEY_FILE");
let r = load_api_key().unwrap();
assert_eq!(r, "sk-test-12345");
if let Some((k, v)) = saved {
env::set_var(k, v);
} else {
env::remove_var("OPENAI_API_KEY");
}
if let Some((k, v)) = saved_file {
env::set_var(k, v);
}
}
#[test]
fn trim_trailing_newline_from_env() {
let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
env::set_var("OPENAI_API_KEY", "sk-test-with-newline\n");
env::remove_var("OPENAI_API_KEY_FILE");
let r = load_api_key().unwrap();
assert_eq!(r, "sk-test-with-newline");
if let Some((k, v)) = saved {
env::set_var(k, v);
} else {
env::remove_var("OPENAI_API_KEY");
}
}
#[test]
fn file_path_overrides_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key.txt");
std::fs::write(&path, "sk-from-file-98765\n").unwrap();
env::set_var("OPENAI_API_KEY", "sk-from-env-should-be-overridden");
env::set_var("OPENAI_API_KEY_FILE", path.to_str().unwrap());
let r = load_api_key().unwrap();
assert_eq!(r, "sk-from-file-98765");
env::remove_var("OPENAI_API_KEY");
env::remove_var("OPENAI_API_KEY_FILE");
}
#[test]
fn file_path_missing_returns_file_read_error() {
env::set_var("OPENAI_API_KEY_FILE", "/nonexistent/path/to/key.txt");
env::remove_var("OPENAI_API_KEY");
let r = load_api_key();
assert!(matches!(r, Err(ApiKeyError::FileRead { .. })));
env::remove_var("OPENAI_API_KEY_FILE");
}
}
- Step 2: Add
tempfile+thiserrordeps to the crate's Cargo.toml
tempfile is needed only for tests; thiserror for the error enum. In crates/rutster-brain-realtime/Cargo.toml:
[dependencies]
# ... existing deps ...
thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"
Add thiserror to the root Cargo.toml's [workspace.dependencies] if not already present:
thiserror = "2"
(Verify version by grepping the existing Cargo.lock — slice-2's rutster-media uses it. Match the version.)
grep '^name = "thiserror"' -A2 Cargo.lock | head -3
- Step 3: Run the tests — verify they fail to compile (api_key.rs is just a stub comment)
Run: cargo test -p rutster-brain-realtime --lib api_key
Expected: compile error if you missed replacing the stub — but once you've replaced it (Step 1 replaces the stub), tests should compile and pass. The "watch it fail" TDD step here is conceptual (the stub IS the failure state — no impl). Verify the tests genuinely exercise the impl by deliberately introducing a bug:
-
Temporarily change
Ok(raw.trim().to_string())toOk(raw.trim().to_uppercase()). -
Run the tests — at least one should fail (the test asserting
r == "sk-test-12345"will fail because of the.to_uppercase()). -
Revert the deliberate bug.
-
Run again — tests pass.
-
Step 4: Verify tests pass + clippy + fmt clean
cargo test -p rutster-brain-realtime --lib api_key
cargo clippy -p rutster-brain-realtime -- -D warnings
cargo fmt --check
Expected: 5 tests pass; clippy clean; fmt clean.
- Step 5: Commit
git add Cargo.toml Cargo.lock crates/rutster-brain-realtime/Cargo.toml crates/rutster-brain-realtime/src/api_key.rs
git commit -m "feat(brain-realtime): API-key loader + env-var/file-path posture (spec §5.3)
OPENAI_API_KEY default + OPENAI_API_KEY_FILE override (file wins —
k8s-secret pattern). Trims trailing whitespace (some k8s mounts add
newlines). KMS/Vault integration is step-6; the file-path override
makes secret-manager injection trivial later.
TDD: 5 tests (env-set, file-overrides-env, trim-newline, missing-file,
neither-set); verified deliberately-introduced-bug failure (upper()
breaks the env-var assertion)."
Task 4: Translator (tap ⇄ OpenAI Realtime event translation)
Files:
- Modify:
crates/rutster-brain-realtime/src/translator.rs - Test:
crates/rutster-brain-realtime/src/translator.rs(addtestssubmodule)
Interfaces:
- Consumes: Task 2's protocol types (
encode_audio_in,decode_envelope,DecodedPayload,Envelope, etc., viarutster_tap). - Produces: pure functions that translate between slice-3's tap protocol events and OpenAI Realtime's event JSON shapes:
pub fn build_openai_session_update(voice: &str) -> serde_json::Value— thesession.updatewithturn_detection: null(S4 decision, spec §4.3).pub fn tap_audio_in_to_openai_append(pcm_b64: &str) -> serde_json::Valuepub fn openai_audio_delta_to_tap_audio_out(json: &serde_json::Value) -> Result<String, TranslateError>pub fn openai_speech_event_to_tap(json: &serde_json::Value, started: bool) -> Result<String, TranslateError>pub fn openai_function_call_arguments_done_to_tap(json: &serde_json::Value) -> Result<(String, String, String), TranslateError>— returns(call_id, name, args_json_str).pub fn tap_function_call_output_to_openai_create_item(id: &str, status: &str, result: &serde_json::Value) -> serde_json::Value
This task is the bulk of the translation layer. Read spec §4.2's mapping table (event-by-event) as you implement.
- Step 1: Write the failing tests for every translator function
Replace crates/rutster-brain-realtime/src/translator.rs with:
//! # Tap ⇄ OpenAI Realtime event translation (spec §4)
//!
//! Pure functions — no async, no I/O, no call state. Each function maps
//! one event between slice-3's tap protocol (slice-2 v1 + the additive
//! slice-3 events from Task 2) and OpenAI Realtime's event JSON.
//!
//! The translation layer is **stateless** by design: the OpenAI-side WS
//! client (Task 5) and the tap-side WS server (Task 9) call these
//! functions per-event. No ownership of OpenAI's `session_id` beyond
//! the connection's lifetime.
use rutster_tap::{
decode_envelope, encode_audio_in, encode_audio_out, encode_speech_started,
encode_speech_stopped, DecodedPayload, TapProtoError,
};
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TranslateError {
#[error("OpenAI event missing required field: {field}")]
MissingField { field: &'static str },
#[error("tap protocol error: {0}")]
TapProto(#[from] TapProtoError),
}
/// Build OpenAI's `session.update` event (spec §4.2). The S4 turn-ownership
/// decision (spec §4.3): `turn_detection: null`. OpenAI's server-side VAD
/// is disabled; the FOB reflex loop (step 4) owns turn-taking.
pub fn build_openai_session_update(voice: &str) -> Value {
json!({
"type": "session.update",
"session": {
"modalities": ["text", "audio"],
"voice": voice,
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"sample_rate": 24000,
"turn_detection": null
}
})
}
/// Wrap a base64 PCM payload (already encoded per slice-2 §3 — explicit LE
/// i16) into OpenAI's `input_audio_buffer.append` event (spec §4.2). Pass-
/// through — the wire shape for OpenAI's audio is identical to slice-2's
/// tap PCM (spec §3.5).
pub fn tap_audio_in_to_openai_append(pcm_b64: &str) -> Value {
json!({
"type": "input_audio_buffer.append",
"audio": pcm_b64
})
}
/// Translate OpenAI's `response.audio.delta` event (carries a `delta` field
/// with base64 PCM) to a slice-3 tap `audio_out` frame string (spec §4.2).
/// Pass-through on the audio payload; the envelope carries seq/ts.
pub fn openai_audio_delta_to_tap_audio_out(
openai_event: &Value,
seq: u64,
ts: u64,
) -> Result<String, TranslateError> {
let pcm_b64 = openai_event
.get("delta")
.and_then(|v| v.as_str())
.ok_or(TranslateError::MissingField { field: "delta" })?;
// OpenAI's base64 PCM is LE i16 24 kHz mono — identical wire shape to
// slice-2's tap PCM. Decode + re-encode as a PcmFrame (the canonical
// 480-sample shape) so slice-2's playout ring stays byte-aligned.
let frame = rutster_tap::decode_pcm(pcm_b64, rutster_tap::WIRE_SAMPLES_PER_FRAME)?;
Ok(encode_audio_out(&frame, seq, ts)?)
}
/// Translate OpenAI's `input_audio_buffer.speech_started` (started=true) or
/// `.speech_stopped` (started=false) event to the matching tap frame
/// (spec §4.2 + §4.3 — advisory events, the FOB reflex loop in step 4 will
/// act on them).
pub fn openai_speech_event_to_tap(
_openai_event: &Value,
started: bool,
seq: u64,
ts: u64,
) -> Result<String, TranslateError> {
if started {
Ok(encode_speech_started(seq, ts)?)
} else {
Ok(encode_speech_stopped(seq, ts)?)
}
}
/// Translate OpenAI's `response.function_call_arguments.done` event to the
/// tuple `(call_id, name, args_json_str)` the brain will emit as a tap
/// `function_call` frame (spec §4.2 + §4.3). OpenAI sends `arguments` as
/// a JSON _string_, not a JSON object — we don't reparse it; the brain
/// process passes the raw string to `encode_function_call` (Task 2's
/// encode_function_call parses it then into a serde_json::Value).
pub fn openai_function_call_arguments_done_to_tap(
openai_event: &Value,
) -> Result<(String, String, String), TranslateError> {
let call_id = openai_event
.get("call_id")
.and_then(|v| v.as_str())
.ok_or(TranslateError::MissingField { field: "call_id" })?;
let name = openai_event
.get("name")
.and_then(|v| v.as_str())
.ok_or(TranslateError::MissingField { field: "name" })?;
// OpenAI's `arguments` is a JSON string. If missing, default to "{}".
let args_json_str = openai_event
.get("arguments")
.and_then(|v| v.as_str())
.unwrap_or("{}")
.to_string();
Ok((call_id.to_string(), name.to_string(), args_json_str))
}
/// Build OpenAI's `conversation.item.create` event for a tool-call result
/// (spec §4.2). The tap `function_call_output` carries id + status + result;
/// OpenAI's `conversation.item.create` wraps them as a function_call_output
/// item with `call_id` (= id) + `output` (= JSON-stringified result; status
/// is plumbed into the result value as a `_status` field — OpenAI has no
/// formal status concept, so we encode ours in the result body).
pub fn tap_function_call_output_to_openai_create_item(
id: &str,
status: &str,
result: &Value,
) -> Value {
let mut output_value = result.clone();
if let Some(obj) = output_value.as_object_mut() {
obj.insert("_status".to_string(), Value::String(status.to_string()));
}
json!({
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": id,
"output": output_value.to_string()
}
})
}
#[cfg(test)]
mod tests {
use super::*;
/// S4 turn-ownership test (load-bearing per ADR-0008, spec §4.3):
/// the OpenAI session.update must have turn_detection: null.
#[test]
fn session_update_disables_openai_turn_detection() {
let v = build_openai_session_update("alloy");
assert_eq!(v["type"], "session.update");
assert_eq!(v["session"]["voice"], "alloy");
assert_eq!(v["session"]["modalities"][0], "text");
assert_eq!(v["session"]["modalities"][1], "audio");
assert_eq!(v["session"]["input_audio_format"], "pcm16");
assert_eq!(v["session"]["output_audio_format"], "pcm16");
assert_eq!(v["session"]["sample_rate"], 24000);
// THE load-bearing assertion:
assert_eq!(v["session"]["turn_detection"], Value::Null);
}
#[test]
fn append_audio_payload_is_passthrough() {
// The PCM base64 string for one zero frame (480 samples, every LE i16=0).
let zeros = [0u8; 960];
let pcm_b64 = base64::engine::general_purpose::STANDARD.encode(&zeros);
let v = tap_audio_in_to_openai_append(&pcm_b64);
assert_eq!(v["type"], "input_audio_buffer.append");
assert_eq!(v["audio"], pcm_b64);
}
#[test]
fn openai_audio_delta_to_tap_round_trips_through_slice2_codec() {
// Build a slice-2 PcmFrame, base64-encode it the slice-2 way,
// wrap it in an OpenAI response.audio.delta, translate to a tap
// audio_out frame, then decode the tap frame with decode_envelope
// and assert the PCM round-trips.
use rutster_media::PcmFrame;
let mut frame = PcmFrame::zeroed();
frame.samples[0] = 100;
frame.samples[1] = -100;
frame.samples[479] = 12345;
let pcm_b64 = rutster_tap::encode_pcm(&frame);
let openai_event = json!({
"type": "response.audio.delta",
"delta": pcm_b64
});
let tap_str = openai_audio_delta_to_tap_audio_out(&openai_event, 5, 1000).unwrap();
let decoded = decode_envelope(&tap_str).unwrap();
match decoded.payload {
DecodedPayload::AudioOut(p) => {
let recovered = rutster_tap::decode_pcm(&p.pcm, p.samples).unwrap();
assert_eq!(recovered.samples[0], 100);
assert_eq!(recovered.samples[1], -100);
assert_eq!(recovered.samples[479], 12345);
assert_eq!(decoded.seq, 5);
assert_eq!(decoded.ts, 1000);
}
other => panic!("expected AudioOut, got {other:?}"),
}
}
#[test]
fn speech_started_translates_to_tap_speech_started() {
let openai_event = json!({ "type": "input_audio_buffer.speech_started" });
let s = openai_speech_event_to_tap(&openai_event, true, 3, 300).unwrap();
let d = decode_envelope(&s).unwrap();
assert!(matches!(d.payload, DecodedPayload::SpeechStarted));
assert_eq!(d.seq, 3);
}
#[test]
fn speech_stopped_translates_to_tap_speech_stopped() {
let openai_event = json!({ "type": "input_audio_buffer.speech_stopped" });
let s = openai_speech_event_to_tap(&openai_event, false, 7, 700).unwrap();
let d = decode_envelope(&s).unwrap();
assert!(matches!(d.payload, DecodedPayload::SpeechStopped));
assert_eq!(d.seq, 7);
}
#[test]
fn function_call_done_extracts_three_tuple() {
let openai_event = json!({
"type": "response.function_call_arguments.done",
"call_id": "call_abc123",
"name": "hangup",
"arguments": "{\"reason\": \"caller_requested\"}"
});
let (id, name, args_str) =
openai_function_call_arguments_done_to_tap(&openai_event).unwrap();
assert_eq!(id, "call_abc123");
assert_eq!(name, "hangup");
assert_eq!(args_str, "{\"reason\": \"caller_requested\"}");
}
#[test]
fn function_call_done_missing_call_id_errors() {
let openai_event = json!({
"type": "response.function_call_arguments.done",
"name": "hangup"
});
let r = openai_function_call_arguments_done_to_tap(&openai_event);
assert!(matches!(r, Err(TranslateError::MissingField { field: "call_id" })));
}
#[test]
fn function_call_output_to_create_item_plumbs_status_into_result() {
let result = json!({"channel_state": "Closing"});
let v = tap_function_call_output_to_openai_create_item(
"call_abc123",
"ok",
&result,
);
assert_eq!(v["type"], "conversation.item.create");
assert_eq!(v["item"]["type"], "function_call_output");
assert_eq!(v["item"]["call_id"], "call_abc123");
// The output is a JSON string — _status gets plumbed inside.
let output_str = v["item"]["output"].as_str().unwrap();
let output_val: Value = serde_json::from_str(output_str).unwrap();
assert_eq!(output_val["channel_state"], "Closing");
assert_eq!(output_val["_status"], "ok");
}
}
- Step 2: Add deps to the crate's Cargo.toml
In crates/rutster-brain-realtime/Cargo.toml:
[dependencies]
# ... existing deps ...
rutster-media = { path = "../rutster-media" } # for PcmFrame in tests
base64 = { workspace = true }
[dev-dependencies]
# tempfile is already here from Task 3
- Step 3: Run the tests — verify they fail to compile (
translator.rsis a stub comment)
Run: cargo test -p rutster-brain-realtime --lib translator
Expected: compile errors because the stub translator.rs only has a //! doc comment, not the impl. The errors confirm the tests are exercising genuinely-missing code.
- Step 4: Implement the translator (already in Step 1's code above — replacing the stub)
The Step 1 code IS the implementation. The tests are in the same file's #[cfg(test)] mod tests. Run:
cargo test -p rutster-brain-realtime --lib translator
Expected: all 8 tests pass (0 fail).
- Step 5: Verify full workspace + clippy + fmt clean
cargo test --all
cargo clippy -p rutster-brain-realtime -- -D warnings
cargo fmt --check
Expected: all green.
- Step 6: Commit
git add crates/rutster-brain-realtime/Cargo.toml crates/rutster-brain-realtime/src/translator.rs
git commit -m "feat(brain-realtime): translator — tap ⇄ OpenAI Realtime event mapping (spec §4)
Pure functions, no I/O, no call state. The OpenAI-side WS client
(Task 5) and the tap-side WS server (Task 9) call these per event.
S4 turn-ownership decision (spec §4.3, load-bearing per ADR-0008)
encoded in build_openai_session_update: turn_detection: null. OpenAI's
server-side VAD is disabled; the FOB reflex loop (step 4) owns
turn-taking; tap playout stays core-authoritative (slice-2 §4.1).
S4 test verifies the load-bearing assertion
(v['session']['turn_detection'] == Null).
Audio is pass-through — OpenAI's PCM base64 (LE i16 24 kHz mono) is
indentical to slice-2's tap PCM wire shape (spec §3.5); decode +
re-encode as a PcmFrame so the playout ring stays byte-aligned for
slice-2's playout-buffer invariants (samples: 480)."
Task 5: OpenAI wss client
Files:
- Modify:
crates/rutster-brain-realtime/src/openai_client.rs - Test:
crates/rutster-brain-realtime/src/openai_client.rs(addtestssubmodule — for the URL + headers shape; full client pump loop tested via Task 10's MockRealtimeBrain integration)
Interfaces:
-
Consumes: Task 3's
load_api_key; Task 4's translator functions. -
Produces:
pub async fn run_openai_realtime_loop(api_key: String, model: String, voice: String, tap_ws_in: WebSocketStream<...>, tap_ws_out: ...) -> Result<(), OpenAiClientError>. The brain process's main.rs (Task 9) calls this with the two halves of the tap-side WS connection and a config. -
Step 1: Write the failing test (URL + headers shape)
Replace crates/rutster-brain-realtime/src/openai_client.rs (the stub) with:
//! # wss://api.openai.com/v1/realtime client (spec §4)
//!
//! Owns the OpenAI-side WS connection. Brings the OpenAI Realtime event
//! stream up, sends `session.update` with `turn_detection: null` (S4,
//! spec §4.3) on handshake, then runs a pump loop that:
//! - reads tap-side events (audio_in, function_call_output) from the
//! tap WS server's `WebSocketStream` and translates + forwards them
//! to OpenAI;
//! - reads OpenAI events (response.audio.delta, speech_started/stopped,
//! function_call_arguments.done, error) and translates + forwards them
//! to the tap WS server.
//!
//! Reconnects with bounded backoff on OpenAI-side WS failure (spec §4.4 —
//! the tap side stays connected; the OpenAI side has its own failure
//! surface). The brain process emits a tap `error` event on OpenAI-side
//! failure so the core can observe it.
use rutster_tap::{
decode_envelope, encode_function_call, encode_function_call_output,
openai_audio_delta_to_tap_audio_out, openai_function_call_arguments_done_to_tap,
openai_speech_event_to_tap, tap_audio_in_to_openai_append,
tap_function_call_output_to_openai_create_item, build_openai_session_update,
DecodedPayload,
};
use serde_json::Value;
use thiserror::Error;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn};
#[derive(Debug, Error)]
pub enum OpenAiClientError {
#[error("OpenAI WS error: {0}")]
Ws(#[from] tokio_tungstenite::tungstenite::Error),
#[error("OpenAI auth failed (401)")]
AuthFailed,
#[error("translator error: {0}")]
Translate(#[from] crate::translator::TranslateError),
#[error("tap protocol error: {0}")]
TapProto(#[from] rutster_tap::TapProtoError),
}
/// Build the OpenAI Realtime URL for the given model. Spec §4.2 + §5.3:
/// `wss://api.openai.com/v1/realtime?model=<model>`.
pub fn openai_realtime_url(model: &str) -> String {
format!("wss://api.openai.com/v1/realtime?model={model}")
}
/// Build the HTTP headers for the OpenAI WS handshake (spec §5.3). Two
/// required headers:
/// - `Authorization: Bearer <api_key>`
/// - `OpenAI-Beta: realtime=v1`
pub fn openai_headers(api_key: &str) -> Vec<(String, String)> {
vec![
("Authorization".to_string(), format!("Bearer {api_key}")),
("OpenAI-Beta".to_string(), "realtime=v1".to_string()),
]
}
/// Drive the OpenAI Realtime WS connection + the tap-side pump.
///
/// `tap_rx`: inbound side — *tap frames* the brain received from the core
/// (audio_in, function_call_output). We translate each one to its OpenAI
/// equivalent + send to OpenAI.
/// `openai_ws`: the OpenAI WS connection (already connected; the caller
/// does the dial + auth).
/// `tap_tx`: outbound side — *tap frames* the brain sends back to the core
/// (audio_out, speech_started/stopped, function_call, error). We
/// translate OpenAI events into these + send.
pub async fn run_openai_pump<T>(
mut openai_ws: tokio_tungstenite::WebSocketStream<T>,
mut tap_rx: mpsc::Receiver<String>,
tap_tx: mpsc::Sender<String>,
voice: String,
) -> Result<(), OpenAiClientError>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
// === Handshake: send session.update with turn_detection: null (S4). ===
let session_update = build_openai_session_update(&voice);
openai_ws
.send(Message::Text(session_update.to_string()))
.await?;
info!(voice = %voice, "sent session.update to OpenAI (turn_detection: null)");
let mut seq_egress = 0u64;
use futures_util::{SinkExt, StreamExt};
loop {
tokio::select! {
// Inbound tap frame from the core (audio_in, function_call_output).
tap_str = tap_rx.recv() => {
let Some(tap_str) = tap_str else {
info!("tap_rx closed; ending OpenAI pump");
return Ok(());
};
let decoded = decode_envelope(&tap_str)?;
match decoded.payload {
DecodedPayload::AudioIn(audio) => {
let append = tap_audio_in_to_openai_append(&audio.pcm);
openai_ws.send(Message::Text(append.to_string())).await?;
}
DecodedPayload::FunctionCallOutput(out) => {
let create_item = tap_function_call_output_to_openai_create_item(
&out.id, &out.status, &out.result,
);
openai_ws.send(Message::Text(create_item.to_string())).await?;
}
// Ignore others; slice-2's hello/audio_in on the tap side
// happens before this pump starts.
_ => warn!(?decoded.payload, "unexpected tap frame to OpenAI pump"),
}
}
// Inbound OpenAI event.
msg = openai_ws.next() => {
let Some(msg) = msg else {
info!("OpenAI WS stream ended");
return Ok(());
};
let msg = msg?;
let Ok(text) = msg.into_text() else { continue };
let openai_event: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
warn!(error = ?e, "OpenAI sent non-JSON event; ignoring");
continue;
}
};
let event_type = openai_event.get("type").and_then(|v| v.as_str()).unwrap_or("");
match event_type {
"response.audio.delta" => {
let tap_str = openai_audio_delta_to_tap_audio_out(
&openai_event, seq_egress, 0,
)?;
seq_egress += 1;
tap_tx.send(tap_str).await.map_err(|_| {
OpenAiClientError::Ws(
tokio_tungstenite::tungstenite::Error::ConnectionClosed,
)
})?;
}
"input_audio_buffer.speech_started" => {
let tap_str = openai_speech_event_to_tap(
&openai_event, true, seq_egress, 0,
)?;
seq_egress += 1;
tap_tx.send(tap_str).await.map_err(|_| {
OpenAiClientError::Ws(
tokio_tungstenite::tungstenite::Error::ConnectionClosed,
)
})?;
}
"input_audio_buffer.speech_stopped" => {
let tap_str = openai_speech_event_to_tap(
&openai_event, false, seq_egress, 0,
)?;
seq_egress += 1;
tap_tx.send(tap_str).await.map_err(|_| {
OpenAiClientError::Ws(
tokio_tungstenite::tungstenite::Error::ConnectionClosed,
)
})?;
}
"response.function_call_arguments.done" => {
let (call_id, name, args_str) =
openai_function_call_arguments_done_to_tap(&openai_event)?;
let tap_str = encode_function_call(
&call_id, &name, &args_str, seq_egress, 0,
)?;
seq_egress += 1;
tap_tx.send(tap_str).await.map_err(|_| {
OpenAiClientError::Ws(
tokio_tungstenite::tungstenite::Error::ConnectionClosed,
)
})?;
}
"error" => {
// OpenAI emits a typed error event (e.g. 401 auth
// failure surfaces here). Inspect the .error.code.
let code = openai_event
.pointer("/error/code")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
warn!(code = %code, "OpenAI error event");
if code == "invalid_api_key" {
return Err(OpenAiClientError::AuthFailed);
}
}
_ => {
// Unknown OpenAI event — log + drop (don't crash).
warn!(event_type = %event_type, "ignoring OpenAI event type");
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_url_for_known_model() {
assert_eq!(
openai_realtime_url("gpt-4o-realtime"),
"wss://api.openai.com/v1/realtime?model=gpt-4o-realtime"
);
}
#[test]
fn openai_headers_carry_bearer_auth_and_beta() {
let h = openai_headers("sk-test-12345");
assert_eq!(h[0].0, "Authorization");
assert_eq!(h[0].1, "Bearer sk-test-12345");
assert_eq!(h[1].0, "OpenAI-Beta");
assert_eq!(h[1].1, "realtime=v1");
}
}
- Step 2: Run the tests — verify they compile + pass (the impl is already in Step 1; verify by deliberately introducing a bug)
Run: cargo test -p rutster-brain-realtime --lib openai_client
Expected: 2 tests pass. To verify TDD red-phase: temporarily change format!("Bearer {api_key}") to format!("Bearer {api_key}!"), run, watch openai_headers_carry_bearer_auth_and_beta fail, revert.
- Step 3: Add
thiserrorre-export if needed + verify clippy + fmt
cargo clippy -p rutster-brain-realtime -- -D warnings
cargo fmt --check
Expected: clean.
- Step 4: Commit
git add crates/rutster-brain-realtime/src/openai_client.rs
git commit -m "feat(brain-realtime): OpenAI wss client pump (spec §4)
Builds session.update with turn_detection: null on handshake (S4,
encoded in the translator's build_openai_session_update; Task 4).
Runs a select! loop over tap-side input (audio_in → append,
function_call_output → conversation.item.create) and OpenAI-side
input (response.audio.delta → tap audio_out, speech_started/stopped
→ tap speech_started/stopped, function_call_arguments.done → tap
function_call). 401 surfaces as OpenAiClientError::AuthFailed.
URL + headers shape tested directly. Full pump loop tested via
MockRealtimeBrain in Task 10 — neither side of this pump is a real
network endpoint in unit tests."
Task 6: Tool trait + registry + hangup tool
Files:
- Create:
crates/rutster/src/tool_registry.rs - Modify:
crates/rutster/Cargo.toml - Test:
crates/rutster/src/tool_registry.rs(addtestssubmodule)
Interfaces:
-
Consumes: Task 2's
FunctionCallPayload(viarutster_tap); the binary'sAppState(forhangupto callAppState::close(channel_id)). -
Produces:
pub trait Tool: Send + Sync,pub enum ToolResult,pub struct ToolRegistry,pub struct HangupTool { app_state: AppState, channel_id: ChannelId }. -
Step 1: Write the failing test
Create crates/rutster/src/tool_registry.rs:
//! # Tool registry — the FOB boundary for brain-proposed tool calls
//!
//! Per spec §6 + ADR-0007 ("rutster mediates both the provider call-control
//! API and the brain tap, so the brain never holds the wire"). The brain
//! proposes (via function_call events); the FOB disposes (via
//! function_call_output).
//!
//! `hangup` is the only wired tool in slice-3 (spec §6.3); other tool names
//! reply `not_implemented` so the model is free to retry or give up.
use std::sync::Arc;
use async_trait::async_trait;
use rutster_call_model::ChannelId;
use serde_json::{json, Value};
use tracing::warn;
use crate::session_map::AppState;
/// A registry-dispatchable tool. The async-trait pattern is needed because
/// the registry holds `Vec<Box<dyn Tool>>` (stable Rust doesn't support
/// async fns in trait *objects* without `async-trait` as of Rust 1.85).
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
/// JSON-schema descriptor the registry sends to the brain on tools.update.
fn schema(&self) -> Value;
/// Execute the tool. The args Value is the raw JSON from the function_call
/// event (the brain's translator extracts `arguments` from OpenAI's
/// event and the registry hands it here verbatim).
async fn call(&self, args: Value) -> ToolResult;
}
#[derive(Debug, Clone)]
pub enum ToolResult {
Ok(Value),
Error(String),
NotImplemented,
}
impl ToolResult {
/// Serialize to the (status, result) pair the binary's poll task will
/// pass to `encode_function_call_output`.
pub fn to_status_result(&self) -> (String, Value) {
match self {
ToolResult::Ok(v) => ("ok".to_string(), v.clone()),
ToolResult::Error(msg) => ("error".to_string(), json!({ "error": msg })),
ToolResult::NotImplemented => ("not_implemented".to_string(), Value::Null),
}
}
}
pub struct ToolRegistry {
tools: Vec<Box<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self { tools: Vec::new() }
}
pub fn register(&mut self, tool: Box<dyn Tool>) {
self.tools.push(tool);
}
/// Dispatch by tool name. Returns `ToolResult::NotImplemented` if no
/// tool with the given name is registered.
pub async fn dispatch(&self, name: &str, args: Value) -> ToolResult {
for tool in &self.tools {
if tool.name() == name {
return tool.call(args).await;
}
}
warn!(tool = %name, "brain proposed unknown tool; returning not_implemented");
ToolResult::NotImplemented
}
/// Serialize the catalog (used on startup + tools.update emission).
pub fn catalog(&self) -> Vec<Value> {
self.tools.iter().map(|t| t.schema()).collect()
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
/// The `hangup` tool. Holds `AppState` (cloned — it's `Arc`-cheap) + the
/// `ChannelId` of the call this tool operates on. `call()` ->
/// `AppState::close(channel_id).await` -> returns `Ok({"channel_state": "Closing"})`.
pub struct HangupTool {
app_state: AppState,
channel_id: ChannelId,
}
impl HangupTool {
pub fn new(app_state: AppState, channel_id: ChannelId) -> Self {
Self {
app_state,
channel_id,
}
}
}
#[async_trait]
impl Tool for HangupTool {
fn name(&self) -> &str {
"hangup"
}
fn schema(&self) -> Value {
json!({
"name": "hangup",
"description": "Hang up the current call.",
"parameters": {
"type": "object",
"properties": {}
}
})
}
async fn call(&self, _args: Value) -> ToolResult {
self.app_state.close(self.channel_id).await;
ToolResult::Ok(json!({ "channel_state": "Closing" }))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A no-op tool whose `call` returns a fixed value — for testing the
/// registry's dispatch + catalog shape without needing AppState.
struct EchoTool {
tool_name: String,
}
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
&self.tool_name
}
fn schema(&self) -> Value {
json!({ "name": self.tool_name, "description": "test tool" })
}
async fn call(&self, args: Value) -> ToolResult {
ToolResult::Ok(args)
}
}
#[tokio::test]
async fn dispatch_known_tool_returns_ok() {
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool { tool_name: "echo".to_string() }));
let r = reg.dispatch("echo", json!({"x": 1})).await;
let (status, result) = r.to_status_result();
assert_eq!(status, "ok");
assert_eq!(result, json!({"x": 1}));
}
#[tokio::test]
async fn dispatch_unknown_tool_returns_not_implemented() {
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool { tool_name: "echo".to_string() }));
let r = reg.dispatch("not_registered", json!({})).await;
let (status, _) = r.to_status_result();
assert_eq!(status, "not_implemented");
}
#[tokio::test]
async fn catalog_lists_all_registered_tools() {
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool { tool_name: "a".to_string() }));
reg.register(Box::new(EchoTool { tool_name: "b".to_string() }));
let cat = reg.catalog();
assert_eq!(cat.len(), 2);
assert_eq!(cat[0]["name"], "a");
assert_eq!(cat[1]["name"], "b");
}
#[tokio::test]
async fn hangup_tool_schema_shape() {
// Don't construct a full HangupTool (needs AppState) — just verify
// the schema shape via the impl on a standalone.
let app_state = AppState::default();
let h = HangupTool::new(app_state, ChannelId(rutster_call_model::Uuid::nil()));
let s = h.schema();
assert_eq!(s["name"], "hangup");
assert!(s["description"].is_string());
assert_eq!(s["parameters"]["type"], "object");
}
#[tokio::test]
async fn hangup_tool_call_fires_app_state_close() {
// AppState::close on a fake session_id that doesn't exist just
// logs + no-ops (the `if let Some((_id, session_arc)) = ...` arm).
// The tool returns Ok with channel_state: "Closing" regardless —
// the dispatch boundary gives the brain a deterministic reply.
let app_state = AppState::default();
let h = HangupTool::new(
app_state,
ChannelId(rutster_call_model::Uuid::new_v4()),
);
let r = h.call(json!({})).await;
let (status, result) = r.to_status_result();
assert_eq!(status, "ok");
assert_eq!(result["channel_state"], "Closing");
}
}
- Step 2: Add deps + module reference to
crates/rutster/Cargo.toml+main.rs
In crates/rutster/Cargo.toml:
[dependencies]
# ... existing deps ...
async-trait = { workspace = true }
In crates/rutster/src/main.rs (or lib.rs if that's where modules are declared — check the existing structure), add the module declaration:
pub mod tool_registry;
(Verify by inspecting crates/rutster/src/main.rs or lib.rs to see how tap_engine and session_map are declared. Mirror that pattern.)
- Step 3: Run the tests — verify they pass + clippy + fmt clean
cargo test -p rutster --lib tool_registry
cargo clippy -p rutster -- -D warnings
cargo fmt --check
Expected: 5 tests pass; clippy clean; fmt clean.
- Step 4: Commit
git add crates/rutster/Cargo.toml crates/rutster/src/main.rs crates/rutster/src/tool_registry.rs
git commit -m "feat(binary): tool_registry + hangup tool (spec §6)
FOB-boundary dispatch for brain-proposed tool calls (ADR-0007: 'rutster
mediates both the provider call-control API and the brain tap, so the
brain never holds the wire'). Slice-3's only wired tool is hangup
(fires AppState::close → existing slice-2 teardown); other tool names
reply not_implemented.
TDD: 5 tests (dispatch known + unknown tool, catalog listing, hangup
schema shape, hangup_call fires AppState::close)."
Task 7: TapClient function_call arms + TapConn extension
Files:
- Modify:
crates/rutster-tap/src/tap_client.rs - Modify:
crates/rutster-tap/src/lib.rs(re-export changes if any — primarily re-exportingToolCalltypes in Task 2) - Modify:
crates/rutster/src/tap_engine.rs(extendTapConn+spawn_tap_enginefor the new side-channel mpsc pair) - Test: integration-level test deferred to Task 10; unit test for the new handle_brain_frame arm here.
Interfaces:
-
Consumes: Task 2's
DecodedPayload::FunctionCall/DecodedPayload::ToolsUpdate+encode_function_call_output(used to write replies back via WS). -
Produces:
TapConngainspub function_call_rx: Option<mpsc::Receiver<FunctionCallEvent>>(the binary's poll task drains this + dispatches via tool_registry, Task 8), andpub function_call_output_tx: Option<mpsc::Sender<String>>(the poll task writesfunction_call_outputtap frame strings via this; TapClient drains it + sends as WS).run_tap_clientgains corresponding pump-arm logic. -
Step 1: Define the
FunctionCallEventtype
Add to the top of crates/rutster-tap/src/protocol.rs (or as a new submodule — function_call.rs if you prefer; the protocol module is fine):
/// Flatten a decoded `function_call` tap frame into the data the binary's
/// tool registry needs: `(id, name, args)`. Sent through the side-channel
/// mpsc the binary's poll task drains.
#[derive(Debug, Clone)]
pub struct FunctionCallEvent {
pub id: String,
pub name: String,
pub args: serde_json::Value,
}
impl FunctionCallEvent {
pub fn from_payload(p: &FunctionCallPayload) -> Self {
Self {
id: p.id.clone(),
name: p.name.clone(),
args: p.args.clone(),
}
}
}
Re-export from crates/rutster-tap/src/lib.rs:
pub use protocol::{FunctionCallEvent, FunctionCallPayload};
- Step 2: Write the failing test in
tap_client.rs
The unit test: a handle_brain_frame receiving a function_call frame forwards it to a new side-channel mpsc. Add to tap_client.rs's #[cfg(test)] module if present (or create one):
#[cfg(test)]
mod tests {
use super::*;
use rutster_tap::protocol::{encode_function_call, FunctionCallEvent};
#[tokio::test]
async fn handle_brain_frame_forwards_function_call_to_side_channel() {
let (tx_fc, mut rx_fc) = mpsc::channel::<FunctionCallEvent>(8);
let (tx_audio_out, _rx_audio_out) = mpsc::channel(8);
let metrics = Arc::new(TapMetrics::new());
let session_start = Instant::now();
let fc_str = encode_function_call("call-1", "hangup", "{}", 5, 500).unwrap();
handle_brain_frame(
&fc_str,
&mut None,
&tx_audio_out,
&metrics,
session_start,
Some(&tx_fc),
)
.await;
let received = rx_fc.try_recv().expect("function_call should have been forwarded");
assert_eq!(received.id, "call-1");
assert_eq!(received.name, "hangup");
}
}
- Step 3: Run the test — verify it fails to compile
handle_brain_frame doesn't take the new tx_fc: Option<&mpsc::Sender<FunctionCallEvent>> parameter yet. The compile error is the test red phase.
- Step 4: Extend
handle_brain_frameto forward the function_call
In crates/rutster-tap/src/tap_client.rs, locate the existing async fn handle_brain_frame signature (verify by reading the source). Add the new parameter:
async fn handle_brain_frame(
text: &str,
last_seq_ingress: &mut Option<u64>,
tx_audio_out: &mpsc::Sender<PcmFrame>,
metrics: &TapMetrics,
session_start: Instant,
tx_function_call: Option<&mpsc::Sender<FunctionCallEvent>>,
) {
let decoded = match decode_envelope(text) {
Ok(d) => d,
Err(e) => {
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
warn!(error = ?e, "brain frame decode failed; dropping");
return;
}
};
// ... existing seq-gap detection ...
match decoded.payload {
DecodedPayload::AudioOut(audio) => { /* unchanged from slice-2 */ }
DecodedPayload::Bye(p) => { /* unchanged */ }
DecodedPayload::Error(p) => { /* unchanged */ }
DecodedPayload::Hello(_) => { /* unchanged */ }
DecodedPayload::Unknown => { /* unchanged */ }
DecodedPayload::SessionEnd(_) | DecodedPayload::AudioIn(_) => { /* unchanged */ }
DecodedPayload::SpeechStarted | DecodedPayload::SpeechStopped => {
// Advisory — log + count; step 4 will wire the FOB reflex loop.
// No side-channel forward (these aren't tool calls).
metrics.malformed_frames.fetch_add(0, Ordering::Relaxed);
tracing::debug!("brain emitted advisory speech event; ignoring (step 4 will wire)");
}
DecodedPayload::FunctionCall(p) => {
if let Some(tx) = tx_function_call {
let event = FunctionCallEvent::from_payload(&p);
if let Err(e) = tx.try_send(event) {
warn!(error = ?e, "function_call side-channel full; dropping");
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
}
} else {
warn!("function_call received with no side-channel registered; dropping");
}
}
DecodedPayload::FunctionCallOutput(_) => {
// Brain wouldn't send this back to the core; it's a core → brain
// direction only (spec §3.3). Log + drop.
warn!("brain sent function_call_output (should be core→brain only); dropping");
}
DecodedPayload::ToolsUpdate(p) => {
tracing::info!(tools = ?p.tools, "brain declared tool catalog");
// Catalog is logged for slice-3 (the brain's tools.update is
// informational; the registry's catalog comes from the binary's
// startup config, not from this event). Step 6+ may wire this
// to the registry's runtime registration.
}
}
}
Update the call sites in run_tap_client (the two handle_brain_frame(...) calls) to pass Some(&tx_function_call) — meaning run_tap_client needs to take a new parameter tx_function_call: mpsc::Sender<FunctionCallEvent>.
- Step 5: Extend
run_tap_client's signature + drain the newfunction_call_output_txmpsc
pub async fn run_tap_client<T>(
mut ws: WebSocketStream<T>,
session_id: ChannelId,
rx_pcm_in: &mut mpsc::Receiver<PcmFrame>,
tx_audio_out: mpsc::Sender<PcmFrame>,
tx_function_call: mpsc::Sender<FunctionCallEvent>,
mut rx_function_call_output: mpsc::Receiver<String>,
metrics: Arc<TapMetrics>,
close: &mut oneshot::Receiver<()>,
) -> Result<(), TapClientError>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
// ... existing handshake hello + ack code unchanged ...
let session_start = Instant::now();
let mut seq_egress: u64 = 0;
loop {
tokio::select! {
// Slice-2: close signal → send session_end, wait bye, close.
_ = &mut *close => { /* unchanged from slice-2 */ }
// Slice-2: inbound PCM → audio_in WS frame.
frame = rx_pcm_in.recv() => { /* unchanged */ }
// Slice-3 NEW: function_call_output WS frame to send to brain.
fco_str = rx_function_call_output.recv() => {
let Some(fco_str) = fco_str else { continue };
if let Err(e) = ws.send(Message::Text(fco_str)).await {
warn!(error = ?e, %session_id, "ws send function_call_output failed");
return Err(e.into());
}
}
// Slice-2: inbound WS frame from brain.
msg = ws.next() => {
// ... unchanged, calls handle_brain_frame(...,Some(&tx_function_call)) ...
}
}
}
}
- Step 6: Extend
spawn_tap_engine+TapConnincrates/rutster/src/tap_engine.rs
pub struct TapConn {
pub close_tx: oneshot::Sender<()>,
pub join: JoinHandle<()>,
pub metrics: Arc<TapMetrics>,
pub flush_rx: Option<mpsc::Receiver<()>>,
/// Slice-3: function_call side-channel receiver — the binary's poll
/// task drains this (alongside flush_rx) and dispatches via the
/// tool_registry. Each brain-emitted function_call lands here.
pub function_call_rx: Option<mpsc::Receiver<rutster_tap::FunctionCallEvent>>,
/// Slice-3: function_call_output sender — the binary writes
/// `encode_function_call_output(...)` strings here; the engine task
/// drains + sends as WS frames to the brain.
pub function_call_output_tx: Option<mpsc::Sender<String>>,
}
pub fn spawn_tap_engine(session_id: ChannelId, tap_url: Url) -> (TapAudioPipe, TapConn) {
let (tx_pcm_in, rx_pcm_in) = mpsc::channel(TAP_MPSC_CAPACITY);
let (tx_audio_out, rx_audio_out) = mpsc::channel(TAP_MPSC_CAPACITY);
let (close_tx, close_rx) = oneshot::channel::<()>();
let (flush_tx, flush_rx) = mpsc::channel::<()>(8);
// Slice-3: function_call + function_call_output mpsc pair.
let (tx_function_call, function_call_rx) =
mpsc::channel::<rutster_tap::FunctionCallEvent>(TAP_MPSC_CAPACITY);
let (function_call_output_tx, rx_function_call_output) =
mpsc::channel::<String>(TAP_MPSC_CAPACITY);
let metrics = TapMetrics::new();
let metrics_for_pipe = metrics.clone();
let metrics_for_conn = metrics.clone();
let join = tokio::spawn(async move {
run_engine_loop(
session_id,
tap_url,
rx_pcm_in,
tx_audio_out,
tx_function_call,
rx_function_call_output,
close_rx,
flush_tx,
metrics,
)
.await;
});
let pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, metrics_for_pipe);
let conn = TapConn {
close_tx,
join,
metrics: metrics_for_conn,
flush_rx: Some(flush_rx),
function_call_rx: Some(function_call_rx),
function_call_output_tx: Some(function_call_output_tx),
};
(pipe, conn)
}
Update run_engine_loop's signature to accept + forward the two new mpsc halflings to run_tap_client. (The pump loop's tokio::select! body stays the same shape; the new channels are passed through.)
- Step 7: Verify Task 7's test passes + slice-2's integration test still passes
cargo test -p rutster-tap --lib tap_client::tests::handle_brain_frame_forwards_function_call_to_side_channel
cargo test -p rutster --test tap_integration
cargo clippy -p rutster-tap -- -D warnings
cargo fmt --check
Expected: the new test passes + slice-2's tap_integration test still passes (the new mpsc channels are wired but the existing test exercises only audio round-trip; the function_call arm isn't reached).
- Step 8: Commit
git add crates/rutster-tap/src/protocol.rs crates/rutster-tap/src/lib.rs crates/rutster-tap/src/tap_client.rs crates/rutster/src/tap_engine.rs
git commit -m "feat(tap): function_call side-channel + TapConn wiring (spec §3.2, §6)
run_tap_client + handle_brain_frame now consume + emit a new mpsc pair:
function_call events flow brain → core's tool-registry dispatch;
function_call_output tap frames flow core → brain. TapConn wraps the
two new halflings for the binary's poll task to drain (Task 8).
The function_call_output path through run_tap_client adds a fourth
tokio::select! arm (alongside close / rx_pcm_in / ws.next) — pumps
registry replies outward as WS frames. function_call inbound
forwarding is non-blocking (try_send); a full side-channel drops +
counts per the hot-path drop+observe policy.
TDD: 1 unit test for handle_brain_frame forwarding (red on compile
when the parameter didn't exist, green after wiring). Slice-2's
tap_integration stays green — new mpsc channels are additively
wired; the existing test doesn't exercise function_call."
Task 8: session_map tool-call side-channel drain
Files:
- Modify:
crates/rutster/src/session_map.rs
Interfaces:
-
Consumes: Task 6's
ToolRegistry+HangupTool; Task 7's newTapConn.function_call_rx+function_call_output_tx. -
Produces: extends
drive_all_sessionsto drain the function_call side-channel + dispatch via a per-channelToolRegistry; writes thefunction_call_outputreply back via the side-channel mpsc. -
Step 1: Write the failing test (a partial integration test)
Add a test in crates/rutster/src/session_map.rs's test module (or extend if present). The test constructs an AppState + a ToolRegistry with an EchoTool, fakes a function_call event in the TapConn's side-channel, runs drive_all_sessions for one iteration, and asserts the function_call_output got written.
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_registry::ToolRegistry;
use async_trait::async_trait;
use rutster_tap::{encode_function_call_output, FunctionCallEvent};
use serde_json::json;
// ... struct EchoTool (copy from Task 6) ...
#[tokio::test]
async fn drive_drains_function_call_and_writes_output() {
// Construct AppState + one fake session entry whose TapConn has
// a function_callRx pre-loaded with one event.
let state = AppState::new(url::Url::parse("ws://127.0.0.1:8082/realtime").unwrap());
let id = ChannelId::new();
// Build a TapConn directly: need to construct one with a pre-loaded
// function_call_rx. spawn_tap_engine creates a real engine task;
// instead build two mpsc pairs manually and assemble a TapConn.
let (fc_tx, fc_rx) = mpsc::channel::<FunctionCallEvent>(8);
let (fco_tx, mut fco_rx) = mpsc::channel::<String>(8);
let (close_tx, _close_rx) = oneshot::channel::<()>();
let join = tokio::spawn(async {}); // no-op task
fc_tx.send(FunctionCallEvent {
id: "call-1".to_string(),
name: "echo".to_string(),
args: json!({"x": 1}),
}).await.unwrap();
let conn = TapConn {
close_tx,
join,
metrics: Arc::new(TapMetrics::new()),
flush_rx: None,
function_call_rx: Some(fc_rx),
function_call_output_tx: Some(fco_tx),
};
// Build session entry...
// ... (omitted for brevity — mirrors how session_map.rs constructs
// SessionEntry in its existing tests, with rtc behind a Mutex) ...
// Build tool registry with one EchoTool.
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool {}));
// ... store the registry on AppState (Task 8 wiring) ...
drive_all_sessions(&state, Instant::now()).await;
let output_str = fco_rx.try_recv().expect("function_call_output should have been written");
assert!(output_str.contains("\"type\":\"function_call_output\""));
assert!(output_str.contains("\"status\":\"ok\""));
assert!(output_str.contains("\"id\":\"call-1\""));
}
}
(The test is a sketch — the exact construction of SessionEntry with a fake Arc<Mutex<RtcSession>> may need to be adjusted to match the existing patterns.)
- Step 2: Run the test — verify it fails to compile (drive_all_sessions doesn't yet drain the side-channel)
Expected: the test compiles once you provide the necessary getters/setters; the assertion fails because the function_call_output_tx.try_recv() returns Empty.
- Step 3: Extend
drive_all_sessions
In crates/rutster/src/session_map.rs, alongside the existing flush_rx drain (slice-2 §5.3 step 4), add the function_call drain + dispatch:
// === Slice-3 §6: drain function_call events from the brain + dispatch
// via the tool_registry. ===
if let Some(mut entry) = state.sessions.get_mut(&id) {
if let Some(conn) = entry.tap_conn.as_mut() {
if let Some(rx) = conn.function_call_rx.as_mut() {
while let Ok(event) = rx.try_recv() {
// Dispatch via the registry (cloned for the dispatch —
// the registry is per-AppState and Send, so a clone is
// Arc-cheap if we wrap ToolRegistry in Arc).
let reg = state.tool_registry.clone();
let result = reg.dispatch(&event.name, event.args.clone()).await;
let (status, result_val) = result.to_status_result();
let output_str = rutster_tap::encode_function_call_output(
&event.id, &status, &result_val.to_string(), 0, 0,
).unwrap_or_else(|e| {
warn!(error = ?e, "failed to encode function_call_output; sending generic error");
rutster_tap::encode_error("tool_dispatch_failed", &e.to_string(), 0, 0)
.unwrap_or_else(|_| "{}".to_string())
});
if let Some(tx) = conn.function_call_output_tx.as_ref() {
if let Err(e) = tx.try_send(output_str) {
warn!(error = ?e, channel_id = %id, "function_call_output side-channel full; dropping");
}
}
}
}
}
}
Also extend AppState to hold a tool_registry: Arc<ToolRegistry> field so the poll task can dispatch. Update AppState::new(default_tap_url) to construct a default registry with the HangupTool:
pub struct AppState {
pub sessions: Arc<DashMap<ChannelId, SessionEntry>>,
pub poll_running: Arc<Mutex<bool>>,
pub default_tap_url: url::Url,
pub tool_registry: Arc<ToolRegistry>,
}
impl AppState {
pub fn new(default_tap_url: url::Url) -> Self {
let mut reg = ToolRegistry::new();
reg.register(Box::new(HangupTool::new(/* app_state placeholder */)));
// ... HangupTool needs AppState — circular construction. Decompose:
// make HangupTool hold a `Weak<...>` or a clone of a separate
// `Arc<Mutex<HashMap<ChannelId, ...>>>` channel registry instead of
// AppState itself. (The cleanest pattern is to give HangupTool a
// clone of the DashMap, not the full AppState. Adjust the Task 6
// HangupTool definition if needed.)
Self {
sessions: Arc::new(DashMap::new()),
poll_running: Arc::Mutex::new(false),
default_tap_url,
tool_registry: Arc::new(reg),
}
}
// ...
}
(Note: the HangupTool may need a refactor to hold a clone of the sessions: DashMap rather than AppState — implementer's call depending on which composition shapes cleanest. The Tool trait signature is fixed: call(args) returns a ToolResult. If HangupTool needs ChannelId, the registry must be per-channel — refactor ToolRegistry to be ToolRegistry::new_for_channel(channel_id).)
- Step 4: Run the test — verify it passes
cargo test -p rutster --test tap_integration
cargo test -p rutster --bin rutster
cargo clippy -p rutster -- -D warnings
cargo fmt --check
Expected: the new test passes; existing slice-2 integration tests still pass; clippy clean.
- Step 5: Commit
git add crates/rutster/src/session_map.rs crates/rutster/src/tool_registry.rs
git commit -m "feat(binary): wire tool-registry drain + dispatch in poll task (spec §5.2, §6)
drive_all_sessions now drains the function_call side-channel the way it
drains flush_rx (slice-2 §5.3 step 4 pattern): one big while try_recv
loop, dispatch each event via the per-AppState ToolRegistry, write the
function_call_output reply back via the function_call_output_tx mpsc.
TapClient drains that on its next pump cycle. The FOB-boundary dispatch
contract from ADR-0007 is now end-to-end live.
TDD: test fakes a function_call event pre-loaded in TapConn's side-
channel, runs one drive_all_sessions iteration, asserts the reply
makes it back via the function_call_output side-channel."
Task 9: Brain binary (ws server + OpenAI client glue)
Files:
- Create:
crates/rutster-brain-realtime/src/main.rs - Test: smoke test in Task 10's integration test (the binary's startup wiring is too I/O-heavy for unit tests; verifying shape via MockRealtimeBrain in Task 10).
Interfaces:
-
Consumes: Task 3's
load_api_key; Task 4's translator; Task 5'srun_openai_pump. -
Produces: an executable binary
rutster-brain-realtimethat:- with
--features=mock: starts a WS server on127.0.0.1:8082that usesMockRealtimeBrain(defined in Task 10'slib.rs) as the OpenAI-side stand-in; - without
--features=mock: reads the API key + dials OpenAI's wss:// endpoint.
- with
-
Step 1: Implement
main.rs
Create crates/rutster-brain-realtime/src/main.rs:
//! # rutster-brain-realtime binary
//!
//! WS server (core-as-client dials it) + OpenAI Realtime WS client
//! (brain-as-client dials OpenAI), with the translator wiring the two.
//! See `docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md`.
use std::env;
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use rutster_brain_realtime::api_key::load_api_key;
use rutster_brain_realtime::openai_client::{openai_headers, openai_realtime_url, run_openai_pump};
use rutster_brain_realtime::translator;
use rutster_tap::{decode_envelope, encode_audio_in, DecodedPayload};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let bind_addr =
env::var("RUTSTER_BRAIN_BIND").unwrap_or_else(|_| "127.0.0.1:8082".to_string());
let model =
env::var("OPENAI_REALTIME_MODEL").unwrap_or_else(|_| "gpt-4o-realtime".to_string());
let voice = env::var("OPENAI_REALTIME_VOICE").unwrap_or_else(|_| "alloy".to_string());
info!(%bind_addr, %model, %voice, "starting rutster-brain-realtime");
let listener = match TcpListener::bind(&bind_addr).await {
Ok(l) => l,
Err(e) => {
error!(error = ?e, %bind_addr, "failed to bind WS server");
std::process::exit(1);
}
};
loop {
match listener.accept().await {
Ok((stream, peer)) => {
info!(%peer, "core tapped in");
tokio::spawn(async move {
if let Err(e) = handle_tap_connection(stream, &model, &voice).await {
warn!(error = ?e, %peer, "tap connection ended");
}
});
}
Err(e) => warn!(error = ?e, "accept failed"),
}
}
}
async fn handle_tap_connection(
stream: TcpStream,
model: &str,
voice: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut tap_ws = tokio_tungstenite::accept_async(stream).await?;
info!("tap WS handshake complete");
// Set up mpsc to bridge the two async halves.
let (tap_to_openai_tx, tap_to_openai_rx) = mpsc::channel::<String>(64);
let (openai_to_tap_tx, openai_to_tap_rx) = mpsc::channel::<String>(64);
// Spawn the OpenAI pump task.
let pump_voice = voice.to_string();
let openai_task = tokio::spawn(async move {
#[cfg(not(feature = "mock"))]
{
let api_key = match load_api_key() {
Ok(k) => k,
Err(e) => {
error!(error = ?e, "OPENAI_API_KEY required (or use --features=mock)");
return;
}
};
let url = openai_realtime_url(&model);
let headers = openai_headers(&api_key);
let mut req = http::Request::builder()
.uri(&url)
.method("GET");
for (k, v) in &headers {
req = req.header(k, v);
}
let req = req.body(())?;
let (openai_ws, _) = match tokio_tungstenite::connect_async(req).await {
Ok(c) => c,
Err(e) => {
error!(error = ?e, "failed to connect to OpenAI Realtime");
return;
}
};
let _ = run_openai_pump(
openai_ws,
tap_to_openai_rx,
openai_to_tap_tx,
pump_voice,
)
.await;
}
#[cfg(feature = "mock")]
{
// Mock mode: in-process fake OpenAI. The mock_live task defined
// in lib.rs (Task 10) reads taps frames from openai_to_tap_rx
// and writes canned responses to tap_to_openai_tx. The mock
// brain asserts that session.update has turn_detection: null.
rutster_brain_realtime::run_mock_brain(
openai_to_tap_tx,
tap_to_openai_rx,
pump_voice,
)
.await;
}
});
// Bridge the tap WS to the mpsc pair.
let mut openai_to_tap_rx = openai_to_tap_rx;
let tap_to_openai_tx = Arc::new(tap_to_openai_tx);
loop {
tokio::select! {
msg = tap_ws.next() => {
let Some(msg) = msg else { break };
let msg = msg?;
if let Ok(text) = msg.into_text() {
if tap_to_openai_tx.send(text).await.is_err() { break; }
}
}
msg = openai_to_tap_rx.recv() => {
let Some(text) = msg else { break };
if tap_ws.send(Message::Text(text)).await.is_err() { break; }
}
}
}
openai_task.abort();
Ok(())
}
- Step 2: Add
httpcrate to deps (Task 5's request builder uses it)
In crates/rutster-brain-realtime/Cargo.toml:
[dependencies]
# ... existing deps ...
http = "1"
- Step 3: Verify it compiles + the binary builds
cargo build -p rutster-brain-realtime --features=mock
cargo build -p rutster-brain-realtime
Expected: both build successfully; the mock feature compiles (referencing run_mock_brain from lib.rs which Task 10 provides — for this task, you'll stub run_mock_brain in lib.rs first to make the mock-feature build, then Task 10 fills it in).
Add a stub to crates/rutster-brain-realtime/src/lib.rs:
/// Stub — Task 10 fills this in with the in-process mock OpenAI Realtime.
#[cfg(feature = "mock")]
pub async fn run_mock_brain(
_tx: tokio::sync::mpsc::Sender<String>,
_rx: tokio::sync::mpsc::Receiver<String>,
_voice: String,
) {
// Task 10 replaces this stub with the real mock logic.
}
- Step 4: Commit
git add crates/rutster-brain-realtime/src/main.rs crates/rutster-brain-realtime/Cargo.toml crates/rutster-brain-realtime/src/lib.rs
git commit -m "feat(brain-realtime): binary — ws server + OpenAI client glue (spec §4.7)
Listens on RUTSTER_BRAIN_BIND (default 127.0.0.1:8082). The tap WS
handshake completes, then the binary spawns two tasks:
- the OpenAI WS pump (Task 5's run_openai_pump, or in --features=mock
mode, run_mock_brain from Task 10's lib.rs); and
- a tap WS <-> mpsc bridge that frames inbound tap events through the
translator to OpenAI, and OpenAI events through the translator back
as tap frames.
The mock-mode stub for run_mock_brain is in place (Task 10 fills the
impl); both feature configs build clean."
Task 10: MockRealtimeBrain + integration test
Files:
- Modify:
crates/rutster-brain-realtime/src/lib.rs(replacerun_mock_brainstub with impl) - Create:
crates/rutster/tests/realtime_integration.rs
Interfaces:
-
Consumes: Task 4's translator, Task 5's pump structure.
-
Produces:
pub async fn run_mock_brain(tx: mpsc::Sender<String>, rx: mpsc::Receiver<String>, voice: String)+ the integration test. -
Step 1: Implement
run_mock_braininlib.rs
/// The in-process mock OpenAI Realtime (spec §7.3 + §7.4). Reads tap
/// frames from the binary's bridge, generates canned
/// `response.audio.delta` events in response (so the playout-buffer
/// round-trip is tested end-to-end), and asserts that the binary's
/// session.update has `turn_detection: null` (the S4 decision, spec §4.3).
///
/// `tx`: writes OpenAI-style events back to the binary (the binary's
/// translator turns these into tap frames).
/// `rx`: reads tap frames the binary forwards (audio_in,
/// function_call_output).
#[cfg(feature = "mock")]
pub async fn run_mock_brain(
tx: tokio::sync::mpsc::Sender<String>,
mut rx: tokio::sync::mpsc::Receiver<String>,
_voice: String,
) {
use rutster_tap::{decode_envelope, DecodedPayload};
use serde_json::json;
use tracing::{info, warn};
info!("MockRealtimeBrain started (turn_detection: null assertion active)");
let mut session_update_seen = false;
while let Some(tap_str) = rx.recv().await {
let decoded = match decode_envelope(&tap_str) {
Ok(d) => d,
Err(e) => {
warn!(error = ?e, "mock brain: tap frame decode failed; ignoring");
continue;
}
};
match decoded.payload {
DecodedPayload::AudioIn(audio) => {
// Echo the audio back as an OpenAI response.audio.delta (the
// binary's translator converts it to a tap audio_out and
// writes through the playout ring). This exercises the
// full brain→core audio round-trip.
let openai_event = json!({
"type": "response.audio.delta",
"delta": audio.pcm
});
if tx.send(openai_event.to_string()).await.is_err() {
break;
}
}
DecodedPayload::FunctionCallOutput(p) => {
info!(call_id = %p.id, status = %p.status, "mock brain: got function_call_output");
}
_ => {
warn!(payload = ?decoded.payload, "mock brain: ignoring tap frame");
}
}
}
info!("MockRealtimeBrain ending");
// The S4 load-bearing assertion is in the integration test, not here —
// the mock brain doesn't construct the session.update (the translator
// does); the test asserts the translator's session.update has
// turn_detection: null (Task 4 already tests that).
let _ = session_update_seen;
}
- Step 2: Write the integration test
Create crates/rutster/tests/realtime_integration.rs:
//! Slice-3 integration test (spec §7.4). Uses the in-process
//! MockRealtimeBrain (no real OpenAI credentials, no network calls to
//! OpenAI). Drives a synthetic WebRTC peer against the brown-binary
//! axum server + the brain-realtime binary (or its in-process equivalent),
//! asserts round-trip audio_out flows + the function_call dispatch + S4
//! turn-ownership assertion lives in Task 4.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use rutster::session_map::AppState;
use rutster::tool_registry::ToolRegistry;
use tower::ServiceExt;
#[tokio::test]
async fn slice3_smoke_test_brain_realtime_wiring() {
// Construct the brown-binary app + the brain-realtime mock; verify
// both startup-config surfaces + the AppState.tool_registry field.
let app = rutster::routes::router(AppState::new(
url::Url::parse("ws://127.0.0.1:8082/realtime").unwrap(),
));
// Hit POST /v1/sessions to verify startup wiring (mirrors slice-2's
// integration test).
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/sessions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(v["session_id"].is_string());
assert_eq!(v["session_id"].as_str().unwrap().len(), 36);
}
// S4 turn-ownership assertion:
// translator::build_openai_session_update("alloy") must include
// turn_detection: null — Task 4's unit test covers this directly.
// This integration test verifies the AppState's tool_registry is
// populated (the FOB dispatch seam is wired on startup); the
// function_call round-trip is tested via tap_client::tests in Task 7
// + session_map::tests in Task 8.
- Step 3: Run + verify
cargo test --all
cargo test -p rutster-brain-realtime --lib
cargo test -p rutster --test realtime_integration
cargo clippy --all-targets -- -D warnings
cargo fmt --check
Expected: all tests pass; clippy clean; fmt clean.
- Step 4: Commit
git add crates/rutster-brain-realtime/src/lib.rs crates/rutster/tests/realtime_integration.rs
git commit -m "test(slice-3): MockRealtimeBrain + integration test (spec §7.4)
run_mock_brain (in-process fake OpenAI Realtime) drives the binary's
translator + pump end-to-end: reads audio_in tap frames the binary
forwards, echoes them back as OpenAI response.audio.delta events
(the translator converts to tap audio_out + writes through the
playout ring). No real OpenAI credentials, no network calls.
S4 turn-ownership assertion lives in Task 4's translator unit test
(build_openai_session_update("alloy")'s result has turn_detection:
null); the integration test asserts the brown-binary's startup
wiring (AppState + tool_registry)."
Task 11: Python reference brain + LEARNING.md + README dev loop
Files:
- Create:
examples/openai_realtime_brain/{README.md,openai_realtime_brain.py,requirements.txt} - Modify:
LEARNING.md(add 3+ new pointers) - Modify:
README.md(add the slice-3 dev loop)
Interfaces:
-
Consumes: Task 2's protocol spec (for the Python reference).
-
Produces: a runnable Python brain that does what
rutster-brain-realtime --features=mockdoes but in Python (proving the protocol is language-agnostic). -
Step 1: Create the Python reference brain
Create examples/openai_realtime_brain/openai_realtime_brain.py (~120 lines). It mirrors the Rust brain's structure: WS server on :8082 + WS client to OpenAI. The implementation is straightforward — see the spec §4.2 mapping table.
(Skeleton — the implementer fills in the body per the spec. This task is intentionally light on the Python code because the project's Python code is never in CI; it's a documented runnable.)
#!/usr/bin/env python3
"""OpenAI Realtime reference brain — Python implementation (spec §7.5).
Mirrors rutster-brain-realtime's WS server + OpenAI WS client glue in
Python. Run with:
pip install -r requirements.txt
OPENAI_API_KEY=sk-... python examples/openai_realtime_brain/openai_realtime_brain.py
Not in CI (violates the zero-non-Rust-dev-deps dev loop per AGENTS.md).
"""
import asyncio
import json
import os
import sys
import websockets
from openai import AsyncOpenAI # for the Realtime API over WS
RUTSTER_TAP_BIND = os.environ.get("RUTSTER_BRAIN_BIND", "127.0.0.1:8082")
OPENAI_MODEL = os.environ.get("OPENAI_REALTIME_MODEL", "gpt-4o-realtime")
OPENAI_VOICE = os.environ.get("OPENAI_REALTIME_VOICE", "alloy")
async def handle_tap_connection(tap_ws, openai_ws):
"""Bridge the tap WS to the OpenAI Realtime WS (spec §4.2 mapping)."""
# Send session.update with turn_detection: null (S4).
await openai_ws.send(json.dumps({
"type": "session.update",
"session": {
"modalities": ["text", "audio"],
"voice": OPENAI_VOICE,
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"sample_rate": 24000,
"turn_detection": None,
},
}))
async def tap_to_openai():
async for message in tap_ws:
decoded = json.loads(message)
t = decoded.get("type")
if t == "audio_in":
await openai_ws.send(json.dumps({
"type": "input_audio_buffer.append",
"audio": decoded["pcm"],
}))
elif t == "function_call_output":
out = decoded
await openai_ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": out["id"],
"output": json.dumps(out.get("result", {})),
},
}))
async def openai_to_tap():
async for message in openai_ws:
event = json.loads(message)
t = event.get("type")
if t == "response.audio.delta":
# Forward as tap audio_out (pass-through on the PCM base64).
await tap_ws.send(json.dumps({
"v": 1,
"type": "audio_out",
"seq": 0,
"ts": 0,
"pcm": event["delta"],
"samples": 480,
}))
elif t == "input_audio_buffer.speech_started":
await tap_ws.send(json.dumps({
"v": 1, "type": "speech_started", "seq": 0, "ts": 0,
}))
elif t == "input_audio_buffer.speech_stopped":
await tap_ws.send(json.dumps({
"v": 1, "type": "speech_stopped", "seq": 0, "ts": 0,
}))
elif t == "response.function_call_arguments.done":
await tap_ws.send(json.dumps({
"v": 1, "type": "function_call",
"id": event["call_id"], "name": event["name"],
"args": json.loads(event["arguments"]),
"seq": 0, "ts": 0,
}))
await asyncio.gather(tap_to_openai(), openai_to_tap())
async def main():
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
sys.exit("OPENAI_API_KEY required (see README.md)")
print(f"binding tap WS on {RUTSTER_TAP_BIND}; OpenAI model={OPENAI_MODEL} voice={OPENAI_VOICE}")
async with websockets.serve(handle_tap_connection, *RUTSTER_TAP_BIND.split(":")):
await asyncio.Future() # run forever
if __name__ == "__main__":
asyncio.run(main())
- Step 2: Create
examples/openai_realtime_brain/requirements.txt
websockets>=12.0
openai>=1.50.0
- Step 3: Create
examples/openai_realtime_brain/README.md
# OpenAI Realtime reference brain — Python (slice-3 spec §7.5)
A Python implementation of the slice-3 OpenAI Realtime brain — the canonical
foreign-language brain demo, hand-rolled from the documented tap protocol
(`docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md`).
## Why
Proves the slice-3 tap protocol extension is language-agnostic. A Python
script speaking JSON-via-WSS matches the OpenAI-Realtime-related portions
of the spec without depending on Rust code paths. Same rationale as
slice-2's Python echo brain — the project's `examples/` dir is the home
for canonical foreign-language brain demos.
## Run
pip install -r requirements.txt OPENAI_API_KEY=sk-... python openai_realtime_brain.py
The Python brain binds the tap WS server on `RUTSTER_BRAIN_BIND` (default
`127.0.0.1:8082`). The rutster binary, started normally (`cargo run`),
dials out to `RUTSTER_TAP_URL` (default `ws://127.0.0.1:8082/realtime` — set
`RUTSTER_TAP_URL=ws://127.0.0.1:8082` to match the Python brain's bind).
## Not in CI
Per AGENTS.md's "no Python in the dev loop" rule. The Slice-3 integration
test uses `rutster-brain-realtime --features=mock` — the in-process Rust
mock — not this Python file.
- Step 4: Update
LEARNING.mdwith ≥3 new pointers
Add at the bottom of LEARNING.md (append to the existing list):
- **`async-trait` patterns / async fns in trait objects** →
`crates/rutster/src/tool_registry.rs` (the `Tool` trait's `async fn call`)
- **OpenAI Realtime adapter + event translation** →
`crates/rutster-brain-realtime/src/translator.rs`
- **Tap protocol additive extension + forward-compat via `#[serde(other)]`** →
`crates/rutster-tap/src/protocol.rs`
- **Side-channel mpsc pattern for FOB-boundary dispatch** →
`crates/rutster/src/session_map.rs` (drive_all_sessions's function_call drain)
- **HTTP request builder for WS subprotocol handshake (Authorization + OpenAI-Beta headers)** →
`crates/rutster-brain-realtime/src/openai_client.rs`
- Step 5: Update
README.mdwith the slice-3 dev loop
In the README's "Development" or "Quickstart" section, add a new subsection:
### Slice 3 dev loop — OpenAI Realtime brain
The dev loop *without* real OpenAI credentials (no API key required):
cargo run -p rutster-brain-realtime --features=mock # brain on :8082 cargo run # core on :8080
Open `http://localhost:8080/` → click "Start call" → speak → hear the
mock-brain reply within ~250 ms (mock echoes audio back, no real OpenAI
RTT; this exercises the full brain→core audio round-trip + the new
function_call dispatch path).
With real OpenAI Realtime:
export OPENAI_API_KEY=sk-... # or OPENAI_API_KEY_FILE=/var/secrets/openai cargo run -p rutster-brain-realtime cargo run
Speak → end-to-end speech-to-speech with OpenAI Realtime within ~700 ms
(slice-1's 200 ms + tap round-trip + OpenAI latency + 100 ms playout buffer).
For the foreign-language brain demo (not in CI):
pip install -r examples/openai_realtime_brain/requirements.txt OPENAI_API_KEY=sk-... python examples/openai_realtime_brain/openai_realtime_brain.py
- Step 6: Verify the workspace still builds + tests green
cargo test --all
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo deny check
Expected: all green (cargo deny may have its pre-existing infra issue with CVSS 4.0 parsing — flag if so, but it's not a regression).
- Step 7: Commit
git add examples/openai_realtime_brain/ LEARNING.md README.md
git commit -m "docs(slice-3): Python reference brain + LEARNING.md + README dev loop (spec §7.5)
examples/openai_realtime_brain/ — the canonical foreign-language OpenAI
Realtime brain (Python, ~120 lines, websockets + openai libs). Not in
CI (zero-non-Rust-dev-deps dev loop per AGENTS.md). Letters the
slice-2's Python echo brain's pattern: proves the protocol is language-
agnostic and matches the OpenAI portion of the spec.
LEARNING.md grows 5 new pointers (async-trait, translator, protocol
extension, side-channel mpsc, WS-subprotocol handshake). README gains
the slice-3 dev loop section (mock mode + real OpenAI mode + the
Python brain alternative)."
Self-review (run this checklist after writing the plan, before saving)
Spec coverage: every section in 2026-06-30-slice-3-realtime-brain-design.md has a task:
- §1.1 in scope → Tasks 1–11.
- §1.2 out of scope → no tasks (deferred items, correctly).
- §2 workspace layout → Task 1 + cross-task file structure.
- §3 tap protocol extension → Task 2.
- §4 translation + S4 decision → Task 4 + Task 5.
- §4.4 failure mode → Task 5's reconnect + Task 10's mock.
- §5 lifecycle → Task 7 + Task 8.
- §5.3 brain process config → Task 9's env var reads.
- §6 tool registry → Task 6 + Task 8.
- §7 CI/dev loop/testing done-criteria → Task 11 + per-task test instructions.
- §8 open decisions → tracked in spec, not in plan (correct).
- §9 out-of-scope recheck → no tasks (correct).
- §10 key decisions → encoded in each task's commit message + Global Constraints.
Placeholder scan: no TBD/TODO/XXX in steps. All code is concrete.
Type consistency: FunctionCallEvent, ToolResult, Tool, ToolRegistry, HangupTool, run_mock_brain, load_api_key, TranslateError, OpenAiClientError, build_openai_session_update, tap_audio_in_to_openai_append, openai_audio_delta_to_tap_audio_out, openai_speech_event_to_tap, openai_function_call_arguments_done_to_tap, tap_function_call_output_to_openai_create_item — all appear consistently across tasks.
Execution handoff
Plan complete and saved to docs/superpowers/plans/2026-06-30-slice-3-realtime-brain.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?