#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use everruns::sqlite as rusqlite;
use std::ffi::OsString;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::anyhow;
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use clap::Parser;
use serde_json::{Value, json};
use serve::{App, Mode, Server};
use tokio::sync::OnceCell;
use tower::ServiceExt;
pub const SESSION_HEADER: &str = "x-amzn-bedrock-agentcore-runtime-session-id";
pub const PORT: u16 = 8080;
pub const SESSION_STORAGE: &str = "/mnt/workspace";
#[derive(Parser, Debug)]
#[command(
about = "Serve this app on the Amazon Bedrock AgentCore Runtime contract (experimental)."
)]
struct Cli {
#[arg(long, env = "PORT", default_value_t = PORT)]
port: u16,
#[arg(long, env = "SERVE_AGENTCORE_AGENT")]
agent: Option<String>,
#[arg(long)]
dev: bool,
}
#[derive(Clone, Debug)]
pub struct Options {
pub mode: Mode,
pub agent: Option<String>,
pub data_dir: Option<PathBuf>,
pub workspace: Option<PathBuf>,
}
impl Options {
pub fn new(mode: Mode) -> Self {
Self {
mode,
agent: None,
data_dir: None,
workspace: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Storage {
pub data_dir: PathBuf,
pub workspace: PathBuf,
pub persistent: bool,
}
impl Storage {
pub fn resolve(options: &Options) -> serve::Result<Self> {
let workspace_env = std::env::var_os("SERVE_WORKSPACE").map(PathBuf::from);
let explicit = options.data_dir.clone().map(Ok).or_else(|| {
(env_set("SERVE_DATA_DIR") || env_set("DATABASE_URL")).then(serve::data_dir)
});
if let Some(data_dir) = explicit {
let data_dir = data_dir?;
let workspace = options
.workspace
.clone()
.or(workspace_env)
.unwrap_or_else(|| data_dir.join("workspace"));
return Ok(Self {
data_dir,
workspace,
persistent: true,
});
}
let mount = Path::new(SESSION_STORAGE);
if mount.is_dir() {
return Ok(Self {
data_dir: mount.join(".serve"),
workspace: options
.workspace
.clone()
.or(workspace_env)
.unwrap_or_else(|| mount.to_path_buf()),
persistent: true,
});
}
let scratch = std::env::temp_dir().join("serve-agentcore");
Ok(Self {
data_dir: scratch.join(".serve"),
workspace: options
.workspace
.clone()
.or(workspace_env)
.unwrap_or_else(|| scratch.join("workspace")),
persistent: false,
})
}
}
fn env_set(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| !value.is_empty())
}
pub async fn start(app: App) -> serve::Result {
let mut args: Vec<OsString> = std::env::args_os().collect();
match args.get(1).and_then(|arg| arg.to_str()) {
None => {}
Some("agentcore") => {
args.remove(1);
}
Some(_) => return serve::start(app).await,
}
let cli = Cli::parse_from(args);
let mut options = Options::new(if cli.dev { Mode::Dev } else { Mode::Start });
options.agent = cli.agent;
let name = app.name().to_string();
sqlite_for_session_storage();
let (router, agent) = router(app, options.clone())?;
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
let listener = tokio::net::TcpListener::bind(addr).await?;
println!(
"serve-agentcore · {name} · agent {agent} · {:?} · listening on {addr}",
options.mode
);
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
Ok(())
}
fn sqlite_for_session_storage() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
DOTFILE_LOCKS.store(true, std::sync::atomic::Ordering::Relaxed);
rusqlite::use_dotfile_locks();
});
}
static DOTFILE_LOCKS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn clear_stale_locks(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let stale = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".db.lock"));
if stale {
eprintln!("serve-agentcore: removing stale lock {}", path.display());
let _ = std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path));
} else if path.is_dir() && entry.file_name() == "everruns" {
clear_stale_locks(&path);
}
}
}
pub fn router(app: App, options: Options) -> serve::Result<(Router, String)> {
let probe = boot(app.clone(), &options, None)?;
let agent = agent(&probe, options.agent.clone())?;
drop(probe);
let target = Target {
app,
options: Arc::new(options),
agent: agent.clone(),
booted: Arc::new(OnceCell::new()),
};
let router = Router::new()
.route("/ping", get(ping))
.route("/invocations", post(invocations))
.route("/ws", get(ws))
.fallback(forward)
.with_state(target);
Ok((router, agent))
}
fn boot(app: App, options: &Options, storage: Option<&Storage>) -> serve::Result<Server> {
let workspace = storage
.map(|storage| storage.workspace.clone())
.or_else(|| options.workspace.clone())
.unwrap_or_else(|| PathBuf::from(SESSION_STORAGE));
let mut builder =
Server::builder(app, options.mode).microvm(move |agent| microvm(agent, &workspace));
if let Some(storage) = storage {
builder = builder.data_dir(storage.data_dir.clone());
}
builder.build()
}
fn microvm(agent: everruns::AgentBuilder, workspace: &Path) -> everruns::AgentBuilder {
agent
.workspace(workspace)
.workspace_policy(everruns::WorkspacePolicy::read_write())
.capability(everruns::HostShell::new().containment(everruns::ContainmentMode::FullAccess))
}
fn agent(server: &Server, requested: Option<String>) -> serve::Result<String> {
match requested {
Some(name) => {
let known = server
.app()
.manifest()
.agents
.iter()
.any(|agent| agent.name == name && !agent.sub);
if known {
Ok(name)
} else {
Err(anyhow!(
"SERVE_AGENTCORE_AGENT `{name}` is not a top-level agent of this app"
))
}
}
None => server.default_agent().ok_or_else(|| {
anyhow!(
"this app has several agents and none is the default; set SERVE_AGENTCORE_AGENT"
)
}),
}
}
#[derive(Clone)]
struct Target {
app: App,
options: Arc<Options>,
agent: String,
booted: Arc<OnceCell<Booted>>,
}
struct Booted {
server: Server,
wire: Router,
}
impl Target {
async fn booted(&self) -> Result<&Booted, Response> {
self.booted
.get_or_try_init(|| async {
let storage = Storage::resolve(&self.options)?;
if !storage.persistent {
eprintln!(
"serve-agentcore: no session storage at {SESSION_STORAGE} and no SERVE_DATA_DIR; \
sessions live in {} and are lost when the microVM stops",
storage.data_dir.display()
);
}
std::fs::create_dir_all(&storage.data_dir)?;
if DOTFILE_LOCKS.load(std::sync::atomic::Ordering::Relaxed) {
clear_stale_locks(&storage.data_dir);
}
let server = boot(self.app.clone(), &self.options, Some(&storage))?;
server.spawn_schedules();
let wire = server.router();
Ok::<_, serve::Error>(Booted { server, wire })
})
.await
.map_err(|err| {
eprintln!("serve-agentcore: boot failed: {err:#}");
problem(StatusCode::INTERNAL_SERVER_ERROR, format!("{err:#}"))
})
}
}
async fn ping(State(target): State<Target>) -> Json<Value> {
let busy = target
.booted
.get()
.is_some_and(|booted| booted.server.busy());
Json(json!({ "status": if busy { "HealthyBusy" } else { "Healthy" } }))
}
async fn invocations(State(target): State<Target>, headers: HeaderMap, body: Bytes) -> Response {
let session = headers
.get(SESSION_HEADER)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty());
let input = match run_agent_input(&body, session) {
Ok(input) => input,
Err(why) => return problem(StatusCode::BAD_REQUEST, why),
};
match target.booted().await {
Ok(booted) => logged(booted.server.ag_ui(&target.agent, &input).await),
Err(response) => response,
}
}
fn logged(response: Response) -> Response {
if !response.status().is_success() {
eprintln!("serve-agentcore: invocation answered {}", response.status());
}
response
}
async fn ws(
State(target): State<Target>,
headers: HeaderMap,
upgrade: WebSocketUpgrade,
) -> Response {
let session = headers
.get(SESSION_HEADER)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(str::to_string);
upgrade.on_upgrade(move |socket| converse(target, session, socket))
}
async fn converse(target: Target, session: Option<String>, mut socket: WebSocket) {
while let Some(Ok(message)) = socket.recv().await {
let body = match message {
Message::Text(text) => Bytes::from(text.as_str().to_owned()),
Message::Binary(bytes) => bytes,
Message::Close(_) => break,
Message::Ping(_) | Message::Pong(_) => continue,
};
let response = match run_agent_input(&body, session.as_deref()) {
Ok(input) => match target.booted().await {
Ok(booted) => logged(booted.server.ag_ui(&target.agent, &input).await),
Err(response) => response,
},
Err(why) => problem(StatusCode::BAD_REQUEST, why),
};
if relay(response, &mut socket).await.is_err() {
break;
}
}
}
async fn relay(response: Response, socket: &mut WebSocket) -> Result<(), axum::Error> {
use futures_util::StreamExt;
if !response.status().is_success() {
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.unwrap_or_default();
let detail = serde_json::from_slice::<Value>(&body)
.ok()
.and_then(|problem| problem["detail"].as_str().map(str::to_string))
.unwrap_or_else(|| status.to_string());
let error =
json!({ "type": "RUN_ERROR", "message": detail, "code": status.as_u16().to_string() });
return socket.send(Message::Text(error.to_string().into())).await;
}
let mut stream = response.into_body().into_data_stream();
let mut pending = String::new();
while let Some(chunk) = stream.next().await {
let Ok(chunk) = chunk else { break };
pending.push_str(&String::from_utf8_lossy(&chunk));
while let Some(end) = pending.find("\n\n") {
let frame: String = pending.drain(..end + 2).collect();
if let Some(data) = sse_data(&frame) {
socket.send(Message::Text(data.into())).await?;
}
}
}
if let Some(data) = sse_data(&pending) {
socket.send(Message::Text(data.into())).await?;
}
Ok(())
}
fn sse_data(frame: &str) -> Option<String> {
let lines: Vec<&str> = frame
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(|data| data.strip_prefix(' ').unwrap_or(data))
.collect();
(!lines.is_empty()).then(|| lines.join("\n"))
}
async fn forward(State(target): State<Target>, request: Request<Body>) -> Response {
match target.booted().await {
Ok(booted) => match booted.wire.clone().oneshot(request).await {
Ok(response) => response,
Err(never) => match never {},
},
Err(response) => response,
}
}
fn run_agent_input(body: &[u8], session: Option<&str>) -> Result<Vec<u8>, String> {
let mut input: Value =
serde_json::from_slice(body).map_err(|err| format!("invalid JSON body: {err}"))?;
let Some(fields) = input.as_object_mut() else {
return Err("the body must be a JSON object".into());
};
if !fields.contains_key("messages") {
let Some(prompt) = fields.remove("prompt") else {
return Err(
"expected an AG-UI RunAgentInput (with `messages`) or `{\"prompt\": \"...\"}`"
.into(),
);
};
let Some(prompt) = prompt.as_str().map(str::to_string) else {
return Err("`prompt` must be a string".into());
};
fields.insert(
"messages".into(),
json!([{ "id": new_id(), "role": "user", "content": prompt }]),
);
for key in ["tools", "context"] {
fields.entry(key).or_insert_with(|| json!([]));
}
for key in ["state", "forwardedProps"] {
fields.entry(key).or_insert_with(|| json!({}));
}
}
let has_thread = fields
.get("threadId")
.and_then(Value::as_str)
.is_some_and(|thread| !thread.trim().is_empty());
if !has_thread {
let Some(session) = session else {
return Err(format!(
"no `threadId` in the body and no `{SESSION_HEADER}` header"
));
};
fields.insert("threadId".into(), json!(session));
}
fields.entry("runId").or_insert_with(|| json!(new_id()));
serde_json::to_vec(&input).map_err(|err| err.to_string())
}
fn new_id() -> String {
uuid::Uuid::now_v7().to_string()
}
fn problem(status: StatusCode, detail: String) -> Response {
let body = json!({
"title": status.canonical_reason().unwrap_or("Error"),
"status": status.as_u16(),
"detail": detail,
});
(
status,
[(header::CONTENT_TYPE, "application/problem+json")],
body.to_string(),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(bytes: Vec<u8>) -> Value {
serde_json::from_slice(&bytes).unwrap()
}
#[test]
fn prompt_becomes_a_one_message_run_on_the_agentcore_session() {
let input = parse(run_agent_input(br#"{"prompt":"hi"}"#, Some("s-1")).unwrap());
assert_eq!(input["threadId"], "s-1");
assert_eq!(input["messages"][0]["role"], "user");
assert_eq!(input["messages"][0]["content"], "hi");
assert!(input["runId"].as_str().is_some_and(|id| !id.is_empty()));
assert_eq!(input["tools"], json!([]));
assert_eq!(input["forwardedProps"], json!({}));
assert!(input.get("prompt").is_none());
}
#[test]
fn run_agent_input_passes_through_and_keeps_its_thread() {
let body = json!({
"threadId": "t-1", "runId": "r-1",
"messages": [{ "id": "m", "role": "user", "content": "x" }],
"tools": [], "context": [], "state": {}, "forwardedProps": {}
});
let input = parse(run_agent_input(body.to_string().as_bytes(), Some("s-1")).unwrap());
assert_eq!(input, body);
}
#[test]
fn missing_thread_falls_back_to_the_session_header() {
let body = br#"{"messages":[{"id":"m","role":"user","content":"x"}],"threadId":" "}"#;
let input = parse(run_agent_input(body, Some("s-2")).unwrap());
assert_eq!(input["threadId"], "s-2");
}
#[test]
fn rejects_bodies_it_cannot_run() {
let no_thread = run_agent_input(br#"{"prompt":"hi"}"#, None).unwrap_err();
assert!(no_thread.contains(SESSION_HEADER), "{no_thread}");
assert!(run_agent_input(b"not json", Some("s")).is_err());
assert!(run_agent_input(b"[]", Some("s")).is_err());
assert!(run_agent_input(br#"{"prompt":7}"#, Some("s")).is_err());
assert!(run_agent_input(br#"{"input":"hi"}"#, Some("s")).is_err());
}
}