1mod auth;
9mod handlers;
10mod rate_limit;
11
12pub use auth::{AuthConfig, JwtConfig};
13pub use handlers::map_agent_event;
14pub use rate_limit::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, rate_limiter_from_env};
24
25use axum::{
26 routing::{get, post},
27 Router,
28};
29use std::collections::HashMap;
30use std::sync::atomic::AtomicU64;
31use std::sync::Arc;
32use tokio::sync::{broadcast, RwLock};
33
34use crate::config::Config;
35use crate::llm::LlmProvider;
36use crate::runtime::AgentRuntime;
37use crate::tools::plan_mode::PlanApprovalGate;
38use crate::tools::ToolRegistry;
39
40#[derive(Default)]
44pub struct Metrics {
45 pub requests_total: AtomicU64,
46 pub requests_active: AtomicU64,
47 pub agent_runs_total: AtomicU64,
48 pub agent_runs_success: AtomicU64,
49 pub agent_runs_failed: AtomicU64,
50 pub tokens_prompt_total: AtomicU64,
51 pub tokens_completion_total: AtomicU64,
52 pub agent_steps_total: AtomicU64,
53}
54
55pub struct SessionState {
62 pub id: String,
63 pub created_at: String,
64 pub title: Option<String>,
66 pub runtime: Arc<tokio::sync::Mutex<AgentRuntime>>,
69 pub plan_approval_gate: Arc<PlanApprovalGate>,
73 pub interrupt_token: Arc<tokio::sync::Mutex<Option<tokio_util::sync::CancellationToken>>>,
78}
79
80#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
82pub struct SessionInfo {
83 pub id: String,
84 pub created_at: String,
85 pub message_count: usize,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub title: Option<String>,
89}
90
91#[derive(serde::Deserialize, Debug)]
93pub struct CreateSessionRequest {
94 pub system_prompt: Option<String>,
95}
96
97#[derive(serde::Serialize, Debug)]
99pub struct CreateSessionResponse {
100 pub id: String,
101 pub created_at: String,
102}
103
104#[derive(serde::Deserialize, Debug)]
106pub struct SessionMessageRequest {
107 pub content: String,
108}
109
110#[derive(serde::Serialize, Debug)]
112pub struct SessionMessageResponse {
113 pub role: String,
114 pub content: String,
115}
116
117#[derive(serde::Serialize, Debug)]
119pub struct SessionDetailResponse {
120 pub id: String,
121 pub created_at: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub title: Option<String>,
125 pub messages: Vec<serde_json::Value>,
126 pub todos: Vec<crate::tools::todo::TodoItem>,
128 pub status: String,
130 pub pending_plan: Option<String>,
132 pub goal: Option<crate::runtime::GoalState>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub first_prompt: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub last_prompt: Option<String>,
140}
141
142#[derive(serde::Deserialize, Debug)]
146pub struct SetGoalRequest {
147 pub condition: String,
149 pub max_turns: Option<u32>,
151}
152
153#[derive(serde::Serialize, Debug)]
155pub struct GoalResponse {
156 pub status: String,
157}
158
159#[derive(Clone, serde::Serialize, Debug)]
163pub struct SlashCommandInfo {
164 pub name: String,
165 pub description: String,
166 pub source: String,
167 #[serde(skip_serializing_if = "Vec::is_empty")]
168 pub aliases: Vec<String>,
169 #[serde(skip_serializing_if = "String::is_empty")]
170 pub argument_hint: String,
171}
172
173#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
180#[serde(tag = "type", rename_all = "snake_case")]
181pub enum SseContentBlock {
182 Text { text: String },
184 ToolUse {
186 id: String,
187 name: String,
188 input: serde_json::Value,
189 },
190}
191
192#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
194#[serde(tag = "type", rename_all = "snake_case")]
195pub enum SseEvent {
196 Message {
201 role: String,
202 content: Vec<SseContentBlock>,
203 },
204 PartialMessage { text: String, step: usize },
207 ToolCall { name: String, step: usize },
209 ToolResult { name: String, success: bool },
211 Done {
213 finish_reason: String,
214 total_steps: usize,
215 },
216 Error { message: String },
218 PlanProposed { plan: String },
220 GoalContinuing { reason: String, turns: u32 },
222 GoalAchieved { condition: String, turns: u32 },
224 ToolProgress {
228 tool_use_id: String,
229 tool_name: String,
230 elapsed_ms: u64,
231 },
232}
233
234#[derive(Clone)]
238pub struct AppState {
239 pub tools: Vec<ToolInfo>,
240 pub tool_registry: ToolRegistry,
244 pub config: Config,
245 pub provider: Arc<dyn LlmProvider>,
246 pub sessions: Arc<RwLock<HashMap<String, SessionState>>>,
248 pub event_channels: Arc<RwLock<HashMap<String, broadcast::Sender<SseEvent>>>>,
250 pub metrics: Arc<Metrics>,
251 pub slash_commands: Arc<Vec<SlashCommandInfo>>,
254}
255
256#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
258pub struct ToolInfo {
259 pub name: String,
260 pub description: String,
261 pub parameters: serde_json::Value,
262}
263
264#[derive(serde::Deserialize, Debug)]
266pub struct RunRequest {
267 pub goal: String,
268 pub max_steps: Option<u32>,
269 pub system_prompt: Option<String>,
270}
271
272#[derive(serde::Serialize, Debug)]
274pub struct RunResponse {
275 pub status: String,
276 pub finish_reason: String,
277 pub messages: Vec<serde_json::Value>,
278 pub usage: UsageInfo,
279}
280
281#[derive(serde::Serialize, Debug)]
283pub struct UsageInfo {
284 pub total_steps: u32,
285 pub total_tokens: u64,
286}
287
288#[derive(serde::Serialize, Debug)]
290pub struct ErrorResponse {
291 pub status: String,
292 pub error: String,
293}
294
295#[derive(serde::Deserialize, Debug, Default)]
297pub struct ListSessionsQuery {
298 pub limit: Option<usize>,
300 pub offset: Option<usize>,
302}
303
304pub fn build_router(state: AppState) -> Router {
324 build_router_with_auth(state, auth_config_from_env())
325}
326
327pub fn build_router_with_auth(state: AppState, auth: AuthConfig) -> Router {
336 build_router_with_auth_and_rate_limit(state, auth, rate_limiter_from_env())
337}
338
339pub fn build_router_with_auth_and_rate_limit(
347 state: AppState,
348 auth: AuthConfig,
349 limiter: RateLimiter,
350) -> Router {
351 Router::new()
352 .route("/health", get(health))
353 .route("/tools", get(list_tools))
354 .route("/run", post(run_agent))
355 .route("/sessions", post(create_session))
356 .route("/sessions", get(list_sessions))
357 .route("/sessions/{id}", get(get_session))
358 .route("/sessions/{id}", axum::routing::delete(delete_session))
359 .route("/sessions/{id}", axum::routing::patch(patch_session))
360 .route("/sessions/{id}/messages", post(send_session_message))
361 .route("/sessions/{id}/events", get(session_events))
362 .route("/sessions/{id}/plan/confirm", post(session_plan_confirm))
363 .route("/sessions/{id}/plan/reject", post(session_plan_reject))
364 .route("/sessions/{id}/goal", post(session_set_goal))
366 .route(
367 "/sessions/{id}/goal",
368 axum::routing::delete(session_clear_goal),
369 )
370 .route("/sessions/{id}/interrupt", post(session_interrupt))
372 .route("/sessions/{id}/fork", post(fork_session))
374 .route("/slash-commands", get(list_slash_commands))
376 .route("/agui", post(agui_run))
377 .route("/openapi.json", get(openapi_spec))
378 .route("/metrics", get(metrics_handler))
379 .layer(axum::middleware::from_fn_with_state(
380 state.metrics.clone(),
381 metrics_middleware,
382 ))
383 .layer(axum::middleware::from_fn_with_state(
384 limiter,
385 rate_limit_middleware,
386 ))
387 .layer(axum::middleware::from_fn_with_state(auth, auth_middleware))
388 .with_state(Arc::new(state))
389}
390
391pub fn build_openapi_spec() -> serde_json::Value {
393 serde_json::json!({
394 "openapi": "3.0.3",
395 "info": {
396 "title": "Recursive Agent API",
397 "version": "0.4.0",
398 "description": "HTTP API for the Recursive coding agent"
399 },
400 "paths": {
401 "/health": {
402 "get": {
403 "summary": "Health check",
404 "description": "Returns 'ok' if the server is running.",
405 "responses": {
406 "200": {
407 "description": "Server is healthy",
408 "content": {
409 "text/plain": {
410 "schema": { "type": "string", "example": "ok" }
411 }
412 }
413 }
414 }
415 }
416 },
417 "/tools": {
418 "get": {
419 "summary": "List registered tools",
420 "description": "Returns the JSON array of tools available to the agent.",
421 "responses": {
422 "200": {
423 "description": "Array of tool descriptors",
424 "content": {
425 "application/json": {
426 "schema": {
427 "type": "array",
428 "items": { "$ref": "#/components/schemas/ToolInfo" }
429 }
430 }
431 }
432 }
433 }
434 }
435 },
436 "/run": {
437 "post": {
438 "summary": "Run the agent",
439 "description": "Execute the agent with a goal and return the outcome.",
440 "requestBody": {
441 "required": true,
442 "content": {
443 "application/json": {
444 "schema": { "$ref": "#/components/schemas/RunRequest" }
445 }
446 }
447 },
448 "responses": {
449 "200": {
450 "description": "Agent completed successfully",
451 "content": {
452 "application/json": {
453 "schema": { "$ref": "#/components/schemas/RunResponse" }
454 }
455 }
456 },
457 "400": {
458 "description": "Invalid request (e.g. empty goal)",
459 "content": {
460 "application/json": {
461 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
462 }
463 }
464 },
465 "422": { "description": "Request body failed deserialization" },
466 "500": {
467 "description": "Internal server error",
468 "content": {
469 "application/json": {
470 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
471 }
472 }
473 }
474 }
475 }
476 },
477 "/sessions": {
478 "get": {
479 "summary": "List sessions",
480 "description": "Returns all active sessions.",
481 "responses": {
482 "200": {
483 "description": "Array of session info objects",
484 "content": {
485 "application/json": {
486 "schema": {
487 "type": "array",
488 "items": { "$ref": "#/components/schemas/SessionInfo" }
489 }
490 }
491 }
492 }
493 }
494 },
495 "post": {
496 "summary": "Create a session",
497 "description": "Create a new multi-turn conversation session.",
498 "requestBody": {
499 "required": true,
500 "content": {
501 "application/json": {
502 "schema": { "$ref": "#/components/schemas/CreateSessionRequest" }
503 }
504 }
505 },
506 "responses": {
507 "201": {
508 "description": "Session created",
509 "content": {
510 "application/json": {
511 "schema": { "$ref": "#/components/schemas/CreateSessionResponse" }
512 }
513 }
514 }
515 }
516 }
517 },
518 "/sessions/{id}": {
519 "get": {
520 "summary": "Get session detail",
521 "description": "Returns session metadata and full message transcript.",
522 "parameters": [{
523 "name": "id",
524 "in": "path",
525 "required": true,
526 "schema": { "type": "string" }
527 }],
528 "responses": {
529 "200": {
530 "description": "Session detail with messages",
531 "content": {
532 "application/json": {
533 "schema": { "$ref": "#/components/schemas/SessionDetailResponse" }
534 }
535 }
536 },
537 "404": { "description": "Session not found" }
538 }
539 },
540 "delete": {
541 "summary": "Delete a session",
542 "description": "Remove a session and its transcript.",
543 "parameters": [{
544 "name": "id",
545 "in": "path",
546 "required": true,
547 "schema": { "type": "string" }
548 }],
549 "responses": {
550 "204": { "description": "Session deleted" },
551 "404": { "description": "Session not found" }
552 }
553 }
554 },
555 "/sessions/{id}/messages": {
556 "post": {
557 "summary": "Send a message",
558 "description": "Send a user message in a session and get the assistant response.",
559 "parameters": [{
560 "name": "id",
561 "in": "path",
562 "required": true,
563 "schema": { "type": "string" }
564 }],
565 "requestBody": {
566 "required": true,
567 "content": {
568 "application/json": {
569 "schema": { "$ref": "#/components/schemas/SessionMessageRequest" }
570 }
571 }
572 },
573 "responses": {
574 "200": {
575 "description": "Assistant response",
576 "content": {
577 "application/json": {
578 "schema": { "$ref": "#/components/schemas/SessionMessageResponse" }
579 }
580 }
581 },
582 "404": {
583 "description": "Session not found",
584 "content": {
585 "application/json": {
586 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
587 }
588 }
589 },
590 "500": {
591 "description": "Internal server error",
592 "content": {
593 "application/json": {
594 "schema": { "$ref": "#/components/schemas/ErrorResponse" }
595 }
596 }
597 }
598 }
599 }
600 },
601 "/sessions/{id}/events": {
602 "get": {
603 "summary": "Subscribe to session events",
604 "description": "SSE stream of real-time agent events for a session.",
605 "parameters": [{
606 "name": "id",
607 "in": "path",
608 "required": true,
609 "schema": { "type": "string" }
610 }],
611 "responses": {
612 "200": {
613 "description": "SSE event stream",
614 "content": {
615 "text/event-stream": {
616 "schema": { "type": "string" }
617 }
618 }
619 },
620 "404": { "description": "Session not found" }
621 }
622 }
623 },
624 "/agui": {
625 "post": {
626 "summary": "Run an AG-UI agent",
627 "description": "Drive a recursive agent run via the AG-UI protocol \
628 (https://docs.ag-ui.com). Body is an AG-UI RunAgentInput; the \
629 response is an SSE stream of AG-UI events (RunStarted, \
630 TextMessageStart/Content/End, ToolCall*, RunFinished, ...).",
631 "requestBody": {
632 "required": true,
633 "content": {
634 "application/json": {
635 "schema": { "type": "object" },
636 "description": "AG-UI RunAgentInput payload"
637 }
638 }
639 },
640 "responses": {
641 "200": {
642 "description": "AG-UI SSE event stream",
643 "content": {
644 "text/event-stream": {
645 "schema": { "type": "string" }
646 }
647 }
648 },
649 "400": { "description": "Invalid AG-UI RunAgentInput" }
650 }
651 }
652 },
653 "/openapi.json": {
654 "get": {
655 "summary": "OpenAPI specification",
656 "description": "Returns this OpenAPI 3.0.3 spec as JSON.",
657 "responses": {
658 "200": {
659 "description": "OpenAPI spec document",
660 "content": {
661 "application/json": {
662 "schema": { "type": "object" }
663 }
664 }
665 }
666 }
667 }
668 }
669 },
670 "components": {
671 "schemas": {
672 "ToolInfo": {
673 "type": "object",
674 "properties": {
675 "name": { "type": "string" },
676 "description": { "type": "string" },
677 "parameters": { "type": "object" }
678 },
679 "required": ["name", "description", "parameters"]
680 },
681 "RunRequest": {
682 "type": "object",
683 "properties": {
684 "goal": { "type": "string" },
685 "max_steps": { "type": "integer", "nullable": true },
686 "system_prompt": { "type": "string", "nullable": true }
687 },
688 "required": ["goal"]
689 },
690 "RunResponse": {
691 "type": "object",
692 "properties": {
693 "status": { "type": "string" },
694 "finish_reason": { "type": "string" },
695 "messages": { "type": "array", "items": { "type": "object" } },
696 "usage": { "$ref": "#/components/schemas/UsageInfo" }
697 },
698 "required": ["status", "finish_reason", "messages", "usage"]
699 },
700 "UsageInfo": {
701 "type": "object",
702 "properties": {
703 "total_steps": { "type": "integer" },
704 "total_tokens": { "type": "integer" }
705 },
706 "required": ["total_steps", "total_tokens"]
707 },
708 "ErrorResponse": {
709 "type": "object",
710 "properties": {
711 "status": { "type": "string" },
712 "error": { "type": "string" }
713 },
714 "required": ["status", "error"]
715 },
716 "CreateSessionRequest": {
717 "type": "object",
718 "properties": {
719 "system_prompt": { "type": "string", "nullable": true }
720 }
721 },
722 "CreateSessionResponse": {
723 "type": "object",
724 "properties": {
725 "id": { "type": "string" },
726 "created_at": { "type": "string" }
727 },
728 "required": ["id", "created_at"]
729 },
730 "SessionInfo": {
731 "type": "object",
732 "properties": {
733 "id": { "type": "string" },
734 "created_at": { "type": "string" },
735 "message_count": { "type": "integer" }
736 },
737 "required": ["id", "created_at", "message_count"]
738 },
739 "SessionDetailResponse": {
740 "type": "object",
741 "properties": {
742 "id": { "type": "string" },
743 "created_at": { "type": "string" },
744 "messages": { "type": "array", "items": { "type": "object" } }
745 },
746 "required": ["id", "created_at", "messages"]
747 },
748 "SessionMessageRequest": {
749 "type": "object",
750 "properties": {
751 "content": { "type": "string" }
752 },
753 "required": ["content"]
754 },
755 "SessionMessageResponse": {
756 "type": "object",
757 "properties": {
758 "role": { "type": "string" },
759 "content": { "type": "string" }
760 },
761 "required": ["role", "content"]
762 }
763 }
764 }
765 })
766}