mod auth;
mod handlers;
mod rate_limit;
pub use auth::{AuthConfig, JwtConfig};
pub use handlers::map_agent_event;
pub use rate_limit::{rate_limiter_from_env, RateLimiter};
use auth::{auth_config_from_env, auth_middleware};
use handlers::{
agui_run, create_session, delete_session, fork_session, get_session, health, list_sessions,
list_slash_commands, list_tools, metrics_handler, openapi_spec, patch_session, run_agent,
send_session_message, session_clear_goal, session_events, session_interrupt,
session_plan_confirm, session_plan_reject, session_set_goal,
};
use rate_limit::{metrics_middleware, rate_limit_middleware};
use axum::{
extract::DefaultBodyLimit,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
const MAX_BODY_BYTES: usize = 1024 * 1024;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use crate::config::Config;
use crate::llm::ChatProvider;
use crate::runtime::AgentRuntime;
use crate::tools::plan_mode::PlanApprovalGate;
use crate::tools::ToolRegistry;
#[derive(Default)]
pub struct Metrics {
pub requests_total: AtomicU64,
pub requests_active: AtomicU64,
pub agent_runs_total: AtomicU64,
pub agent_runs_success: AtomicU64,
pub agent_runs_failed: AtomicU64,
pub tokens_prompt_total: AtomicU64,
pub tokens_completion_total: AtomicU64,
pub agent_steps_total: AtomicU64,
pub sessions_active: AtomicU64,
pub rate_limits_rejected: AtomicU64,
}
pub struct SessionState {
pub id: String,
pub created_at: String,
pub title: Option<String>,
pub runtime: Arc<tokio::sync::Mutex<AgentRuntime>>,
pub plan_approval_gate: Arc<PlanApprovalGate>,
pub interrupt_token: Arc<tokio::sync::Mutex<Option<tokio_util::sync::CancellationToken>>>,
pub non_system_message_count: Arc<std::sync::atomic::AtomicUsize>,
pub last_active_ms: Arc<AtomicU64>,
pub prompt_tokens: Arc<AtomicU64>,
pub completion_tokens: Arc<AtomicU64>,
}
static SESSION_EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
fn session_epoch() -> std::time::Instant {
*SESSION_EPOCH.get_or_init(std::time::Instant::now)
}
pub fn now_session_ms() -> u64 {
session_epoch().elapsed().as_millis() as u64
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
pub struct SessionInfo {
pub id: String,
pub created_at: String,
pub message_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
pub struct CreateSessionRequest {
pub system_prompt: Option<String>,
pub append_system_prompt: Option<String>,
pub session_name: Option<String>,
pub max_steps: Option<u32>,
pub thinking_budget: Option<u32>,
pub permission_mode: Option<String>,
pub max_budget_usd: Option<f64>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct CreateSessionResponse {
pub id: String,
pub created_at: String,
}
#[derive(serde::Deserialize, Debug)]
pub struct SessionMessageRequest {
pub content: String,
}
#[derive(serde::Serialize, Debug)]
pub struct SessionMessageResponse {
pub role: String,
pub content: String,
}
#[derive(serde::Serialize, Debug)]
pub struct SessionDetailResponse {
pub id: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub messages: Vec<serde_json::Value>,
pub todos: Vec<crate::tools::todo::TodoItem>,
pub status: String,
pub pending_plan: Option<String>,
pub goal: Option<crate::runtime::GoalState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_prompt: Option<String>,
pub prompt_tokens: u64,
pub completion_tokens: u64,
}
#[derive(serde::Deserialize, Debug)]
pub struct SetGoalRequest {
pub condition: String,
pub max_turns: Option<u32>,
}
#[derive(serde::Serialize, Debug)]
pub struct GoalResponse {
pub status: String,
}
#[derive(Clone, serde::Serialize, Debug)]
pub struct SlashCommandInfo {
pub name: String,
pub description: String,
pub source: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub argument_hint: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SseContentBlock {
Text { text: String },
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SseEvent {
Message {
role: String,
content: Vec<SseContentBlock>,
},
PartialMessage { text: String, step: usize },
ToolCall { name: String, step: usize },
ToolResult { name: String, success: bool },
Done {
finish_reason: String,
total_steps: usize,
},
Error { message: String },
PlanProposed { plan: String },
GoalContinuing { reason: String, turns: u32 },
GoalAchieved { condition: String, turns: u32 },
ToolProgress {
tool_use_id: String,
tool_name: String,
elapsed_ms: u64,
},
}
#[derive(Clone)]
pub struct AppState {
pub tools: Vec<ToolInfo>,
pub tool_registry: ToolRegistry,
pub config: Config,
pub provider: Arc<dyn ChatProvider>,
pub sessions: Arc<RwLock<HashMap<String, SessionState>>>,
pub event_channels: Arc<RwLock<HashMap<String, broadcast::Sender<SseEvent>>>>,
pub metrics: Arc<Metrics>,
pub slash_commands: Arc<Vec<SlashCommandInfo>>,
pub session_ttl_secs: u64,
pub run_semaphore: Arc<tokio::sync::Semaphore>,
pub rate_limiter: RateLimiter,
pub skills: Vec<crate::skills::Skill>,
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
pub struct ToolInfo {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(serde::Deserialize, Debug)]
pub struct RunRequest {
pub goal: String,
pub max_steps: Option<u32>,
pub system_prompt: Option<String>,
pub append_system_prompt: Option<String>,
pub thinking_budget: Option<u32>,
pub permission_mode: Option<String>,
pub max_budget_usd: Option<f64>,
}
#[derive(serde::Serialize, Debug)]
pub struct RunResponse {
pub status: String,
pub finish_reason: String,
pub messages: Vec<serde_json::Value>,
pub usage: UsageInfo,
}
#[derive(serde::Serialize, Debug)]
pub struct UsageInfo {
pub total_steps: u32,
pub total_tokens: u64,
}
#[derive(serde::Serialize, Debug)]
pub struct ErrorResponse {
pub status: String,
pub error: String,
}
#[derive(serde::Serialize)]
struct ErrorBody {
error: String,
}
pub(super) struct ApiError {
status: StatusCode,
message: String,
retry_after_secs: Option<u32>,
}
impl ApiError {
pub(super) fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
retry_after_secs: None,
}
}
pub(super) fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
pub(super) fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, message)
}
pub(super) fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
pub(super) fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub(super) fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, message)
}
pub(super) fn with_retry_after(mut self, secs: u32) -> Self {
self.retry_after_secs = Some(secs);
self
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let body = Json(ErrorBody {
error: self.message,
});
let mut resp = (self.status, body).into_response();
if let Some(secs) = self.retry_after_secs {
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, val);
}
}
resp
}
}
#[derive(serde::Deserialize, Debug, Default)]
pub struct ListSessionsQuery {
pub limit: Option<usize>,
pub offset: Option<usize>,
}
pub fn build_router(state: AppState) -> Router {
build_router_with_auth(state, auth_config_from_env())
}
pub async fn serve_with_graceful_shutdown(
listener: tokio::net::TcpListener,
router: Router,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> std::io::Result<()> {
axum::serve(listener, router)
.with_graceful_shutdown(shutdown)
.await
}
pub fn build_router_with_auth(state: AppState, auth: AuthConfig) -> Router {
let limiter = state.rate_limiter.clone();
build_router_with_auth_and_rate_limit(state, auth, limiter)
}
pub fn build_router_with_auth_and_rate_limit(
state: AppState,
auth: AuthConfig,
limiter: RateLimiter,
) -> Router {
let state_arc = Arc::new(state);
let public = Router::new()
.route("/health", get(health))
.route("/openapi.json", get(openapi_spec))
.route("/metrics", get(metrics_handler));
let protected = Router::new()
.route("/tools", get(list_tools))
.route("/run", post(run_agent))
.route("/sessions", post(create_session))
.route("/sessions", get(list_sessions))
.route("/sessions/{id}", get(get_session))
.route("/sessions/{id}", axum::routing::delete(delete_session))
.route("/sessions/{id}", axum::routing::patch(patch_session))
.route("/sessions/{id}/messages", post(send_session_message))
.route("/sessions/{id}/events", get(session_events))
.route("/sessions/{id}/plan/confirm", post(session_plan_confirm))
.route("/sessions/{id}/plan/reject", post(session_plan_reject))
.route("/sessions/{id}/goal", post(session_set_goal))
.route(
"/sessions/{id}/goal",
axum::routing::delete(session_clear_goal),
)
.route("/sessions/{id}/interrupt", post(session_interrupt))
.route("/sessions/{id}/fork", post(fork_session))
.route("/slash-commands", get(list_slash_commands))
.route("/agui", post(agui_run))
.layer(axum::middleware::from_fn_with_state(auth, auth_middleware))
.layer(axum::middleware::from_fn_with_state(
(limiter.clone(), state_arc.metrics.clone()),
rate_limit_middleware,
));
Router::new()
.merge(public)
.merge(protected)
.layer(axum::middleware::from_fn_with_state(
state_arc.metrics.clone(),
metrics_middleware,
))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state_arc)
}
pub fn build_openapi_spec() -> serde_json::Value {
serde_json::json!({
"openapi": "3.0.3",
"info": {
"title": "Recursive Agent API",
"version": "0.4.0",
"description": "HTTP API for the Recursive coding agent"
},
"paths": {
"/health": {
"get": {
"summary": "Health check",
"description": "Returns 'ok' if the server is running.",
"responses": {
"200": {
"description": "Server is healthy",
"content": {
"text/plain": {
"schema": { "type": "string", "example": "ok" }
}
}
}
}
}
},
"/tools": {
"get": {
"summary": "List registered tools",
"description": "Returns the JSON array of tools available to the agent.",
"responses": {
"200": {
"description": "Array of tool descriptors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/ToolInfo" }
}
}
}
}
}
}
},
"/run": {
"post": {
"summary": "Run the agent",
"description": "Execute the agent with a goal and return the outcome.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/RunRequest" }
}
}
},
"responses": {
"200": {
"description": "Agent completed successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/RunResponse" }
}
}
},
"400": {
"description": "Invalid request (e.g. empty goal)",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"422": { "description": "Request body failed deserialization" },
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
}
}
},
"/sessions": {
"get": {
"summary": "List sessions",
"description": "Returns all active sessions wrapped in an envelope \
with the un-paginated `total` count plus the `sessions` page slice. \
Clients use `total` to render \"page X of Y\" / scrollbars without \
having to fetch every page just to count sessions.",
"responses": {
"200": {
"description": "Envelope with total session count and the paginated slice",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionList" }
}
}
}
}
},
"post": {
"summary": "Create a session",
"description": "Create a new multi-turn conversation session.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateSessionRequest" }
}
}
},
"responses": {
"201": {
"description": "Session created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateSessionResponse" }
}
}
}
}
}
},
"/sessions/{id}": {
"get": {
"summary": "Get session detail",
"description": "Returns session metadata and full message transcript.",
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}],
"responses": {
"200": {
"description": "Session detail with messages",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionDetailResponse" }
}
}
},
"404": { "description": "Session not found" }
}
},
"delete": {
"summary": "Delete a session",
"description": "Remove a session and its transcript.",
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}],
"responses": {
"204": { "description": "Session deleted" },
"404": { "description": "Session not found" }
}
}
},
"/sessions/{id}/messages": {
"post": {
"summary": "Send a message",
"description": "Send a user message in a session and get the assistant response.",
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionMessageRequest" }
}
}
},
"responses": {
"200": {
"description": "Assistant response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SessionMessageResponse" }
}
}
},
"404": {
"description": "Session not found",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
}
}
},
"/sessions/{id}/events": {
"get": {
"summary": "Subscribe to session events",
"description": "SSE stream of real-time agent events for a session.",
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}],
"responses": {
"200": {
"description": "SSE event stream",
"content": {
"text/event-stream": {
"schema": { "type": "string" }
}
}
},
"404": { "description": "Session not found" }
}
}
},
"/agui": {
"post": {
"summary": "Run an AG-UI agent",
"description": "Drive a recursive agent run via the AG-UI protocol \
(https://docs.ag-ui.com). Body is an AG-UI RunAgentInput; the \
response is an SSE stream of AG-UI events (RunStarted, \
TextMessageStart/Content/End, ToolCall*, RunFinished, ...).",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "type": "object" },
"description": "AG-UI RunAgentInput payload"
}
}
},
"responses": {
"200": {
"description": "AG-UI SSE event stream",
"content": {
"text/event-stream": {
"schema": { "type": "string" }
}
}
},
"400": { "description": "Invalid AG-UI RunAgentInput" }
}
}
},
"/metrics": {
"get": {
"summary": "Prometheus metrics",
"description": "Returns Prometheus-compatible metrics exposition. \
Includes the standard counters (requests, agent runs, tokens) plus \
`recursive_sessions_active` (gauge, count of currently open sessions) \
and `recursive_rate_limits_rejected_total` (counter, requests rejected \
by rate limiting). Added in G292, documented in G298.",
"responses": {
"200": {
"description": "Prometheus text format",
"content": {
"text/plain": {
"schema": { "type": "string" }
}
}
}
}
}
},
"/openapi.json": {
"get": {
"summary": "OpenAPI specification",
"description": "Returns this OpenAPI 3.0.3 spec as JSON.",
"responses": {
"200": {
"description": "OpenAPI spec document",
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"ToolInfo": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"parameters": { "type": "object" }
},
"required": ["name", "description", "parameters"]
},
"RunRequest": {
"type": "object",
"properties": {
"goal": { "type": "string" },
"max_steps": { "type": "integer", "nullable": true },
"system_prompt": { "type": "string", "nullable": true },
"append_system_prompt": { "type": "string", "nullable": true },
"thinking_budget": { "type": "integer", "nullable": true },
"permission_mode": { "type": "string", "enum": ["default", "auto", "strict", "bypass"], "nullable": true },
"max_budget_usd": { "type": "number", "nullable": true }
},
"required": ["goal"]
},
"RunResponse": {
"type": "object",
"properties": {
"status": { "type": "string" },
"finish_reason": { "type": "string" },
"messages": { "type": "array", "items": { "type": "object" } },
"usage": { "$ref": "#/components/schemas/UsageInfo" }
},
"required": ["status", "finish_reason", "messages", "usage"]
},
"UsageInfo": {
"type": "object",
"properties": {
"total_steps": { "type": "integer" },
"total_tokens": { "type": "integer" }
},
"required": ["total_steps", "total_tokens"]
},
"ErrorResponse": {
"type": "object",
"properties": {
"status": { "type": "string" },
"error": { "type": "string" }
},
"required": ["status", "error"]
},
"CreateSessionRequest": {
"type": "object",
"properties": {
"system_prompt": { "type": "string", "nullable": true },
"append_system_prompt": { "type": "string", "nullable": true },
"session_name": { "type": "string", "nullable": true },
"max_steps": { "type": "integer", "nullable": true },
"thinking_budget": { "type": "integer", "nullable": true },
"permission_mode": { "type": "string", "enum": ["default", "auto", "strict", "bypass"], "nullable": true },
"max_budget_usd": { "type": "number", "nullable": true }
}
},
"CreateSessionResponse": {
"type": "object",
"properties": {
"id": { "type": "string" },
"created_at": { "type": "string" }
},
"required": ["id", "created_at"]
},
"SessionInfo": {
"type": "object",
"properties": {
"id": { "type": "string" },
"created_at": { "type": "string" },
"message_count": { "type": "integer" },
"title": { "type": "string", "nullable": true }
},
"required": ["id", "created_at", "message_count"]
},
"SessionList": {
"type": "object",
"description": "Response envelope for GET /sessions. `total` is the count of all sessions before pagination, so clients can compute total pages without fetching every page.",
"properties": {
"total": {
"type": "integer",
"description": "Number of sessions known to the server (before applying limit/offset)."
},
"sessions": {
"type": "array",
"items": { "$ref": "#/components/schemas/SessionInfo" },
"description": "The paginated slice of session info objects."
}
},
"required": ["total", "sessions"]
},
"SessionDetailResponse": {
"type": "object",
"properties": {
"id": { "type": "string" },
"created_at": { "type": "string" },
"title": { "type": "string", "nullable": true },
"messages": { "type": "array", "items": { "type": "object" } },
"todos": { "type": "array", "items": { "type": "object" } },
"status": {
"type": "string",
"description": "Session lifecycle state: idle | plan_pending_approval"
},
"pending_plan": { "type": "string", "nullable": true },
"goal": { "type": "object", "nullable": true },
"first_prompt": { "type": "string", "nullable": true },
"last_prompt": { "type": "string", "nullable": true },
"prompt_tokens": {
"type": "integer",
"description": "Cumulative prompt tokens for this session"
},
"completion_tokens": {
"type": "integer",
"description": "Cumulative completion tokens for this session"
}
},
"required": ["id", "created_at", "messages", "status", "todos", "prompt_tokens", "completion_tokens"]
},
"SessionMessageRequest": {
"type": "object",
"properties": {
"content": { "type": "string" }
},
"required": ["content"]
},
"SessionMessageResponse": {
"type": "object",
"properties": {
"role": { "type": "string" },
"content": { "type": "string" }
},
"required": ["role", "content"]
}
}
}
})
}
pub fn spawn_session_reaper(
state: Arc<AppState>,
check_interval: std::time::Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(check_interval).await;
let ttl = std::time::Duration::from_secs(state.session_ttl_secs);
let mut to_evict: Vec<String> = Vec::new();
{
let sessions = state.sessions.read().await;
for (id, session) in sessions.iter() {
let last_ms = session.last_active_ms.load(Ordering::Relaxed);
let elapsed = std::time::Duration::from_millis(now_session_ms() - last_ms);
if elapsed >= ttl {
to_evict.push(id.clone());
}
}
}
state.rate_limiter.prune().await;
if to_evict.is_empty() {
continue;
}
{
let mut sessions = state.sessions.write().await;
for id in &to_evict {
if let Some(session) = sessions.remove(id) {
if let Ok(mut rt) = session.runtime.try_lock() {
rt.close(None).await;
}
state
.metrics
.sessions_active
.fetch_sub(1, Ordering::Relaxed);
tracing::info!("reaper: evicted idle session {id}");
}
}
}
{
let mut channels = state.event_channels.write().await;
for id in &to_evict {
channels.remove(id);
}
}
}
})
}
#[cfg(test)]
mod goal_272_route_level_auth_bypass {
#[test]
fn public_subrouter_has_no_auth_layer() {
let src = include_str!("mod.rs");
let between = src
.split("let public = Router::new()")
.nth(1)
.expect("public sub-router block must exist");
let public_block = between
.split("let protected = Router::new()")
.next()
.expect("public block must end before protected");
assert!(
!public_block.contains("auth_middleware"),
"public sub-router must NOT include auth_middleware layer"
);
assert!(
!public_block.contains("rate_limit_middleware"),
"public sub-router must NOT include rate_limit_middleware layer"
);
assert!(public_block.contains("/health"));
assert!(public_block.contains("/openapi.json"));
assert!(public_block.contains("/metrics"));
}
#[test]
fn auth_middleware_no_longer_short_circuits_on_path() {
let auth_src = include_str!("auth.rs");
assert!(
!auth_src.contains("path == \"/health\""),
"auth_middleware must not hardcode /health bypass"
);
assert!(
!auth_src.contains("path == \"/metrics\""),
"auth_middleware must not hardcode /metrics bypass"
);
assert!(
!auth_src.contains("path == \"/openapi.json\""),
"auth_middleware must not hardcode /openapi.json bypass"
);
}
#[test]
fn protected_routes_built_with_auth_layer() {
let src = include_str!("mod.rs");
let protected_block = src
.split("let protected = Router::new()")
.nth(1)
.expect("protected sub-router block must exist");
let protected_end = protected_block
.split(".merge(protected)")
.next()
.expect("protected merge call must exist");
assert!(
protected_end.contains("auth_middleware"),
"protected sub-router must include auth_middleware layer"
);
assert!(
protected_end.contains("rate_limit_middleware"),
"protected sub-router must include rate_limit_middleware layer"
);
}
}