Slice 3 — OpenAI Realtime brain: swap echo for the brain (#4)
This commit was merged in pull request #4.
This commit is contained in:
@@ -41,10 +41,14 @@ pub use protocol::{
|
||||
AudioPayload, DecodedFrame, DecodedPayload, Envelope, ErrorPayload, FrameKind, HelloPayload,
|
||||
PROTOCOL_VERSION, Payload, ReasonPayload, SAMPLES_PER_FRAME as WIRE_SAMPLES_PER_FRAME,
|
||||
SessionEndPayload, TapProtoError, decode_envelope, decode_pcm, encode_audio_in,
|
||||
encode_audio_out, encode_bye, encode_error, encode_hello, encode_pcm, encode_session_end,
|
||||
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,
|
||||
};
|
||||
// Slice-3 additive (spec §3).
|
||||
pub use protocol::{FunctionCallOutputPayload, FunctionCallPayload, ToolsUpdatePayload};
|
||||
pub use tap_audio_pipe::TapAudioPipe;
|
||||
pub use tap_client::{TapClientError, run_tap_client};
|
||||
pub use tap_client::{FunctionCallEvent, FunctionCallOutputEvent, TapClientError, run_tap_client};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -47,6 +47,21 @@ pub enum FrameKind {
|
||||
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 (spec §3.4: log + count + drop).
|
||||
/// `#[serde(other)]` catches any string not in the variants above.
|
||||
#[serde(other)]
|
||||
@@ -111,6 +126,11 @@ impl Serialize for Envelope {
|
||||
Payload::AudioIn(_) | Payload::AudioOut(_) => 2, // pcm, samples
|
||||
Payload::SessionEnd(_) | Payload::Bye(_) => 1, // reason
|
||||
Payload::Error(_) => 2, // code, message
|
||||
// Slice-3 adds: empty payload (0 fields), 3-field payloads, 1-field payload.
|
||||
Payload::SpeechStarted | Payload::SpeechStopped => 0,
|
||||
Payload::FunctionCall(_) => 3, // id, name, args
|
||||
Payload::FunctionCallOutput(_) => 3, // id, status, result
|
||||
Payload::ToolsUpdate(_) => 1, // tools
|
||||
};
|
||||
let mut st = serializer.serialize_struct("Envelope", 4 + payload_field_count)?;
|
||||
st.serialize_field("v", &self.v)?;
|
||||
@@ -142,6 +162,23 @@ impl Serialize for Envelope {
|
||||
st.serialize_field("code", &p.code)?;
|
||||
st.serialize_field("message", &p.message)?;
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -165,6 +202,16 @@ pub enum Payload {
|
||||
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),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -211,6 +258,42 @@ pub struct ErrorPayload {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// `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,
|
||||
}
|
||||
|
||||
/// Decoded frame — what `decode_envelope` returns. The kind is split into
|
||||
/// `audio_in` / `audio_out` variants (not just `Audio(AudioPayload)`) so
|
||||
/// callers `match` exhaustively on direction (spec §3.2 vs §3.3 — the two
|
||||
@@ -231,7 +314,17 @@ pub enum DecodedPayload {
|
||||
SessionEnd(SessionEndPayload),
|
||||
Bye(ReasonPayload),
|
||||
Error(ErrorPayload),
|
||||
/// Unknown `type` — log + count + drop (spec §3.4).
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -369,6 +462,111 @@ pub fn encode_error(code: &str, msg: &str, seq: u64, ts: u64) -> Result<String,
|
||||
Ok(serde_json::to_string(&env)?)
|
||||
}
|
||||
|
||||
/// 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)?)
|
||||
}
|
||||
|
||||
/// Decode an incoming wire string into a `DecodedFrame`. Validates `v` and
|
||||
/// (for audio frames) `samples` and PCM round-trip. Unknown `type` values
|
||||
/// decode to `DecodedPayload::Unknown` (caller logs + counts + drops per
|
||||
@@ -440,6 +638,20 @@ pub fn decode_envelope(s: &str) -> Result<DecodedFrame, TapProtoError> {
|
||||
let p: ErrorPayload = serde_json::from_value(extra_value)?;
|
||||
DecodedPayload::Error(p)
|
||||
}
|
||||
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)
|
||||
}
|
||||
FrameKind::Unknown => DecodedPayload::Unknown,
|
||||
};
|
||||
Ok(DecodedFrame {
|
||||
@@ -596,4 +808,96 @@ mod tests {
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +63,46 @@ pub enum TapClientError {
|
||||
/// `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>
|
||||
@@ -175,6 +210,44 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
@@ -188,7 +261,7 @@ where
|
||||
if let Ok(text) = msg.into_text() {
|
||||
handle_brain_frame(
|
||||
&text, &mut last_seq_ingress, &tx_audio_out,
|
||||
&metrics, session_start,
|
||||
&tx_function_call, &metrics, session_start,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
@@ -251,6 +324,7 @@ 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,
|
||||
) {
|
||||
@@ -307,6 +381,41 @@ async fn handle_brain_frame(
|
||||
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
|
||||
}
|
||||
@@ -315,6 +424,36 @@ 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
|
||||
@@ -322,6 +461,7 @@ mod tests {
|
||||
// tests here cover the pure helpers.
|
||||
|
||||
use super::*;
|
||||
use crate::protocol::encode_function_call;
|
||||
|
||||
#[test]
|
||||
fn elapsed_ms_is_monotonic_nonneg() {
|
||||
@@ -330,4 +470,83 @@ mod tests {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user