Skip to main content

ferrin_core/realtime/
session.rs

1//! Connection task of a realtime session.
2
3use std::sync::Arc;
4use std::sync::Mutex;
5use std::sync::atomic::AtomicBool;
6use std::sync::atomic::Ordering;
7
8use ferrin_spec::DynRealtimeModel;
9use ferrin_spec::JsonValue;
10use ferrin_spec::ToolCallId;
11use ferrin_spec::ToolName;
12use ferrin_spec::realtime_model::ClientSecret;
13use ferrin_spec::realtime_model::ConversationItem;
14use ferrin_spec::realtime_model::ConversationRole;
15use ferrin_spec::realtime_model::RealtimeClientEvent;
16use ferrin_spec::realtime_model::RealtimeServerEvent;
17use ferrin_spec::realtime_model::RealtimeSessionConfig;
18use ferrin_tool::Tool;
19use ferrin_tool::ToolContext;
20use ferrin_tool::ToolSet;
21use ferrin_tool::execute_to_completion;
22use futures_util::SinkExt;
23use futures_util::StreamExt;
24use http::HeaderValue;
25use http::header::SEC_WEBSOCKET_PROTOCOL;
26use http::header::USER_AGENT;
27use tokio::net::TcpStream;
28use tokio::sync::mpsc;
29use tokio::task::JoinSet;
30use tokio_tungstenite::MaybeTlsStream;
31use tokio_tungstenite::WebSocketStream;
32use tokio_tungstenite::connect_async_tls_with_config;
33use tokio_tungstenite::tungstenite::Message;
34use tokio_tungstenite::tungstenite::client::IntoClientRequest;
35use tokio_util::sync::CancellationToken;
36
37use super::RealtimeEvent;
38use super::tools::ToolTurn;
39use crate::error::Error;
40
41type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
42
43/// Capacity of the outbound message queue.
44const OUTBOUND_BUFFER: usize = 64;
45
46/// State shared between the connection task and the handles.
47struct Shared {
48    model: Arc<dyn DynRealtimeModel>,
49    outbound: mpsc::Sender<JsonValue>,
50    cancellation: CancellationToken,
51    closed: AtomicBool,
52    tool_turn: Mutex<ToolTurn>,
53}
54
55impl Shared {
56    fn tool_turn(&self) -> std::sync::MutexGuard<'_, ToolTurn> {
57        self.tool_turn
58            .lock()
59            .unwrap_or_else(std::sync::PoisonError::into_inner)
60    }
61}
62
63/// Sends events into a realtime session and closes it.
64///
65/// Handles are cheap to clone and remain valid until the session ends.
66#[derive(Clone)]
67pub struct RealtimeHandle {
68    shared: Arc<Shared>,
69}
70
71impl std::fmt::Debug for RealtimeHandle {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("RealtimeHandle")
74            .field("provider", self.shared.model.provider())
75            .field("model_id", self.shared.model.model_id())
76            .field("closed", &self.is_closed())
77            .finish()
78    }
79}
80
81impl RealtimeHandle {
82    /// Sends a client event.
83    ///
84    /// # Errors
85    ///
86    /// Fails when the model cannot serialize the event or the session is
87    /// closed.
88    pub async fn send(&self, event: RealtimeClientEvent) -> Result<(), Error> {
89        let raw = self.shared.model.serialize_client_event(event).await?;
90        self.send_raw(raw).await
91    }
92
93    /// Sends a raw provider message.
94    ///
95    /// # Errors
96    ///
97    /// Fails when the session is closed.
98    pub async fn send_raw(&self, raw: JsonValue) -> Result<(), Error> {
99        self.shared
100            .outbound
101            .send(raw)
102            .await
103            .map_err(|_| closed_error())
104    }
105
106    /// Sends a user text message and requests a response.
107    ///
108    /// # Errors
109    ///
110    /// See [`RealtimeHandle::send`].
111    pub async fn send_text(&self, text: impl Into<String>) -> Result<(), Error> {
112        self.send(RealtimeClientEvent::ConversationItemCreate {
113            item: ConversationItem::TextMessage {
114                role: ConversationRole::User,
115                text: text.into(),
116            },
117        })
118        .await?;
119        self.send(RealtimeClientEvent::ResponseCreate { options: None })
120            .await
121    }
122
123    /// Submits the output of a tool call. A follow-up response is requested
124    /// once every tool call of the response has an output.
125    ///
126    /// # Errors
127    ///
128    /// See [`RealtimeHandle::send`].
129    pub async fn add_tool_output(&self, call_id: &str, output: &JsonValue) -> Result<(), Error> {
130        let name = self
131            .shared
132            .tool_turn()
133            .name(call_id)
134            .map(|name| name.as_str().to_owned());
135        self.send(RealtimeClientEvent::ConversationItemCreate {
136            item: ConversationItem::FunctionCallOutput {
137                call_id: call_id.to_owned(),
138                name,
139                output: output.to_string(),
140            },
141        })
142        .await?;
143        let request_response = self.shared.tool_turn().output_submitted(call_id);
144        if request_response {
145            self.send(RealtimeClientEvent::ResponseCreate { options: None })
146                .await?;
147        }
148        Ok(())
149    }
150
151    /// Requests a response with the default options.
152    ///
153    /// # Errors
154    ///
155    /// See [`RealtimeHandle::send`].
156    pub async fn request_response(&self) -> Result<(), Error> {
157        self.send(RealtimeClientEvent::ResponseCreate { options: None })
158            .await
159    }
160
161    /// Asks the connection task to close the connection.
162    pub fn close(&self) {
163        self.shared.cancellation.cancel();
164    }
165
166    /// Returns `true` once the connection task has finished.
167    #[must_use]
168    pub fn is_closed(&self) -> bool {
169        self.shared.closed.load(Ordering::Acquire)
170    }
171}
172
173fn closed_error() -> Error {
174    Error::message("realtime session is closed")
175}
176
177/// Inputs of [`start`].
178pub(super) struct StartOptions {
179    pub(super) model: Arc<dyn DynRealtimeModel>,
180    pub(super) secret: ClientSecret,
181    pub(super) config: RealtimeSessionConfig,
182    pub(super) tools: Arc<ToolSet>,
183    pub(super) tools_context: Option<JsonValue>,
184    pub(super) cancellation: CancellationToken,
185    pub(super) events: mpsc::Sender<RealtimeEvent>,
186}
187
188/// Opens the WebSocket, sends the initial `session-update` and spawns the
189/// connection task into `tasks`.
190pub(super) async fn start(
191    options: StartOptions,
192    tasks: &mut JoinSet<()>,
193) -> Result<RealtimeHandle, Error> {
194    let StartOptions {
195        model,
196        secret,
197        config,
198        tools,
199        tools_context,
200        cancellation,
201        events,
202    } = options;
203    let ws_config = model.websocket_config(&secret.token, &secret.url);
204    let mut request = ws_config
205        .url
206        .as_str()
207        .into_client_request()
208        .map_err(Error::other)?;
209    if !ws_config.protocols.is_empty() {
210        let value = HeaderValue::from_str(&ws_config.protocols.join(", "))
211            .map_err(|error| Error::invalid_argument("protocols", error.to_string()))?;
212        request.headers_mut().insert(SEC_WEBSOCKET_PROTOCOL, value);
213    }
214    request
215        .headers_mut()
216        .insert(USER_AGENT, HeaderValue::from_static(crate::USER_AGENT));
217
218    let connect = connect_async_tls_with_config(request, None, false, None);
219    let (mut socket, _response) = tokio::select! {
220        result = connect => result.map_err(Error::other)?,
221        () = cancellation.cancelled() => return Err(Error::Cancelled),
222    };
223
224    let initial = model
225        .serialize_client_event(RealtimeClientEvent::SessionUpdate {
226            config: Box::new(config),
227        })
228        .await?;
229    socket
230        .send(Message::text(initial.to_string()))
231        .await
232        .map_err(Error::other)?;
233
234    let (outbound_tx, outbound_rx) = mpsc::channel(OUTBOUND_BUFFER);
235    let shared = Arc::new(Shared {
236        model,
237        outbound: outbound_tx,
238        cancellation,
239        closed: AtomicBool::new(false),
240        tool_turn: Mutex::new(ToolTurn::default()),
241    });
242    let handle = RealtimeHandle {
243        shared: Arc::clone(&shared),
244    };
245    tasks.spawn(run(Connection {
246        socket,
247        outbound: outbound_rx,
248        shared,
249        events,
250        tools,
251        tools_context,
252    }));
253    Ok(handle)
254}
255
256struct Connection {
257    socket: Socket,
258    outbound: mpsc::Receiver<JsonValue>,
259    shared: Arc<Shared>,
260    events: mpsc::Sender<RealtimeEvent>,
261    tools: Arc<ToolSet>,
262    tools_context: Option<JsonValue>,
263}
264
265async fn run(mut connection: Connection) {
266    let mut tool_tasks: JoinSet<()> = JoinSet::new();
267    loop {
268        let stop = tokio::select! {
269            biased;
270            () = connection.shared.cancellation.cancelled() => {
271                let _ = connection.socket.send(Message::Close(None)).await;
272                true
273            }
274            outbound = connection.outbound.recv() => match outbound {
275                Some(raw) => connection.send_raw(raw).await.is_err(),
276                None => {
277                    let _ = connection.socket.send(Message::Close(None)).await;
278                    true
279                }
280            },
281            frame = connection.socket.next() => match frame {
282                Some(Ok(Message::Text(text))) => {
283                    connection.handle_text(text.as_str(), &mut tool_tasks).await
284                }
285                Some(Ok(Message::Binary(bytes))) => match std::str::from_utf8(&bytes) {
286                    Ok(text) => connection.handle_text(text, &mut tool_tasks).await,
287                    Err(_) => false,
288                },
289                Some(Ok(Message::Close(_))) | None => true,
290                Some(Ok(_)) => false,
291                Some(Err(error)) => {
292                    connection.emit(Err(Error::other(error))).await;
293                    true
294                }
295            },
296            Some(joined) = tool_tasks.join_next(), if !tool_tasks.is_empty() => {
297                if let Err(error) = joined
298                    && !error.is_cancelled()
299                {
300                    connection.emit(Err(Error::other(error))).await;
301                }
302                false
303            }
304        };
305        if stop {
306            break;
307        }
308    }
309    tool_tasks.shutdown().await;
310    connection.shared.closed.store(true, Ordering::Release);
311    connection.shared.cancellation.cancel();
312}
313
314impl Connection {
315    /// Forwards an item to the consumer; returns `false` when the consumer is
316    /// gone.
317    async fn emit(&self, item: RealtimeEvent) -> bool {
318        self.events.send(item).await.is_ok()
319    }
320
321    async fn send_raw(&mut self, raw: JsonValue) -> Result<(), ()> {
322        match self.socket.send(Message::text(raw.to_string())).await {
323            Ok(()) => Ok(()),
324            Err(error) => {
325                self.emit(Err(Error::other(error))).await;
326                Err(())
327            }
328        }
329    }
330
331    /// Handles one text frame; returns `true` when the loop must stop.
332    async fn handle_text(&mut self, text: &str, tool_tasks: &mut JoinSet<()>) -> bool {
333        let raw: JsonValue = match serde_json::from_str(text) {
334            Ok(raw) => raw,
335            Err(error) => {
336                tracing::debug!(error = %error, "ignoring non-JSON realtime message");
337                return false;
338            }
339        };
340        if let Some(reply) = self.shared.model.health_check_response(&raw)
341            && self.send_raw(reply).await.is_err()
342        {
343            return true;
344        }
345        let events = match self.shared.model.parse_server_event(raw) {
346            Ok(events) => events,
347            Err(error) => return !self.emit(Err(Error::from(error))).await,
348        };
349        for event in events {
350            if !self.handle_event(event, tool_tasks).await {
351                return true;
352            }
353        }
354        false
355    }
356
357    /// Handles one standardized event; returns `false` when the consumer is
358    /// gone or sending failed.
359    async fn handle_event(
360        &mut self,
361        event: RealtimeServerEvent,
362        tool_tasks: &mut JoinSet<()>,
363    ) -> bool {
364        let follow_up = match &event {
365            RealtimeServerEvent::FunctionCallArgumentsDone {
366                call_id,
367                name,
368                arguments,
369                ..
370            } => {
371                let name = ToolName::new(name.clone());
372                self.shared.tool_turn().call_started(call_id, &name);
373                Some(ToolCall {
374                    call_id: call_id.clone(),
375                    name,
376                    arguments: arguments.clone(),
377                })
378            }
379            _ => None,
380        };
381        let response_done = matches!(event, RealtimeServerEvent::ResponseDone { .. });
382
383        if !self.emit(Ok(event)).await {
384            return false;
385        }
386        if let Some(call) = follow_up
387            && !self.start_tool_call(call, tool_tasks).await
388        {
389            return false;
390        }
391        if response_done && self.shared.tool_turn().response_done() {
392            let handle = RealtimeHandle {
393                shared: Arc::clone(&self.shared),
394            };
395            if let Err(error) = handle.request_response().await {
396                return self.emit(Err(error)).await;
397            }
398        }
399        true
400    }
401
402    /// Starts executing a tool call; returns `false` when the consumer is
403    /// gone.
404    async fn start_tool_call(&self, call: ToolCall, tool_tasks: &mut JoinSet<()>) -> bool {
405        let Some(tool) = self.tools.get(call.name.as_str()).map(Arc::clone) else {
406            let available = self.tools.names().cloned().collect();
407            return self
408                .emit(Err(Error::no_such_tool(call.name, available)))
409                .await;
410        };
411        if !tool.is_executable() {
412            // Advertised without an executor: the application answers through
413            // `add_tool_output`.
414            return true;
415        }
416        let input: JsonValue = match serde_json::from_str(&call.arguments) {
417            Ok(input) => input,
418            Err(error) => {
419                return self
420                    .emit(Err(Error::invalid_tool_input(
421                        call.name,
422                        call.arguments,
423                        error,
424                    )))
425                    .await;
426            }
427        };
428        let input = match tool.validate_input(&call.name, input) {
429            Ok(input) => input,
430            Err(error) => {
431                return self
432                    .emit(Err(Error::invalid_tool_input(
433                        call.name,
434                        call.arguments,
435                        error,
436                    )))
437                    .await;
438            }
439        };
440        let ctx = ToolContext::new(ToolCallId::new(call.call_id.clone()))
441            .with_cancellation(self.shared.cancellation.child_token())
442            .with_tools_context(self.tools_context.clone());
443        let handle = RealtimeHandle {
444            shared: Arc::clone(&self.shared),
445        };
446        let events = self.events.clone();
447        tool_tasks.spawn(execute_tool_call(
448            tool,
449            input,
450            ctx,
451            call.call_id,
452            handle,
453            events,
454        ));
455        true
456    }
457}
458
459struct ToolCall {
460    call_id: String,
461    name: ToolName,
462    arguments: String,
463}
464
465async fn execute_tool_call(
466    tool: Arc<Tool>,
467    input: JsonValue,
468    ctx: ToolContext,
469    call_id: String,
470    handle: RealtimeHandle,
471    events: mpsc::Sender<RealtimeEvent>,
472) {
473    let Some(stream) = tool.execute(input, ctx) else {
474        return;
475    };
476    let result = match execute_to_completion(stream, |_| {}).await {
477        Ok(output) => handle.add_tool_output(&call_id, &output).await,
478        Err(error) => Err(Error::from(error)),
479    };
480    if let Err(error) = result {
481        let _ = events.send(Err(error)).await;
482    }
483}