1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
54
55use everruns_durable::sqlite as rusqlite;
56use std::ffi::OsString;
57use std::net::SocketAddr;
58use std::path::{Path, PathBuf};
59use std::sync::Arc;
60
61use anyhow::anyhow;
62use axum::body::{Body, Bytes};
63use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
64use axum::extract::{Request, State};
65use axum::http::{HeaderMap, StatusCode, header};
66use axum::response::{IntoResponse, Response};
67use axum::routing::{get, post};
68use axum::{Json, Router};
69use clap::Parser;
70use serde_json::{Value, json};
71use serve::{App, Mode, Server};
72use tokio::sync::OnceCell;
73use tower::ServiceExt;
74
75pub const SESSION_HEADER: &str = "x-amzn-bedrock-agentcore-runtime-session-id";
78
79pub const PORT: u16 = 8080;
81
82pub const SESSION_STORAGE: &str = "/mnt/workspace";
85
86#[derive(Parser, Debug)]
88#[command(
89 about = "Serve this app on the Amazon Bedrock AgentCore Runtime contract (experimental)."
90)]
91struct Cli {
92 #[arg(long, env = "PORT", default_value_t = PORT)]
94 port: u16,
95 #[arg(long, env = "SERVE_AGENTCORE_AGENT")]
97 agent: Option<String>,
98 #[arg(long)]
101 dev: bool,
102}
103
104#[derive(Clone, Debug)]
106pub struct Options {
107 pub mode: Mode,
109 pub agent: Option<String>,
111 pub data_dir: Option<PathBuf>,
114 pub workspace: Option<PathBuf>,
117}
118
119impl Options {
120 pub fn new(mode: Mode) -> Self {
122 Self {
123 mode,
124 agent: None,
125 data_dir: None,
126 workspace: None,
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct Storage {
134 pub data_dir: PathBuf,
136 pub workspace: PathBuf,
139 pub persistent: bool,
141}
142
143impl Storage {
144 pub fn resolve(options: &Options) -> serve::Result<Self> {
156 let workspace_env = std::env::var_os("SERVE_WORKSPACE").map(PathBuf::from);
157 let explicit = options.data_dir.clone().map(Ok).or_else(|| {
158 (env_set("SERVE_DATA_DIR") || env_set("DATABASE_URL")).then(serve::data_dir)
159 });
160 if let Some(data_dir) = explicit {
161 let data_dir = data_dir?;
162 let workspace = options
163 .workspace
164 .clone()
165 .or(workspace_env)
166 .unwrap_or_else(|| data_dir.join("workspace"));
167 return Ok(Self {
168 data_dir,
169 workspace,
170 persistent: true,
171 });
172 }
173 let mount = Path::new(SESSION_STORAGE);
174 if mount.is_dir() {
175 return Ok(Self {
176 data_dir: mount.join(".serve"),
177 workspace: options
178 .workspace
179 .clone()
180 .or(workspace_env)
181 .unwrap_or_else(|| mount.to_path_buf()),
182 persistent: true,
183 });
184 }
185 let scratch = std::env::temp_dir().join("serve-agentcore");
186 Ok(Self {
187 data_dir: scratch.join(".serve"),
188 workspace: options
189 .workspace
190 .clone()
191 .or(workspace_env)
192 .unwrap_or_else(|| scratch.join("workspace")),
193 persistent: false,
194 })
195 }
196}
197
198fn env_set(name: &str) -> bool {
199 std::env::var_os(name).is_some_and(|value| !value.is_empty())
200}
201
202pub async fn start(app: App) -> serve::Result {
205 let mut args: Vec<OsString> = std::env::args_os().collect();
206 match args.get(1).and_then(|arg| arg.to_str()) {
207 None => {}
208 Some("agentcore") => {
209 args.remove(1);
210 }
211 Some(_) => return serve::start(app).await,
212 }
213 let cli = Cli::parse_from(args);
214 let mut options = Options::new(if cli.dev { Mode::Dev } else { Mode::Start });
215 options.agent = cli.agent;
216 let name = app.name().to_string();
217 sqlite_for_session_storage();
218 let (router, agent) = router(app, options.clone())?;
219
220 let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
221 let listener = tokio::net::TcpListener::bind(addr).await?;
222 println!(
223 "serve-agentcore · {name} · agent {agent} · {:?} · listening on {addr}",
224 options.mode
225 );
226 axum::serve(listener, router)
227 .with_graceful_shutdown(async {
228 let _ = tokio::signal::ctrl_c().await;
229 })
230 .await?;
231 Ok(())
232}
233
234fn sqlite_for_session_storage() {
245 static ONCE: std::sync::Once = std::sync::Once::new();
246 ONCE.call_once(|| {
247 DOTFILE_LOCKS.store(true, std::sync::atomic::Ordering::Relaxed);
248 rusqlite::use_dotfile_locks();
249 });
250}
251
252static DOTFILE_LOCKS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
255
256fn clear_stale_locks(dir: &Path) {
260 let Ok(entries) = std::fs::read_dir(dir) else {
261 return;
262 };
263 for entry in entries.flatten() {
264 let path = entry.path();
265 let stale = path
266 .file_name()
267 .and_then(|name| name.to_str())
268 .is_some_and(|name| name.ends_with(".db.lock"));
269 if stale {
270 eprintln!("serve-agentcore: removing stale lock {}", path.display());
271 let _ = std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path));
272 } else if path.is_dir() && entry.file_name() == "everruns" {
273 clear_stale_locks(&path);
274 }
275 }
276}
277
278pub fn router(app: App, options: Options) -> serve::Result<(Router, String)> {
285 let probe = boot(app.clone(), &options, None)?;
287 let agent = agent(&probe, options.agent.clone())?;
288 drop(probe);
289 let target = Target {
290 app,
291 options: Arc::new(options),
292 agent: agent.clone(),
293 booted: Arc::new(OnceCell::new()),
294 };
295 let router = Router::new()
296 .route("/ping", get(ping))
297 .route("/invocations", post(invocations))
298 .route("/ws", get(ws))
299 .fallback(forward)
300 .with_state(target);
301 Ok((router, agent))
302}
303
304fn boot(app: App, options: &Options, storage: Option<&Storage>) -> serve::Result<Server> {
306 let workspace = storage
307 .map(|storage| storage.workspace.clone())
308 .or_else(|| options.workspace.clone())
309 .unwrap_or_else(|| PathBuf::from(SESSION_STORAGE));
310 let mut builder =
311 Server::builder(app, options.mode).microvm(move |agent| microvm(agent, &workspace));
312 if let Some(storage) = storage {
313 builder = builder.data_dir(storage.data_dir.clone());
314 }
315 builder.build()
316}
317
318fn microvm(agent: everruns::AgentBuilder, workspace: &Path) -> everruns::AgentBuilder {
327 agent
328 .workspace(workspace)
329 .workspace_policy(everruns::WorkspacePolicy::read_write())
330 .capability(everruns::HostShell::new().containment(everruns::ContainmentMode::FullAccess))
331}
332
333fn agent(server: &Server, requested: Option<String>) -> serve::Result<String> {
336 match requested {
337 Some(name) => {
338 let known = server
339 .app()
340 .manifest()
341 .agents
342 .iter()
343 .any(|agent| agent.name == name && !agent.sub);
344 if known {
345 Ok(name)
346 } else {
347 Err(anyhow!(
348 "SERVE_AGENTCORE_AGENT `{name}` is not a top-level agent of this app"
349 ))
350 }
351 }
352 None => server.default_agent().ok_or_else(|| {
353 anyhow!(
354 "this app has several agents and none is the default; set SERVE_AGENTCORE_AGENT"
355 )
356 }),
357 }
358}
359
360#[derive(Clone)]
361struct Target {
362 app: App,
363 options: Arc<Options>,
364 agent: String,
365 booted: Arc<OnceCell<Booted>>,
366}
367
368struct Booted {
369 server: Server,
370 wire: Router,
371}
372
373impl Target {
374 async fn booted(&self) -> Result<&Booted, Response> {
376 self.booted
377 .get_or_try_init(|| async {
378 let storage = Storage::resolve(&self.options)?;
379 if !storage.persistent {
380 eprintln!(
381 "serve-agentcore: no session storage at {SESSION_STORAGE} and no SERVE_DATA_DIR; \
382 sessions live in {} and are lost when the microVM stops",
383 storage.data_dir.display()
384 );
385 }
386 std::fs::create_dir_all(&storage.data_dir)?;
387 if DOTFILE_LOCKS.load(std::sync::atomic::Ordering::Relaxed) {
388 clear_stale_locks(&storage.data_dir);
389 }
390 let server = boot(self.app.clone(), &self.options, Some(&storage))?;
391 server.spawn_schedules();
392 let wire = server.router();
393 Ok::<_, serve::Error>(Booted { server, wire })
394 })
395 .await
396 .map_err(|err| {
397 eprintln!("serve-agentcore: boot failed: {err:#}");
400 problem(StatusCode::INTERNAL_SERVER_ERROR, format!("{err:#}"))
401 })
402 }
403}
404
405async fn ping(State(target): State<Target>) -> Json<Value> {
409 let busy = target
410 .booted
411 .get()
412 .is_some_and(|booted| booted.server.busy());
413 Json(json!({ "status": if busy { "HealthyBusy" } else { "Healthy" } }))
414}
415
416async fn invocations(State(target): State<Target>, headers: HeaderMap, body: Bytes) -> Response {
418 let session = headers
419 .get(SESSION_HEADER)
420 .and_then(|value| value.to_str().ok())
421 .filter(|value| !value.is_empty());
422 let input = match run_agent_input(&body, session) {
423 Ok(input) => input,
424 Err(why) => return problem(StatusCode::BAD_REQUEST, why),
425 };
426 match target.booted().await {
427 Ok(booted) => logged(booted.server.ag_ui(&target.agent, &input).await),
428 Err(response) => response,
429 }
430}
431
432fn logged(response: Response) -> Response {
434 if !response.status().is_success() {
435 eprintln!("serve-agentcore: invocation answered {}", response.status());
436 }
437 response
438}
439
440async fn ws(
448 State(target): State<Target>,
449 headers: HeaderMap,
450 upgrade: WebSocketUpgrade,
451) -> Response {
452 let session = headers
453 .get(SESSION_HEADER)
454 .and_then(|value| value.to_str().ok())
455 .filter(|value| !value.is_empty())
456 .map(str::to_string);
457 upgrade.on_upgrade(move |socket| converse(target, session, socket))
458}
459
460async fn converse(target: Target, session: Option<String>, mut socket: WebSocket) {
461 while let Some(Ok(message)) = socket.recv().await {
462 let body = match message {
463 Message::Text(text) => Bytes::from(text.as_str().to_owned()),
464 Message::Binary(bytes) => bytes,
465 Message::Close(_) => break,
466 Message::Ping(_) | Message::Pong(_) => continue,
467 };
468 let response = match run_agent_input(&body, session.as_deref()) {
469 Ok(input) => match target.booted().await {
470 Ok(booted) => logged(booted.server.ag_ui(&target.agent, &input).await),
471 Err(response) => response,
472 },
473 Err(why) => problem(StatusCode::BAD_REQUEST, why),
474 };
475 if relay(response, &mut socket).await.is_err() {
476 break;
477 }
478 }
479}
480
481async fn relay(response: Response, socket: &mut WebSocket) -> Result<(), axum::Error> {
484 use futures_util::StreamExt;
485
486 if !response.status().is_success() {
487 let status = response.status();
488 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
489 .await
490 .unwrap_or_default();
491 let detail = serde_json::from_slice::<Value>(&body)
492 .ok()
493 .and_then(|problem| problem["detail"].as_str().map(str::to_string))
494 .unwrap_or_else(|| status.to_string());
495 let error =
496 json!({ "type": "RUN_ERROR", "message": detail, "code": status.as_u16().to_string() });
497 return socket.send(Message::Text(error.to_string().into())).await;
498 }
499 let mut stream = response.into_body().into_data_stream();
500 let mut pending = String::new();
501 while let Some(chunk) = stream.next().await {
502 let Ok(chunk) = chunk else { break };
503 pending.push_str(&String::from_utf8_lossy(&chunk));
504 while let Some(end) = pending.find("\n\n") {
505 let frame: String = pending.drain(..end + 2).collect();
506 if let Some(data) = sse_data(&frame) {
507 socket.send(Message::Text(data.into())).await?;
508 }
509 }
510 }
511 if let Some(data) = sse_data(&pending) {
512 socket.send(Message::Text(data.into())).await?;
513 }
514 Ok(())
515}
516
517fn sse_data(frame: &str) -> Option<String> {
519 let lines: Vec<&str> = frame
520 .lines()
521 .filter_map(|line| line.strip_prefix("data:"))
522 .map(|data| data.strip_prefix(' ').unwrap_or(data))
523 .collect();
524 (!lines.is_empty()).then(|| lines.join("\n"))
525}
526
527async fn forward(State(target): State<Target>, request: Request<Body>) -> Response {
529 match target.booted().await {
530 Ok(booted) => match booted.wire.clone().oneshot(request).await {
531 Ok(response) => response,
532 Err(never) => match never {},
533 },
534 Err(response) => response,
535 }
536}
537
538fn run_agent_input(body: &[u8], session: Option<&str>) -> Result<Vec<u8>, String> {
545 let mut input: Value =
546 serde_json::from_slice(body).map_err(|err| format!("invalid JSON body: {err}"))?;
547 let Some(fields) = input.as_object_mut() else {
548 return Err("the body must be a JSON object".into());
549 };
550 if !fields.contains_key("messages") {
551 let Some(prompt) = fields.remove("prompt") else {
552 return Err(
553 "expected an AG-UI RunAgentInput (with `messages`) or `{\"prompt\": \"...\"}`"
554 .into(),
555 );
556 };
557 let Some(prompt) = prompt.as_str().map(str::to_string) else {
558 return Err("`prompt` must be a string".into());
559 };
560 fields.insert(
561 "messages".into(),
562 json!([{ "id": new_id(), "role": "user", "content": prompt }]),
563 );
564 for key in ["tools", "context"] {
565 fields.entry(key).or_insert_with(|| json!([]));
566 }
567 for key in ["state", "forwardedProps"] {
568 fields.entry(key).or_insert_with(|| json!({}));
569 }
570 }
571 let has_thread = fields
572 .get("threadId")
573 .and_then(Value::as_str)
574 .is_some_and(|thread| !thread.trim().is_empty());
575 if !has_thread {
576 let Some(session) = session else {
577 return Err(format!(
578 "no `threadId` in the body and no `{SESSION_HEADER}` header"
579 ));
580 };
581 fields.insert("threadId".into(), json!(session));
582 }
583 fields.entry("runId").or_insert_with(|| json!(new_id()));
584 serde_json::to_vec(&input).map_err(|err| err.to_string())
585}
586
587fn new_id() -> String {
588 uuid::Uuid::now_v7().to_string()
589}
590
591fn problem(status: StatusCode, detail: String) -> Response {
593 let body = json!({
594 "title": status.canonical_reason().unwrap_or("Error"),
595 "status": status.as_u16(),
596 "detail": detail,
597 });
598 (
599 status,
600 [(header::CONTENT_TYPE, "application/problem+json")],
601 body.to_string(),
602 )
603 .into_response()
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn parse(bytes: Vec<u8>) -> Value {
611 serde_json::from_slice(&bytes).unwrap()
612 }
613
614 #[test]
615 fn prompt_becomes_a_one_message_run_on_the_agentcore_session() {
616 let input = parse(run_agent_input(br#"{"prompt":"hi"}"#, Some("s-1")).unwrap());
617 assert_eq!(input["threadId"], "s-1");
618 assert_eq!(input["messages"][0]["role"], "user");
619 assert_eq!(input["messages"][0]["content"], "hi");
620 assert!(input["runId"].as_str().is_some_and(|id| !id.is_empty()));
621 assert_eq!(input["tools"], json!([]));
622 assert_eq!(input["forwardedProps"], json!({}));
623 assert!(input.get("prompt").is_none());
624 }
625
626 #[test]
627 fn run_agent_input_passes_through_and_keeps_its_thread() {
628 let body = json!({
629 "threadId": "t-1", "runId": "r-1",
630 "messages": [{ "id": "m", "role": "user", "content": "x" }],
631 "tools": [], "context": [], "state": {}, "forwardedProps": {}
632 });
633 let input = parse(run_agent_input(body.to_string().as_bytes(), Some("s-1")).unwrap());
634 assert_eq!(input, body);
635 }
636
637 #[test]
638 fn missing_thread_falls_back_to_the_session_header() {
639 let body = br#"{"messages":[{"id":"m","role":"user","content":"x"}],"threadId":" "}"#;
640 let input = parse(run_agent_input(body, Some("s-2")).unwrap());
641 assert_eq!(input["threadId"], "s-2");
642 }
643
644 #[test]
645 fn rejects_bodies_it_cannot_run() {
646 let no_thread = run_agent_input(br#"{"prompt":"hi"}"#, None).unwrap_err();
647 assert!(no_thread.contains(SESSION_HEADER), "{no_thread}");
648 assert!(run_agent_input(b"not json", Some("s")).is_err());
649 assert!(run_agent_input(b"[]", Some("s")).is_err());
650 assert!(run_agent_input(br#"{"prompt":7}"#, Some("s")).is_err());
651 assert!(run_agent_input(br#"{"input":"hi"}"#, Some("s")).is_err());
652 }
653}