Session manager (#4648)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -3,10 +3,11 @@ use goose::agents::extension::ToolInfo;
|
||||
use goose::agents::ExtensionConfig;
|
||||
use goose::config::permission::PermissionLevel;
|
||||
use goose::config::ExtensionEntry;
|
||||
use goose::conversation::Conversation;
|
||||
use goose::permission::permission_confirmation::PrincipalType;
|
||||
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
|
||||
use goose::session::info::SessionInfo;
|
||||
use goose::session::SessionMetadata;
|
||||
|
||||
use goose::session::{Session, SessionInsights};
|
||||
use rmcp::model::{
|
||||
Annotations, Content, EmbeddedResource, ImageContent, RawEmbeddedResource, RawImageContent,
|
||||
RawResource, RawTextContent, ResourceContents, Role, TextContent, Tool, ToolAnnotations,
|
||||
@@ -45,8 +46,6 @@ macro_rules! derive_utoipa {
|
||||
}
|
||||
|
||||
fn convert_schemars_to_utoipa(schema: rmcp::schemars::Schema) -> RefOr<Schema> {
|
||||
// For schemars 1.0+, we need to work with the public API
|
||||
// The schema is now a wrapper around a JSON Value that can be either an object or bool
|
||||
if let Some(true) = schema.as_bool() {
|
||||
return RefOr::T(Schema::Object(ObjectBuilder::new().build()));
|
||||
}
|
||||
@@ -55,12 +54,10 @@ fn convert_schemars_to_utoipa(schema: rmcp::schemars::Schema) -> RefOr<Schema> {
|
||||
return RefOr::T(Schema::Object(ObjectBuilder::new().build()));
|
||||
}
|
||||
|
||||
// For object schemas, we'll need to work with the JSON Value directly
|
||||
if let Some(obj) = schema.as_object() {
|
||||
return convert_json_object_to_utoipa(obj);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
RefOr::T(Schema::Object(ObjectBuilder::new().build()))
|
||||
}
|
||||
|
||||
@@ -69,12 +66,10 @@ fn convert_json_object_to_utoipa(
|
||||
) -> RefOr<Schema> {
|
||||
use serde_json::Value;
|
||||
|
||||
// Handle $ref
|
||||
if let Some(Value::String(reference)) = obj.get("$ref") {
|
||||
return RefOr::Ref(Ref::new(reference.clone()));
|
||||
}
|
||||
|
||||
// Handle oneOf, allOf, anyOf
|
||||
if let Some(Value::Array(one_of)) = obj.get("oneOf") {
|
||||
let mut builder = OneOfBuilder::new();
|
||||
for item in one_of {
|
||||
@@ -105,11 +100,9 @@ fn convert_json_object_to_utoipa(
|
||||
return RefOr::T(Schema::AnyOf(builder.build()));
|
||||
}
|
||||
|
||||
// Handle type-based schemas
|
||||
match obj.get("type") {
|
||||
Some(Value::String(type_str)) => convert_typed_schema(type_str, obj),
|
||||
Some(Value::Array(types)) => {
|
||||
// Multiple types - use AnyOf
|
||||
let mut builder = AnyOfBuilder::new();
|
||||
for type_val in types {
|
||||
if let Value::String(type_str) = type_val {
|
||||
@@ -119,7 +112,7 @@ fn convert_json_object_to_utoipa(
|
||||
RefOr::T(Schema::AnyOf(builder.build()))
|
||||
}
|
||||
None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
|
||||
_ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), // Handle other value types
|
||||
_ => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +126,6 @@ fn convert_typed_schema(
|
||||
"object" => {
|
||||
let mut object_builder = ObjectBuilder::new();
|
||||
|
||||
// Add properties
|
||||
if let Some(Value::Object(properties)) = obj.get("properties") {
|
||||
for (name, prop_value) in properties {
|
||||
if let Ok(prop_schema) = rmcp::schemars::Schema::try_from(prop_value.clone()) {
|
||||
@@ -143,7 +135,6 @@ fn convert_typed_schema(
|
||||
}
|
||||
}
|
||||
|
||||
// Add required fields
|
||||
if let Some(Value::Array(required)) = obj.get("required") {
|
||||
for req in required {
|
||||
if let Value::String(field_name) = req {
|
||||
@@ -152,7 +143,6 @@ fn convert_typed_schema(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle additional properties
|
||||
if let Some(additional) = obj.get("additionalProperties") {
|
||||
match additional {
|
||||
Value::Bool(false) => {
|
||||
@@ -178,7 +168,6 @@ fn convert_typed_schema(
|
||||
"array" => {
|
||||
let mut array_builder = ArrayBuilder::new();
|
||||
|
||||
// Add items schema
|
||||
if let Some(items) = obj.get("items") {
|
||||
match items {
|
||||
Value::Object(_) | Value::Bool(_) => {
|
||||
@@ -188,7 +177,6 @@ fn convert_typed_schema(
|
||||
}
|
||||
}
|
||||
Value::Array(item_schemas) => {
|
||||
// Multiple item types - use AnyOf
|
||||
let mut any_of = AnyOfBuilder::new();
|
||||
for item in item_schemas {
|
||||
if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) {
|
||||
@@ -202,7 +190,6 @@ fn convert_typed_schema(
|
||||
}
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
if let Some(Value::Number(min_items)) = obj.get("minItems") {
|
||||
if let Some(min) = min_items.as_u64() {
|
||||
array_builder = array_builder.min_items(Some(min as usize));
|
||||
@@ -333,8 +320,6 @@ struct AnnotatedSchema {}
|
||||
|
||||
impl<'__s> ToSchema<'__s> for AnnotatedSchema {
|
||||
fn schema() -> (&'__s str, utoipa::openapi::RefOr<utoipa::openapi::Schema>) {
|
||||
// Create a oneOf schema with only the variants we actually use in the API
|
||||
// This avoids the circular reference from RawContent::Audio(AudioContent)
|
||||
let schema = Schema::OneOf(
|
||||
OneOfBuilder::new()
|
||||
.item(RefOr::Ref(Ref::new("#/components/schemas/RawTextContent")))
|
||||
@@ -352,7 +337,6 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used by utoipa for OpenAPI generation
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
@@ -384,7 +368,10 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
|
||||
super::routes::reply::confirm_permission,
|
||||
super::routes::context::manage_context,
|
||||
super::routes::session::list_sessions,
|
||||
super::routes::session::get_session_history,
|
||||
super::routes::session::get_session,
|
||||
super::routes::session::get_session_insights,
|
||||
super::routes::session::update_session_description,
|
||||
super::routes::session::delete_session,
|
||||
super::routes::schedule::create_schedule,
|
||||
super::routes::schedule::list_schedules,
|
||||
super::routes::schedule::delete_schedule,
|
||||
@@ -419,7 +406,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
|
||||
super::routes::context::ContextManageRequest,
|
||||
super::routes::context::ContextManageResponse,
|
||||
super::routes::session::SessionListResponse,
|
||||
super::routes::session::SessionHistoryResponse,
|
||||
super::routes::session::UpdateSessionDescriptionRequest,
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageMetadata,
|
||||
@@ -454,9 +441,10 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
|
||||
PermissionLevel,
|
||||
PrincipalType,
|
||||
ModelInfo,
|
||||
SessionInfo,
|
||||
SessionMetadata,
|
||||
goose::session::ExtensionData,
|
||||
Session,
|
||||
SessionInsights,
|
||||
Conversation,
|
||||
goose::session::extension_data::ExtensionData,
|
||||
super::routes::schedule::CreateScheduleRequest,
|
||||
super::routes::schedule::UpdateScheduleRequest,
|
||||
super::routes::schedule::KillJobResponse,
|
||||
@@ -498,7 +486,6 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
|
||||
super::routes::agent::UpdateRouterToolSelectorRequest,
|
||||
super::routes::agent::StartAgentRequest,
|
||||
super::routes::agent::ResumeAgentRequest,
|
||||
super::routes::agent::StartAgentResponse,
|
||||
super::routes::agent::ErrorResponse,
|
||||
super::routes::setup::SetupResponse,
|
||||
))
|
||||
|
||||
@@ -6,13 +6,11 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use goose::config::PermissionManager;
|
||||
use goose::conversation::message::Message;
|
||||
use goose::conversation::Conversation;
|
||||
|
||||
use goose::model::ModelConfig;
|
||||
use goose::providers::create;
|
||||
use goose::recipe::{Recipe, Response};
|
||||
use goose::session;
|
||||
use goose::session::SessionMetadata;
|
||||
use goose::session::{Session, SessionManager};
|
||||
use goose::{
|
||||
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
|
||||
config::permission::PermissionLevel,
|
||||
@@ -22,7 +20,6 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ExtendPromptRequest {
|
||||
@@ -81,14 +78,6 @@ pub struct ResumeAgentRequest {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
// This is the same as SessionHistoryResponse
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct StartAgentResponse {
|
||||
session_id: String,
|
||||
metadata: SessionMetadata,
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
error: String,
|
||||
@@ -99,7 +88,7 @@ pub struct ErrorResponse {
|
||||
path = "/agent/start",
|
||||
request_body = StartAgentRequest,
|
||||
responses(
|
||||
(status = 200, description = "Agent started successfully", body = StartAgentResponse),
|
||||
(status = 200, description = "Agent started successfully", body = Session),
|
||||
(status = 400, description = "Bad request - invalid working directory"),
|
||||
(status = 401, description = "Unauthorized - invalid secret key"),
|
||||
(status = 500, description = "Internal server error")
|
||||
@@ -108,39 +97,28 @@ pub struct ErrorResponse {
|
||||
async fn start_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<StartAgentRequest>,
|
||||
) -> Result<Json<StartAgentResponse>, StatusCode> {
|
||||
let session_id = session::generate_session_id();
|
||||
) -> Result<Json<Session>, StatusCode> {
|
||||
let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let description = format!("New session {}", counter);
|
||||
|
||||
let metadata = SessionMetadata {
|
||||
working_dir: PathBuf::from(&payload.working_dir),
|
||||
description: format!("New session {}", counter),
|
||||
schedule_id: None,
|
||||
message_count: 0,
|
||||
total_tokens: Some(0),
|
||||
input_tokens: Some(0),
|
||||
output_tokens: Some(0),
|
||||
accumulated_total_tokens: Some(0),
|
||||
accumulated_input_tokens: Some(0),
|
||||
accumulated_output_tokens: Some(0),
|
||||
extension_data: Default::default(),
|
||||
recipe: payload.recipe,
|
||||
};
|
||||
let mut session =
|
||||
SessionManager::create_session(PathBuf::from(&payload.working_dir), description)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
if let Some(recipe) = payload.recipe {
|
||||
SessionManager::update_session(&session.id)
|
||||
.recipe(Some(recipe))
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let conversation = Conversation::empty();
|
||||
session::storage::save_messages_with_metadata(&session_path, &metadata, &conversation)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
session = SessionManager::get_session(&session.id, false)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
Ok(Json(StartAgentResponse {
|
||||
session_id,
|
||||
metadata,
|
||||
messages: conversation.messages().clone(),
|
||||
}))
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -148,7 +126,7 @@ async fn start_agent(
|
||||
path = "/agent/resume",
|
||||
request_body = ResumeAgentRequest,
|
||||
responses(
|
||||
(status = 200, description = "Agent started successfully", body = StartAgentResponse),
|
||||
(status = 200, description = "Agent started successfully", body = Session),
|
||||
(status = 400, description = "Bad request - invalid working directory"),
|
||||
(status = 401, description = "Unauthorized - invalid secret key"),
|
||||
(status = 500, description = "Internal server error")
|
||||
@@ -156,28 +134,12 @@ async fn start_agent(
|
||||
)]
|
||||
async fn resume_agent(
|
||||
Json(payload): Json<ResumeAgentRequest>,
|
||||
) -> Result<Json<StartAgentResponse>, StatusCode> {
|
||||
let session_path =
|
||||
match session::get_path(session::Identifier::Name(payload.session_id.clone())) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
) -> Result<Json<Session>, StatusCode> {
|
||||
let session = SessionManager::get_session(&payload.session_id, true)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let conversation = match session::read_messages(&session_path) {
|
||||
Ok(messages) => messages,
|
||||
Err(e) => {
|
||||
error!("Failed to read session messages: {:?}", e);
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(StartAgentResponse {
|
||||
session_id: payload.session_id.clone(),
|
||||
metadata,
|
||||
messages: conversation.messages().clone(),
|
||||
}))
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -822,8 +822,8 @@ mod tests {
|
||||
let status_code = result.unwrap_err();
|
||||
|
||||
assert!(status_code == StatusCode::BAD_REQUEST,
|
||||
"Expected BAD_REQUEST (authentication error) or INTERNAL_SERVER_ERROR (other errors), got: {}",
|
||||
status_code
|
||||
"Expected BAD_REQUEST (authentication error) or INTERNAL_SERVER_ERROR (other errors), got: {}",
|
||||
status_code
|
||||
);
|
||||
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
|
||||
@@ -11,14 +11,12 @@ use futures::{stream::StreamExt, Stream};
|
||||
use goose::conversation::message::{Message, MessageContent};
|
||||
use goose::conversation::Conversation;
|
||||
use goose::execution::SessionExecutionMode;
|
||||
use goose::permission::{Permission, PermissionConfirmation};
|
||||
use goose::session::SessionManager;
|
||||
use goose::{
|
||||
agents::{AgentEvent, SessionConfig},
|
||||
permission::permission_confirmation::PrincipalType,
|
||||
};
|
||||
use goose::{
|
||||
permission::{Permission, PermissionConfirmation},
|
||||
session,
|
||||
};
|
||||
use mcp_core::ToolResult;
|
||||
use rmcp::model::{Content, ServerNotification};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -228,30 +226,13 @@ async fn reply_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Load session metadata to get the working directory and other config
|
||||
let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get session path for {}: {}", session_id, e);
|
||||
let _ = stream_event(
|
||||
MessageEvent::Error {
|
||||
error: format!("Failed to get session path: {}", e),
|
||||
},
|
||||
&task_tx,
|
||||
&cancel_token,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let session_metadata = match session::read_metadata(&session_path) {
|
||||
let session = match SessionManager::get_session(&session_id, false).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read session metadata for {}: {}", session_id, e);
|
||||
tracing::error!("Failed to read session for {}: {}", session_id, e);
|
||||
let _ = stream_event(
|
||||
MessageEvent::Error {
|
||||
error: format!("Failed to read session metadata: {}", e),
|
||||
error: format!("Failed to read session: {}", e),
|
||||
},
|
||||
&task_tx,
|
||||
&cancel_token,
|
||||
@@ -262,9 +243,9 @@ async fn reply_handler(
|
||||
};
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session::Identifier::Name(session_id.clone()),
|
||||
working_dir: session_metadata.working_dir.clone(),
|
||||
schedule_id: session_metadata.schedule_id.clone(),
|
||||
id: session_id.clone(),
|
||||
working_dir: session.working_dir.clone(),
|
||||
schedule_id: session.schedule_id.clone(),
|
||||
execution_mode: None,
|
||||
max_turns: None,
|
||||
retry_config: None,
|
||||
@@ -294,22 +275,6 @@ async fn reply_handler(
|
||||
};
|
||||
|
||||
let mut all_messages = messages.clone();
|
||||
let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get session path: {}", e);
|
||||
let _ = stream_event(
|
||||
MessageEvent::Error {
|
||||
error: format!("Failed to get session path: {}", e),
|
||||
},
|
||||
&task_tx,
|
||||
&cancel_token,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let saved_message_count = all_messages.len();
|
||||
|
||||
let mut heartbeat_interval = tokio::time::interval(Duration::from_millis(500));
|
||||
loop {
|
||||
@@ -376,40 +341,18 @@ async fn reply_handler(
|
||||
}
|
||||
}
|
||||
|
||||
if all_messages.len() > saved_message_count {
|
||||
if let Ok(provider) = agent.provider().await {
|
||||
let provider = Arc::clone(&provider);
|
||||
let session_path_clone = session_path.to_path_buf();
|
||||
let all_messages_clone = all_messages.clone();
|
||||
let working_dir = session_config.working_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = session::persist_messages(
|
||||
&session_path_clone,
|
||||
&all_messages_clone,
|
||||
Some(provider),
|
||||
Some(working_dir),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to store session history: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let session_duration = session_start.elapsed();
|
||||
|
||||
if let Ok(metadata) = session::read_metadata(&session_path) {
|
||||
let total_tokens = metadata.total_tokens.unwrap_or(0);
|
||||
let message_count = metadata.message_count;
|
||||
|
||||
if let Ok(session) = SessionManager::get_session(&session_id, true).await {
|
||||
let total_tokens = session.total_tokens.unwrap_or(0);
|
||||
tracing::info!(
|
||||
counter.goose.session_completions = 1,
|
||||
session_type = "app",
|
||||
interface = "ui",
|
||||
exit_type = "normal",
|
||||
duration_ms = session_duration.as_millis() as u64,
|
||||
total_tokens,
|
||||
message_count,
|
||||
total_tokens = total_tokens,
|
||||
message_count = session.message_count,
|
||||
"Session completed"
|
||||
);
|
||||
|
||||
|
||||
@@ -319,24 +319,23 @@ async fn sessions_handler(
|
||||
.await
|
||||
{
|
||||
Ok(session_tuples) => {
|
||||
// Expecting Vec<(String, goose::session::storage::SessionMetadata)>
|
||||
let display_infos: Vec<SessionDisplayInfo> = session_tuples
|
||||
.into_iter()
|
||||
.map(|(session_name, metadata)| SessionDisplayInfo {
|
||||
let mut display_infos = Vec::new();
|
||||
for (session_name, session) in session_tuples {
|
||||
display_infos.push(SessionDisplayInfo {
|
||||
id: session_name.clone(),
|
||||
name: metadata.description, // Use description as name
|
||||
name: session.description,
|
||||
created_at: parse_session_name_to_iso(&session_name),
|
||||
working_dir: metadata.working_dir.to_string_lossy().into_owned(),
|
||||
schedule_id: metadata.schedule_id, // This is the ID of the schedule itself
|
||||
message_count: metadata.message_count,
|
||||
total_tokens: metadata.total_tokens,
|
||||
input_tokens: metadata.input_tokens,
|
||||
output_tokens: metadata.output_tokens,
|
||||
accumulated_total_tokens: metadata.accumulated_total_tokens,
|
||||
accumulated_input_tokens: metadata.accumulated_input_tokens,
|
||||
accumulated_output_tokens: metadata.accumulated_output_tokens,
|
||||
})
|
||||
.collect();
|
||||
working_dir: session.working_dir.to_string_lossy().into_owned(),
|
||||
schedule_id: session.schedule_id,
|
||||
message_count: session.message_count,
|
||||
total_tokens: session.total_tokens,
|
||||
input_tokens: session.input_tokens,
|
||||
output_tokens: session.output_tokens,
|
||||
accumulated_total_tokens: session.accumulated_total_tokens,
|
||||
accumulated_input_tokens: session.accumulated_input_tokens,
|
||||
accumulated_output_tokens: session.accumulated_output_tokens,
|
||||
});
|
||||
}
|
||||
Ok(Json(display_infos))
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::Path,
|
||||
@@ -9,65 +5,28 @@ use axum::{
|
||||
routing::{delete, get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use goose::conversation::message::Message;
|
||||
use goose::session;
|
||||
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
||||
use goose::session::SessionMetadata;
|
||||
use goose::session::session_manager::SessionInsights;
|
||||
use goose::session::{Session, SessionManager};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
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<SessionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionHistoryResponse {
|
||||
/// Unique identifier for the session
|
||||
session_id: String,
|
||||
/// Session metadata containing creation time and other details
|
||||
metadata: SessionMetadata,
|
||||
/// List of messages in the session conversation
|
||||
messages: Vec<Message>,
|
||||
sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateSessionMetadataRequest {
|
||||
pub struct UpdateSessionDescriptionRequest {
|
||||
/// Updated description (name) for the session (max 200 characters)
|
||||
description: String,
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH: usize = 200;
|
||||
|
||||
#[derive(Serialize, ToSchema, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInsights {
|
||||
/// Total number of sessions
|
||||
total_sessions: usize,
|
||||
/// Most active working directories with session counts
|
||||
most_active_dirs: Vec<(String, usize)>,
|
||||
/// Average session duration in minutes
|
||||
avg_session_duration: f64,
|
||||
/// Total tokens used across all sessions
|
||||
total_tokens: i64,
|
||||
/// Activity trend for the last 7 days
|
||||
recent_activity: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Debug)]
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActivityHeatmapCell {
|
||||
pub week: usize,
|
||||
pub day: usize,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/sessions",
|
||||
@@ -81,9 +40,9 @@ pub struct ActivityHeatmapCell {
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
// List all available sessions
|
||||
async fn list_sessions() -> Result<Json<SessionListResponse>, StatusCode> {
|
||||
let sessions = get_valid_sorted_sessions(SortOrder::Descending)
|
||||
let sessions = SessionManager::list_sessions()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(SessionListResponse { sessions }))
|
||||
@@ -96,7 +55,7 @@ async fn list_sessions() -> Result<Json<SessionListResponse>, StatusCode> {
|
||||
("session_id" = String, Path, description = "Unique identifier for the session")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Session history retrieved successfully", body = SessionHistoryResponse),
|
||||
(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")
|
||||
@@ -106,40 +65,13 @@ async fn list_sessions() -> Result<Json<SessionListResponse>, StatusCode> {
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
// Get a specific session's history
|
||||
async fn get_session_history(
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Json<SessionHistoryResponse>, StatusCode> {
|
||||
let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
async fn get_session(Path(session_id): Path<String>) -> Result<Json<Session>, StatusCode> {
|
||||
let session = SessionManager::get_session(&session_id, true)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let messages = match session::read_messages(&session_path) {
|
||||
Ok(messages) => messages,
|
||||
Err(e) => {
|
||||
error!("Failed to read session messages: {:?}", e);
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter messages to only include user_visible ones
|
||||
let user_visible_messages: Vec<Message> = messages
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|m| m.is_user_visible())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(Json(SessionHistoryResponse {
|
||||
session_id,
|
||||
metadata,
|
||||
messages: user_visible_messages,
|
||||
}))
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/sessions/insights",
|
||||
@@ -154,115 +86,21 @@ async fn get_session_history(
|
||||
tag = "Session Management"
|
||||
)]
|
||||
async fn get_session_insights() -> Result<Json<SessionInsights>, StatusCode> {
|
||||
info!("Received request for session insights");
|
||||
|
||||
let sessions = get_valid_sorted_sessions(SortOrder::Descending).map_err(|e| {
|
||||
error!("Failed to get session info: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Filter out sessions without descriptions
|
||||
let sessions: Vec<SessionInfo> = sessions
|
||||
.into_iter()
|
||||
.filter(|session| !session.metadata.description.is_empty())
|
||||
.collect();
|
||||
|
||||
info!("Found {} sessions with descriptions", sessions.len());
|
||||
|
||||
// Calculate insights
|
||||
let total_sessions = sessions.len();
|
||||
|
||||
// Debug: Log if we have very few sessions, which might indicate filtering issues
|
||||
if total_sessions == 0 {
|
||||
info!("Warning: No sessions found with descriptions");
|
||||
}
|
||||
|
||||
// Track directory usage
|
||||
let mut dir_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut total_duration = 0.0;
|
||||
let mut total_tokens = 0;
|
||||
let mut activity_by_date: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for session in &sessions {
|
||||
// Track directory usage
|
||||
let dir = session.metadata.working_dir.to_string_lossy().to_string();
|
||||
*dir_counts.entry(dir).or_insert(0) += 1;
|
||||
|
||||
// Track tokens - only add positive values to prevent negative totals
|
||||
if let Some(tokens) = session.metadata.accumulated_total_tokens {
|
||||
match tokens.cmp(&0) {
|
||||
std::cmp::Ordering::Greater => {
|
||||
total_tokens += tokens as i64;
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
// Log negative token values for debugging
|
||||
info!(
|
||||
"Warning: Session {} has negative accumulated_total_tokens: {}",
|
||||
session.id, tokens
|
||||
);
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
// Zero tokens, no action needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track activity by date
|
||||
if let Ok(date) = DateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC") {
|
||||
let date_str = date.format("%Y-%m-%d").to_string();
|
||||
*activity_by_date.entry(date_str).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
// Calculate session duration from messages
|
||||
let session_path = session::get_path(session::Identifier::Name(session.id.clone()));
|
||||
if let Ok(session_path) = session_path {
|
||||
if let Ok(messages) = session::read_messages(&session_path) {
|
||||
if let (Some(first), Some(last)) = (messages.first(), messages.last()) {
|
||||
let duration = (last.created - first.created) as f64 / 60.0; // Convert to minutes
|
||||
total_duration += duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get top 3 most active directories
|
||||
let mut dir_vec: Vec<(String, usize)> = dir_counts.into_iter().collect();
|
||||
dir_vec.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let most_active_dirs = dir_vec.into_iter().take(3).collect();
|
||||
|
||||
// Calculate average session duration
|
||||
let avg_session_duration = if total_sessions > 0 {
|
||||
total_duration / total_sessions as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Get last 7 days of activity
|
||||
let mut activity_vec: Vec<(String, usize)> = activity_by_date.into_iter().collect();
|
||||
activity_vec.sort_by(|a, b| b.0.cmp(&a.0)); // Sort by date descending
|
||||
let recent_activity = activity_vec.into_iter().take(7).collect();
|
||||
|
||||
let insights = SessionInsights {
|
||||
total_sessions,
|
||||
most_active_dirs,
|
||||
avg_session_duration,
|
||||
total_tokens,
|
||||
recent_activity,
|
||||
};
|
||||
|
||||
info!("Returning insights: {:?}", insights);
|
||||
let insights = SessionManager::get_insights()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(insights))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/sessions/{session_id}/metadata",
|
||||
request_body = UpdateSessionMetadataRequest,
|
||||
path = "/sessions/{session_id}/description",
|
||||
request_body = UpdateSessionDescriptionRequest,
|
||||
params(
|
||||
("session_id" = String, Path, description = "Unique identifier for the session")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Session metadata updated successfully"),
|
||||
(status = 200, description = "Session description updated successfully"),
|
||||
(status = 400, description = "Bad request - Description too long (max 200 characters)"),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Session not found"),
|
||||
@@ -273,27 +111,17 @@ async fn get_session_insights() -> Result<Json<SessionInsights>, StatusCode> {
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
// Update session metadata
|
||||
async fn update_session_metadata(
|
||||
async fn update_session_description(
|
||||
Path(session_id): Path<String>,
|
||||
Json(request): Json<UpdateSessionMetadataRequest>,
|
||||
Json(request): Json<UpdateSessionDescriptionRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
// Validate description length
|
||||
if request.description.len() > MAX_DESCRIPTION_LENGTH {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let session_path = session::get_path(session::Identifier::Name(session_id.clone()))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// Read current metadata
|
||||
let mut metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Update description
|
||||
metadata.description = request.description;
|
||||
|
||||
// Save updated metadata
|
||||
session::update_metadata(&session_path, &metadata)
|
||||
SessionManager::update_session(&session_id)
|
||||
.description(request.description)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
@@ -302,7 +130,7 @@ async fn update_session_metadata(
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/sessions/{session_id}/delete",
|
||||
path = "/sessions/{session_id}",
|
||||
params(
|
||||
("session_id" = String, Path, description = "Unique identifier for the session")
|
||||
),
|
||||
@@ -317,93 +145,29 @@ async fn update_session_metadata(
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
// Delete a session
|
||||
async fn delete_session(Path(session_id): Path<String>) -> Result<StatusCode, StatusCode> {
|
||||
// Get the session path
|
||||
let session_path = match session::get_path(session::Identifier::Name(session_id.clone())) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
// Check if session file exists
|
||||
if !session_path.exists() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Delete the session file
|
||||
std::fs::remove_file(&session_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
SessionManager::delete_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// Configure routes for this module
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/sessions", get(list_sessions))
|
||||
.route("/sessions/{session_id}", get(get_session_history))
|
||||
.route("/sessions/{session_id}/delete", delete(delete_session))
|
||||
.route("/sessions/{session_id}", get(get_session))
|
||||
.route("/sessions/{session_id}", delete(delete_session))
|
||||
.route("/sessions/insights", get(get_session_insights))
|
||||
.route(
|
||||
"/sessions/{session_id}/metadata",
|
||||
put(update_session_metadata),
|
||||
"/sessions/{session_id}/description",
|
||||
put(update_session_description),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_session_metadata_request_deserialization() {
|
||||
// Test that our request struct can be deserialized properly
|
||||
let json = r#"{"description": "test description"}"#;
|
||||
let request: UpdateSessionMetadataRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(request.description, "test description");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_session_metadata_request_validation() {
|
||||
// Test empty description
|
||||
let empty_request = UpdateSessionMetadataRequest {
|
||||
description: "".to_string(),
|
||||
};
|
||||
assert_eq!(empty_request.description, "");
|
||||
|
||||
// Test normal description
|
||||
let normal_request = UpdateSessionMetadataRequest {
|
||||
description: "My Session Name".to_string(),
|
||||
};
|
||||
assert_eq!(normal_request.description, "My Session Name");
|
||||
|
||||
// Test description at max length (should be valid)
|
||||
let max_length_description = "A".repeat(MAX_DESCRIPTION_LENGTH);
|
||||
let max_request = UpdateSessionMetadataRequest {
|
||||
description: max_length_description.clone(),
|
||||
};
|
||||
assert_eq!(max_request.description, max_length_description);
|
||||
assert_eq!(max_request.description.len(), MAX_DESCRIPTION_LENGTH);
|
||||
|
||||
// Test description over max length
|
||||
let over_max_description = "A".repeat(MAX_DESCRIPTION_LENGTH + 1);
|
||||
let over_max_request = UpdateSessionMetadataRequest {
|
||||
description: over_max_description.clone(),
|
||||
};
|
||||
assert_eq!(over_max_request.description, over_max_description);
|
||||
assert!(over_max_request.description.len() > MAX_DESCRIPTION_LENGTH);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_description_length_validation() {
|
||||
// Test the validation logic used in the endpoint
|
||||
let valid_description = "A".repeat(MAX_DESCRIPTION_LENGTH);
|
||||
assert!(valid_description.len() <= MAX_DESCRIPTION_LENGTH);
|
||||
|
||||
let invalid_description = "A".repeat(MAX_DESCRIPTION_LENGTH + 1);
|
||||
assert!(invalid_description.len() > MAX_DESCRIPTION_LENGTH);
|
||||
|
||||
// Test edge cases
|
||||
assert!(String::new().len() <= MAX_DESCRIPTION_LENGTH); // Empty string
|
||||
assert!("Short".len() <= MAX_DESCRIPTION_LENGTH); // Short string
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user