Skip to main content

ag_ui/axum/
mod.rs

1//! Serve an [AG-UI] agent from an [axum] router.
2//!
3//! [`ag_ui::server`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/index.html) turns an [`Agent`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/agent/trait.Agent.html) into a
4//! stream of events and stops there, on purpose: it has no executor and no web
5//! framework, so it builds for wasm. This crate is the other half — the POST
6//! endpoint, the `text/event-stream` body, content negotiation, and telling the
7//! agent when the client hangs up. It is the only crate in the workspace that
8//! depends on tokio, axum or tower.
9//!
10//! Mounting an agent is one line, and the router is still an ordinary router:
11//!
12//! ```
13//! use ag_ui::axum::RouterExt;
14//! use ag_ui::{RunAgentInput, RunOutcome};
15//! use ag_ui::server::{Agent, Result, RunContext};
16//! use axum::Router;
17//! use axum::routing::get;
18//!
19//! struct Greeter;
20//!
21//! impl Agent for Greeter {
22//!     type State = ();
23//!
24//!     async fn run(&self, ctx: &mut RunContext<()>) -> Result<RunOutcome> {
25//!         ctx.say("Hello!")?;
26//!         Ok(RunOutcome::Success)
27//!     }
28//! }
29//!
30//! let app: Router = Router::new()
31//!     .route("/health", get(|| async { "ok" }))
32//!     .route_agui("/agent", Greeter);
33//!
34//! # let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
35//! # rt.block_on(async {
36//! # use axum::body::Body;
37//! # use axum::http::Request;
38//! # use tower::ServiceExt as _;
39//! let request = Request::post("/agent")
40//!     .header("content-type", "application/json")
41//!     .body(Body::from(
42//!         serde_json::to_vec(&RunAgentInput::new("thread-1", "run-1")).unwrap(),
43//!     ))
44//!     .unwrap();
45//!
46//! let response = app.oneshot(request).await.unwrap();
47//! assert_eq!(response.headers()["content-type"], "text/event-stream");
48//!
49//! let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
50//! let body = String::from_utf8(body.to_vec()).unwrap();
51//! assert!(body.starts_with(r#"data: {"type":"RUN_STARTED""#), "{body}");
52//! assert!(body.contains(r#""type":"TEXT_MESSAGE_CONTENT","messageId":"run-1-msg-1","delta":"Hello!""#));
53//! assert!(body.trim_end().ends_with(r#""type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1","outcome":{"type":"success"}}"#));
54//! # });
55//! ```
56//!
57//! # What the endpoint answers with
58//!
59//! | Situation | Answer |
60//! |---|---|
61//! | A run, however it ends | `200`, `text/event-stream`, terminated by `RUN_FINISHED` or `RUN_ERROR` |
62//! | Body is not AG-UI JSON | `400` and a JSON message naming the field |
63//! | `Content-Type` is not JSON | `415` |
64//! | Body over the body limit | `413` |
65//! | `Accept` excludes everything this build emits | `406` |
66//! | Any method but `POST` | `405`, from axum |
67//!
68//! A run that *fails* is still a `200`: by the time an agent can fail the
69//! status line is long sent, so the failure is a `RUN_ERROR` event in a
70//! well-formed stream rather than a connection that drops. This is what lets a
71//! client tell "the agent errored" from "the network died".
72//!
73//! The one case with no good answer is a *panicking* agent. It unwinds through
74//! hyper's connection task and the client sees a truncated stream, because the
75//! `200` has already been sent and there is no status left to change. Return
76//! [`Err`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/error/enum.Error.html#method.agent) instead; reach for
77//! `tower_http::catch_panic` only for the panics you did not plan.
78//!
79//! # Cancellation on disconnect
80//!
81//! The response body owns the run — polling the stream is what runs the agent —
82//! so when the client goes away, hyper drops the body and the run goes with it.
83//! That much is automatic. What is not automatic is telling everything the run
84//! reached *outside* itself: a spawned tool call, an in-flight model request.
85//! So the body also holds a guard that trips the run's
86//! [`CancellationToken`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/cancel/struct.CancellationToken.html) on drop, and disarms
87//! itself if the run got to finish. An agent sees it through
88//! [`RunContext::is_cancelled`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/context/struct.RunContext.html#method.is_cancelled),
89//! [`until_cancelled`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/context/struct.RunContext.html#method.until_cancelled), or simply by
90//! using `?` on its emits — every emit after cancellation fails.
91//!
92//! # Why there is no `AgUiLayer`
93//!
94//! A tower layer wraps a `Service`, so it sees a `Request` and a `Response` —
95//! at that point the events have already been serialized into an SSE body.
96//! Applying a [`StreamTransformer`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/transform/trait.StreamTransformer.html) there
97//! would mean parsing the frames back into events, transforming, and
98//! re-encoding: slower, lossy at the edges, and it would silently mangle the
99//! body of any *other* route the layer happened to cover.
100//! [`AgentEndpoint::transformer`] applies transformers where the events are
101//! still typed, one chain per run. Layers you actually want — CORS, auth,
102//! timeouts, tracing, compression — are the ones tower already ships, and they
103//! compose with this endpoint like any other route.
104//!
105//! [AG-UI]: https://docs.ag-ui.com
106//! [axum]: https://docs.rs/axum
107
108pub mod error;
109pub mod extract;
110pub mod respond;
111pub mod router;
112
113pub use error::{Error, Result};
114pub use extract::AgUiInput;
115pub use respond::{SseResponse, negotiate};
116pub use router::{AgentEndpoint, RouterExt};