Files
rutster/crates/rutster-tap/src/tap_client.rs
opencode controller 5c3baf9155 feat(tap): tool-call side-channel wiring (spec §5.2)
slice-3 §5.2 + §6: the binary's poll task now drains the brain's
function_call proposals from rx_function_call, dispatches through the
per-channel ToolRegistry (HangupTool wired at spawn_tap_engine time),
and writes function_call_output replies back through tx_function_call_output
which run_tap_client forwards as tap WS frames to the brain.

TapClient: handle_brain_frame now forwards function_call events to a
new tx_function_call mpsc side-channel instead of dropping them.
run_tap_client adds a select! arm draining rx_function_call_output +
sending each as a tap frame. Advisory events (speech_started/stopped,
tools.update) still log + count (slice-3 deferred-action posture).

TapEngine: spawn_tap_engine now takes AppState + constructs a per-channel
ToolRegistry (spec §6.2) with HangupTool pre-registered (§6.3). TapConn
gains rx_function_call, tx_function_call_output, tool_registry fields.

session_map: drive_all_sessions calls drain_function_calls in the same
cycle as the slice-2 §5.3 step 4 flush drain (one extra channel, same
cycle); the helper spawns each dispatch as its own task so the 750 ms
hangup teardown bound (AppState::close) can't stall the 10 ms poll
cadence.

files touched: crates/rutster-tap/src/{lib,tap_client}.rs,
crates/rutster/src/{session_map,tap_engine}.rs,
crates/rutster/tests/tap_integration.rs ( AppState arg ),
crates/rutster-brain-realtime/src/translator.rs (clippy needless_borrow ).

NOT touched: loop_driver.rs, rtc_session.rs (seam test §7.5 #6).

gates: cargo fmt --check OK. cargo clippy --all --tests -D warnings OK.
cargo test --all OK. cargo deny check has pre-existing environmental
failure (CVSS 4.0 unsupported in advisory-db; same on main).
2026-06-30 23:21:58 -04:00

553 lines
24 KiB
Rust

//! # TapClient — the async WSS pump loop (spec §4.2)
//!
//! Lives inside the `TapEngine` task (in the binary crate). Owns one WS
//! connection + the pump loop that shovels PCM between mpsc channels and
//! WS frames. The media loop never sees it — `TapAudioPipe` is the only
//! sync surface `loop_driver` touches.
//!
//! # Why TapClient never decides to reconnect
//!
//! Reconnect is the `TapEngine`'s job (spec §4.3). On any WS close / error,
//! `run_tap_client` returns; the engine rebuilds the client + applies
//! backoff. This keeps "the connection" (the wire) and "the reconnect
//! policy" (the backoff) as separate concerns — the one knows the wire,
//! the other knows the timing.
//!
//! # The `close` oneshot is shared, not owned
//!
//! `run_tap_client` takes `close: &mut oneshot::Receiver<()>` (a shared
//! borrow of the receiver), NOT `close: oneshot::Receiver<()>` by value.
//! Rationale: the `TapEngine` reconnect loop (Task 7) holds ONE close
//! oneshot per session and passes `&mut close` to each reconnect attempt
//! of `run_tap_client`. If this fn consumed it by value, each reconnect
//! would need a fresh oneshot — breaking the model where the binary holds
//! one close signal per session that survives across reconnects.
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use rutster_call_model::ChannelId;
use rutster_media::PcmFrame;
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, trace, warn};
use crate::metrics::TapMetrics;
use crate::protocol::{
DecodedPayload, TapProtoError, decode_envelope, encode_audio_in, encode_hello,
encode_session_end,
};
#[derive(Debug, Error)]
pub enum TapClientError {
#[error("WebSocket error: {0}")]
Ws(#[from] tokio_tungstenite::tungstenite::Error),
#[error("Protocol error: {0}")]
Proto(#[from] TapProtoError),
#[error("hello handshake timed out")]
HelloTimeout,
#[error("close signal received")]
Closed,
}
/// Run the pump loop on an already-connected `WebSocketStream`.
///
/// Returns `Ok(())` on graceful close (brain sent `bye` or the close
/// oneshot fired). Returns `Err(_)` on WS / protocol errors — the
/// `TapEngine` caller decides to retry.
///
/// `close` is a shared borrow (`&mut oneshot::Receiver<()>`) so the
/// `TapEngine` reconnect loop (Task 7) can share one close signal across
/// reconnect attempts of the same session (see module docs).
///
/// # slice-3 §5.2 — the tool-call side-channel
///
/// Two extra mpsc halves (relative to slice-2's audio-only pump) carry the
/// brain's `function_call` proposals out to the binary's poll task and the
/// binary's `function_call_output` replies back onto the wire:
///
/// - `tx_function_call` — the TapClient emits a `FunctionCallEvent` here
/// whenever it observes a tap `function_call` frame on its inbound WS
/// stream. The binary's poll task drains this in the same cycle it drains
/// the existing `flush_tx` side-channel (slice-2 §5.3 step 4 — one extra
/// channel, same cycle) and dispatches via `ToolRegistry::dispatch`.
/// - `rx_function_call_output` — the binary's poll task writes
/// `FunctionCallOutputEvent`s here (after a `ToolRegistry::dispatch` call
/// completes); the TapClient drains this in the same `tokio::select!`
/// pump as the audio + close arms and sends each as a `function_call_output`
/// tap WS frame to the brain.
///
/// Both halves are mpsc ends (not oneshot) because the brain may propose
/// multiple tool calls per session (one-of-many, not one-of-one) and the
/// binary may queue multiple replies before the TapClient's pump cycle
/// drains them.
//
// clippy::too_many_arguments: the slice-3 §5.2 design added two more mpsc
// halves to slice-2's already-5-arg pump signature for a total of 8. Each
// arg is a distinct channel end with a distinct lifetime owner (the WS
// stream, the session id, two sender/receiver pairs for audio, two for the
// tool-call side-channel, the metrics Arc, the close oneshot). Wrapping
// them in a struct would obscure that all-but-one are channel ends shared
// with the binary's poll task — the flat signature mirrors slice-2's
// precedent and keeps the call site readable. Suppress per AGENTS.md's
// "documented inline rationale" exception to the -D warnings bar.
#[allow(clippy::too_many_arguments)]
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>,
rx_function_call_output: &mut mpsc::Receiver<FunctionCallOutputEvent>,
metrics: Arc<TapMetrics>,
close: &mut oneshot::Receiver<()>,
) -> Result<(), TapClientError>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let session_start = Instant::now();
let mut seq_egress: u64 = 0;
// === Handshake: send hello, await brain hello (bounded 2s). ===
let hello_str = encode_hello(&session_id.to_string(), seq_egress, 0)?;
seq_egress += 1;
ws.send(Message::Text(hello_str)).await?;
info!(%session_id, "sent hello to brain");
let hello_brain = tokio::time::timeout(
std::time::Duration::from_secs(2),
wait_for_brain_hello(&mut ws),
)
.await;
// Every arm either returns or assigns last_seq_ingress — using a
// match expression (rather than `let mut x = None; match { x = ... }`)
// avoids the dead-store warning that an unconditional `None` initializer
// would produce under `-D unused-assignments`.
let mut last_seq_ingress: Option<u64> = match hello_brain {
Ok(Ok(brain_seq)) => {
info!(%session_id, "brain hello acked");
Some(brain_seq)
}
Ok(Err(e)) => {
warn!(error = ?e, %session_id, "brain hello failed");
return Err(e);
}
Err(_) => return Err(TapClientError::HelloTimeout),
};
// === Pump loop. ===
loop {
tokio::select! {
// Close signal from the binary (Channel::Closing). `close` is a
// shared `&mut oneshot::Receiver<()>`; `&mut *close` reborrows
// the inner receiver so the engine's reconnect loop can hand
// us the same oneshot across reconnect attempts of one session.
_ = &mut *close => {
// slice-2 spec §5.1 step 5 + §5.2: on core-side teardown the
// TapClient sends `session_end` (NOT `bye`) and awaits the
// brain's `bye` ack, bounded by 500 ms. A brain that doesn't
// `bye` back in time just gets a WS close — acceptable.
info!(%session_id, "close signal; sending session_end + awaiting brain bye");
let end_str = encode_session_end(
"core_shutdown",
seq_egress,
elapsed_ms(session_start),
)?;
// No `seq_egress += 1` here — the close path returns
// immediately after the WS send, so the bump would be a
// dead write (the brain never sees a higher seq from us).
if let Err(e) = ws.send(Message::Text(end_str)).await {
warn!(error = ?e, %session_id, "ws send session_end failed; closing");
let _ = ws.close(None).await;
return Err(TapClientError::Closed);
}
// Bounded wait for brain `bye` (spec §5.2: 500 ms).
// `wait_for_brain_bye` reads frames until it sees `bye` (or
// the WS stream ends / errors). On timeout we just close the
// WS — the brain didn't ack in time, the call is hanging up
// anyway.
match tokio::time::timeout(
Duration::from_millis(500),
wait_for_brain_bye(&mut ws),
)
.await
{
Ok(Ok(())) => {
info!(%session_id, "brain bye acked; closing ws");
}
Ok(Err(e)) => {
warn!(error = ?e, %session_id, "brain bye wait errored; closing ws");
}
Err(_) => {
warn!(%session_id, "brain bye timed out (500ms); closing ws");
}
}
let _ = ws.close(None).await;
return Err(TapClientError::Closed);
}
// Inbound PCM from peer → audio_in WS frame.
frame = rx_pcm_in.recv() => {
let Some(frame) = frame else {
// Peer side gone; just keep the WS open until close.
continue;
};
let ts = elapsed_ms(session_start);
match encode_audio_in(&frame, seq_egress, ts) {
Ok(s) => {
seq_egress += 1;
if let Err(e) = ws.send(Message::Text(s)).await {
warn!(error = ?e, %session_id, "ws send audio_in failed");
return Err(e.into());
}
}
Err(e) => {
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
warn!(error = ?e, "encode audio_in failed; dropping");
}
}
}
// slice-3 §5.2: drain a `function_call_output` event the binary's
// poll task wrote (after `ToolRegistry::dispatch` returned) + send
// it as a `function_call_output` tap WS frame to the brain. The
// `seq_egress` bump mirrors the audio arm — every egress frame
// shares the same per-direction counter (spec §3.1).
//
// Like `rx_pcm_in.recv()`, this is one of many arms in the
// select! — the engine's `tx_function_call_output` sender lives
// in the binary; `run_tap_client` returns when the brain WS ends
// or close fires, regardless of pending function_call_output
// events (they're dropped on close — same posture as pending
// audio_out frames on teardown).
out = rx_function_call_output.recv() => {
if let Some(out) = out {
let ts = elapsed_ms(session_start);
let result_str = out.0.result.to_string();
match crate::protocol::encode_function_call_output(
&out.0.id,
&out.0.status,
&result_str,
seq_egress,
ts,
) {
Ok(s) => {
seq_egress += 1;
if let Err(e) = ws.send(Message::Text(s)).await {
warn!(error = ?e, %session_id, "ws send function_call_output failed");
return Err(e.into());
}
info!(%session_id, call_id = %out.0.id, status = %out.0.status, "sent function_call_output to brain");
}
Err(e) => {
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
warn!(error = ?e, "encode function_call_output failed; dropping");
}
}
}
}
// Inbound WS frame from brain.
msg = ws.next() => {
let Some(msg) = msg else {
info!(%session_id, "brain WS stream ended");
return Ok(());
};
let msg = msg?;
// `Message::into_text` returns `Result<String>`; non-text
// frames (Binary/Ping/Pong/Close) yield `Err(_)` and are
// silently dropped (v1 is text-JSON only — spec §3.4).
if let Ok(text) = msg.into_text() {
handle_brain_frame(
&text, &mut last_seq_ingress, &tx_audio_out,
&tx_function_call, &metrics, session_start,
).await;
}
}
}
}
}
async fn wait_for_brain_hello<T>(ws: &mut WebSocketStream<T>) -> Result<u64, TapClientError>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
while let Some(msg) = ws.next().await {
let msg = msg?;
if let Ok(text) = msg.into_text() {
let decoded = decode_envelope(&text)?;
if let DecodedPayload::Hello(_) = decoded.payload {
return Ok(decoded.seq);
}
// Non-hello frames before ack — log + continue (drop + observe).
warn!("pre-hello frame from brain; ignoring");
}
}
Err(TapClientError::Ws(
tokio_tungstenite::tungstenite::Error::ConnectionClosed,
))
}
/// Bounded-wait helper: read frames from the brain until we see `bye`
/// (the brain's ack to our `session_end`), or the WS stream ends / errors.
/// slice-2 spec §5.2: the bound is enforced by the caller (`tokio::time::
/// timeout`); this fn just consumes frames until it sees `bye`.
///
/// Non-bye frames during the bye-wait (e.g. an in-flight `audio_out` the
/// brain had already queued) are silently dropped — the session is
/// hanging up, no more playout is desired.
async fn wait_for_brain_bye<T>(ws: &mut WebSocketStream<T>) -> Result<(), TapClientError>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
while let Some(msg) = ws.next().await {
let msg = msg?;
if let Ok(text) = msg.into_text() {
if let Ok(decoded) = decode_envelope(&text) {
if matches!(decoded.payload, DecodedPayload::Bye(_)) {
return Ok(());
}
// Non-bye frame during teardown — ignore + continue.
continue;
}
}
}
// WS stream ended without an explicit `bye` — surface as the same Ws
// error `wait_for_brain_hello` returns; the caller logs + closes.
Err(TapClientError::Ws(
tokio_tungstenite::tungstenite::Error::ConnectionClosed,
))
}
async fn handle_brain_frame(
text: &str,
last_seq_ingress: &mut Option<u64>,
tx_audio_out: &mpsc::Sender<PcmFrame>,
tx_function_call: &mpsc::Sender<FunctionCallEvent>,
metrics: &Arc<TapMetrics>,
session_start: Instant,
) {
let decoded = match decode_envelope(text) {
Ok(d) => d,
Err(e) => {
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
warn!(error = ?e, "malformed brain frame; dropping");
return;
}
};
// seq gap detection (spec §3.1: gaps = loss; log + count; don't drop).
if let Some(prev) = *last_seq_ingress {
if decoded.seq > prev + 1 {
let gap = decoded.seq - prev - 1;
metrics.seq_gaps.fetch_add(gap, Ordering::Relaxed);
debug!(gap, "seq gap detected");
}
}
*last_seq_ingress = Some(decoded.seq);
match decoded.payload {
DecodedPayload::AudioOut(audio) => {
match crate::protocol::decode_pcm(&audio.pcm, audio.samples) {
Ok(frame) => {
if tx_audio_out.try_send(frame).is_err() {
metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed);
trace!("outbound PCM dropped (playout ring full)");
}
}
Err(e) => {
metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
warn!(error = ?e, "failed to decode brain audio_out PCM");
}
}
}
DecodedPayload::Bye(p) => {
info!(reason = %p.reason, "brain sent bye; closing");
// Caller's pump loop sees Ok(()) and lets TapEngine decide to retry.
}
DecodedPayload::Error(p) => {
warn!(code = %p.code, message = %p.message, "brain error frame");
}
DecodedPayload::Hello(_) => {
// Re-hello mid-session (after a reconnect from the brain's POV).
// Fine; we already tracked last_seq_ingress above.
}
DecodedPayload::Unknown => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
warn!("unknown frame type from brain; dropping");
}
// SessionEnd is core→brain only; ignore if we receive one.
DecodedPayload::SessionEnd(_) | DecodedPayload::AudioIn(_) => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
warn!("unexpected frame direction from brain; dropping");
}
// Slice-3 spec §5.2: `function_call` flows through the side-channel
// (NON-BLOCKING try_send — the binary's poll task drains on its own
// cycle). The same "drop + observe" posture as audio_out applies if
// the channel is full: a backed-up binary means we drop the proposal
// and the brain gets no reply (the brain process knows no
// function_call_output arrived → its OpenAI pump keeps going; the
// model tolerates missing replies per OpenAI's design).
DecodedPayload::FunctionCall(p) => {
if tx_function_call.try_send(FunctionCallEvent(p)).is_err() {
metrics.outbound_dropped.fetch_add(1, Ordering::Relaxed);
warn!(
"function_call dropped (binary poll task not draining; brain will see no reply)"
);
}
}
// Slice-3 (spec §3.2): `function_call_output` is core→brain; ignore
// if a brain sends one back (a misbehaving brain — same posture as
// `SessionEnd`/`AudioIn` from brain above).
DecodedPayload::FunctionCallOutput(_) => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
warn!("unexpected function_call_output from brain; dropping");
}
// Slice-3 advisory — same "logged + counted, not forwarded" posture
// as `Unknown`. The FOB reflex loop in step 4 will act on these;
// slice-3 only pre-paves the wire event.
DecodedPayload::SpeechStarted | DecodedPayload::SpeechStopped => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
debug!("advisory interruption event observed; not acted on in slice-3");
}
DecodedPayload::ToolsUpdate(_) => {
metrics.unknown_frames.fetch_add(1, Ordering::Relaxed);
debug!(
"tools.update observed; slice-3 dispatch keys off function_call by name, not catalog"
);
}
}
let _ = session_start; // used for ts computation if added later
}
fn elapsed_ms(start: Instant) -> u64 {
start.elapsed().as_millis() as u64
}
/// A `function_call` event the TapClient **observed** on its inbound WS
/// stream and forwarded to the binary's poll task via the
/// `tx_function_call` side-channel (spec §5.2). The binary's poll task
/// drains this (alongside the existing `flush_rx` side-channel — slice-2
/// §5.3 step 4) and dispatches each event through `ToolRegistry::dispatch`.
///
/// # Why a thin newtype over `FunctionCallPayload` (and not a bare alias)?
///
/// A type alias (`pub type FunctionCallEvent = FunctionCallPayload;`) would
/// let the binary pass a `FunctionCallPayload` where a `FunctionCallEvent`
/// is expected without surfacing the intent. The newtype draws a small but
/// real boundary: the wire-payload type (`FunctionCallPayload`) lives in
/// `protocol.rs` for (de)serialization; the side-channel event type
/// (`FunctionCallEvent`) lives here for dispatch. They share a shape but
/// carry different semantic weight — honoring the newtype-over-primitives
/// convention from AGENTS.md even at the message level.
#[derive(Debug, Clone)]
pub struct FunctionCallEvent(pub crate::protocol::FunctionCallPayload);
/// A `function_call_output` event the binary's poll task **emits** back to
/// the TapClient via the `tx_function_call_output` side-channel (spec §5.2
/// — the binary's poll task dispatches through `ToolRegistry::dispatch`,
/// serializes the `ToolResult`, and writes the output here). The TapClient
/// drains this in the same `tokio::select!` pump cycle as the audio + close
/// arms and sends each as a `function_call_output` tap WS frame.
///
/// Same newtype-over-payload rationale as `FunctionCallEvent`.
#[derive(Debug, Clone)]
pub struct FunctionCallOutputEvent(pub crate::protocol::FunctionCallOutputPayload);
#[cfg(test)]
mod tests {
// TapClient is heavily async; its real behavior is exercised in the
// integration test (Task 8) against the in-process EchoServer. Unit
// tests here cover the pure helpers.
use super::*;
use crate::protocol::encode_function_call;
#[test]
fn elapsed_ms_is_monotonic_nonneg() {
let start = Instant::now();
let ms = elapsed_ms(start);
// First call ~0; just assert it's a valid u64.
assert_eq!(ms, ms); // tautology but clippy-clean
}
/// slice-3 spec §5.2: when the TapClient observes a tap `function_call`
/// frame on its inbound WS stream it emits a `FunctionCallEvent` on
/// the `tx_function_call` side-channel. The binary's poll task drains
/// that and dispatches via `ToolRegistry::dispatch`. This test pins the
/// contract end-to-end through the pure helper (`handle_brain_frame`):
/// hand it a wire-encoded function_call frame + a fresh mpsc pair and
/// assert the receiver observes the forwarded event.
#[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::<PcmFrame>(8);
let metrics = Arc::new(TapMetrics::new());
// Build a wire function_call frame: id="call-1", name="hangup", args={}.
let wire = encode_function_call("call-1", "hangup", "{}", 1, 100).unwrap();
let mut last_seq: Option<u64> = None;
handle_brain_frame(
&wire,
&mut last_seq,
&tx_audio_out,
&tx_fc,
&metrics,
Instant::now(),
)
.await;
// The side-channel must have observed exactly one FunctionCallEvent
// carrying the wire's id/name/args.
let event = tokio::time::timeout(Duration::from_millis(200), rx_fc.recv())
.await
.expect("tx_function_call drained within 200ms")
.expect("channel not closed");
assert_eq!(event.0.id, "call-1");
assert_eq!(event.0.name, "hangup");
assert_eq!(event.0.args, serde_json::json!({}));
// seq tracking still updates for the side-channeled event.
assert_eq!(last_seq, Some(1));
}
/// slice-3 spec §5.2 — the *advisory* interrupt events (`speech_started`
/// /`speech_stopped`) and `tools.update` are observed (logged + counted)
/// but do NOT flow through the function_call side-channel (only
/// `function_call` does — that's the only event with a binary-side
/// disposal). This pins that boundary: an advisory event must NOT
/// produce a `FunctionCallEvent` even with the channel plumbed.
#[tokio::test]
async fn advisory_events_are_logged_not_forwarded_to_function_call_channel() {
let (tx_fc, mut rx_fc) = mpsc::channel::<FunctionCallEvent>(8);
let (tx_audio_out, _rx_audio_out) = mpsc::channel::<PcmFrame>(8);
let metrics = Arc::new(TapMetrics::new());
let wire = crate::protocol::encode_speech_started(2, 200).unwrap();
let mut last_seq: Option<u64> = None;
handle_brain_frame(
&wire,
&mut last_seq,
&tx_audio_out,
&tx_fc,
&metrics,
Instant::now(),
)
.await;
// No FunctionCallEvent forwarded — the channel stays empty. Pick a
// tight bounded receive so the test fails fast if a future refactor
// starts forwarding advisory events here.
assert!(
tokio::time::timeout(Duration::from_millis(50), rx_fc.recv())
.await
.is_err(),
"no FunctionCallEvent expected for advisory events"
);
// The advisory event IS still observed via metrics (seq gap tracking
// + the unknown-slot counter remains 0 — speech_started is now a
// known payload variant).
assert_eq!(last_seq, Some(2));
}
}