Skip to main content

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}