recursive/http/mod.rs
1//! HTTP API server for the Recursive agent.
2//!
3//! Provides a lightweight axum-based HTTP server that exposes the agent's
4//! tool registry as a read-only JSON endpoint, a health check, a POST /run
5//! endpoint that executes the agent with a given goal, session management
6//! endpoints for multi-turn conversations, and SSE streaming of agent events.
7
8mod auth;
9mod handlers;
10mod rate_limit;
11
12pub use auth::{AuthConfig, JwtConfig};
13pub use handlers::map_agent_event;
14pub use rate_limit::{rate_limiter_from_env, RateLimiter};
15
16use auth::{auth_config_from_env, auth_middleware};
17use handlers::{
18 agui_run, create_session, delete_session, fork_session, get_session, health, list_sessions,
19 list_slash_commands, list_tools, metrics_handler, openapi_spec, patch_session, run_agent,
20 send_session_message, session_clear_goal, session_events, session_interrupt,
21 session_plan_confirm, session_plan_reject, session_set_goal,
22};
23use rate_limit::{metrics_middleware, rate_limit_middleware};
24
25use axum::{
26 extract::DefaultBodyLimit,
27 http::StatusCode,
28 response::IntoResponse,
29 routing::{get, post},
30 Json, Router,
31};
32
33/// Maximum accepted request body size (1 MiB).
34///
35/// Prevents OOM via maliciously large JSON payloads on POST /run and
36/// POST /sessions/:id/messages, both of which accept unbounded user strings.
37const MAX_BODY_BYTES: usize = 1024 * 1024;
38use std::collections::HashMap;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::Arc;
41use tokio::sync::{broadcast, RwLock};
42
43use crate::config::Config;
44use crate::llm::ChatProvider;
45use crate::runtime::AgentRuntime;
46use crate::tools::plan_mode::PlanApprovalGate;
47use crate::tools::ToolRegistry;
48
49// ── Metrics ────────────────────────────────────────────────────────────────
50
51/// Prometheus-compatible metrics collector using lock-free atomic counters.
52#[derive(Default)]
53pub struct Metrics {
54 pub requests_total: AtomicU64,
55 pub requests_active: AtomicU64,
56 pub agent_runs_total: AtomicU64,
57 pub agent_runs_success: AtomicU64,
58 pub agent_runs_failed: AtomicU64,
59 pub tokens_prompt_total: AtomicU64,
60 pub tokens_completion_total: AtomicU64,
61 pub agent_steps_total: AtomicU64,
62 /// Number of currently open sessions (gauge).
63 pub sessions_active: AtomicU64,
64 /// Number of requests rejected by rate limiting (counter).
65 pub rate_limits_rejected: AtomicU64,
66}
67
68// ── Session types ──────────────────────────────────────────────────────────
69
70/// Internal session state (not directly serialized to clients).
71///
72/// The [`AgentRuntime`] owns the transcript; this struct adds HTTP-layer
73/// metadata (id, created_at) and the broadcast channel for SSE clients.
74pub struct SessionState {
75 pub id: String,
76 pub created_at: String,
77 /// Optional human-readable title, settable via `PATCH /sessions/:id`.
78 pub title: Option<String>,
79 /// Runtime is wrapped in a per-session Mutex so concurrent HTTP requests
80 /// for the same session are serialized without blocking the global lock.
81 pub runtime: Arc<tokio::sync::Mutex<AgentRuntime>>,
82 /// Shared gate for plan-mode approval. Stored here so HTTP handlers can
83 /// approve/reject without taking the runtime Mutex (which may be held
84 /// by a running agent turn).
85 pub plan_approval_gate: Arc<PlanApprovalGate>,
86 /// Goal-170: cancellation token for the currently running agent turn.
87 /// `POST /sessions/:id/interrupt` cancels this token, which causes
88 /// the kernel to exit with `FinishReason::Cancelled` at the next step
89 /// boundary. Replaced with a fresh token at the start of every turn.
90 pub interrupt_token: Arc<tokio::sync::Mutex<Option<tokio_util::sync::CancellationToken>>>,
91 /// Approximate non-system message count, updated atomically as messages
92 /// are appended. Allows `list_sessions` to read the count without taking
93 /// the runtime Mutex (which may be held by a running agent turn).
94 pub non_system_message_count: Arc<std::sync::atomic::AtomicUsize>,
95 /// Milliseconds since [`SESSION_EPOCH`] when this session was last active.
96 /// Updated atomically on every message. Used by the session reaper.
97 pub last_active_ms: Arc<AtomicU64>,
98 /// Cumulative prompt tokens consumed in this session (all turns combined).
99 pub prompt_tokens: Arc<AtomicU64>,
100 /// Cumulative completion tokens generated in this session.
101 pub completion_tokens: Arc<AtomicU64>,
102}
103
104/// Reference instant for session last_active timestamps.
105/// Stored as a `OnceLock` so it's computed once at startup.
106static SESSION_EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
107
108fn session_epoch() -> std::time::Instant {
109 *SESSION_EPOCH.get_or_init(std::time::Instant::now)
110}
111
112/// Read the current session timestamp as milliseconds since [`SESSION_EPOCH`].
113pub fn now_session_ms() -> u64 {
114 session_epoch().elapsed().as_millis() as u64
115}
116
117/// Serialized session info for list/detail endpoints.
118#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
119pub struct SessionInfo {
120 pub id: String,
121 pub created_at: String,
122 pub message_count: usize,
123 /// Optional human-readable title, set via `PATCH /sessions/:id`.
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub title: Option<String>,
126}
127
128/// Request body for `POST /sessions`.
129#[derive(serde::Deserialize, Debug)]
130pub struct CreateSessionRequest {
131 pub system_prompt: Option<String>,
132 /// Append additional text to the server's default system prompt instead of
133 /// replacing it. Ignored when `system_prompt` is also provided.
134 pub append_system_prompt: Option<String>,
135 /// Human-readable display name for the session (shown in session list /
136 /// resume picker).
137 pub session_name: Option<String>,
138 /// Maximum number of steps (tool calls) allowed in this session.
139 pub max_steps: Option<u32>,
140 /// Extended-thinking token budget for models that support it (e.g.
141 /// Anthropic claude-3-7). `0` disables thinking.
142 pub thinking_budget: Option<u32>,
143 /// Permission mode: `"default"`, `"auto"`, `"strict"`, or `"bypass"`.
144 pub permission_mode: Option<String>,
145 /// Maximum total API spend in USD for this session. Agent stops after any
146 /// turn that would exceed this limit.
147 pub max_budget_usd: Option<f64>,
148}
149
150/// Response body for `POST /sessions`.
151#[derive(serde::Serialize, serde::Deserialize, Debug)]
152pub struct CreateSessionResponse {
153 pub id: String,
154 pub created_at: String,
155}
156
157/// Request body for `POST /sessions/:id/messages`.
158#[derive(serde::Deserialize, Debug)]
159pub struct SessionMessageRequest {
160 pub content: String,
161}
162
163/// Response body for `POST /sessions/:id/messages`.
164#[derive(serde::Serialize, Debug)]
165pub struct SessionMessageResponse {
166 pub role: String,
167 pub content: String,
168}
169
170/// Detail response for `GET /sessions/:id`.
171#[derive(serde::Serialize, Debug)]
172pub struct SessionDetailResponse {
173 pub id: String,
174 pub created_at: String,
175 /// Optional human-readable title (set via `PATCH /sessions/:id`).
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub title: Option<String>,
178 pub messages: Vec<serde_json::Value>,
179 /// Goal-167: current task list as maintained by `todo_write` calls.
180 pub todos: Vec<crate::tools::todo::TodoItem>,
181 /// Current session lifecycle state: `"idle"` | `"plan_pending_approval"`.
182 pub status: String,
183 /// Non-null when `status` is `"plan_pending_approval"`.
184 pub pending_plan: Option<String>,
185 /// Goal-168: active goal state, or `null` when no goal is set.
186 pub goal: Option<crate::runtime::GoalState>,
187 /// First user message in the session (for quick display).
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub first_prompt: Option<String>,
190 /// Most recent user message in the session.
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub last_prompt: Option<String>,
193 /// Total prompt tokens consumed across all turns in this session.
194 pub prompt_tokens: u64,
195 /// Total completion tokens generated across all turns in this session.
196 pub completion_tokens: u64,
197}
198
199// ── Goal-168: goal endpoint types ────────────────────────────────────────
200
201/// Request body for `POST /sessions/:id/goal`.
202#[derive(serde::Deserialize, Debug)]
203pub struct SetGoalRequest {
204 /// The completion condition (free-form text).
205 pub condition: String,
206 /// Hard cap on autonomous turns. Defaults to 20.
207 pub max_turns: Option<u32>,
208}
209
210/// Response body for goal mutation endpoints.
211#[derive(serde::Serialize, Debug)]
212pub struct GoalResponse {
213 pub status: String,
214}
215
216// ── Goal-169: slash commands endpoint types ───────────────────────────────
217
218/// One slash command entry in `GET /slash-commands`.
219#[derive(Clone, serde::Serialize, Debug)]
220pub struct SlashCommandInfo {
221 pub name: String,
222 pub description: String,
223 pub source: String,
224 #[serde(skip_serializing_if = "Vec::is_empty")]
225 pub aliases: Vec<String>,
226 #[serde(skip_serializing_if = "String::is_empty")]
227 pub argument_hint: String,
228}
229
230// ── SSE event types ──────────────────────────────────────────────────────
231
232/// A single block of message content (mirrors Claude Agent SDK's
233/// `TextBlock` / `ToolUseBlock`). Emitted as part of [`SseEvent::Message`]
234/// so SDK clients can iterate `for block in msg.content` without doing a
235/// second round-trip to the session detail endpoint.
236#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
237#[serde(tag = "type", rename_all = "snake_case")]
238pub enum SseContentBlock {
239 /// A run of plain text from the assistant.
240 Text { text: String },
241 /// A request from the assistant to call a tool.
242 ToolUse {
243 id: String,
244 name: String,
245 input: serde_json::Value,
246 },
247}
248
249/// Server-Sent Event payload emitted during an agent session run.
250#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
251#[serde(tag = "type", rename_all = "snake_case")]
252pub enum SseEvent {
253 /// A full role-tagged message, with content broken into typed blocks.
254 /// Modeled on Claude Agent SDK's `AssistantMessage` / `UserMessage`
255 /// streaming shape so the TS / Python SDKs can yield typed messages
256 /// without falling back to the session detail endpoint.
257 Message {
258 role: String,
259 content: Vec<SseContentBlock>,
260 },
261 /// A partial text delta during streaming. Concatenate `text` deltas
262 /// keyed by `step` to reconstruct the eventual `Message::Text` block.
263 PartialMessage { text: String, step: usize },
264 /// A tool is being called.
265 ToolCall { name: String, step: usize },
266 /// A tool call completed.
267 ToolResult { name: String, success: bool },
268 /// The agent run completed.
269 Done {
270 finish_reason: String,
271 total_steps: usize,
272 },
273 /// An error occurred.
274 Error { message: String },
275 /// Agent proposed a plan and is waiting for human review.
276 PlanProposed { plan: String },
277 /// Goal-168: judge found condition not yet met; loop continues.
278 GoalContinuing { reason: String, turns: u32 },
279 /// Goal-168: judge confirmed condition met.
280 GoalAchieved { condition: String, turns: u32 },
281 /// SDK Phase B: a tool call just completed; elapsed_ms is wall-clock time
282 /// from when the ToolCall event was received to when ToolResult arrived.
283 /// Emitted in addition to (and after) the `tool_result` event.
284 ToolProgress {
285 tool_use_id: String,
286 tool_name: String,
287 elapsed_ms: u64,
288 },
289}
290
291// ── App state ──────────────────────────────────────────────────────────────
292
293/// Shared application state for the HTTP server.
294#[derive(Clone)]
295pub struct AppState {
296 pub tools: Vec<ToolInfo>,
297 /// Live tool registry used to construct per-request and per-session
298 /// AgentRuntimes. Without this, runtimes get an empty registry and
299 /// every tool_call from the LLM resolves to "tool not found".
300 pub tool_registry: ToolRegistry,
301 pub config: Config,
302 pub provider: Arc<dyn ChatProvider>,
303 /// Session state keyed by session ID.
304 pub sessions: Arc<RwLock<HashMap<String, SessionState>>>,
305 /// Per-session SSE broadcast channels.
306 pub event_channels: Arc<RwLock<HashMap<String, broadcast::Sender<SseEvent>>>>,
307 pub metrics: Arc<Metrics>,
308 /// Goal-169: registered slash commands (built-in + skill-backed).
309 /// Pre-built at startup for cheap `GET /slash-commands` responses.
310 pub slash_commands: Arc<Vec<SlashCommandInfo>>,
311 pub session_ttl_secs: u64,
312 /// Semaphore limiting concurrent agent runs. Acquired in `run_agent` and
313 /// `send_session_message` before creating an `AgentRuntime`. When the
314 /// configured `max_concurrent_runs` is 0 (unlimited), this is initialised
315 /// with `Semaphore::MAX_PERMITS`.
316 pub run_semaphore: Arc<tokio::sync::Semaphore>,
317 /// Shared rate limiter for all API requests. Stored on `AppState` so the
318 /// session reaper can prune idle token buckets.
319 pub rate_limiter: RateLimiter,
320 /// Discovered skills for skill_index injection into the system prompt.
321 /// Empty if no skills found. Goal-312.
322 pub skills: Vec<crate::skills::Skill>,
323}
324
325/// Serializable tool info for the `/tools` endpoint.
326#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
327pub struct ToolInfo {
328 pub name: String,
329 pub description: String,
330 pub parameters: serde_json::Value,
331}
332
333/// Request body for `POST /run`.
334#[derive(serde::Deserialize, Debug)]
335pub struct RunRequest {
336 pub goal: String,
337 pub max_steps: Option<u32>,
338 pub system_prompt: Option<String>,
339 /// Append additional text to the server's default system prompt instead of
340 /// replacing it. Ignored when `system_prompt` is also provided.
341 pub append_system_prompt: Option<String>,
342 /// Extended-thinking token budget for models that support it (e.g.
343 /// Anthropic claude-3-7). `0` disables thinking.
344 pub thinking_budget: Option<u32>,
345 /// Permission mode: `"default"`, `"auto"`, `"strict"`, or `"bypass"`.
346 pub permission_mode: Option<String>,
347 /// Maximum total API spend in USD for this run.
348 pub max_budget_usd: Option<f64>,
349}
350
351/// Successful response from `POST /run`.
352#[derive(serde::Serialize, Debug)]
353pub struct RunResponse {
354 pub status: String,
355 pub finish_reason: String,
356 pub messages: Vec<serde_json::Value>,
357 pub usage: UsageInfo,
358}
359
360/// Token/step usage information.
361#[derive(serde::Serialize, Debug)]
362pub struct UsageInfo {
363 pub total_steps: u32,
364 pub total_tokens: u64,
365}
366
367/// Error response body.
368#[derive(serde::Serialize, Debug)]
369pub struct ErrorResponse {
370 pub status: String,
371 pub error: String,
372}
373
374// ── Goal 295: standardized JSON error envelope ───────────────────────────
375
376/// JSON body shape for [`ApiError`] responses.
377///
378/// Every endpoint that returns a 4xx/5xx via [`ApiError`] produces a body
379/// of exactly `{"error": "<message>"}`, so clients can write a single
380/// error handler that always parses the response as JSON.
381#[derive(serde::Serialize)]
382struct ErrorBody {
383 error: String,
384}
385
386/// A standardized JSON error response for all API endpoints.
387///
388/// Replaces bare `Err(StatusCode::NOT_FOUND)` returns in handlers so every
389/// error carries a parseable `{"error": "..."}` body. Handlers that already
390/// return `(StatusCode, Json(json!(...)))` are intentionally left alone —
391/// they already produce a JSON body; wrapping them in [`ApiError`] would
392/// only add a layer.
393///
394/// Use [`ApiError::with_retry_after`] to attach a `Retry-After` header
395/// (Goal-313: needed by `session_clear_goal` when the runtime is busy).
396pub(super) struct ApiError {
397 status: StatusCode,
398 message: String,
399 /// Optional `Retry-After: <secs>` header value. `Some(secs)` causes
400 /// [`IntoResponse`] to inject the header into the response.
401 retry_after_secs: Option<u32>,
402}
403
404impl ApiError {
405 /// Build an [`ApiError`] from an arbitrary status + message.
406 pub(super) fn new(status: StatusCode, message: impl Into<String>) -> Self {
407 Self {
408 status,
409 message: message.into(),
410 retry_after_secs: None,
411 }
412 }
413
414 /// 404 Not Found with a message.
415 pub(super) fn not_found(message: impl Into<String>) -> Self {
416 Self::new(StatusCode::NOT_FOUND, message)
417 }
418
419 /// 409 Conflict with a message.
420 pub(super) fn conflict(message: impl Into<String>) -> Self {
421 Self::new(StatusCode::CONFLICT, message)
422 }
423
424 /// 500 Internal Server Error with a message.
425 pub(super) fn internal(message: impl Into<String>) -> Self {
426 Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
427 }
428
429 /// 400 Bad Request with a message.
430 pub(super) fn bad_request(message: impl Into<String>) -> Self {
431 Self::new(StatusCode::BAD_REQUEST, message)
432 }
433
434 /// 403 Forbidden with a message.
435 pub(super) fn forbidden(message: impl Into<String>) -> Self {
436 Self::new(StatusCode::FORBIDDEN, message)
437 }
438
439 /// Attach a `Retry-After: <secs>` header to this error response.
440 ///
441 /// Goal-313: lets `session_clear_goal` preserve the `Retry-After: 5`
442 /// hint it used to emit when the runtime Mutex was held by an
443 /// in-flight turn, while still routing through [`ApiError`] for the
444 /// JSON envelope.
445 pub(super) fn with_retry_after(mut self, secs: u32) -> Self {
446 self.retry_after_secs = Some(secs);
447 self
448 }
449}
450
451impl IntoResponse for ApiError {
452 fn into_response(self) -> axum::response::Response {
453 let body = Json(ErrorBody {
454 error: self.message,
455 });
456 let mut resp = (self.status, body).into_response();
457 if let Some(secs) = self.retry_after_secs {
458 // `secs` is u32 (max ~136 years), so the decimal repr fits
459 // comfortably in a single-digit-to-ten-char HeaderValue.
460 if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
461 resp.headers_mut()
462 .insert(axum::http::header::RETRY_AFTER, val);
463 }
464 }
465 resp
466 }
467}
468
469/// Query parameters for `GET /sessions`.
470#[derive(serde::Deserialize, Debug, Default)]
471pub struct ListSessionsQuery {
472 /// Maximum number of sessions to return (default: all).
473 pub limit: Option<usize>,
474 /// Number of sessions to skip before returning results (default: 0).
475 pub offset: Option<usize>,
476}
477
478// ── Router builders ────────────────────────────────────────────────────────
479
480/// Build the axum [`Router`] with all API routes.
481///
482/// Routes:
483/// - `GET /health` — returns `"ok"` (200)
484/// - `GET /tools` — returns JSON array of [`ToolInfo`]
485/// - `POST /run` — runs the agent with a goal and returns the outcome
486/// - `POST /sessions` — create a new session
487/// - `GET /sessions` — list all sessions
488/// - `GET /sessions/:id` — get session detail with messages
489/// - `POST /sessions/:id/messages` — send a message in a session
490/// - `DELETE /sessions/:id` — remove a session
491/// - `GET /sessions/:id/events` — SSE stream of agent events for a session
492/// - `GET /openapi.json` — returns the OpenAPI 3.0.3 specification
493///
494/// Auth is sourced from the `RECURSIVE_HTTP_AUTH_KEYS` env var. For tests
495/// that need a deterministic auth state (no env-var races across parallel
496/// test threads), use [`build_router_with_auth`] instead.
497pub fn build_router(state: AppState) -> Router {
498 build_router_with_auth(state, auth_config_from_env())
499}
500
501/// Run a `Router` on an already-bound [`tokio::net::TcpListener`] until
502/// `shutdown` resolves, then stop gracefully.
503///
504/// This encapsulates the `axum::serve` call so callers — notably the
505/// `recursive http` CLI subcommand — can serve the API without taking a
506/// direct `axum` dependency. `shutdown` is any future that completes when
507/// the server should stop (e.g. `CancellationToken::cancelled()` wrapped in
508/// an `async move` block fired on SIGINT/SIGTERM).
509pub async fn serve_with_graceful_shutdown(
510 listener: tokio::net::TcpListener,
511 router: Router,
512 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
513) -> std::io::Result<()> {
514 axum::serve(listener, router)
515 .with_graceful_shutdown(shutdown)
516 .await
517}
518
519/// Build the HTTP router with an explicit `AuthConfig`.
520///
521/// Tests use this to inject a known auth state without touching
522/// process-global env vars. Production code paths use [`build_router`].
523///
524/// The rate limiter is taken from `AppState.rate_limiter`. Callers should
525/// populate it via [`rate_limiter_from_env`] for production or with
526/// [`RateLimiter::new`] for deterministic tests. Tests that need
527/// race-free rate-limit assertions should use
528/// [`build_router_with_auth_and_rate_limit`] instead.
529pub fn build_router_with_auth(state: AppState, auth: AuthConfig) -> Router {
530 let limiter = state.rate_limiter.clone();
531 build_router_with_auth_and_rate_limit(state, auth, limiter)
532}
533
534/// Build the HTTP router with an explicit `AuthConfig` AND an explicit
535/// `RateLimiter`.
536///
537/// This is the lowest-level constructor — both of the layered
538/// middleware states are caller-supplied. Production code paths
539/// invoke [`build_router`] (env-driven on both); tests that need
540/// race-free rate-limit assertions use this directly.
541pub fn build_router_with_auth_and_rate_limit(
542 state: AppState,
543 auth: AuthConfig,
544 limiter: RateLimiter,
545) -> Router {
546 let state_arc = Arc::new(state);
547
548 // Public sub-router: no auth or rate-limit middleware. Routes
549 // added here are implicitly reachable without credentials. This
550 // makes the bypass **structural** (the route lives outside the
551 // auth layer entirely) instead of a string-compare in
552 // `auth_middleware` — the previous implementation, which
553 // silently let `/openapi.json` 401 because it was added to the
554 // router but not to the bypass list.
555 let public = Router::new()
556 .route("/health", get(health))
557 .route("/openapi.json", get(openapi_spec))
558 .route("/metrics", get(metrics_handler));
559
560 // Protected sub-router: every other route goes through auth and
561 // rate-limit. The rate-limit layer is the **outermost** of the
562 // two so it runs first — unauthenticated (brute-force) requests
563 // are counted against the IP-based bucket and cannot bypass
564 // limits by rotating API keys (SEC-006).
565 let protected = Router::new()
566 .route("/tools", get(list_tools))
567 .route("/run", post(run_agent))
568 .route("/sessions", post(create_session))
569 .route("/sessions", get(list_sessions))
570 .route("/sessions/{id}", get(get_session))
571 .route("/sessions/{id}", axum::routing::delete(delete_session))
572 .route("/sessions/{id}", axum::routing::patch(patch_session))
573 .route("/sessions/{id}/messages", post(send_session_message))
574 .route("/sessions/{id}/events", get(session_events))
575 .route("/sessions/{id}/plan/confirm", post(session_plan_confirm))
576 .route("/sessions/{id}/plan/reject", post(session_plan_reject))
577 .route("/sessions/{id}/goal", post(session_set_goal))
578 .route(
579 "/sessions/{id}/goal",
580 axum::routing::delete(session_clear_goal),
581 )
582 .route("/sessions/{id}/interrupt", post(session_interrupt))
583 .route("/sessions/{id}/fork", post(fork_session))
584 .route("/slash-commands", get(list_slash_commands))
585 .route("/agui", post(agui_run))
586 .layer(axum::middleware::from_fn_with_state(auth, auth_middleware))
587 .layer(axum::middleware::from_fn_with_state(
588 (limiter.clone(), state_arc.metrics.clone()),
589 rate_limit_middleware,
590 ));
591
592 // Top router: merge public + protected, then add the cross-cutting
593 // layers (metrics, body-limit) that apply to both. metrics counts
594 // every request (public + protected); body-limit caps every body.
595 Router::new()
596 .merge(public)
597 .merge(protected)
598 .layer(axum::middleware::from_fn_with_state(
599 state_arc.metrics.clone(),
600 metrics_middleware,
601 ))
602 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
603 .with_state(state_arc)
604}
605
606/// Build a static OpenAPI 3.0.3 specification describing all API endpoints.
607pub fn build_openapi_spec() -> serde_json::Value {
608 serde_json::json!({
609 "openapi": "3.0.3",
610 "info": {
611 "title": "Recursive Agent API",
612 "version": "0.4.0",
613 "description": "HTTP API for the Recursive coding agent"
614 },
615 "paths": {
616 "/health": {
617 "get": {
618 "summary": "Health check",
619 "description": "Returns 'ok' if the server is running.",
620 "responses": {
621 "200": {
622 "description": "Server is healthy",
623 "content": {
624 "text/plain": {
625 "schema": { "type": "string", "example": "ok" }
626 }
627 }
628 }
629 }
630 }
631 },
632 "/tools": {
633 "get": {
634 "summary": "List registered tools",
635 "description": "Returns the JSON array of tools available to the agent.",
636 "responses": {
637 "200": {
638 "description": "Array of tool descriptors",
639 "content": {
640 "application/json": {
641 "schema": {
642 "type": "array",
643 "items": { "$ref": "#/components/schemas/ToolInfo" }
644 }
645 }
646 }
647 }
648 }
649 }
650 },
651 "/run": {
652 "post": {
653 "summary": "Run the agent",
654 "description": "Execute the agent with a goal and return the outcome.",
655 "requestBody": {
656 "required": true,
657 "content": {
658 "application/json": {
659 "schema": { "$ref": "#/components/schemas/RunRequest" }
660 }
661 }
662 },
663 "responses": {
664 "200": {
665 "description": "Agent completed successfully",
666 "content": {
667 "application/json": {
668 "schema": { "$ref": "#/components/schemas/RunResponse" }
669 }
670 }
671 },
672 "400": {
673 "description": "Invalid request (e.g. empty goal)",
674 "content": {
675 "application/json": {
676 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
677 }
678 }
679 },
680 "422": { "description": "Request body failed deserialization" },
681 "500": {
682 "description": "Internal server error",
683 "content": {
684 "application/json": {
685 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
686 }
687 }
688 }
689 }
690 }
691 },
692 "/sessions": {
693 "get": {
694 "summary": "List sessions",
695 "description": "Returns all active sessions wrapped in an envelope \
696 with the un-paginated `total` count plus the `sessions` page slice. \
697 Clients use `total` to render \"page X of Y\" / scrollbars without \
698 having to fetch every page just to count sessions.",
699 "responses": {
700 "200": {
701 "description": "Envelope with total session count and the paginated slice",
702 "content": {
703 "application/json": {
704 "schema": { "$ref": "#/components/schemas/SessionList" }
705 }
706 }
707 }
708 }
709 },
710 "post": {
711 "summary": "Create a session",
712 "description": "Create a new multi-turn conversation session.",
713 "requestBody": {
714 "required": true,
715 "content": {
716 "application/json": {
717 "schema": { "$ref": "#/components/schemas/CreateSessionRequest" }
718 }
719 }
720 },
721 "responses": {
722 "201": {
723 "description": "Session created",
724 "content": {
725 "application/json": {
726 "schema": { "$ref": "#/components/schemas/CreateSessionResponse" }
727 }
728 }
729 }
730 }
731 }
732 },
733 "/sessions/{id}": {
734 "get": {
735 "summary": "Get session detail",
736 "description": "Returns session metadata and full message transcript.",
737 "parameters": [{
738 "name": "id",
739 "in": "path",
740 "required": true,
741 "schema": { "type": "string" }
742 }],
743 "responses": {
744 "200": {
745 "description": "Session detail with messages",
746 "content": {
747 "application/json": {
748 "schema": { "$ref": "#/components/schemas/SessionDetailResponse" }
749 }
750 }
751 },
752 "404": { "description": "Session not found" }
753 }
754 },
755 "delete": {
756 "summary": "Delete a session",
757 "description": "Remove a session and its transcript.",
758 "parameters": [{
759 "name": "id",
760 "in": "path",
761 "required": true,
762 "schema": { "type": "string" }
763 }],
764 "responses": {
765 "204": { "description": "Session deleted" },
766 "404": { "description": "Session not found" }
767 }
768 }
769 },
770 "/sessions/{id}/messages": {
771 "post": {
772 "summary": "Send a message",
773 "description": "Send a user message in a session and get the assistant response.",
774 "parameters": [{
775 "name": "id",
776 "in": "path",
777 "required": true,
778 "schema": { "type": "string" }
779 }],
780 "requestBody": {
781 "required": true,
782 "content": {
783 "application/json": {
784 "schema": { "$ref": "#/components/schemas/SessionMessageRequest" }
785 }
786 }
787 },
788 "responses": {
789 "200": {
790 "description": "Assistant response",
791 "content": {
792 "application/json": {
793 "schema": { "$ref": "#/components/schemas/SessionMessageResponse" }
794 }
795 }
796 },
797 "404": {
798 "description": "Session not found",
799 "content": {
800 "application/json": {
801 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
802 }
803 }
804 },
805 "500": {
806 "description": "Internal server error",
807 "content": {
808 "application/json": {
809 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
810 }
811 }
812 }
813 }
814 }
815 },
816 "/sessions/{id}/events": {
817 "get": {
818 "summary": "Subscribe to session events",
819 "description": "SSE stream of real-time agent events for a session.",
820 "parameters": [{
821 "name": "id",
822 "in": "path",
823 "required": true,
824 "schema": { "type": "string" }
825 }],
826 "responses": {
827 "200": {
828 "description": "SSE event stream",
829 "content": {
830 "text/event-stream": {
831 "schema": { "type": "string" }
832 }
833 }
834 },
835 "404": { "description": "Session not found" }
836 }
837 }
838 },
839 "/agui": {
840 "post": {
841 "summary": "Run an AG-UI agent",
842 "description": "Drive a recursive agent run via the AG-UI protocol \
843 (https://docs.ag-ui.com). Body is an AG-UI RunAgentInput; the \
844 response is an SSE stream of AG-UI events (RunStarted, \
845 TextMessageStart/Content/End, ToolCall*, RunFinished, ...).",
846 "requestBody": {
847 "required": true,
848 "content": {
849 "application/json": {
850 "schema": { "type": "object" },
851 "description": "AG-UI RunAgentInput payload"
852 }
853 }
854 },
855 "responses": {
856 "200": {
857 "description": "AG-UI SSE event stream",
858 "content": {
859 "text/event-stream": {
860 "schema": { "type": "string" }
861 }
862 }
863 },
864 "400": { "description": "Invalid AG-UI RunAgentInput" }
865 }
866 }
867 },
868 "/metrics": {
869 "get": {
870 "summary": "Prometheus metrics",
871 "description": "Returns Prometheus-compatible metrics exposition. \
872 Includes the standard counters (requests, agent runs, tokens) plus \
873 `recursive_sessions_active` (gauge, count of currently open sessions) \
874 and `recursive_rate_limits_rejected_total` (counter, requests rejected \
875 by rate limiting). Added in G292, documented in G298.",
876 "responses": {
877 "200": {
878 "description": "Prometheus text format",
879 "content": {
880 "text/plain": {
881 "schema": { "type": "string" }
882 }
883 }
884 }
885 }
886 }
887 },
888 "/openapi.json": {
889 "get": {
890 "summary": "OpenAPI specification",
891 "description": "Returns this OpenAPI 3.0.3 spec as JSON.",
892 "responses": {
893 "200": {
894 "description": "OpenAPI spec document",
895 "content": {
896 "application/json": {
897 "schema": { "type": "object" }
898 }
899 }
900 }
901 }
902 }
903 }
904 },
905 "components": {
906 "schemas": {
907 "ToolInfo": {
908 "type": "object",
909 "properties": {
910 "name": { "type": "string" },
911 "description": { "type": "string" },
912 "parameters": { "type": "object" }
913 },
914 "required": ["name", "description", "parameters"]
915 },
916 "RunRequest": {
917 "type": "object",
918 "properties": {
919 "goal": { "type": "string" },
920 "max_steps": { "type": "integer", "nullable": true },
921 "system_prompt": { "type": "string", "nullable": true },
922 "append_system_prompt": { "type": "string", "nullable": true },
923 "thinking_budget": { "type": "integer", "nullable": true },
924 "permission_mode": { "type": "string", "enum": ["default", "auto", "strict", "bypass"], "nullable": true },
925 "max_budget_usd": { "type": "number", "nullable": true }
926 },
927 "required": ["goal"]
928 },
929 "RunResponse": {
930 "type": "object",
931 "properties": {
932 "status": { "type": "string" },
933 "finish_reason": { "type": "string" },
934 "messages": { "type": "array", "items": { "type": "object" } },
935 "usage": { "$ref": "#/components/schemas/UsageInfo" }
936 },
937 "required": ["status", "finish_reason", "messages", "usage"]
938 },
939 "UsageInfo": {
940 "type": "object",
941 "properties": {
942 "total_steps": { "type": "integer" },
943 "total_tokens": { "type": "integer" }
944 },
945 "required": ["total_steps", "total_tokens"]
946 },
947 "ErrorResponse": {
948 "type": "object",
949 "properties": {
950 "status": { "type": "string" },
951 "error": { "type": "string" }
952 },
953 "required": ["status", "error"]
954 },
955 "CreateSessionRequest": {
956 "type": "object",
957 "properties": {
958 "system_prompt": { "type": "string", "nullable": true },
959 "append_system_prompt": { "type": "string", "nullable": true },
960 "session_name": { "type": "string", "nullable": true },
961 "max_steps": { "type": "integer", "nullable": true },
962 "thinking_budget": { "type": "integer", "nullable": true },
963 "permission_mode": { "type": "string", "enum": ["default", "auto", "strict", "bypass"], "nullable": true },
964 "max_budget_usd": { "type": "number", "nullable": true }
965 }
966 },
967 "CreateSessionResponse": {
968 "type": "object",
969 "properties": {
970 "id": { "type": "string" },
971 "created_at": { "type": "string" }
972 },
973 "required": ["id", "created_at"]
974 },
975 "SessionInfo": {
976 "type": "object",
977 "properties": {
978 "id": { "type": "string" },
979 "created_at": { "type": "string" },
980 "message_count": { "type": "integer" },
981 "title": { "type": "string", "nullable": true }
982 },
983 "required": ["id", "created_at", "message_count"]
984 },
985 "SessionList": {
986 "type": "object",
987 "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.",
988 "properties": {
989 "total": {
990 "type": "integer",
991 "description": "Number of sessions known to the server (before applying limit/offset)."
992 },
993 "sessions": {
994 "type": "array",
995 "items": { "$ref": "#/components/schemas/SessionInfo" },
996 "description": "The paginated slice of session info objects."
997 }
998 },
999 "required": ["total", "sessions"]
1000 },
1001 "SessionDetailResponse": {
1002 "type": "object",
1003 "properties": {
1004 "id": { "type": "string" },
1005 "created_at": { "type": "string" },
1006 "title": { "type": "string", "nullable": true },
1007 "messages": { "type": "array", "items": { "type": "object" } },
1008 "todos": { "type": "array", "items": { "type": "object" } },
1009 "status": {
1010 "type": "string",
1011 "description": "Session lifecycle state: idle | plan_pending_approval"
1012 },
1013 "pending_plan": { "type": "string", "nullable": true },
1014 "goal": { "type": "object", "nullable": true },
1015 "first_prompt": { "type": "string", "nullable": true },
1016 "last_prompt": { "type": "string", "nullable": true },
1017 "prompt_tokens": {
1018 "type": "integer",
1019 "description": "Cumulative prompt tokens for this session"
1020 },
1021 "completion_tokens": {
1022 "type": "integer",
1023 "description": "Cumulative completion tokens for this session"
1024 }
1025 },
1026 "required": ["id", "created_at", "messages", "status", "todos", "prompt_tokens", "completion_tokens"]
1027 },
1028 "SessionMessageRequest": {
1029 "type": "object",
1030 "properties": {
1031 "content": { "type": "string" }
1032 },
1033 "required": ["content"]
1034 },
1035 "SessionMessageResponse": {
1036 "type": "object",
1037 "properties": {
1038 "role": { "type": "string" },
1039 "content": { "type": "string" }
1040 },
1041 "required": ["role", "content"]
1042 }
1043 }
1044 }
1045 })
1046}
1047
1048/// Spawn a background task that periodically evicts idle sessions.
1049///
1050/// Every `check_interval` seconds, the reaper scans all sessions and removes
1051/// those whose `last_active` is older than `session_ttl_secs`. The reaper
1052/// calls `runtime.close()` on each evicted session so the transcript is
1053/// saved to the storage backend before the session is dropped.
1054pub fn spawn_session_reaper(
1055 state: Arc<AppState>,
1056 check_interval: std::time::Duration,
1057) -> tokio::task::JoinHandle<()> {
1058 tokio::spawn(async move {
1059 loop {
1060 tokio::time::sleep(check_interval).await;
1061 let ttl = std::time::Duration::from_secs(state.session_ttl_secs);
1062 let mut to_evict: Vec<String> = Vec::new();
1063 // Phase 1: collect eviction candidates under a read lock.
1064 {
1065 let sessions = state.sessions.read().await;
1066 for (id, session) in sessions.iter() {
1067 let last_ms = session.last_active_ms.load(Ordering::Relaxed);
1068 let elapsed = std::time::Duration::from_millis(now_session_ms() - last_ms);
1069 if elapsed >= ttl {
1070 to_evict.push(id.clone());
1071 }
1072 }
1073 }
1074 // Prune idle rate-limit buckets (goal-290). Runs every
1075 // reaper tick so the bucket map doesn't grow unboundedly.
1076 state.rate_limiter.prune().await;
1077 if to_evict.is_empty() {
1078 continue;
1079 }
1080 // Phase 2: evict under a write lock, calling close() on each.
1081 {
1082 let mut sessions = state.sessions.write().await;
1083 for id in &to_evict {
1084 if let Some(session) = sessions.remove(id) {
1085 // Try to close the runtime (best-effort — the lock
1086 // may be held by an in-flight turn).
1087 if let Ok(mut rt) = session.runtime.try_lock() {
1088 rt.close(None).await;
1089 }
1090 state
1091 .metrics
1092 .sessions_active
1093 .fetch_sub(1, Ordering::Relaxed);
1094 tracing::info!("reaper: evicted idle session {id}");
1095 }
1096 }
1097 }
1098 // Prune stale event_channels for evicted sessions.
1099 {
1100 let mut channels = state.event_channels.write().await;
1101 for id in &to_evict {
1102 channels.remove(id);
1103 }
1104 }
1105 }
1106 })
1107}
1108
1109// =====================================================================
1110// Goal 272 — auth bypass is structural (route-level merge), not string-
1111// compare inside auth_middleware. These tests pin that invariant via
1112// source-grep so a future refactor that re-introduces the string-compare
1113// breaks CI instead of silently 401-ing /openapi.json.
1114//
1115// We use the source-grep form because the existing test harness
1116// (tests/http.rs) requires a full AppState builder; a runtime test here
1117// would add a fixture crate. Per the g268 post-mortem, deterministic
1118// source-level assertions are preferred over runtime tests for invariants
1119// that are already enforced by the type system + a structural merge.
1120// =====================================================================
1121#[cfg(test)]
1122mod goal_272_route_level_auth_bypass {
1123
1124 #[test]
1125 fn public_subrouter_has_no_auth_layer() {
1126 let src = include_str!("mod.rs");
1127 // Slice the public sub-router block: from
1128 // `let public = Router::new()` to `let protected = Router::new()`.
1129 let between = src
1130 .split("let public = Router::new()")
1131 .nth(1)
1132 .expect("public sub-router block must exist");
1133 let public_block = between
1134 .split("let protected = Router::new()")
1135 .next()
1136 .expect("public block must end before protected");
1137 assert!(
1138 !public_block.contains("auth_middleware"),
1139 "public sub-router must NOT include auth_middleware layer"
1140 );
1141 assert!(
1142 !public_block.contains("rate_limit_middleware"),
1143 "public sub-router must NOT include rate_limit_middleware layer"
1144 );
1145 // All three public routes must be present.
1146 assert!(public_block.contains("/health"));
1147 assert!(public_block.contains("/openapi.json"));
1148 assert!(public_block.contains("/metrics"));
1149 }
1150
1151 #[test]
1152 fn auth_middleware_no_longer_short_circuits_on_path() {
1153 // Previously `if path == "/health" || path == "/metrics"` lived
1154 // inside `auth_middleware`. The string-compare is gone now.
1155 let auth_src = include_str!("auth.rs");
1156 assert!(
1157 !auth_src.contains("path == \"/health\""),
1158 "auth_middleware must not hardcode /health bypass"
1159 );
1160 assert!(
1161 !auth_src.contains("path == \"/metrics\""),
1162 "auth_middleware must not hardcode /metrics bypass"
1163 );
1164 assert!(
1165 !auth_src.contains("path == \"/openapi.json\""),
1166 "auth_middleware must not hardcode /openapi.json bypass"
1167 );
1168 }
1169
1170 #[test]
1171 fn protected_routes_built_with_auth_layer() {
1172 let src = include_str!("mod.rs");
1173 let protected_block = src
1174 .split("let protected = Router::new()")
1175 .nth(1)
1176 .expect("protected sub-router block must exist");
1177 let protected_end = protected_block
1178 .split(".merge(protected)")
1179 .next()
1180 .expect("protected merge call must exist");
1181 assert!(
1182 protected_end.contains("auth_middleware"),
1183 "protected sub-router must include auth_middleware layer"
1184 );
1185 assert!(
1186 protected_end.contains("rate_limit_middleware"),
1187 "protected sub-router must include rate_limit_middleware layer"
1188 );
1189 }
1190}