Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::export_session,
super::routes::session::import_session,
super::routes::session::update_session_user_recipe_values,
super::routes::session::edit_message,
super::routes::schedule::create_schedule,
super::routes::schedule::list_schedules,
super::routes::schedule::delete_schedule,
Expand Down Expand Up @@ -405,6 +406,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::UpdateSessionNameRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest,
super::routes::session::UpdateSessionUserRecipeValuesResponse,
super::routes::session::EditType,
super::routes::session::EditMessageRequest,
super::routes::session::EditMessageResponse,
Message,
MessageContent,
MessageMetadata,
Expand Down
6 changes: 5 additions & 1 deletion crates/goose-server/src/routes/reply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ pub struct ChatRequest {
session_id: String,
recipe_name: Option<String>,
recipe_version: Option<String>,
#[serde(default)]
skip_add_user_message: bool,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rule number one, no optional

}

pub struct SseResponse {
Expand Down Expand Up @@ -300,10 +302,11 @@ pub async fn reply(
};

let mut stream = match agent
.reply(
.reply_with_options(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this strikes me as cumbersome

what I would do/expect here is when we fork the conversation we fork/truncate without the new message. in the case of edit you can then just call the normal submit which will add the edited message to the conversation just as before.

in the case of fork, you can just do the same thing as we do in hub and immediately submit the new message

user_message.clone(),
session_config,
Some(task_cancel.clone()),
request.skip_add_user_message,
)
.await
{
Expand Down Expand Up @@ -536,6 +539,7 @@ mod tests {
session_id: "test-session".to_string(),
recipe_name: None,
recipe_version: None,
skip_add_user_message: false,
})
.unwrap(),
))
Expand Down
108 changes: 108 additions & 0 deletions crates/goose-server/src/routes/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use axum::{
routing::{delete, get, put},
Json, Router,
};
use goose::conversation::message::Message;
use goose::recipe::Recipe;
use goose::session::session_manager::SessionInsights;
use goose::session::{Session, SessionManager};
Expand Down Expand Up @@ -49,6 +50,36 @@ pub struct ImportSessionRequest {
json: String,
}

#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum EditType {
Fork,
Edit,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EditMessageRequest {
message_row_id: i64,
new_content: String,
#[serde(default = "default_edit_type")]
edit_type: EditType,
}

fn default_edit_type() -> EditType {
EditType::Fork
}

#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EditMessageResponse {
/// New session ID created from the fork (only present for fork edit_type)
#[serde(skip_serializing_if = "Option::is_none")]
new_session_id: Option<String>,
/// Conversation (either in new session for fork, or updated current session for edit)
conversation: Vec<Message>,
}

const MAX_NAME_LENGTH: usize = 200;

#[utoipa::path(
Expand Down Expand Up @@ -307,6 +338,82 @@ async fn import_session(
Ok(Json(session))
}

#[utoipa::path(
post,
path = "/sessions/{session_id}/edit_message",
request_body = EditMessageRequest,
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Message edited successfully", body = EditMessageResponse),
(status = 400, description = "Bad request - Invalid message ID or empty content"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session or message not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn edit_message(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here you'd just want two simple primitive; copy which takes a session id and returns a new one with an id and truncate which takes a session id and a message id and then deletes all messages including and after that message id

Path(session_id): Path<String>,
Json(request): Json<EditMessageRequest>,
) -> Result<Json<EditMessageResponse>, StatusCode> {
if request.new_content.trim().is_empty() {
tracing::warn!("edit_message: empty content provided");
return Err(StatusCode::BAD_REQUEST);
}

match request.edit_type {
EditType::Fork => {
let new_session = SessionManager::fork_session_at_message(
&session_id,
request.message_row_id,
request.new_content,
)
.await
.map_err(|e| {
tracing::error!("Failed to fork session: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;

let conversation = new_session.conversation.ok_or_else(|| {
tracing::error!("Forked session has no conversation");
StatusCode::INTERNAL_SERVER_ERROR
})?;

Ok(Json(EditMessageResponse {
new_session_id: Some(new_session.id),
conversation: conversation.messages().to_vec(),
}))
}
EditType::Edit => {
let updated_session = SessionManager::edit_message_in_place(
&session_id,
request.message_row_id,
request.new_content,
)
.await
.map_err(|e| {
tracing::error!("Failed to edit message in place: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;

let conversation = updated_session.conversation.ok_or_else(|| {
tracing::error!("Updated session has no conversation");
StatusCode::INTERNAL_SERVER_ERROR
})?;

Ok(Json(EditMessageResponse {
new_session_id: None,
conversation: conversation.messages().to_vec(),
}))
}
}
}

pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
Expand All @@ -320,5 +427,6 @@ pub fn routes(state: Arc<AppState>) -> Router {
"/sessions/{session_id}/user_recipe_values",
put(update_session_user_recipe_values),
)
.route("/sessions/{session_id}/edit_message", post(edit_message))
.with_state(state)
}
18 changes: 16 additions & 2 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,18 @@ impl Agent {
user_message: Message,
session_config: SessionConfig,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
self.reply_with_options(user_message, session_config, cancel_token, false)
.await
}

#[instrument(skip(self, user_message, session_config), fields(user_message))]
pub async fn reply_with_options(
&self,
user_message: Message,
session_config: SessionConfig,
cancel_token: Option<CancellationToken>,
skip_add_message: bool,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why split this function? if we need an extra parameter, just add the parameter

) -> Result<BoxStream<'_, Result<AgentEvent>>> {
let is_manual_compact = user_message.content.iter().any(|c| {
if let MessageContent::Text(text) = c {
Expand All @@ -785,9 +797,11 @@ impl Agent {
}
});

SessionManager::add_message(&session_config.id, &user_message).await?;
let session = SessionManager::get_session(&session_config.id, true).await?;
if !skip_add_message {
SessionManager::add_message(&session_config.id, &user_message).await?;
}

let session = SessionManager::get_session(&session_config.id, true).await?;
let conversation = session
.conversation
.clone()
Expand Down
5 changes: 5 additions & 0 deletions crates/goose/src/conversation/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@ impl MessageMetadata {
#[serde(rename_all = "camelCase")]
pub struct Message {
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub row_id: Option<i64>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need a row_id here? can't we just use the id we already have?

pub role: Role,
pub created: i64,
#[serde(deserialize_with = "deserialize_sanitized_content")]
Expand All @@ -479,6 +481,7 @@ impl Message {
pub fn new(role: Role, created: i64, content: Vec<MessageContent>) -> Self {
Message {
id: None,
row_id: None,
role,
created,
content,
Expand All @@ -493,6 +496,7 @@ impl Message {
pub fn user() -> Self {
Message {
id: None,
row_id: None,
role: Role::User,
created: Utc::now().timestamp(),
content: Vec::new(),
Expand All @@ -504,6 +508,7 @@ impl Message {
pub fn assistant() -> Self {
Message {
id: None,
row_id: None,
role: Role::Assistant,
created: Utc::now().timestamp(),
content: Vec::new(),
Expand Down
Loading