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}