use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{apply_recipe_to_agent, build_recipe_with_parameter_values}; use crate::state::AppState; use axum::extract::{DefaultBodyLimit, State}; use axum::routing::post; use axum::{ extract::Path, http::StatusCode, routing::{delete, get, put}, Json, Router, }; use goose::agents::ExtensionConfig; use goose::recipe::Recipe; use goose::session::session_manager::SessionInsights; use goose::session::{EnabledExtensionsState, Session}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SessionListResponse { /// List of available session information objects sessions: Vec, } #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateSessionNameRequest { /// Updated name for the session (max 200 characters) name: String, } #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateSessionUserRecipeValuesRequest { /// Recipe parameter values entered by the user user_recipe_values: HashMap, } #[derive(Debug, Serialize, ToSchema)] pub struct UpdateSessionUserRecipeValuesResponse { recipe: Recipe, } #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ImportSessionRequest { json: String, } #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ForkRequest { timestamp: Option, truncate: bool, copy: bool, } #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ForkResponse { session_id: String, } const MAX_NAME_LENGTH: usize = 200; #[utoipa::path( get, path = "/sessions", responses( (status = 200, description = "List of available sessions retrieved successfully", body = SessionListResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn list_sessions( State(state): State>, ) -> Result, StatusCode> { let sessions = state .session_manager() .list_sessions() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(SessionListResponse { sessions })) } #[utoipa::path( get, path = "/sessions/{session_id}", params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session history retrieved successfully", body = Session), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn get_session( State(state): State>, Path(session_id): Path, ) -> Result, StatusCode> { let session = state .session_manager() .get_session(&session_id, true) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(session)) } #[utoipa::path( get, path = "/sessions/insights", responses( (status = 200, description = "Session insights retrieved successfully", body = SessionInsights), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn get_session_insights( State(state): State>, ) -> Result, StatusCode> { let insights = state .session_manager() .get_insights() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(insights)) } #[utoipa::path( put, path = "/sessions/{session_id}/name", request_body = UpdateSessionNameRequest, params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session name updated successfully"), (status = 400, description = "Bad request - Name too long (max 200 characters)"), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn update_session_name( State(state): State>, Path(session_id): Path, Json(request): Json, ) -> Result { let name = request.name.trim(); if name.is_empty() { return Err(StatusCode::BAD_REQUEST); } if name.len() > MAX_NAME_LENGTH { return Err(StatusCode::BAD_REQUEST); } state .session_manager() .update(&session_id) .user_provided_name(name.to_string()) .apply() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } #[utoipa::path( put, path = "/sessions/{session_id}/user_recipe_values", request_body = UpdateSessionUserRecipeValuesRequest, params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session user recipe values updated successfully", body = UpdateSessionUserRecipeValuesResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), security( ("api_key" = []) ), tag = "Session Management" )] // Update session user recipe parameter values async fn update_session_user_recipe_values( State(state): State>, Path(session_id): Path, Json(request): Json, ) -> Result, ErrorResponse> { state .session_manager() .update(&session_id) .user_recipe_values(Some(request.user_recipe_values)) .apply() .await .map_err(|err| ErrorResponse { message: err.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, })?; let session = state .session_manager() .get_session(&session_id, false) .await .map_err(|err| ErrorResponse { message: err.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, })?; let recipe = session.recipe.ok_or_else(|| ErrorResponse { message: "Recipe not found".to_string(), status: StatusCode::NOT_FOUND, })?; let user_recipe_values = session.user_recipe_values.unwrap_or_default(); match build_recipe_with_parameter_values(&recipe, user_recipe_values).await { Ok(Some(recipe)) => { let agent = state .get_agent_for_route(session_id.clone()) .await .map_err(|status| ErrorResponse { message: format!("Failed to get agent: {}", status), status, })?; if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, false).await { agent .extend_system_prompt("recipe".to_string(), prompt) .await; } Ok(Json(UpdateSessionUserRecipeValuesResponse { recipe })) } Ok(None) => Err(ErrorResponse { message: "Missing required parameters".to_string(), status: StatusCode::BAD_REQUEST, }), Err(e) => Err(ErrorResponse { message: e.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, }), } } #[utoipa::path( delete, path = "/sessions/{session_id}", params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session deleted successfully"), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn delete_session( State(state): State>, Path(session_id): Path, ) -> Result { state .session_manager() .delete_session(&session_id) .await .map_err(|e| { if e.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR } })?; // Cancel any in-flight replies before dropping the bus, so spawned // agent tasks stop consuming tokens for a deleted session. if let Some(bus) = state.get_event_bus(&session_id).await { bus.cancel_all_requests().await; } state.remove_event_bus(&session_id).await; Ok(StatusCode::OK) } #[utoipa::path( get, path = "/sessions/{session_id}/export", params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session exported successfully", body = String), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn export_session( State(state): State>, Path(session_id): Path, ) -> Result, StatusCode> { let exported = state .session_manager() .export_session(&session_id) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(exported)) } #[utoipa::path( post, path = "/sessions/import", request_body = ImportSessionRequest, responses( (status = 200, description = "Session imported successfully", body = Session), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 400, description = "Bad request - Invalid JSON"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn import_session( State(state): State>, Json(request): Json, ) -> Result, StatusCode> { let session = state .session_manager() .import_session(&request.json) .await .map_err(|_| StatusCode::BAD_REQUEST)?; Ok(Json(session)) } #[utoipa::path( post, path = "/sessions/{session_id}/fork", request_body = ForkRequest, params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session forked successfully", body = ForkResponse), (status = 400, description = "Bad request - truncate=true requires timestamp"), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn fork_session( State(state): State>, Path(session_id): Path, Json(request): Json, ) -> Result, ErrorResponse> { if request.truncate && request.timestamp.is_none() { return Err(ErrorResponse { message: "truncate=true requires a timestamp".to_string(), status: StatusCode::BAD_REQUEST, }); } let session_manager = state.session_manager(); let target_session_id = if request.copy { let original = session_manager .get_session(&session_id, false) .await .map_err(|e| { tracing::error!("Failed to get session: {}", e); goose::posthog::emit_error("session_get_failed", &e.to_string()); ErrorResponse { message: if e.to_string().contains("not found") { format!("Session {} not found", session_id) } else { format!("Failed to get session: {}", e) }, status: if e.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }, } })?; let copied = session_manager .copy_session(&session_id, original.name) .await .map_err(|e| { tracing::error!("Failed to copy session: {}", e); goose::posthog::emit_error("session_copy_failed", &e.to_string()); ErrorResponse { message: format!("Failed to copy session: {}", e), status: StatusCode::INTERNAL_SERVER_ERROR, } })?; copied.id } else { session_id.clone() }; if request.truncate { session_manager .truncate_conversation(&target_session_id, request.timestamp.unwrap_or(0)) .await .map_err(|e| { tracing::error!("Failed to truncate conversation: {}", e); goose::posthog::emit_error("session_truncate_failed", &e.to_string()); ErrorResponse { message: format!("Failed to truncate conversation: {}", e), status: StatusCode::INTERNAL_SERVER_ERROR, } })?; } Ok(Json(ForkResponse { session_id: target_session_id, })) } #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SessionExtensionsResponse { extensions: Vec, } #[utoipa::path( get, path = "/sessions/{session_id}/extensions", params( ("session_id" = String, Path, description = "Unique identifier for the session") ), responses( (status = 200, description = "Session extensions retrieved successfully", body = SessionExtensionsResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn get_session_extensions( State(state): State>, Path(session_id): Path, ) -> Result, StatusCode> { let session = state .session_manager() .get_session(&session_id, false) .await .map_err(|_| StatusCode::NOT_FOUND)?; let extensions = EnabledExtensionsState::extensions_or_default( Some(&session.extension_data), goose::config::Config::global(), ); Ok(Json(SessionExtensionsResponse { extensions })) } pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) .route("/sessions/search", get(search_sessions)) .route("/sessions/{session_id}", get(get_session)) .route("/sessions/{session_id}", delete(delete_session)) .route("/sessions/{session_id}/export", get(export_session)) .route( "/sessions/import", post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)), ) .route("/sessions/insights", get(get_session_insights)) .route("/sessions/{session_id}/name", put(update_session_name)) .route( "/sessions/{session_id}/user_recipe_values", put(update_session_user_recipe_values), ) .route("/sessions/{session_id}/fork", post(fork_session)) .route( "/sessions/{session_id}/extensions", get(get_session_extensions), ) .with_state(state) } #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SearchSessionsQuery { /// Search query string (keywords separated by spaces) query: String, /// Maximum number of results to return (default: 10, max: 50) #[serde(default = "default_limit")] limit: usize, /// Filter results to sessions after this date (ISO 8601 format) after_date: Option, /// Filter results to sessions before this date (ISO 8601 format) before_date: Option, } fn default_limit() -> usize { 10 } #[utoipa::path( get, path = "/sessions/search", params( ("query" = String, Query, description = "Search query string"), ("limit" = Option, Query, description = "Maximum results (default: 10, max: 50)"), ("after_date" = Option, Query, description = "Filter after date (ISO 8601)"), ("before_date" = Option, Query, description = "Filter before date (ISO 8601)") ), responses( (status = 200, description = "Matching sessions", body = Vec), (status = 400, description = "Bad request - Invalid query"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("api_key" = []) ), tag = "Session Management" )] async fn search_sessions( State(state): State>, axum::extract::Query(params): axum::extract::Query, ) -> Result>, StatusCode> { let query = params.query.trim(); if query.is_empty() { return Err(StatusCode::BAD_REQUEST); } let limit = params.limit.min(50); let after_date = params .after_date .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&chrono::Utc)); let before_date = params .before_date .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&chrono::Utc)); let search_results = state .session_manager() .search_chat_history(query, Some(limit), after_date, before_date, None) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Get full Session objects for matching session IDs let session_ids: Vec = search_results .results .into_iter() .map(|r| r.session_id) .collect(); let all_sessions = state .session_manager() .list_sessions() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let matching_sessions: Vec = all_sessions .into_iter() .filter(|s| session_ids.contains(&s.id)) .collect(); Ok(Json(matching_sessions)) }