Session manager (#4648)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -1,38 +1,42 @@
|
||||
use crate::session::build_session;
|
||||
use crate::session::SessionBuilderConfig;
|
||||
use crate::{logging, session, Session};
|
||||
use crate::{logging, CliSession};
|
||||
use async_trait::async_trait;
|
||||
use goose::conversation::Conversation;
|
||||
use goose_bench::bench_session::{BenchAgent, BenchBaseSession};
|
||||
use goose_bench::eval_suites::ExtensionRequirements;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// allow session obj to be used in benchmarking
|
||||
#[async_trait]
|
||||
impl BenchBaseSession for Session {
|
||||
impl BenchBaseSession for CliSession {
|
||||
async fn headless(&mut self, message: String) -> anyhow::Result<()> {
|
||||
self.headless(message).await
|
||||
}
|
||||
fn session_file(&self) -> Option<PathBuf> {
|
||||
self.session_file()
|
||||
}
|
||||
fn message_history(&self) -> Conversation {
|
||||
self.message_history()
|
||||
}
|
||||
fn get_total_token_usage(&self) -> anyhow::Result<Option<i32>> {
|
||||
self.get_total_token_usage()
|
||||
// Since the trait requires sync but the session method is async,
|
||||
// we need to block on the async call
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.get_total_token_usage())
|
||||
})
|
||||
}
|
||||
|
||||
fn get_session_id(&self) -> anyhow::Result<String> {
|
||||
self.session_id()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session ID available"))
|
||||
}
|
||||
}
|
||||
pub async fn agent_generator(
|
||||
requirements: ExtensionRequirements,
|
||||
session_id: String,
|
||||
) -> BenchAgent {
|
||||
let identifier = Some(session::Identifier::Name(session_id));
|
||||
|
||||
let base_session = build_session(SessionBuilderConfig {
|
||||
identifier,
|
||||
session_id: Some(session_id),
|
||||
resume: false,
|
||||
no_session: false,
|
||||
extensions: requirements.external,
|
||||
@@ -56,10 +60,8 @@ pub async fn agent_generator(
|
||||
})
|
||||
.await;
|
||||
|
||||
// package session obj into benchmark-compatible struct
|
||||
let bench_agent = BenchAgent::new(Box::new(base_session));
|
||||
|
||||
// Initialize logging with error capture
|
||||
let errors = Some(Arc::new(Mutex::new(bench_agent.get_errors().await)));
|
||||
logging::setup_logging(Some("bench"), errors).expect("Failed to initialize logging");
|
||||
|
||||
|
||||
@@ -219,11 +219,10 @@ pub async fn handle_schedule_sessions(id: String, limit: Option<u32>) -> Result<
|
||||
// sessions is now Vec<(String, SessionMetadata)>
|
||||
for (session_name, metadata) in sessions {
|
||||
println!(
|
||||
" - Session ID: {}, Working Dir: {}, Description: \"{}\", Messages: {}, Schedule ID: {:?}",
|
||||
" - Session ID: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}",
|
||||
session_name, // Display the session_name as Session ID
|
||||
metadata.working_dir.display(),
|
||||
metadata.description,
|
||||
metadata.message_count,
|
||||
metadata.schedule_id.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use crate::session::message_to_markdown;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use cliclack::{confirm, multiselect, select};
|
||||
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
||||
use goose::session::{self, Identifier};
|
||||
use goose::session::{Session, SessionManager};
|
||||
use goose::utils::safe_truncate;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const TRUNCATED_DESC_LENGTH: usize = 60;
|
||||
|
||||
pub fn remove_sessions(sessions: Vec<SessionInfo>) -> Result<()> {
|
||||
pub async fn remove_sessions(sessions: Vec<Session>) -> Result<()> {
|
||||
println!("The following sessions will be removed:");
|
||||
for session in &sessions {
|
||||
println!("- {}", session.id);
|
||||
println!("- {} {}", session.id, session.description);
|
||||
}
|
||||
|
||||
let should_delete = confirm("Are you sure you want to delete these sessions?")
|
||||
@@ -22,8 +22,7 @@ pub fn remove_sessions(sessions: Vec<SessionInfo>) -> Result<()> {
|
||||
|
||||
if should_delete {
|
||||
for session in sessions {
|
||||
fs::remove_file(session.path.clone())
|
||||
.with_context(|| format!("Failed to remove session file '{}'", session.path))?;
|
||||
SessionManager::delete_session(&session.id).await?;
|
||||
println!("Session `{}` removed.", session.id);
|
||||
}
|
||||
} else {
|
||||
@@ -33,7 +32,7 @@ pub fn remove_sessions(sessions: Vec<SessionInfo>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result<Vec<SessionInfo>> {
|
||||
fn prompt_interactive_session_removal(sessions: &[Session]) -> Result<Vec<Session>> {
|
||||
if sessions.is_empty() {
|
||||
println!("No sessions to delete.");
|
||||
return Ok(vec![]);
|
||||
@@ -43,16 +42,16 @@ fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result<Vec<Se
|
||||
"Select sessions to delete (use spacebar, Enter to confirm, Ctrl+C to cancel):",
|
||||
);
|
||||
|
||||
let display_map: std::collections::HashMap<String, SessionInfo> = sessions
|
||||
let display_map: std::collections::HashMap<String, Session> = sessions
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let desc = if s.metadata.description.is_empty() {
|
||||
let desc = if s.description.is_empty() {
|
||||
"(no description)"
|
||||
} else {
|
||||
&s.metadata.description
|
||||
&s.description
|
||||
};
|
||||
let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH);
|
||||
let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id);
|
||||
let display_text = format!("{} - {} ({})", s.updated_at, truncated_desc, s.id);
|
||||
(display_text, s.clone())
|
||||
})
|
||||
.collect();
|
||||
@@ -63,7 +62,7 @@ fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result<Vec<Se
|
||||
|
||||
let selected_display_texts: Vec<String> = selector.interact()?;
|
||||
|
||||
let selected_sessions: Vec<SessionInfo> = selected_display_texts
|
||||
let selected_sessions: Vec<Session> = selected_display_texts
|
||||
.into_iter()
|
||||
.filter_map(|text| display_map.get(&text).cloned())
|
||||
.collect();
|
||||
@@ -71,8 +70,8 @@ fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result<Vec<Se
|
||||
Ok(selected_sessions)
|
||||
}
|
||||
|
||||
pub fn handle_session_remove(id: Option<String>, regex_string: Option<String>) -> Result<()> {
|
||||
let all_sessions = match get_valid_sorted_sessions(SortOrder::Descending) {
|
||||
pub async fn handle_session_remove(id: Option<String>, regex_string: Option<String>) -> Result<()> {
|
||||
let all_sessions = match SessionManager::list_sessions().await {
|
||||
Ok(sessions) => sessions,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to retrieve sessions: {:?}", e);
|
||||
@@ -80,7 +79,7 @@ pub fn handle_session_remove(id: Option<String>, regex_string: Option<String>) -
|
||||
}
|
||||
};
|
||||
|
||||
let matched_sessions: Vec<SessionInfo>;
|
||||
let matched_sessions: Vec<Session>;
|
||||
|
||||
if let Some(id_val) = id {
|
||||
if let Some(session) = all_sessions.iter().find(|s| s.id == id_val) {
|
||||
@@ -112,23 +111,16 @@ pub fn handle_session_remove(id: Option<String>, regex_string: Option<String>) -
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
remove_sessions(matched_sessions)
|
||||
remove_sessions(matched_sessions).await
|
||||
}
|
||||
|
||||
pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Result<()> {
|
||||
let sort_order = if ascending {
|
||||
SortOrder::Ascending
|
||||
pub async fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Result<()> {
|
||||
let mut sessions = SessionManager::list_sessions().await?;
|
||||
if ascending {
|
||||
sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
|
||||
} else {
|
||||
SortOrder::Descending
|
||||
};
|
||||
|
||||
let sessions = match get_valid_sorted_sessions(sort_order) {
|
||||
Ok(sessions) => sessions,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list sessions: {:?}", e);
|
||||
return Err(anyhow::anyhow!("Failed to list sessions"));
|
||||
}
|
||||
};
|
||||
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
}
|
||||
|
||||
match format.as_str() {
|
||||
"json" => {
|
||||
@@ -138,27 +130,18 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re
|
||||
if sessions.is_empty() {
|
||||
println!("No sessions found");
|
||||
return Ok(());
|
||||
} else {
|
||||
println!("Available sessions:");
|
||||
for SessionInfo {
|
||||
id,
|
||||
path,
|
||||
metadata,
|
||||
modified,
|
||||
} in sessions
|
||||
{
|
||||
let description = if metadata.description.is_empty() {
|
||||
"(none)"
|
||||
} else {
|
||||
&metadata.description
|
||||
};
|
||||
let output = format!("{} - {} - {}", id, description, modified);
|
||||
if verbose {
|
||||
println!(" {}", output);
|
||||
println!(" Path: {}", path);
|
||||
} else {
|
||||
println!("{}", output);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Available sessions:");
|
||||
for session in sessions {
|
||||
let output = format!(
|
||||
"{} - {} - {}",
|
||||
session.id, session.description, session.updated_at
|
||||
);
|
||||
if verbose {
|
||||
println!(" {}", output);
|
||||
} else {
|
||||
println!("{}", output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,68 +149,55 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export a session to Markdown without creating a full Session object
|
||||
///
|
||||
/// This function directly reads messages from the session file and converts them to Markdown
|
||||
/// without creating an Agent or prompting about working directories.
|
||||
pub fn handle_session_export(identifier: Identifier, output_path: Option<PathBuf>) -> Result<()> {
|
||||
// Get the session file path
|
||||
let session_file_path = match goose::session::get_path(identifier.clone()) {
|
||||
Ok(path) => path,
|
||||
pub async fn handle_session_export(
|
||||
session_id: String,
|
||||
output_path: Option<PathBuf>,
|
||||
format: String,
|
||||
) -> Result<()> {
|
||||
let session = match SessionManager::get_session(&session_id, true).await {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Invalid session identifier: {}", e));
|
||||
return Err(anyhow::anyhow!(
|
||||
"Session '{}' not found or failed to read: {}",
|
||||
session_id,
|
||||
e
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !session_file_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Session file not found (expected path: {})",
|
||||
session_file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Read messages directly without using Session
|
||||
let messages = match goose::session::read_messages(&session_file_path) {
|
||||
Ok(msgs) => msgs,
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Failed to read session messages: {}", e));
|
||||
let output = match format.as_str() {
|
||||
"json" => serde_json::to_string_pretty(&session)?,
|
||||
"yaml" => serde_yaml::to_string(&session)?,
|
||||
"markdown" => {
|
||||
let conversation = session
|
||||
.conversation
|
||||
.ok_or_else(|| anyhow::anyhow!("Session has no messages"))?;
|
||||
export_session_to_markdown(conversation.messages().to_vec(), &session.description)
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
|
||||
};
|
||||
|
||||
// Generate the markdown content using the export functionality
|
||||
let markdown =
|
||||
export_session_to_markdown(messages.messages().clone(), &session_file_path, None);
|
||||
|
||||
// Output the markdown
|
||||
if let Some(output) = output_path {
|
||||
fs::write(&output, markdown)
|
||||
.with_context(|| format!("Failed to write to output file: {}", output.display()))?;
|
||||
println!("Session exported to {}", output.display());
|
||||
if let Some(output_path) = output_path {
|
||||
fs::write(&output_path, output).with_context(|| {
|
||||
format!("Failed to write to output file: {}", output_path.display())
|
||||
})?;
|
||||
println!("Session exported to {}", output_path.display());
|
||||
} else {
|
||||
println!("{}", markdown);
|
||||
println!("{}", output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a list of messages to markdown format for session export
|
||||
///
|
||||
/// This function handles the formatting of a complete session including headers,
|
||||
/// message organization, and proper tool request/response pairing.
|
||||
fn export_session_to_markdown(
|
||||
messages: Vec<goose::conversation::message::Message>,
|
||||
session_file: &Path,
|
||||
session_name_override: Option<&str>,
|
||||
session_name: &String,
|
||||
) -> String {
|
||||
let mut markdown_output = String::new();
|
||||
|
||||
let session_name = session_name_override.unwrap_or_else(|| {
|
||||
session_file
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Unnamed Session")
|
||||
});
|
||||
|
||||
markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name));
|
||||
|
||||
if messages.is_empty() {
|
||||
@@ -293,15 +263,8 @@ fn export_session_to_markdown(
|
||||
/// Prompt the user to interactively select a session
|
||||
///
|
||||
/// Shows a list of available sessions and lets the user select one
|
||||
pub fn prompt_interactive_session_selection() -> Result<session::Identifier> {
|
||||
// Get sessions sorted by modification date (newest first)
|
||||
let sessions = match get_valid_sorted_sessions(SortOrder::Descending) {
|
||||
Ok(sessions) => sessions,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list sessions: {:?}", e);
|
||||
return Err(anyhow::anyhow!("Failed to list sessions"));
|
||||
}
|
||||
};
|
||||
pub async fn prompt_interactive_session_selection() -> Result<String> {
|
||||
let sessions = SessionManager::list_sessions().await?;
|
||||
|
||||
if sessions.is_empty() {
|
||||
return Err(anyhow::anyhow!("No sessions found"));
|
||||
@@ -311,19 +274,17 @@ pub fn prompt_interactive_session_selection() -> Result<session::Identifier> {
|
||||
let mut selector = select("Select a session to export:");
|
||||
|
||||
// Map to display text
|
||||
let display_map: std::collections::HashMap<String, SessionInfo> = sessions
|
||||
let display_map: std::collections::HashMap<String, Session> = sessions
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let desc = if s.metadata.description.is_empty() {
|
||||
let desc = if s.description.is_empty() {
|
||||
"(no description)"
|
||||
} else {
|
||||
&s.metadata.description
|
||||
&s.description
|
||||
};
|
||||
let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH);
|
||||
|
||||
// Truncate description if too long
|
||||
let truncated_desc = safe_truncate(desc, 40);
|
||||
|
||||
let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id);
|
||||
let display_text = format!("{} - {} ({})", s.updated_at, truncated_desc, s.id);
|
||||
(display_text, s.clone())
|
||||
})
|
||||
.collect();
|
||||
@@ -346,7 +307,7 @@ pub fn prompt_interactive_session_selection() -> Result<session::Identifier> {
|
||||
|
||||
// Retrieve the selected session
|
||||
if let Some(session) = display_map.get(&selected_display_text) {
|
||||
Ok(goose::session::Identifier::Name(session.id.clone()))
|
||||
Ok(session.id.clone())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Invalid selection"))
|
||||
}
|
||||
|
||||
@@ -8,24 +8,25 @@ use axum::{
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use goose::session::SessionManager;
|
||||
use webbrowser;
|
||||
|
||||
use futures::{sink::SinkExt, stream::StreamExt};
|
||||
use goose::agents::{Agent, AgentEvent};
|
||||
use goose::conversation::message::Message as GooseMessage;
|
||||
use goose::conversation::Conversation;
|
||||
use goose::session;
|
||||
|
||||
use axum::response::Redirect;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::error;
|
||||
|
||||
type SessionStore = Arc<RwLock<std::collections::HashMap<String, Arc<Mutex<Conversation>>>>>;
|
||||
type CancellationStore = Arc<RwLock<std::collections::HashMap<String, tokio::task::AbortHandle>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
agent: Arc<Agent>,
|
||||
sessions: SessionStore,
|
||||
cancellations: CancellationStore,
|
||||
}
|
||||
|
||||
@@ -123,7 +124,6 @@ pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> {
|
||||
|
||||
let state = AppState {
|
||||
agent: Arc::new(agent),
|
||||
sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
};
|
||||
|
||||
@@ -169,8 +169,15 @@ pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_index() -> Html<&'static str> {
|
||||
Html(include_str!("../../static/index.html"))
|
||||
async fn serve_index() -> Result<Redirect, (http::StatusCode, String)> {
|
||||
let session = SessionManager::create_session(
|
||||
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||||
"Web session".to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
|
||||
Ok(Redirect::to(&format!("/session/{}", session.id)))
|
||||
}
|
||||
|
||||
async fn serve_session(
|
||||
@@ -222,23 +229,19 @@ async fn health_check() -> Json<serde_json::Value> {
|
||||
}
|
||||
|
||||
async fn list_sessions() -> Json<serde_json::Value> {
|
||||
match session::list_sessions() {
|
||||
match SessionManager::list_sessions().await {
|
||||
Ok(sessions) => {
|
||||
let session_info: Vec<serde_json::Value> = sessions
|
||||
.into_iter()
|
||||
.filter_map(|(name, path)| {
|
||||
session::read_metadata(&path).ok().map(|metadata| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"path": path,
|
||||
"description": metadata.description,
|
||||
"message_count": metadata.message_count,
|
||||
"working_dir": metadata.working_dir
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut session_info = Vec::new();
|
||||
|
||||
for session in sessions {
|
||||
session_info.push(serde_json::json!({
|
||||
"name": session.id,
|
||||
"path": session.id,
|
||||
"description": session.description,
|
||||
"message_count": session.message_count,
|
||||
"working_dir": session.working_dir
|
||||
}));
|
||||
}
|
||||
Json(serde_json::json!({
|
||||
"sessions": session_info
|
||||
}))
|
||||
@@ -251,30 +254,14 @@ async fn list_sessions() -> Json<serde_json::Value> {
|
||||
async fn get_session(
|
||||
axum::extract::Path(session_id): axum::extract::Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let session_file = match session::get_path(session::Identifier::Name(session_id)) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
return Json(serde_json::json!({
|
||||
"error": format!("Invalid session ID: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let error_response = |e: Box<dyn std::error::Error>| {
|
||||
Json(serde_json::json!({
|
||||
match SessionManager::get_session(&session_id, true).await {
|
||||
Ok(session) => Json(serde_json::json!({
|
||||
"metadata": session,
|
||||
"messages": session.conversation.unwrap_or_default().messages()
|
||||
})),
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"error": e.to_string()
|
||||
}))
|
||||
};
|
||||
|
||||
match session::read_messages(&session_file) {
|
||||
Ok(messages) => match session::read_metadata(&session_file) {
|
||||
Ok(metadata) => Json(serde_json::json!({
|
||||
"metadata": metadata,
|
||||
"messages": messages
|
||||
})),
|
||||
Err(e) => error_response(e.into()),
|
||||
},
|
||||
Err(e) => error_response(e.into()),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,46 +286,14 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
session_id,
|
||||
..
|
||||
}) => {
|
||||
// Get session file path from session_id
|
||||
let session_file = match session::get_path(session::Identifier::Name(
|
||||
session_id.clone(),
|
||||
)) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get session path: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Get or create session in memory (for fast access during processing)
|
||||
let session_messages = {
|
||||
let sessions = state.sessions.read().await;
|
||||
if let Some(session) = sessions.get(&session_id) {
|
||||
session.clone()
|
||||
} else {
|
||||
drop(sessions);
|
||||
let mut sessions = state.sessions.write().await;
|
||||
|
||||
// Load existing messages from JSONL file if it exists
|
||||
let existing_messages =
|
||||
session::read_messages(&session_file).unwrap_or_default();
|
||||
|
||||
let new_session = Arc::new(Mutex::new(existing_messages));
|
||||
sessions.insert(session_id.clone(), new_session.clone());
|
||||
new_session
|
||||
}
|
||||
};
|
||||
|
||||
// Clone sender for async processing
|
||||
let sender_clone = sender.clone();
|
||||
let agent = state.agent.clone();
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// Process message in a separate task to allow streaming
|
||||
let task_handle = tokio::spawn(async move {
|
||||
let result = process_message_streaming(
|
||||
&agent,
|
||||
session_messages,
|
||||
session_file,
|
||||
session_id_clone,
|
||||
content,
|
||||
sender_clone,
|
||||
)
|
||||
@@ -349,25 +304,21 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
});
|
||||
|
||||
// Store the abort handle
|
||||
{
|
||||
let mut cancellations = state.cancellations.write().await;
|
||||
cancellations
|
||||
.insert(session_id.clone(), task_handle.abort_handle());
|
||||
}
|
||||
|
||||
// Wait for task completion and handle abort
|
||||
// Handle task completion and cleanup
|
||||
let sender_for_abort = sender.clone();
|
||||
let session_id_for_cleanup = session_id.clone();
|
||||
let cancellations_for_cleanup = state.cancellations.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match task_handle.await {
|
||||
Ok(_) => {
|
||||
// Task completed normally
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) if e.is_cancelled() => {
|
||||
// Task was aborted
|
||||
let mut sender = sender_for_abort.lock().await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
@@ -387,11 +338,8 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cancellation token
|
||||
{
|
||||
let mut cancellations = cancellations_for_cleanup.write().await;
|
||||
cancellations.remove(&session_id_for_cleanup);
|
||||
}
|
||||
let mut cancellations = cancellations_for_cleanup.write().await;
|
||||
cancellations.remove(&session_id_for_cleanup);
|
||||
});
|
||||
}
|
||||
Ok(WebSocketMessage::Cancel { session_id }) => {
|
||||
@@ -436,27 +384,16 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
|
||||
async fn process_message_streaming(
|
||||
agent: &Agent,
|
||||
session_messages: Arc<Mutex<Conversation>>,
|
||||
session_file: std::path::PathBuf,
|
||||
session_id: String,
|
||||
content: String,
|
||||
sender: Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
|
||||
) -> Result<()> {
|
||||
use futures::StreamExt;
|
||||
use goose::agents::SessionConfig;
|
||||
use goose::conversation::message::MessageContent;
|
||||
use goose::session;
|
||||
|
||||
// Create a user message
|
||||
let user_message = GooseMessage::user().with_text(content.clone());
|
||||
|
||||
// Messages will be auto-compacted in agent.reply() if needed
|
||||
let messages: Conversation = {
|
||||
let mut session_msgs = session_messages.lock().await;
|
||||
session_msgs.push(user_message.clone());
|
||||
session_msgs.clone()
|
||||
};
|
||||
|
||||
// Persist messages to JSONL file with provider for automatic description generation
|
||||
let provider = agent.provider().await;
|
||||
if provider.is_err() {
|
||||
let error_msg = "I'm not properly configured yet. Please configure a provider through the CLI first using `goose configure`.".to_string();
|
||||
@@ -475,19 +412,13 @@ async fn process_message_streaming(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let provider = provider.unwrap();
|
||||
let working_dir = Some(std::env::current_dir()?);
|
||||
session::persist_messages(
|
||||
&session_file,
|
||||
&messages,
|
||||
Some(provider.clone()),
|
||||
working_dir.clone(),
|
||||
)
|
||||
.await?;
|
||||
let session = SessionManager::get_session(&session_id, true).await?;
|
||||
let mut messages = session.conversation.unwrap_or_default();
|
||||
messages.push(user_message);
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session::Identifier::Path(session_file.clone()),
|
||||
working_dir: std::env::current_dir()?,
|
||||
id: session.id.clone(),
|
||||
working_dir: session.working_dir,
|
||||
schedule_id: None,
|
||||
execution_mode: None,
|
||||
max_turns: None,
|
||||
@@ -502,29 +433,11 @@ async fn process_message_streaming(
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(AgentEvent::Message(message)) => {
|
||||
// Add message to our session
|
||||
{
|
||||
let mut session_msgs = session_messages.lock().await;
|
||||
session_msgs.push(message.clone());
|
||||
}
|
||||
SessionManager::add_message(&session_id, &message).await?;
|
||||
|
||||
// Persist messages to JSONL file (no provider needed for assistant messages)
|
||||
let current_messages = {
|
||||
let session_msgs = session_messages.lock().await;
|
||||
session_msgs.clone()
|
||||
};
|
||||
session::persist_messages(
|
||||
&session_file,
|
||||
¤t_messages,
|
||||
None,
|
||||
working_dir.clone(),
|
||||
)
|
||||
.await?;
|
||||
// Handle different message content types
|
||||
for content in &message.content {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
// Send the text response
|
||||
let mut sender = sender.lock().await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
@@ -539,7 +452,6 @@ async fn process_message_streaming(
|
||||
.await;
|
||||
}
|
||||
MessageContent::ToolRequest(req) => {
|
||||
// Send tool request notification
|
||||
let mut sender = sender.lock().await;
|
||||
if let Ok(tool_call) = &req.tool_call {
|
||||
let _ = sender
|
||||
@@ -557,13 +469,8 @@ async fn process_message_streaming(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
MessageContent::ToolResponse(_resp) => {
|
||||
// Tool responses are already included in the complete message stream
|
||||
// and will be persisted to session history. No need to send separate
|
||||
// WebSocket messages as this would cause duplicates.
|
||||
}
|
||||
MessageContent::ToolResponse(_resp) => {}
|
||||
MessageContent::ToolConfirmationRequest(confirmation) => {
|
||||
// Send tool confirmation request
|
||||
let mut sender = sender.lock().await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
@@ -580,8 +487,6 @@ async fn process_message_streaming(
|
||||
))
|
||||
.await;
|
||||
|
||||
// For now, auto-approve in web mode
|
||||
// TODO: Implement proper confirmation UI
|
||||
agent.handle_confirmation(
|
||||
confirmation.id.clone(),
|
||||
goose::permission::PermissionConfirmation {
|
||||
@@ -591,7 +496,6 @@ async fn process_message_streaming(
|
||||
).await;
|
||||
}
|
||||
MessageContent::Thinking(thinking) => {
|
||||
// Send thinking indicator
|
||||
let mut sender = sender.lock().await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
@@ -604,7 +508,6 @@ async fn process_message_streaming(
|
||||
.await;
|
||||
}
|
||||
MessageContent::ContextLengthExceeded(msg) => {
|
||||
// Send context exceeded notification
|
||||
let mut sender = sender.lock().await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
@@ -618,55 +521,27 @@ async fn process_message_streaming(
|
||||
))
|
||||
.await;
|
||||
|
||||
// For now, auto-summarize in web mode
|
||||
// TODO: Implement proper UI for context handling
|
||||
let (summarized_messages, _, _) =
|
||||
agent.summarize_context(messages.messages()).await?;
|
||||
{
|
||||
let mut session_msgs = session_messages.lock().await;
|
||||
*session_msgs = summarized_messages;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Handle other message types as needed
|
||||
SessionManager::replace_conversation(
|
||||
&session_id,
|
||||
&summarized_messages,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AgentEvent::HistoryReplaced(new_messages)) => {
|
||||
// Replace the session's message history with the compacted messages
|
||||
{
|
||||
let mut session_msgs = session_messages.lock().await;
|
||||
*session_msgs = Conversation::new_unvalidated(new_messages);
|
||||
}
|
||||
|
||||
// Persist the updated messages to the JSONL file
|
||||
let current_messages = {
|
||||
let session_msgs = session_messages.lock().await;
|
||||
session_msgs.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = session::persist_messages(
|
||||
&session_file,
|
||||
¤t_messages,
|
||||
None, // No provider needed for persisting
|
||||
working_dir.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to persist compacted messages: {}", e);
|
||||
}
|
||||
Ok(AgentEvent::HistoryReplaced(_new_messages)) => {
|
||||
tracing::info!("History replaced, compacting happened in reply");
|
||||
}
|
||||
Ok(AgentEvent::McpNotification(_notification)) => {
|
||||
// Handle MCP notifications if needed
|
||||
// For now, we'll just log them
|
||||
tracing::info!("Received MCP notification in web interface");
|
||||
}
|
||||
Ok(AgentEvent::ModelChange { model, mode }) => {
|
||||
// Log model change
|
||||
tracing::info!("Model changed to {} in {} mode", model, mode);
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
error!("Error in message stream: {}", e);
|
||||
let mut sender = sender.lock().await;
|
||||
@@ -699,7 +574,6 @@ async fn process_message_streaming(
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion message
|
||||
let mut sender = sender.lock().await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
@@ -713,6 +587,3 @@ async fn process_message_streaming(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Add webbrowser dependency for opening browser
|
||||
use webbrowser;
|
||||
|
||||
Reference in New Issue
Block a user