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