Skip to main content

systemprompt_api/routes/stream/
mod.rs

1//! Server-sent event stream routes for live A2A, `AgUI`, and context-state
2//! feeds.
3//!
4//! Each route opens a per-user SSE connection backed by a broadcaster from
5//! `systemprompt_events`. [`create_sse_stream`] registers the connection (with
6//! a per-user cap), wraps the receiver in a [`StreamWithGuard`] so the
7//! [`ConnectionGuard`] deregisters it on drop, and emits keep-alive frames.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use axum::Router;
13use axum::extract::Extension;
14use axum::response::IntoResponse;
15use axum::response::sse::Sse;
16use axum::routing::get;
17use std::convert::Infallible;
18use std::sync::{Arc, LazyLock};
19use systemprompt_agent::services::ContextProviderService;
20use systemprompt_events::{
21    A2A_BROADCASTER, AGUI_BROADCASTER, Broadcaster, ConnectionGuard, GenericBroadcaster, ToSse,
22    standard_keep_alive,
23};
24use systemprompt_models::RequestContext;
25use systemprompt_runtime::AppContext;
26use tokio::sync::mpsc;
27use tokio_stream::wrappers::ReceiverStream;
28
29pub mod contexts;
30
31#[derive(Clone, Debug)]
32pub struct StreamState {
33    pub context_provider: Arc<ContextProviderService>,
34}
35
36pub fn stream_router(ctx: &AppContext) -> anyhow::Result<Router> {
37    let context_provider = ContextProviderService::new(ctx.db_pool())?;
38    let state = StreamState {
39        context_provider: Arc::new(context_provider),
40    };
41
42    Ok(Router::new()
43        .route("/contexts", get(contexts::stream_context_state))
44        .route("/agui", get(stream_agui_events))
45        .route("/a2a", get(stream_a2a_events))
46        .with_state(state))
47}
48
49pub async fn stream_a2a_events(
50    Extension(request_context): Extension<RequestContext>,
51) -> impl IntoResponse {
52    create_sse_stream(request_context, &A2A_BROADCASTER, "A2A").await
53}
54
55pub async fn stream_agui_events(
56    Extension(request_context): Extension<RequestContext>,
57) -> impl IntoResponse {
58    create_sse_stream(request_context, &AGUI_BROADCASTER, "AgUI").await
59}
60
61#[derive(Debug)]
62pub struct StreamWithGuard<E: ToSse + Clone + Send + Sync + 'static> {
63    stream: ReceiverStream<Result<axum::response::sse::Event, Infallible>>,
64    _cleanup_guard: ConnectionGuard<E>,
65}
66
67impl<E: ToSse + Clone + Send + Sync + 'static> StreamWithGuard<E> {
68    pub const fn new(
69        stream: ReceiverStream<Result<axum::response::sse::Event, Infallible>>,
70        cleanup_guard: ConnectionGuard<E>,
71    ) -> Self {
72        Self {
73            stream,
74            _cleanup_guard: cleanup_guard,
75        }
76    }
77}
78
79impl<E: ToSse + Clone + Send + Sync + 'static> futures_util::Stream for StreamWithGuard<E> {
80    type Item = Result<axum::response::sse::Event, Infallible>;
81
82    fn poll_next(
83        mut self: std::pin::Pin<&mut Self>,
84        cx: &mut std::task::Context<'_>,
85    ) -> std::task::Poll<Option<Self::Item>> {
86        std::pin::Pin::new(&mut self.stream).poll_next(cx)
87    }
88}
89
90pub async fn create_sse_stream<E: ToSse + Clone + Send + Sync + 'static>(
91    request_context: RequestContext,
92    broadcaster: &'static LazyLock<GenericBroadcaster<E>>,
93    stream_name: &str,
94) -> impl IntoResponse {
95    let user_id = request_context.user_id().clone();
96    let user_id_str = user_id.to_string();
97    let conn_id = systemprompt_identifiers::ConnectionId::generate();
98    let conn_id_str = conn_id.as_str().to_owned();
99
100    tracing::info!(user_id = %user_id_str, conn_id = %conn_id_str, stream = %stream_name, "SSE stream opened");
101
102    let (tx, rx) = mpsc::channel(1024);
103
104    if !broadcaster.register(&user_id, &conn_id, tx.clone()).await {
105        tracing::warn!(user_id = %user_id_str, stream = %stream_name, "SSE stream rejected: per-user connection cap reached");
106        return http::StatusCode::TOO_MANY_REQUESTS.into_response();
107    }
108
109    let cleanup_guard = ConnectionGuard::new(broadcaster, user_id, conn_id);
110    let stream = ReceiverStream::new(rx);
111    let stream_with_guard = StreamWithGuard::<E>::new(stream, cleanup_guard);
112
113    tracing::info!(user_id = %user_id_str, conn_id = %conn_id_str, stream = %stream_name, "SSE stream ready");
114
115    Sse::new(stream_with_guard)
116        .keep_alive(standard_keep_alive())
117        .into_response()
118}