collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! REST API routes for the web UI.

use std::sync::Arc;

use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::agent::context::ConversationContext;
use crate::agent::r#loop::AgentEvent;
use crate::agent::prompt;
use crate::project_cache;

use super::state::AppState;

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/api/health", get(health))
        .route("/api/config", get(get_config))
        .route("/api/message", post(send_message))
}

// ── Health ───────────────────────────────────────────────────────────────

#[derive(Serialize)]
struct HealthResponse {
    status: &'static str,
    version: &'static str,
    agent_active: bool,
}

async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
    Json(HealthResponse {
        status: "ok",
        version: env!("CARGO_PKG_VERSION"),
        agent_active: state.is_agent_active(),
    })
}

// ── Config (read-only) ──────────────────────────────────────────────────

#[derive(Serialize)]
struct ConfigResponse {
    model: String,
    base_url: String,
    context_max_tokens: usize,
}

async fn get_config(State(state): State<Arc<AppState>>) -> Json<ConfigResponse> {
    Json(ConfigResponse {
        model: state.config.model.clone(),
        base_url: state.config.base_url.clone(),
        context_max_tokens: state.config.context_max_tokens,
    })
}

// ── Send message ────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct MessageRequest {
    message: String,
    #[serde(default)]
    mode: Option<String>,
}

#[derive(Serialize)]
struct MessageResponse {
    accepted: bool,
    reason: Option<String>,
}

async fn send_message(
    State(state): State<Arc<AppState>>,
    Json(req): Json<MessageRequest>,
) -> (StatusCode, Json<MessageResponse>) {
    if req.message.trim().is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(MessageResponse {
                accepted: false,
                reason: Some("Empty message".into()),
            }),
        );
    }

    // Atomically transition inactive → active. Returns false if already active,
    // preventing two concurrent HTTP requests from both spawning an agent.
    if !state.try_claim_agent() {
        return (
            StatusCode::CONFLICT,
            Json(MessageResponse {
                accepted: false,
                reason: Some("Agent is already processing a request".into()),
            }),
        );
    }

    // Build or reuse conversation context.
    let context = {
        let mut ctx_guard = state.context.lock().await;
        if let Some(ctx) = ctx_guard.take() {
            ctx
        } else {
            let working_dir = state.working_dir().await;
            let snap = {
                let wd = working_dir.clone();
                let query = req.message.clone();
                tokio::task::spawn_blocking(move || {
                    project_cache::global()
                        .get_or_build(&wd)
                        .snapshot_with_query(&query, 10)
                })
                .await
                .unwrap()
            };
            let system_prompt = prompt::build_default_prompt(
                &snap.map_string,
                snap.file_count,
                snap.symbol_count,
                None,
            );
            ConversationContext::with_budget(
                system_prompt,
                state.config.context_max_tokens,
                state.config.compaction_threshold,
            )
        }
    };

    // Spawn agent execution in background (agent_active already set by try_claim_agent above).

    let (event_tx, event_rx) = mpsc::unbounded_channel::<AgentEvent>();
    let cancel = CancellationToken::new();

    let bus = state.event_bus.clone();
    let config = state.config.clone();
    let client = state.client.clone();
    let working_dir = state.working_dir().await;
    let user_msg = req.message;
    let state_clone = Arc::clone(&state);
    let lsp_manager = crate::lsp::manager::LspManager::new(working_dir.clone());

    // Bridge: forward AgentEvents to broadcast WebEvents
    // and capture the returned context.
    tokio::spawn(async move {
        let bridge = super::spawn_event_bridge(event_rx, bus, state_clone.clone());

        // Honour the optional `mode` field: "architect" runs the two-phase architect→code pipeline.
        let agent_mode = req.mode.as_deref().unwrap_or("default");
        let params = crate::agent::r#loop::AgentParams {
            client,
            config,
            context,
            user_msg,
            working_dir,
            event_tx,
            cancel,
            lsp_manager,
            trust_level: crate::trust::TrustLevel::Full,
            approval_gate: crate::agent::approval::ApprovalGate::headless(
                state_clone.approve_mode.clone(),
            ),
            images: Vec::new(),
        };
        if agent_mode == "architect" {
            crate::agent::r#loop::run_architect_code_loop(params).await;
        } else {
            crate::agent::r#loop::run_with_mode(params).await;
        }

        // Wait for bridge to finish forwarding.
        let _ = bridge.await;
        state_clone.set_agent_active(false);
    });

    (
        StatusCode::ACCEPTED,
        Json(MessageResponse {
            accepted: true,
            reason: None,
        }),
    )
}