Skip to main content

ag_ui/axum/
respond.rs

1//! Turning a run's event stream into an HTTP response.
2//!
3//! Two things happen here that are easy to get wrong.
4//!
5//! **Negotiation is a decision, not a fallback.** [`negotiate`] asks
6//! [`crate::encode::media_type`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/encode/fn.media_type.html) what to answer with and refuses the
7//! request when the answer is "nothing" — a client that asked for
8//! `application/xml` gets a `406`, not an SSE stream it cannot read.
9//!
10//! **The body owns the run.** Polling the stream *is* running the agent
11//! ([`crate::server::run()`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/run/fn.run.html) has no executor of its own), so the response body and
12//! the run have exactly the same lifetime. That is what makes disconnect
13//! handling work: when the client goes away hyper drops the body, and the body
14//! drops a guard that trips the run's [`CancellationToken`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/cancel/struct.CancellationToken.html). See
15//! [`SseResponse::cancellation`].
16
17use std::convert::Infallible;
18use std::pin::Pin;
19use std::task::{Context, Poll, ready};
20use std::time::Duration;
21
22use crate::encode::sse;
23use crate::server::CancellationToken;
24use crate::{Event, EventStreamFormatter, RunErrorEvent, SSE_MEDIA_TYPE, SseFormatter, media_type};
25use axum::body::{Body, Bytes};
26use axum::http::{HeaderValue, header};
27use axum::response::Response;
28use futures_util::stream::Stream;
29use tokio::time::{Instant, Sleep, sleep_until};
30
31use crate::axum::error::{Error, Result};
32
33/// SSE keep-alive payload: a comment line, which every conforming client
34/// ignores.
35const KEEP_ALIVE_FRAME: &[u8] = b":\n\n";
36
37/// Picks the response encoding for an `Accept` header.
38///
39/// A missing or empty header means `*/*` and yields SSE. Anything that excludes
40/// every media type this build can emit is [`Error::NotAcceptable`], which
41/// renders as a `406`.
42///
43/// ```
44/// # use ag_ui::axum::respond::negotiate;
45/// assert!(negotiate(None).is_ok());
46/// assert!(negotiate(Some("text/event-stream")).is_ok());
47/// assert!(negotiate(Some("text/*;q=0.4, application/json")).is_ok());
48/// assert!(negotiate(Some("application/xml")).is_err());
49/// assert!(negotiate(Some("*/*;q=0")).is_err());
50/// ```
51pub fn negotiate(accept: Option<&str>) -> Result<SseFormatter> {
52    let refuse = || Error::NotAcceptable {
53        accept: accept.unwrap_or("*/*").to_owned(),
54    };
55    match media_type(accept).map_err(|_| refuse())? {
56        SSE_MEDIA_TYPE => Ok(SseFormatter::new()),
57        // Only reachable if this crate ever enables a core encoding it has not
58        // taught this function to build. Refusing beats answering with a
59        // content type whose body would be SSE.
60        _ => Err(refuse()),
61    }
62}
63
64/// A negotiated event-stream response, waiting for the stream to put in it.
65///
66/// The full manual wiring, for a handler that does its own work before starting
67/// the run — [`route_agui`](crate::axum::RouterExt::route_agui) is this, with the
68/// defaults filled in:
69///
70/// ```
71/// use ag_ui::axum::SseResponse;
72/// use ag_ui::{RunAgentInput, RunOutcome};
73/// use ag_ui::server::{Agent, Result, RunContext, Runner};
74///
75/// # struct Greeter;
76/// # impl Agent for Greeter {
77/// #     type State = ();
78/// #     async fn run(&self, ctx: &mut RunContext<()>) -> Result<RunOutcome> {
79/// #         ctx.say("hi")?;
80/// #         Ok(RunOutcome::Success)
81/// #     }
82/// # }
83/// # fn serve(accept: Option<&str>, input: RunAgentInput) -> axum::response::Result<axum::response::Response> {
84/// let response = SseResponse::negotiate(accept)?;
85///
86/// let runner = Runner::new(Greeter);
87/// // Take the token *before* `run` consumes the runner.
88/// let response = response.cancellation(runner.cancellation_token());
89///
90/// Ok(response.stream(runner.run(input)))
91/// # }
92/// ```
93#[derive(Clone, Debug)]
94#[must_use = "an SseResponse does nothing until a stream is attached"]
95pub struct SseResponse {
96    formatter: SseFormatter,
97    cancellation: Option<CancellationToken>,
98    keep_alive: Option<Duration>,
99}
100
101impl SseResponse {
102    /// Negotiates the encoding for an `Accept` header — see [`negotiate`].
103    pub fn negotiate(accept: Option<&str>) -> Result<Self> {
104        Ok(Self {
105            formatter: self::negotiate(accept)?,
106            cancellation: None,
107            keep_alive: None,
108        })
109    }
110
111    /// Trips `token` when the client disconnects.
112    ///
113    /// The token to pass is the one the run was built with —
114    /// [`Runner::cancellation_token`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/run/struct.Runner.html#method.cancellation_token).
115    ///
116    /// # How the disconnect is noticed
117    ///
118    /// There is no callback to register: hyper *drops* the response body when
119    /// the connection breaks, so a [`Drop`] impl on the body is the signal. The
120    /// guard disarms itself when the stream ends normally, so a completed run
121    /// is never reported as cancelled.
122    ///
123    /// Dropping the body would already stop the agent — the future lives inside
124    /// the stream. The token is what reaches everything *outside* it: a
125    /// spawned tool call, an in-flight model request, a lock the run holds.
126    pub fn cancellation(mut self, token: CancellationToken) -> Self {
127        self.cancellation = Some(token);
128        self
129    }
130
131    /// Sends an SSE comment whenever the agent has produced nothing for
132    /// `interval`.
133    ///
134    /// Off by default. Turn it on when something between the agent and the
135    /// browser closes idle connections — most reverse proxies do, at 30 to 60
136    /// seconds, which is well inside the time a slow first token can take.
137    pub fn keep_alive(mut self, interval: Duration) -> Self {
138        self.keep_alive = Some(interval);
139        self
140    }
141
142    /// Attaches the run's events and builds the response.
143    pub fn stream<S>(self, events: S) -> Response
144    where
145        S: Stream<Item = crate::server::Result<Event>> + Send + 'static,
146    {
147        let body = EventBody {
148            // Cancel first, then drop the run: an agent whose own `Drop` looks
149            // at the token sees the truth.
150            guard: DisconnectGuard {
151                token: self.cancellation,
152                armed: true,
153            },
154            events: Box::pin(events),
155            formatter: self.formatter,
156            keep_alive: self.keep_alive.map(KeepAlive::new),
157            done: false,
158        };
159
160        let mut response = Response::new(Body::from_stream(body));
161        let headers = response.headers_mut();
162        headers.insert(
163            header::CONTENT_TYPE,
164            HeaderValue::from_static(SSE_MEDIA_TYPE),
165        );
166        // `no-transform` is the half that matters: a proxy that gzips this
167        // stream will also buffer it, and the point of the stream is that it
168        // arrives a token at a time.
169        headers.insert(
170            header::CACHE_CONTROL,
171            HeaderValue::from_static("no-cache, no-store, no-transform"),
172        );
173        // nginx honours neither of the above for proxied responses; this is its
174        // opt-out, and it is inert everywhere else.
175        headers.insert(
176            header::HeaderName::from_static("x-accel-buffering"),
177            HeaderValue::from_static("no"),
178        );
179        // The body was chosen by `Accept`, so caches must key on it.
180        headers.insert(header::VARY, HeaderValue::from_static("accept"));
181        response
182    }
183}
184
185/// The response body: SSE frames, and the run that produces them.
186struct EventBody {
187    /// Declared first so it drops first — see [`SseResponse::stream`].
188    guard: DisconnectGuard,
189    events: Pin<Box<dyn Stream<Item = crate::server::Result<Event>> + Send>>,
190    formatter: SseFormatter,
191    keep_alive: Option<KeepAlive>,
192    done: bool,
193}
194
195impl EventBody {
196    /// Encodes one event, or — if that somehow fails — an in-band report of
197    /// why.
198    ///
199    /// Serializing an [`Event`] cannot fail today: every payload is derived
200    /// `Serialize` over owned data. If a future one can, a client that receives
201    /// a `RUN_ERROR` is in far better shape than one whose stream simply
202    /// stopped.
203    fn encode(&self, event: &Event) -> Bytes {
204        match self.formatter.encode(event) {
205            Ok(bytes) => Bytes::from(bytes),
206            Err(error) => Bytes::from(sse::frame(
207                &serde_json::json!({
208                    "type": "RUN_ERROR",
209                    "message": error.to_string(),
210                    "code": "SERIALIZATION",
211                })
212                .to_string(),
213            )),
214        }
215    }
216
217    /// Marks the run finished: no further polling, and no cancellation on drop.
218    fn finish(&mut self) {
219        self.done = true;
220        self.guard.disarm();
221    }
222
223    fn reset_keep_alive(&mut self) {
224        if let Some(keep_alive) = self.keep_alive.as_mut() {
225            keep_alive.reset();
226        }
227    }
228
229    /// What to return when the agent has nothing yet.
230    fn poll_idle(&mut self, cx: &mut Context<'_>) -> Poll<Option<Result<Bytes, Infallible>>> {
231        let Some(keep_alive) = self.keep_alive.as_mut() else {
232            return Poll::Pending;
233        };
234        ready!(keep_alive.poll_tick(cx));
235        Poll::Ready(Some(Ok(Bytes::from_static(KEEP_ALIVE_FRAME))))
236    }
237}
238
239impl Stream for EventBody {
240    type Item = Result<Bytes, Infallible>;
241
242    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
243        let this = self.get_mut();
244        if this.done {
245            return Poll::Ready(None);
246        }
247
248        match this.events.as_mut().poll_next(cx) {
249            Poll::Ready(Some(Ok(event))) => {
250                this.reset_keep_alive();
251                Poll::Ready(Some(Ok(this.encode(&event))))
252            }
253            // The run driver reports agent failures as `RUN_ERROR` events
254            // itself, so this is the event *channel* failing. Say so in the
255            // stream and end it there — the alternative is a body that stops
256            // with no terminal event, which reads as a network fault.
257            Poll::Ready(Some(Err(error))) => {
258                this.finish();
259                let event =
260                    Event::from(RunErrorEvent::new(error.to_string()).with_code(error.code()));
261                Poll::Ready(Some(Ok(this.encode(&event))))
262            }
263            Poll::Ready(None) => {
264                this.finish();
265                Poll::Ready(None)
266            }
267            Poll::Pending => this.poll_idle(cx),
268        }
269    }
270}
271
272/// Trips a run's cancellation token unless the run got to finish.
273struct DisconnectGuard {
274    token: Option<CancellationToken>,
275    armed: bool,
276}
277
278impl DisconnectGuard {
279    fn disarm(&mut self) {
280        self.armed = false;
281    }
282}
283
284impl Drop for DisconnectGuard {
285    fn drop(&mut self) {
286        if !self.armed {
287            return;
288        }
289        if let Some(token) = &self.token {
290            token.cancel();
291        }
292    }
293}
294
295/// The idle timer behind [`SseResponse::keep_alive`].
296///
297/// The timer is created on the first idle poll rather than with the response:
298/// a `Sleep` has to be built inside a tokio runtime, and starting it when the
299/// agent first goes quiet is also the deadline that was wanted.
300struct KeepAlive {
301    interval: Duration,
302    sleep: Option<Pin<Box<Sleep>>>,
303}
304
305impl KeepAlive {
306    fn new(interval: Duration) -> Self {
307        Self {
308            interval,
309            sleep: None,
310        }
311    }
312
313    /// Resolves once a whole `interval` has passed with no event, then rearms.
314    fn poll_tick(&mut self, cx: &mut Context<'_>) -> Poll<()> {
315        let interval = self.interval;
316        let sleep = self
317            .sleep
318            .get_or_insert_with(|| Box::pin(sleep_until(Instant::now() + interval)));
319        ready!(sleep.as_mut().poll(cx));
320        self.reset();
321        Poll::Ready(())
322    }
323
324    /// Pushes the deadline back. A no-op before the first idle poll, when there
325    /// is no deadline yet.
326    fn reset(&mut self) {
327        let deadline = Instant::now() + self.interval;
328        if let Some(sleep) = self.sleep.as_mut() {
329            sleep.as_mut().reset(deadline);
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use axum::http::StatusCode;
338    use axum::response::IntoResponse;
339
340    #[test]
341    fn a_refused_accept_is_a_406() {
342        let error = negotiate(Some("application/xml")).expect_err("should refuse");
343        assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
344        assert_eq!(
345            error.into_response().status(),
346            StatusCode::NOT_ACCEPTABLE,
347            "the rendered response should carry the status too"
348        );
349    }
350
351    #[test]
352    fn a_quality_of_zero_is_a_refusal() {
353        assert!(negotiate(Some("text/event-stream;q=0")).is_err());
354    }
355
356    #[test]
357    fn an_empty_accept_header_means_anything() {
358        assert!(negotiate(Some("")).is_ok());
359        assert!(negotiate(Some("   ")).is_ok());
360    }
361
362    #[test]
363    fn the_message_names_what_the_endpoint_can_emit() {
364        let error = negotiate(Some("application/xml")).expect_err("should refuse");
365        assert!(error.to_string().contains(SSE_MEDIA_TYPE), "{error}");
366    }
367}