Sessions required (#5548)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-11-03 21:04:44 -05:00
committed by GitHub
parent 86c3e42e43
commit 5b93ee587f
31 changed files with 956 additions and 2678 deletions
+21 -10
View File
@@ -19,6 +19,7 @@ use crate::commands::session::{handle_session_list, handle_session_remove};
use crate::recipes::extract_from_cli::extract_recipe_info_from_cli;
use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml};
use crate::session::{build_session, SessionBuilderConfig, SessionSettings};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose_bench::bench_config::BenchRunConfig;
use goose_bench::runners::bench_runner::BenchRunner;
@@ -86,9 +87,12 @@ async fn get_or_create_session_id(
.ok_or_else(|| anyhow::anyhow!("No session found to resume"))?;
Ok(Some(session_id))
} else {
let session =
SessionManager::create_session(std::env::current_dir()?, "CLI Session".to_string())
.await?;
let session = SessionManager::create_session(
std::env::current_dir()?,
"CLI Session".to_string(),
SessionType::User,
)
.await?;
Ok(Some(session.id))
};
};
@@ -105,8 +109,12 @@ async fn get_or_create_session_id(
.ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?;
Ok(Some(session_id))
} else {
let session =
SessionManager::create_session(std::env::current_dir()?, name.clone()).await?;
let session = SessionManager::create_session(
std::env::current_dir()?,
name.clone(),
SessionType::User,
)
.await?;
SessionManager::update_session(&session.id)
.user_provided_name(name)
@@ -123,9 +131,12 @@ async fn get_or_create_session_id(
.ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?;
Ok(Some(session_id))
} else {
let session =
SessionManager::create_session(std::env::current_dir()?, "CLI Session".to_string())
.await?;
let session = SessionManager::create_session(
std::env::current_dir()?,
"CLI Session".to_string(),
SessionType::User,
)
.await?;
Ok(Some(session.id))
}
}
@@ -977,7 +988,7 @@ pub async fn cli() -> anyhow::Result<()> {
let exit_type = if result.is_ok() { "normal" } else { "error" };
let (total_tokens, message_count) = session
.get_metadata()
.get_session()
.await
.map(|m| (m.total_tokens.unwrap_or(0), m.message_count))
.unwrap_or((0, 0));
@@ -1198,7 +1209,7 @@ pub async fn cli() -> anyhow::Result<()> {
let exit_type = if result.is_ok() { "normal" } else { "error" };
let (total_tokens, message_count) = session
.get_metadata()
.get_session()
.await
.map(|m| (m.total_tokens.unwrap_or(0), m.message_count))
.unwrap_or((0, 0));
+22 -27
View File
@@ -3,11 +3,13 @@ use agent_client_protocol::{
ToolCallContent,
};
use anyhow::Result;
use goose::agents::Agent;
use goose::agents::{Agent, SessionConfig};
use goose::config::{get_all_extensions, Config};
use goose::conversation::message::{Message, MessageContent};
use goose::conversation::Conversation;
use goose::providers::create;
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use rmcp::model::{RawContent, ResourceContents};
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -19,17 +21,15 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use url::Url;
/// Represents a single goose session for ACP
struct GooseSession {
struct GooseAcpSession {
messages: Conversation,
tool_call_ids: HashMap<String, String>, // Maps internal tool IDs to ACP tool call IDs
cancel_token: Option<CancellationToken>, // Active cancellation token for prompt processing
}
/// goose ACP Agent implementation that connects to real goose agents
struct GooseAcpAgent {
session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
sessions: Arc<Mutex<HashMap<String, GooseSession>>>,
session_update_tx: mpsc::UnboundedSender<(SessionNotification, oneshot::Sender<()>)>,
sessions: Arc<Mutex<HashMap<String, GooseAcpSession>>>,
agent: Agent, // Shared agent instance
}
@@ -97,7 +97,6 @@ impl GooseAcpAgent {
async fn new(
session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
) -> Result<Self> {
// Load config and create provider
let config = Config::global();
let provider_name: String = config
@@ -217,7 +216,7 @@ impl GooseAcpAgent {
&self,
content_item: &MessageContent,
session_id: &acp::SessionId,
session: &mut GooseSession,
session: &mut GooseAcpSession,
) -> Result<(), acp::Error> {
match content_item {
MessageContent::Text(text) => {
@@ -273,7 +272,7 @@ impl GooseAcpAgent {
&self,
tool_request: &goose::conversation::message::ToolRequest,
session_id: &acp::SessionId,
session: &mut GooseSession,
session: &mut GooseAcpSession,
) -> Result<(), acp::Error> {
// Generate ACP tool call ID and track mapping
let acp_tool_id = format!("tool_{}", uuid::Uuid::new_v4());
@@ -341,7 +340,7 @@ impl GooseAcpAgent {
&self,
tool_response: &goose::conversation::message::ToolResponse,
session_id: &acp::SessionId,
session: &mut GooseSession,
session: &mut GooseAcpSession,
) -> Result<(), acp::Error> {
// Look up the ACP tool call ID
if let Some(acp_tool_id) = session.tool_call_ids.get(&tool_response.id) {
@@ -496,7 +495,7 @@ impl acp::Agent for GooseAcpAgent {
// Generate a unique session ID
let session_id = uuid::Uuid::new_v4().to_string();
let session = GooseSession {
let session = GooseAcpSession {
messages: Conversation::new_unvalidated(Vec::new()),
tool_call_ids: HashMap::new(),
cancel_token: None,
@@ -544,30 +543,26 @@ impl acp::Agent for GooseAcpAgent {
// Create and store cancellation token for this prompt
let cancel_token = CancellationToken::new();
// Convert ACP prompt to Goose message
let user_message = self.convert_acp_prompt_to_message(args.prompt);
// Prepare for agent reply
let messages = {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(&session_id)
.ok_or_else(acp::Error::invalid_params)?;
let session = SessionManager::create_session(
std::env::current_dir().unwrap_or_default(),
"ACP Session".to_string(),
SessionType::Hidden,
)
.await?;
// Add message to conversation
session.messages.push(user_message);
// Store cancellation token
session.cancel_token = Some(cancel_token.clone());
// Clone what we need for the reply call
session.messages.clone()
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: None,
retry_config: None,
};
// Get agent's reply through the Goose agent
let mut stream = self
.agent
.reply(messages, None, Some(cancel_token.clone()))
.reply(user_message, session_config, Some(cancel_token.clone()))
.await
.map_err(|e| {
error!("Error getting agent reply: {}", e);
+1 -3
View File
@@ -26,9 +26,7 @@ impl BenchBaseSession for CliSession {
}
fn get_session_id(&self) -> anyhow::Result<String> {
self.session_id()
.cloned()
.ok_or_else(|| anyhow::anyhow!("No session ID available"))
Ok(self.session_id().to_string())
}
}
pub async fn agent_generator(
@@ -98,7 +98,6 @@ pub async fn handle_schedule_add(
paused: false,
current_session_id: None,
process_start_time: None,
execution_mode: Some("background".to_string()), // Default to background for CLI
};
let scheduler_storage_path =
+4 -7
View File
@@ -15,6 +15,7 @@ use base64::Engine;
use futures::{sink::SinkExt, stream::StreamExt};
use goose::agents::{Agent, AgentEvent};
use goose::conversation::message::Message as GooseMessage;
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -226,6 +227,7 @@ 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(),
SessionType::User,
)
.await
.map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
@@ -467,21 +469,16 @@ async fn process_message_streaming(
let session = SessionManager::get_session(&session_id, true).await?;
let mut messages = session.conversation.unwrap_or_default();
messages.push(user_message);
messages.push(user_message.clone());
let session_config = SessionConfig {
id: session.id.clone(),
working_dir: session.working_dir,
schedule_id: None,
execution_mode: None,
max_turns: None,
retry_config: None,
};
match agent
.reply(messages.clone(), Some(session_config), None)
.await
{
match agent.reply(user_message, session_config, None).await {
Ok(mut stream) => {
while let Some(result) = stream.next().await {
match result {
@@ -9,8 +9,10 @@ use anyhow::Result;
use goose::agents::Agent;
use goose::model::ModelConfig;
use goose::providers::{create, testprovider::TestProvider};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -190,7 +192,6 @@ where
)
};
// Generate messages using the provider
let messages = vec![message_generator(&*provider_arc)];
let mock_client = weather_client();
@@ -218,11 +219,17 @@ where
.update_provider(provider_arc as Arc<dyn goose::providers::base::Provider>)
.await?;
let mut session = CliSession::new(agent, None, false, None, None, None, None).await;
let session = SessionManager::create_session(
PathBuf::default(),
"scenario-runner".to_string(),
SessionType::Hidden,
)
.await?;
let mut cli_session = CliSession::new(agent, session.id, false, None, None, None, None).await;
let mut error = None;
for message in &messages {
if let Err(e) = session
if let Err(e) = cli_session
.process_message(message.clone(), CancellationToken::default())
.await
{
@@ -230,7 +237,7 @@ where
break;
}
}
let updated_messages = session.message_history();
let updated_messages = cli_session.message_history();
if let Some(ref err_msg) = error {
if err_msg.contains("No recorded response found") {
@@ -249,7 +256,7 @@ where
validator(&result)?;
drop(session);
drop(cli_session);
if let Some(provider) = provider_for_saving {
if result.error.is_none() {
+68 -63
View File
@@ -11,6 +11,7 @@ use goose::providers::create;
use goose::recipe::{Response, SubRecipe};
use goose::agents::extension::PlatformExtensionContext;
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose::session::{EnabledExtensionsState, ExtensionState};
use rustyline::EditMode;
@@ -25,7 +26,7 @@ use tokio::task::JoinSet;
/// including session identification, extension configuration, and debug settings.
#[derive(Default, Clone, Debug)]
pub struct SessionBuilderConfig {
/// Optional session ID for resuming or identifying an existing session
/// Session id, optional need to deduce from context
pub session_id: Option<String>,
/// Whether to resume an existing session
pub resume: bool,
@@ -132,8 +133,14 @@ async fn offer_extension_debugging_help(
}
}
// Create the debugging session
let mut debug_session = CliSession::new(debug_agent, None, false, None, None, None, None).await;
let session = SessionManager::create_session(
std::env::current_dir()?,
"CLI Session".to_string(),
SessionType::Hidden,
)
.await?;
let mut debug_session =
CliSession::new(debug_agent, session.id, false, None, None, None, None).await;
// Process the debugging request
println!("{}", style("Analyzing the extension failure...").yellow());
@@ -278,12 +285,20 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
process::exit(1);
});
let session_id: Option<String> = if session_config.no_session {
None
let session_id: String = if session_config.no_session {
let working_dir = std::env::current_dir().expect("Could not get working directory");
let session = SessionManager::create_session(
working_dir,
"CLI Session".to_string(),
SessionType::Hidden,
)
.await
.expect("Could not create session");
session.id
} else if session_config.resume {
if let Some(session_id) = session_config.session_id {
match SessionManager::get_session(&session_id, false).await {
Ok(_) => Some(session_id),
Ok(_) => session_id,
Err(_) => {
output::render_error(&format!(
"Cannot resume session {} - no such session exists",
@@ -294,7 +309,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
} else {
match SessionManager::list_sessions().await {
Ok(sessions) if !sessions.is_empty() => Some(sessions[0].id.clone()),
Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(),
_ => {
output::render_error("Cannot resume - no previous sessions found");
process::exit(1);
@@ -302,46 +317,44 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
}
} else {
session_config.session_id
session_config.session_id.unwrap()
};
agent
.extension_manager
.set_context(PlatformExtensionContext {
session_id: session_id.clone(),
session_id: Some(session_id.clone()),
extension_manager: Some(Arc::downgrade(&agent.extension_manager)),
tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)),
})
.await;
if session_config.resume {
if let Some(session_id) = session_id.as_ref() {
let metadata = SessionManager::get_session(session_id, false)
.await
.unwrap_or_else(|e| {
output::render_error(&format!("Failed to read session metadata: {}", e));
process::exit(1);
});
let session = SessionManager::get_session(&session_id, false)
.await
.unwrap_or_else(|e| {
output::render_error(&format!("Failed to read session metadata: {}", e));
process::exit(1);
});
let current_workdir =
std::env::current_dir().expect("Failed to get current working directory");
if current_workdir != metadata.working_dir {
let change_workdir = cliclack::confirm(format!("{} The original working directory of this session was set to {}. Your current directory is {}. Do you want to switch back to the original working directory?", style("WARNING:").yellow(), style(metadata.working_dir.display()).cyan(), style(current_workdir.display()).cyan()))
let current_workdir =
std::env::current_dir().expect("Failed to get current working directory");
if current_workdir != session.working_dir {
let change_workdir = cliclack::confirm(format!("{} The original working directory of this session was set to {}. Your current directory is {}. Do you want to switch back to the original working directory?", style("WARNING:").yellow(), style(session.working_dir.display()).cyan(), style(current_workdir.display()).cyan()))
.initial_value(true)
.interact().expect("Failed to get user input");
if change_workdir {
if !metadata.working_dir.exists() {
output::render_error(&format!(
"Cannot switch to original working directory - {} no longer exists",
style(metadata.working_dir.display()).cyan()
));
} else if let Err(e) = std::env::set_current_dir(&metadata.working_dir) {
output::render_error(&format!(
"Failed to switch to original working directory: {}",
e
));
}
if change_workdir {
if !session.working_dir.exists() {
output::render_error(&format!(
"Cannot switch to original working directory - {} no longer exists",
style(session.working_dir.display()).cyan()
));
} else if let Err(e) = std::env::set_current_dir(&session.working_dir) {
output::render_error(&format!(
"Failed to switch to original working directory: {}",
e
));
}
}
}
@@ -354,22 +367,18 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
agent.disable_router_for_recipe().await;
extensions.into_iter().collect()
} else if session_config.resume {
if let Some(session_id) = session_id.as_ref() {
match SessionManager::get_session(session_id, false).await {
Ok(session_data) => {
if let Some(saved_state) =
EnabledExtensionsState::from_extension_data(&session_data.extension_data)
{
check_missing_extensions_or_exit(&saved_state.extensions);
saved_state.extensions
} else {
get_enabled_extensions()
}
match SessionManager::get_session(&session_id, false).await {
Ok(session_data) => {
if let Some(saved_state) =
EnabledExtensionsState::from_extension_data(&session_data.extension_data)
{
check_missing_extensions_or_exit(&saved_state.extensions);
saved_state.extensions
} else {
get_enabled_extensions()
}
_ => get_enabled_extensions(),
}
} else {
get_enabled_extensions()
_ => get_enabled_extensions(),
}
} else {
get_enabled_extensions()
@@ -560,23 +569,19 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
}
if let Some(session_id) = session_id.as_ref() {
let session_config_for_save = SessionConfig {
id: session_id.clone(),
working_dir: std::env::current_dir().unwrap_or_default(),
schedule_id: None,
execution_mode: None,
max_turns: None,
retry_config: None,
};
let session_config_for_save = SessionConfig {
id: session_id.clone(),
schedule_id: None,
max_turns: None,
retry_config: None,
};
if let Err(e) = session
.agent
.save_extension_state(&session_config_for_save)
.await
{
tracing::warn!("Failed to save initial extension state: {}", e);
}
if let Err(e) = session
.agent
.save_extension_state(&session_config_for_save)
.await
{
tracing::warn!("Failed to save initial extension state: {}", e);
}
// Add CLI-specific system prompt extension
@@ -603,7 +608,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
session_config.resume,
&provider_name,
&model_name,
&session_id,
&Some(session_id),
Some(&provider_for_display),
);
}
+33 -42
View File
@@ -58,7 +58,7 @@ pub enum RunMode {
pub struct CliSession {
agent: Agent,
messages: Conversation,
session_id: Option<String>,
session_id: String,
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
debug: bool,
run_mode: RunMode,
@@ -122,21 +122,17 @@ pub async fn classify_planner_response(
impl CliSession {
pub async fn new(
agent: Agent,
session_id: Option<String>,
session_id: String,
debug: bool,
scheduled_job_id: Option<String>,
max_turns: Option<u32>,
edit_mode: Option<EditMode>,
retry_config: Option<RetryConfig>,
) -> Self {
let messages = if let Some(session_id) = &session_id {
SessionManager::get_session(session_id, true)
.await
.map(|session| session.conversation.unwrap_or_default())
.unwrap()
} else {
Conversation::new_unvalidated(Vec::new())
};
let messages = SessionManager::get_session(&session_id, true)
.await
.map(|session| session.conversation.unwrap_or_default())
.unwrap();
CliSession {
agent,
@@ -152,8 +148,8 @@ impl CliSession {
}
}
pub fn session_id(&self) -> Option<&String> {
self.session_id.as_ref()
pub fn session_id(&self) -> &String {
&self.session_id
}
/// Add a stdio extension to the session
@@ -359,9 +355,6 @@ impl CliSession {
cancel_token: CancellationToken,
) -> Result<()> {
let cancel_token = cancel_token.clone();
// TODO(Douwe): Make sure we generate the description here still:
self.push_message(message);
self.process_agent_response(false, cancel_token).await?;
Ok(())
@@ -443,7 +436,7 @@ impl CliSession {
// Track the current directory and last instruction in projects.json
if let Err(e) = crate::project_tracker::update_project_tracker(
Some(&content),
self.session_id.as_deref(),
Some(&self.session_id),
) {
eprintln!("Warning: Failed to update project tracker with instruction: {}", e);
}
@@ -583,16 +576,14 @@ impl CliSession {
input::InputResult::Clear => {
save_history(&mut editor);
if let Some(session_id) = &self.session_id {
if let Err(e) = SessionManager::replace_conversation(
session_id,
&Conversation::default(),
)
.await
{
output::render_error(&format!("Failed to clear session: {}", e));
continue;
}
if let Err(e) = SessionManager::replace_conversation(
&self.session_id,
&Conversation::default(),
)
.await
{
output::render_error(&format!("Failed to clear session: {}", e));
continue;
}
self.messages.clear();
@@ -671,9 +662,10 @@ impl CliSession {
}
}
if let Some(id) = &self.session_id {
println!("Closing session. Session ID: {}", console::style(id).cyan());
}
println!(
"Closing session. Session ID: {}",
console::style(&self.session_id).cyan()
);
Ok(())
}
@@ -768,18 +760,20 @@ impl CliSession {
) -> Result<()> {
let cancel_token_clone = cancel_token.clone();
let session_config = self.session_id.as_ref().map(|session_id| SessionConfig {
id: session_id.clone(),
working_dir: std::env::current_dir().unwrap_or_default(),
let session_config = SessionConfig {
id: self.session_id.clone(),
schedule_id: self.scheduled_job_id.clone(),
execution_mode: None,
max_turns: self.max_turns,
retry_config: self.retry_config.clone(),
});
};
let user_message = self
.messages
.last()
.ok_or_else(|| anyhow::anyhow!("No user message"))?;
let mut stream = self
.agent
.reply(
self.messages.clone(),
user_message.clone(),
session_config.clone(),
Some(cancel_token.clone()),
)
@@ -1224,16 +1218,13 @@ impl CliSession {
);
}
pub async fn get_metadata(&self) -> Result<goose::session::Session> {
match &self.session_id {
Some(id) => SessionManager::get_session(id, false).await,
None => Err(anyhow::anyhow!("No session available")),
}
pub async fn get_session(&self) -> Result<goose::session::Session> {
SessionManager::get_session(&self.session_id, false).await
}
// Get the session's total token usage
pub async fn get_total_token_usage(&self) -> Result<Option<i32>> {
let metadata = self.get_metadata().await?;
let metadata = self.get_session().await?;
Ok(metadata.total_tokens)
}
@@ -1265,7 +1256,7 @@ impl CliSession {
}
}
match self.get_metadata().await {
match self.get_session().await {
Ok(metadata) => {
let total_tokens = metadata.total_tokens.unwrap_or(0) as usize;
+2 -1
View File
@@ -6,7 +6,7 @@ use goose::config::ExtensionEntry;
use goose::conversation::Conversation;
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType};
use goose::session::{Session, SessionInsights};
use goose::session::{Session, SessionInsights, SessionType};
use rmcp::model::{
Annotations, Content, EmbeddedResource, Icon, ImageContent, JsonObject, RawAudioContent,
RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ResourceContents, Role,
@@ -444,6 +444,7 @@ derive_utoipa!(Icon as IconSchema);
ModelInfo,
Session,
SessionInsights,
SessionType,
Conversation,
IconSchema,
goose::session::extension_data::ExtensionData,
+11 -9
View File
@@ -18,6 +18,7 @@ use goose::prompt_template::render_global_file;
use goose::providers::{create, create_with_named_model};
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::session_manager::SessionType;
use goose::session::{Session, SessionManager};
use goose::{
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
@@ -137,15 +138,16 @@ async fn start_agent(
let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1;
let name = format!("New session {}", counter);
let mut session = SessionManager::create_session(PathBuf::from(&working_dir), name)
.await
.map_err(|err| {
error!("Failed to create session: {}", err);
ErrorResponse {
message: format!("Failed to create session: {}", err),
status: StatusCode::BAD_REQUEST,
}
})?;
let mut session =
SessionManager::create_session(PathBuf::from(&working_dir), name, SessionType::User)
.await
.map_err(|err| {
error!("Failed to create session: {}", err);
ErrorResponse {
message: format!("Failed to create session: {}", err),
status: StatusCode::BAD_REQUEST,
}
})?;
if let Some(recipe) = original_recipe {
SessionManager::update_session(&session.id)
+17 -4
View File
@@ -257,17 +257,30 @@ pub async fn reply(
let session_config = SessionConfig {
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,
};
let user_message = match messages.last() {
Some(msg) => msg,
_ => {
let _ = stream_event(
MessageEvent::Error {
error: "Reply started with empty messages".to_string(),
},
&task_tx,
&task_cancel,
)
.await;
return;
}
};
let mut stream = match agent
.reply(
messages.clone(),
Some(session_config.clone()),
user_message.clone(),
session_config,
Some(task_cancel.clone()),
)
.await
@@ -115,7 +115,6 @@ async fn create_schedule(
paused: false,
current_session_id: None,
process_start_time: None,
execution_mode: req.execution_mode.or(Some("background".to_string())), // Default to background
};
scheduler
.add_scheduled_job(job.clone())
+26 -6
View File
@@ -1,11 +1,13 @@
use dotenvy::dotenv;
use futures::StreamExt;
use goose::agents::{Agent, AgentEvent, ExtensionConfig};
use goose::agents::{Agent, AgentEvent, ExtensionConfig, SessionConfig};
use goose::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT};
use goose::conversation::message::Message;
use goose::conversation::Conversation;
use goose::providers::create_with_named_model;
use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL;
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use std::path::PathBuf;
#[tokio::main]
async fn main() {
@@ -32,11 +34,29 @@ async fn main() {
println!(" {}", extension);
}
let conversation = Conversation::new(vec![Message::user()
.with_text("can you summarize the readme.md in this dir using just a haiku?")])
.unwrap();
let session = SessionManager::create_session(
PathBuf::default(),
"max-turn-test".to_string(),
SessionType::Hidden,
)
.await
.expect("session manager creation failed");
let session_config = SessionConfig {
id: session.id,
schedule_id: None,
max_turns: None,
retry_config: None,
};
let user_message = Message::user()
.with_text("can you summarize the readme.md in this dir using just a haiku?");
let mut stream = agent
.reply(user_message, session_config, None)
.await
.unwrap();
let mut stream = agent.reply(conversation, None, None).await.unwrap();
while let Some(Ok(AgentEvent::Message(message))) = stream.next().await {
println!("{}", serde_json::to_string_pretty(&message).unwrap());
println!("\n");
+104 -182
View File
@@ -61,7 +61,7 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC
use crate::agents::subagent_task_config::TaskConfig;
use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest};
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
use crate::session::SessionManager;
use crate::session::{Session, SessionManager};
const DEFAULT_MAX_TURNS: u32 = 1000;
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
@@ -75,7 +75,6 @@ pub struct ReplyContext {
pub system_prompt: String,
pub goose_mode: GooseMode,
pub initial_messages: Vec<Message>,
pub config: &'static Config,
}
pub struct ToolCategorizeResult {
@@ -219,16 +218,20 @@ impl Agent {
self.retry_manager.get_attempts().await
}
/// Handle retry logic for the agent reply loop
async fn handle_retry_logic(
&self,
messages: &mut Conversation,
session: &Option<SessionConfig>,
session_config: &SessionConfig,
initial_messages: &[Message],
) -> Result<bool> {
let result = self
.retry_manager
.handle_retry_logic(messages, session, initial_messages, &self.final_output_tool)
.handle_retry_logic(
messages,
session_config,
initial_messages,
&self.final_output_tool,
)
.await?;
match result {
@@ -242,7 +245,6 @@ impl Agent {
async fn prepare_reply_context(
&self,
unfixed_conversation: Conversation,
session: &Option<SessionConfig>,
) -> Result<ReplyContext> {
let unfixed_messages = unfixed_conversation.messages().clone();
let (conversation, issues) = fix_conversation(unfixed_conversation.clone());
@@ -260,9 +262,8 @@ impl Agent {
let config = Config::global();
let (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt().await?;
let goose_mode = Self::determine_goose_mode(session.as_ref(), config);
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
// Update permission inspector mode to match the session mode
self.tool_inspection_manager
.update_permission_inspector_mode(goose_mode)
.await;
@@ -274,7 +275,6 @@ impl Agent {
system_prompt,
goose_mode,
initial_messages,
config,
})
}
@@ -299,7 +299,7 @@ impl Agent {
permission_check_result: &PermissionCheckResult,
message_tool_response: Arc<Mutex<Message>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
session: Option<SessionConfig>,
session: &Session,
) -> Result<Vec<(String, ToolStream)>> {
let mut tool_futures: Vec<(String, ToolStream)> = Vec::new();
@@ -311,7 +311,7 @@ impl Agent {
tool_call,
request.id.clone(),
cancel_token.clone(),
session.clone(),
session,
)
.await;
@@ -392,7 +392,7 @@ impl Agent {
tool_call: CallToolRequestParam,
request_id: String,
cancellation_token: Option<CancellationToken>,
session: Option<SessionConfig>,
session: &Session,
) -> (String, Result<ToolCallResult, ErrorData>) {
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
let arguments = tool_call
@@ -451,17 +451,13 @@ impl Agent {
);
}
};
let (parent_session_id, parent_working_dir) = match session.as_ref() {
Some(s) => (Some(s.id.clone()), s.working_dir.clone()),
None => (None, std::env::current_dir().unwrap_or_default()),
};
// Get extensions from the agent's runtime state rather than global config
// This ensures subagents inherit extensions that were dynamically enabled by the parent
let extensions = self.get_extension_configs().await;
let task_config =
TaskConfig::new(provider, parent_session_id, parent_working_dir, extensions);
TaskConfig::new(provider, &session.id, &session.working_dir, extensions);
let arguments = match tool_call.arguments.clone() {
Some(args) => Value::Object(args),
@@ -731,117 +727,110 @@ impl Agent {
}
}
#[instrument(skip(self, unfixed_conversation, session), fields(user_message))]
#[instrument(skip(self, user_message, session_config), fields(user_message))]
pub async fn reply(
&self,
unfixed_conversation: Conversation,
session: Option<SessionConfig>,
user_message: Message,
session_config: SessionConfig,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
let is_manual_compact = unfixed_conversation.messages().last().is_some_and(|msg| {
msg.content.iter().any(|c| {
if let MessageContent::Text(text) = c {
text.text.trim() == MANUAL_COMPACT_TRIGGER
} else {
false
}
})
let is_manual_compact = user_message.content.iter().any(|c| {
if let MessageContent::Text(text) = c {
text.text.trim() == MANUAL_COMPACT_TRIGGER
} else {
false
}
});
if !is_manual_compact {
let session_metadata = if let Some(session_config) = &session {
SessionManager::get_session(&session_config.id, false)
.await
.ok()
} else {
None
};
SessionManager::add_message(&session_config.id, &user_message).await?;
let session = SessionManager::get_session(&session_config.id, true).await?;
let needs_auto_compact = crate::context_mgmt::check_if_compaction_needed(
self,
&unfixed_conversation,
None,
session_metadata.as_ref(),
)
.await?;
let conversation = session
.conversation
.clone()
.ok_or_else(|| anyhow::anyhow!("Session {} has no conversation", session_config.id))?;
if !needs_auto_compact {
return self
.reply_internal(unfixed_conversation, session, cancel_token)
.await;
}
}
let needs_auto_compact =
crate::context_mgmt::check_if_compaction_needed(self, &conversation, None, &session)
.await?;
let conversation_to_compact = unfixed_conversation.clone();
let conversation_to_compact = conversation.clone();
Ok(Box::pin(async_stream::try_stream! {
if !is_manual_compact {
let config = crate::config::Config::global();
let threshold = config
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
.unwrap_or(DEFAULT_COMPACTION_THRESHOLD);
let threshold_percentage = (threshold * 100.0) as u32;
let final_conversation = if !needs_auto_compact {
conversation
} else {
if !is_manual_compact {
let config = crate::config::Config::global();
let threshold = config
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
.unwrap_or(DEFAULT_COMPACTION_THRESHOLD);
let threshold_percentage = (threshold * 100.0) as u32;
let inline_msg = format!(
"Exceeded auto-compact threshold of {}%. Performing auto-compaction...",
threshold_percentage
);
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
inline_msg,
)
);
}
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage,
COMPACTION_THINKING_TEXT,
)
);
match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await {
Ok((compacted_conversation, summarization_usage)) => {
if let Some(session_to_store) = &session {
SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?;
Self::update_session_metrics(session_to_store, &summarization_usage, true).await?;
}
yield AgentEvent::HistoryReplaced(compacted_conversation.clone());
let inline_msg = format!(
"Exceeded auto-compact threshold of {}%. Performing auto-compaction...",
threshold_percentage
);
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
"Compaction complete",
inline_msg,
)
);
}
if !is_manual_compact {
let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?;
while let Some(event) = reply_stream.next().await {
yield event?;
}
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage,
COMPACTION_THINKING_TEXT,
)
);
match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await {
Ok((compacted_conversation, summarization_usage)) => {
SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?;
Self::update_session_metrics(&session_config, &summarization_usage, true).await?;
yield AgentEvent::HistoryReplaced(compacted_conversation.clone());
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
"Compaction complete",
)
);
compacted_conversation
}
Err(e) => {
yield AgentEvent::Message(
Message::assistant().with_text(
format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session")
)
);
return;
}
}
Err(e) => {
yield AgentEvent::Message(Message::assistant().with_text(
format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session")
));
};
if !is_manual_compact {
let mut reply_stream = self.reply_internal(final_conversation, session_config, session, cancel_token).await?;
while let Some(event) = reply_stream.next().await {
yield event?;
}
}
}))
}
/// Main reply method that handles the actual agent processing
async fn reply_internal(
&self,
conversation: Conversation,
session: Option<SessionConfig>,
session_config: SessionConfig,
session: Session,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
let context = self.prepare_reply_context(conversation, &session).await?;
let context = self.prepare_reply_context(conversation).await?;
let ReplyContext {
mut conversation,
mut tools,
@@ -849,66 +838,22 @@ impl Agent {
mut system_prompt,
goose_mode,
initial_messages,
config,
} = context;
let reply_span = tracing::Span::current();
self.reset_retry_attempts().await;
// This will need further refactoring. In the ideal world we pass the new message into
// reply and load the existing conversation. Until we get to that point, fetch the conversation
// so far and append the last (user) message that the caller already added.
if let Some(session_config) = &session {
let stored_conversation = SessionManager::get_session(&session_config.id, true)
.await?
.conversation
.ok_or_else(|| {
anyhow::anyhow!("Session {} has no conversation", session_config.id)
})?;
match conversation.len().cmp(&stored_conversation.len()) {
std::cmp::Ordering::Equal => {
if conversation != stored_conversation {
warn!("Session messages mismatch - replacing with incoming");
SessionManager::replace_conversation(&session_config.id, &conversation)
.await?;
}
}
std::cmp::Ordering::Greater
if conversation.len() == stored_conversation.len() + 1 =>
{
let last_message = conversation.last().unwrap();
if let Some(content) = last_message.content.first().and_then(|c| c.as_text()) {
debug!("user_message" = &content);
}
SessionManager::add_message(&session_config.id, last_message).await?;
}
_ => {
warn!(
"Unexpected session state: stored={}, incoming={}. Replacing.",
stored_conversation.len(),
conversation.len()
);
SessionManager::replace_conversation(&session_config.id, &conversation).await?;
}
let provider = self.provider().await?;
let session_id = session_config.id.clone();
tokio::spawn(async move {
if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await {
warn!("Failed to generate session description: {}", e);
}
let provider = self.provider().await?;
let session_id = session_config.id.clone();
tokio::spawn(async move {
if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await {
warn!("Failed to generate session description: {}", e);
}
});
}
});
Ok(Box::pin(async_stream::try_stream! {
let _ = reply_span.enter();
let mut turns_taken = 0u32;
let max_turns = session
.as_ref()
.and_then(|s| s.max_turns)
.unwrap_or_else(|| {
config.get_param("GOOSE_MAX_TURNS").unwrap_or(DEFAULT_MAX_TURNS)
});
let max_turns = session_config.max_turns.unwrap_or(DEFAULT_MAX_TURNS);
loop {
if is_token_cancelled(&cancel_token) {
@@ -989,11 +934,8 @@ impl Agent {
}
}
// Record usage for the session
if let Some(ref session_config) = &session {
if let Some(ref usage) = usage {
Self::update_session_metrics(session_config, usage, false).await?;
}
if let Some(ref usage) = usage {
Self::update_session_metrics(&session_config, usage, false).await?;
}
if let Some(response) = response {
@@ -1078,18 +1020,17 @@ impl Agent {
&permission_check_result,
message_tool_response.clone(),
cancel_token.clone(),
session.clone(),
&session,
).await?;
let tool_futures_arc = Arc::new(Mutex::new(tool_futures));
// Process tools requiring approval
let mut tool_approval_stream = self.handle_approval_tool_requests(
&permission_check_result.needs_approval,
tool_futures_arc.clone(),
message_tool_response.clone(),
cancel_token.clone(),
session.clone(),
&session,
&inspection_results,
);
@@ -1136,10 +1077,8 @@ impl Agent {
}
if all_install_successful && !enable_extension_request_ids.is_empty() {
if let Some(ref session_config) = session {
if let Err(e) = self.save_extension_state(session_config).await {
warn!("Failed to save extension state after runtime changes: {}", e);
}
if let Err(e) = self.save_extension_state(&session_config).await {
warn!("Failed to save extension state after runtime changes: {}", e);
}
tools_updated = true;
}
@@ -1168,14 +1107,10 @@ impl Agent {
match crate::context_mgmt::compact_messages(self, &conversation, true).await {
Ok((compacted_conversation, usage)) => {
if let Some(session_to_store) = &session {
SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?;
Self::update_session_metrics(session_to_store, &usage, true).await?;
}
SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?;
Self::update_session_metrics(&session_config, &usage, true).await?;
conversation = compacted_conversation;
did_recovery_compact_this_iteration = true;
yield AgentEvent::HistoryReplaced(conversation.clone());
continue;
}
@@ -1221,7 +1156,7 @@ impl Agent {
} else if did_recovery_compact_this_iteration {
// Avoid setting exit_chat; continue from last user message in the conversation
} else {
match self.handle_retry_logic(&mut conversation, &session, &initial_messages).await {
match self.handle_retry_logic(&mut conversation, &session_config, &initial_messages).await {
Ok(should_retry) => {
if should_retry {
info!("Retry logic triggered, restarting agent loop");
@@ -1242,10 +1177,8 @@ impl Agent {
}
}
if let Some(session_config) = &session {
for msg in &messages_to_add {
SessionManager::add_message(&session_config.id, msg).await?;
}
for msg in &messages_to_add {
SessionManager::add_message(&session_config.id, msg).await?;
}
conversation.extend(messages_to_add);
if exit_chat {
@@ -1257,17 +1190,6 @@ impl Agent {
}))
}
fn determine_goose_mode(session: Option<&SessionConfig>, config: &Config) -> GooseMode {
let mode = session.and_then(|s| s.execution_mode.as_deref());
match mode {
Some("foreground") => GooseMode::Chat,
Some("background") => GooseMode::Auto,
_ => config.get_goose_mode().unwrap_or(GooseMode::Auto),
}
}
/// Extend the system prompt with one line of additional instruction
pub async fn extend_system_prompt(&self, instruction: String) {
let mut prompt_manager = self.prompt_manager.lock().await;
prompt_manager.add_system_prompt_extra(instruction);
@@ -33,7 +33,6 @@ pub fn manage_schedule_tool() -> Tool {
"job_id": {"type": "string", "description": "Job identifier for operations on existing jobs"},
"recipe_path": {"type": "string", "description": "Path to recipe file for create action"},
"cron_expression": {"type": "string", "description": "A cron expression for create action. Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats. 5-field expressions are automatically converted to 6-field by prepending '0' for seconds."},
"execution_mode": {"type": "string", "description": "Execution mode for create action: 'foreground' or 'background'", "enum": ["foreground", "background"], "default": "background"},
"limit": {"type": "integer", "description": "Limit for sessions list", "default": 50},
"session_id": {"type": "string", "description": "Session identifier for session_content action"}
}
+1 -6
View File
@@ -108,18 +108,13 @@ impl RetryManager {
}
}
/// Handle retry logic for the agent reply loop
pub async fn handle_retry_logic(
&self,
messages: &mut Conversation,
session: &Option<SessionConfig>,
session_config: &SessionConfig,
initial_messages: &[Message],
final_output_tool: &Arc<Mutex<Option<crate::agents::final_output_tool::FinalOutputTool>>>,
) -> Result<RetryResult> {
let Some(session_config) = session else {
return Ok(RetryResult::Skipped);
};
let Some(retry_config) = &session_config.retry_config else {
return Ok(RetryResult::Skipped);
};
-1
View File
@@ -186,7 +186,6 @@ impl Agent {
paused: false,
current_session_id: None,
process_start_time: None,
execution_mode: Some(execution_mode.to_string()),
};
match scheduler.add_scheduled_job(job).await {
+24 -52
View File
@@ -1,8 +1,6 @@
use crate::session::session_manager::SessionType;
use crate::{
agents::{
extension::PlatformExtensionContext, subagent_task_config::TaskConfig, Agent, AgentEvent,
SessionConfig,
},
agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig},
conversation::{message::Message, Conversation},
execution::manager::AgentManager,
session::SessionManager,
@@ -10,8 +8,8 @@ use crate::{
use anyhow::{anyhow, Result};
use futures::StreamExt;
use rmcp::model::{ErrorCode, ErrorData};
use std::future::Future;
use std::pin::Pin;
use std::{future::Future, sync::Arc};
use tracing::debug;
/// Standalone function to run a complete subagent task with output options
@@ -104,34 +102,18 @@ fn get_agent_messages(
.map_err(|e| anyhow!("Failed to create AgentManager: {}", e))?;
let parent_session_id = task_config.parent_session_id;
let working_dir = task_config.parent_working_dir;
let (agent, session_id) = match parent_session_id {
Some(parent_session_id) => {
let session = SessionManager::create_session(
working_dir.clone(),
format!("Subagent task for: {}", parent_session_id),
)
.await
.map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?;
let session = SessionManager::create_session(
working_dir.clone(),
format!("Subagent task for: {}", parent_session_id),
SessionType::SubAgent,
)
.await
.map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?;
let agent = agent_manager
.get_or_create_agent(session.id.clone())
.await
.map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?;
(agent, Some(session.id))
}
None => {
let agent = Arc::new(Agent::new());
agent
.extension_manager
.set_context(PlatformExtensionContext {
session_id: None,
extension_manager: Some(Arc::downgrade(&agent.extension_manager)),
tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)),
})
.await;
(agent, None)
}
};
let agent = agent_manager
.get_or_create_agent(session.id.clone())
.await
.map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?;
agent
.update_provider(task_config.provider)
@@ -148,28 +130,18 @@ fn get_agent_messages(
}
}
let mut conversation =
Conversation::new_unvalidated(
vec![Message::user().with_text(text_instruction.clone())],
);
let session_config = if let Some(session_id) = session_id {
Some(SessionConfig {
id: session_id,
working_dir,
schedule_id: None,
execution_mode: None,
max_turns: task_config.max_turns.map(|v| v as u32),
retry_config: None,
})
} else {
None
let user_message = Message::user().with_text(text_instruction);
let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]);
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: task_config.max_turns.map(|v| v as u32),
retry_config: None,
};
let session_id = session_config.as_ref().map(|s| s.id.clone());
let mut stream = crate::session_context::with_session_id(session_id, async {
agent
.reply(conversation.clone(), session_config, None)
.await
let mut stream = crate::session_context::with_session_id(Some(session.id.clone()), async {
agent.reply(user_message, session_config, None).await
})
.await
.map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?;
@@ -2,7 +2,7 @@ use crate::agents::ExtensionConfig;
use crate::providers::base::Provider;
use std::env;
use std::fmt;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Default maximum number of turns for task execution
@@ -15,7 +15,7 @@ pub const GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR: &str = "GOOSE_SUBAGENT_MAX_TURNS";
#[derive(Clone)]
pub struct TaskConfig {
pub provider: Arc<dyn Provider>,
pub parent_session_id: Option<String>,
pub parent_session_id: String,
pub parent_working_dir: PathBuf,
pub extensions: Vec<ExtensionConfig>,
pub max_turns: Option<usize>,
@@ -34,17 +34,16 @@ impl fmt::Debug for TaskConfig {
}
impl TaskConfig {
/// Create a new TaskConfig with all required dependencies
pub fn new(
provider: Arc<dyn Provider>,
parent_session_id: Option<String>,
parent_working_dir: PathBuf,
parent_session_id: &str,
parent_working_dir: &Path,
extensions: Vec<ExtensionConfig>,
) -> Self {
Self {
provider,
parent_session_id,
parent_working_dir,
parent_session_id: parent_session_id.to_owned(),
parent_working_dir: parent_working_dir.to_owned(),
extensions,
max_turns: Some(
env::var(GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR)
+4 -3
View File
@@ -29,8 +29,9 @@ impl From<ToolResult<Vec<Content>>> for ToolCallResult {
}
use super::agent::{tool_stream, ToolStream};
use crate::agents::{Agent, SessionConfig};
use crate::agents::Agent;
use crate::conversation::message::{Message, ToolRequest};
use crate::session::Session;
use crate::tool_inspection::get_security_finding_id_from_results;
pub const DECLINED_RESPONSE: &str = "The user has declined to run this tool. \
@@ -53,7 +54,7 @@ impl Agent {
tool_futures: Arc<Mutex<Vec<(String, ToolStream)>>>,
message_tool_response: Arc<Mutex<Message>>,
cancellation_token: Option<CancellationToken>,
session: Option<SessionConfig>,
session: &'a Session,
inspection_results: &'a [crate::tool_inspection::InspectionResult],
) -> BoxStream<'a, anyhow::Result<Message>> {
try_stream! {
@@ -93,7 +94,7 @@ impl Agent {
}
if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow {
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session.clone()).await;
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session).await;
let mut futures = tool_futures.lock().await;
futures.push((req_id, match tool_result {
+1 -6
View File
@@ -2,7 +2,6 @@ use crate::mcp_utils::ToolResult;
use crate::providers::base::Provider;
use rmcp::model::{Content, Tool};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use utoipa::ToSchema;
@@ -84,14 +83,10 @@ pub struct FrontendTool {
/// Session configuration for an agent
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
/// Unique identifier for the session
/// Identifier of the underlying Session
pub id: String,
/// Working directory for the session
pub working_dir: PathBuf,
/// ID of the schedule that triggered this session, if any
pub schedule_id: Option<String>,
/// Execution mode for scheduled jobs: "foreground" or "background"
pub execution_mode: Option<String>,
/// Maximum number of turns (iterations) allowed without user input
pub max_turns: Option<u32>,
/// Retry configuration for automated validation and recovery
+2 -3
View File
@@ -145,11 +145,10 @@ pub async fn check_if_compaction_needed(
agent: &Agent,
conversation: &Conversation,
threshold_override: Option<f64>,
session_metadata: Option<&crate::session::Session>,
session: &crate::session::Session,
) -> Result<bool> {
let messages = conversation.messages();
let config = Config::global();
// TODO(Douwe): check the default here; it seems to reset to 0.3 sometimes
let threshold = threshold_override.unwrap_or_else(|| {
config
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
@@ -159,7 +158,7 @@ pub async fn check_if_compaction_needed(
let provider = agent.provider().await?;
let context_limit = provider.get_model_config().context_limit();
let (current_tokens, token_source) = match session_metadata.and_then(|m| m.total_tokens) {
let (current_tokens, token_source) = match session.total_tokens {
Some(tokens) => (tokens as usize, "session metadata"),
None => {
let token_counter = create_token_counter()
+5 -13
View File
@@ -21,6 +21,7 @@ use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflic
use crate::providers::create;
use crate::recipe::Recipe;
use crate::scheduler_trait::SchedulerTrait;
use crate::session::session_manager::SessionType;
use crate::session::{Session, SessionManager};
// Track running tasks with their abort handles
@@ -152,8 +153,6 @@ pub struct ScheduledJob {
pub current_session_id: Option<String>,
#[serde(default)]
pub process_start_time: Option<DateTime<Utc>>,
#[serde(default)]
pub execution_mode: Option<String>, // "foreground" or "background"
}
async fn persist_jobs_from_arc(
@@ -1160,8 +1159,6 @@ async fn run_scheduled_job_internal(
});
}
tracing::info!("Agent configured with provider for job '{}'", job.id);
let execution_mode = job.execution_mode.as_deref().unwrap_or("background");
tracing::info!("Job '{}' running in {} mode", job.id, execution_mode);
let current_dir = match std::env::current_dir() {
Ok(cd) => cd,
@@ -1173,10 +1170,10 @@ async fn run_scheduled_job_internal(
}
};
// Create session upfront
let session = match SessionManager::create_session(
current_dir.clone(),
format!("Scheduled job: {}", job.id),
SessionType::Scheduled,
)
.await
{
@@ -1204,23 +1201,19 @@ async fn run_scheduled_job_internal(
.or(recipe.instructions.as_ref())
.unwrap();
let mut conversation =
Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]);
let user_message = Message::user().with_text(prompt_text);
let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]);
let session_config = SessionConfig {
id: session.id.clone(),
working_dir: current_dir.clone(),
schedule_id: Some(job.id.clone()),
execution_mode: job.execution_mode.clone(),
max_turns: None,
retry_config: None,
};
let session_id = Some(session_config.id.clone());
match crate::session_context::with_session_id(session_id, async {
agent
.reply(conversation.clone(), Some(session_config.clone()), None)
.await
agent.reply(user_message, session_config, None).await
})
.await
{
@@ -1455,7 +1448,6 @@ mod tests {
paused: false,
current_session_id: None,
process_start_time: None,
execution_mode: Some("background".to_string()), // Default for test
};
let mock_model_config = ModelConfig::new_or_fail("test_model");
+1 -1
View File
@@ -6,4 +6,4 @@ pub mod session_manager;
pub use diagnostics::generate_diagnostics;
pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState};
pub use session_manager::{Session, SessionInsights, SessionManager};
pub use session_manager::{Session, SessionInsights, SessionManager, SessionType};
+141 -45
View File
@@ -18,7 +18,47 @@ use tokio::sync::OnceCell;
use tracing::{info, warn};
use utoipa::ToSchema;
const CURRENT_SCHEMA_VERSION: i32 = 4;
const CURRENT_SCHEMA_VERSION: i32 = 5;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionType {
User,
Scheduled,
SubAgent,
Hidden,
}
impl Default for SessionType {
fn default() -> Self {
Self::User
}
}
impl std::fmt::Display for SessionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionType::User => write!(f, "user"),
SessionType::SubAgent => write!(f, "sub_agent"),
SessionType::Hidden => write!(f, "hidden"),
SessionType::Scheduled => write!(f, "scheduled"),
}
}
}
impl std::str::FromStr for SessionType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(SessionType::User),
"sub_agent" => Ok(SessionType::SubAgent),
"hidden" => Ok(SessionType::Hidden),
"scheduled" => Ok(SessionType::Scheduled),
_ => Err(anyhow::anyhow!("Invalid session type: {}", s)),
}
}
}
static SESSION_STORAGE: OnceCell<Arc<SessionStorage>> = OnceCell::const_new();
@@ -27,11 +67,12 @@ pub struct Session {
pub id: String,
#[schema(value_type = String)]
pub working_dir: PathBuf,
// Allow importing session exports from before 'description' was renamed to 'name'
#[serde(alias = "description")]
pub name: String,
#[serde(default)]
pub user_set_name: bool,
#[serde(default)]
pub session_type: SessionType,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub extension_data: ExtensionData,
@@ -52,6 +93,7 @@ pub struct SessionUpdateBuilder {
session_id: String,
name: Option<String>,
user_set_name: Option<bool>,
session_type: Option<SessionType>,
working_dir: Option<PathBuf>,
extension_data: Option<ExtensionData>,
total_tokens: Option<Option<i32>>,
@@ -78,6 +120,7 @@ impl SessionUpdateBuilder {
session_id,
name: None,
user_set_name: None,
session_type: None,
working_dir: None,
extension_data: None,
total_tokens: None,
@@ -110,6 +153,11 @@ impl SessionUpdateBuilder {
self
}
pub fn session_type(mut self, session_type: SessionType) -> Self {
self.session_type = Some(session_type);
self
}
pub fn working_dir(mut self, working_dir: PathBuf) -> Self {
self.working_dir = Some(working_dir);
self
@@ -183,10 +231,14 @@ impl SessionManager {
.map(Arc::clone)
}
pub async fn create_session(working_dir: PathBuf, name: String) -> Result<Session> {
pub async fn create_session(
working_dir: PathBuf,
name: String,
session_type: SessionType,
) -> Result<Session> {
Self::instance()
.await?
.create_session(working_dir, name)
.create_session(working_dir, name, session_type)
.await
}
@@ -306,6 +358,7 @@ impl Default for Session {
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
name: String::new(),
user_set_name: false,
session_type: SessionType::default(),
created_at: Default::default(),
updated_at: Default::default(),
extension_data: ExtensionData::default(),
@@ -353,11 +406,17 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
let user_set_name = row.try_get("user_set_name").unwrap_or(false);
let session_type_str: String = row
.try_get("session_type")
.unwrap_or_else(|_| "user".to_string());
let session_type = session_type_str.parse().unwrap_or_default();
Ok(Session {
id: row.try_get("id")?,
working_dir: PathBuf::from(row.try_get::<String, _>("working_dir")?),
name,
user_set_name,
session_type,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
extension_data: serde_json::from_str(&row.try_get::<String, _>("extension_data")?)
@@ -446,6 +505,7 @@ impl SessionStorage {
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
user_set_name BOOLEAN DEFAULT FALSE,
session_type TEXT NOT NULL DEFAULT 'user',
working_dir TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -491,6 +551,9 @@ impl SessionStorage {
sqlx::query("CREATE INDEX idx_sessions_updated ON sessions(updated_at DESC)")
.execute(&pool)
.await?;
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
.execute(&pool)
.await?;
Ok(Self { pool })
}
@@ -553,31 +616,32 @@ impl SessionStorage {
sqlx::query(
r#"
INSERT INTO sessions (
id, name, user_set_name, working_dir, created_at, updated_at, extension_data,
id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&session.id)
.bind(&session.name)
.bind(session.user_set_name)
.bind(session.working_dir.to_string_lossy().as_ref())
.bind(session.created_at)
.bind(session.updated_at)
.bind(serde_json::to_string(&session.extension_data)?)
.bind(session.total_tokens)
.bind(session.input_tokens)
.bind(session.output_tokens)
.bind(session.accumulated_total_tokens)
.bind(session.accumulated_input_tokens)
.bind(session.accumulated_output_tokens)
.bind(&session.schedule_id)
.bind(recipe_json)
.bind(user_recipe_values_json)
.execute(&self.pool)
.await?;
.bind(&session.id)
.bind(&session.name)
.bind(session.user_set_name)
.bind(session.session_type.to_string())
.bind(session.working_dir.to_string_lossy().as_ref())
.bind(session.created_at)
.bind(session.updated_at)
.bind(serde_json::to_string(&session.extension_data)?)
.bind(session.total_tokens)
.bind(session.input_tokens)
.bind(session.output_tokens)
.bind(session.accumulated_total_tokens)
.bind(session.accumulated_input_tokens)
.bind(session.accumulated_output_tokens)
.bind(&session.schedule_id)
.bind(recipe_json)
.bind(user_recipe_values_json)
.execute(&self.pool)
.await?;
if let Some(conversation) = &session.conversation {
self.replace_conversation(&session.id, conversation).await?;
@@ -687,6 +751,19 @@ impl SessionStorage {
.execute(&self.pool)
.await?;
}
5 => {
sqlx::query(
r#"
ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user'
"#,
)
.execute(&self.pool)
.await?;
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
.execute(&self.pool)
.await?;
}
_ => {
anyhow::bail!("Unknown migration version: {}", version);
}
@@ -695,11 +772,16 @@ impl SessionStorage {
Ok(())
}
async fn create_session(&self, working_dir: PathBuf, name: String) -> Result<Session> {
async fn create_session(
&self,
working_dir: PathBuf,
name: String,
session_type: SessionType,
) -> Result<Session> {
let today = chrono::Utc::now().format("%Y%m%d").to_string();
Ok(sqlx::query_as(
r#"
INSERT INTO sessions (id, name, user_set_name, working_dir, extension_data)
INSERT INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data)
VALUES (
? || '_' || CAST(COALESCE((
SELECT MAX(CAST(SUBSTR(id, 10) AS INTEGER))
@@ -709,23 +791,25 @@ impl SessionStorage {
?,
FALSE,
?,
?,
'{}'
)
RETURNING *
"#,
)
.bind(&today)
.bind(&today)
.bind(&name)
.bind(working_dir.to_string_lossy().as_ref())
.fetch_one(&self.pool)
.await?)
.bind(&today)
.bind(&today)
.bind(&name)
.bind(session_type.to_string())
.bind(working_dir.to_string_lossy().as_ref())
.fetch_one(&self.pool)
.await?)
}
async fn get_session(&self, id: &str, include_messages: bool) -> Result<Session> {
let mut session = sqlx::query_as::<_, Session>(
r#"
SELECT id, working_dir, name, description, user_set_name, created_at, updated_at, extension_data,
SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json
@@ -733,10 +817,10 @@ impl SessionStorage {
WHERE id = ?
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
if include_messages {
let conv = self.get_conversation(&session.id).await?;
@@ -773,6 +857,7 @@ impl SessionStorage {
add_update!(builder.name, "name");
add_update!(builder.user_set_name, "user_set_name");
add_update!(builder.session_type, "session_type");
add_update!(builder.working_dir, "working_dir");
add_update!(builder.extension_data, "extension_data");
add_update!(builder.total_tokens, "total_tokens");
@@ -803,6 +888,9 @@ impl SessionStorage {
if let Some(user_set_name) = builder.user_set_name {
q = q.bind(user_set_name);
}
if let Some(session_type) = builder.session_type {
q = q.bind(session_type.to_string());
}
if let Some(wd) = builder.working_dir {
q = q.bind(wd.to_string_lossy().to_string());
}
@@ -872,7 +960,6 @@ impl SessionStorage {
let mut message = Message::new(role, created_timestamp, content);
message.metadata = metadata;
// TODO(Douwe): make id required
message = message.with_id(format!("msg_{}_{}", session_id, idx));
messages.push(message);
}
@@ -942,20 +1029,21 @@ impl SessionStorage {
async fn list_sessions(&self) -> Result<Vec<Session>> {
sqlx::query_as::<_, Session>(
r#"
SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.created_at, s.updated_at, s.extension_data,
SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data,
s.total_tokens, s.input_tokens, s.output_tokens,
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
COUNT(m.id) as message_count
FROM sessions s
INNER JOIN messages m ON s.id = m.session_id
WHERE s.session_type = 'user' OR s.session_type = 'scheduled'
GROUP BY s.id
ORDER BY s.updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(Into::into)
.fetch_all(&self.pool)
.await
.map_err(Into::into)
}
async fn delete_session(&self, session_id: &str) -> Result<()> {
@@ -1008,7 +1096,11 @@ impl SessionStorage {
let import: Session = serde_json::from_str(json)?;
let session = self
.create_session(import.working_dir.clone(), import.name.clone())
.create_session(
import.working_dir.clone(),
import.name.clone(),
import.session_type,
)
.await?;
let mut builder = SessionUpdateBuilder::new(session.id.clone())
@@ -1084,7 +1176,7 @@ mod tests {
let description = format!("Test session {}", i);
let session = session_storage
.create_session(working_dir.clone(), description)
.create_session(working_dir.clone(), description, SessionType::User)
.await
.unwrap();
@@ -1176,7 +1268,11 @@ mod tests {
let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap());
let original = storage
.create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string())
.create_session(
PathBuf::from("/tmp/test"),
DESCRIPTION.to_string(),
SessionType::User,
)
.await
.unwrap();
+403 -1329
View File
File diff suppressed because it is too large Load Diff
-833
View File
@@ -1,833 +0,0 @@
#![cfg(test)]
use rmcp::model::{CallToolRequestParam, ErrorCode};
use rmcp::object;
use serde_json::json;
use goose::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
mod test_support;
use test_support::{
create_temp_recipe, create_test_session_metadata, MockBehavior, ScheduleToolTestBuilder,
};
// Test all actions of the scheduler platform tool
#[tokio::test]
async fn test_schedule_tool_list_action() {
// Create a test builder with existing jobs
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_existing_job("job2", "0 0 * * * *")
.await
.build()
.await;
// Test list action
let arguments = json!({
"action": "list"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content.text.contains("Scheduled Jobs:"));
assert!(text_content.text.contains("job1"));
assert!(text_content.text.contains("job2"));
} else {
panic!("Expected text content");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_list_action_empty() {
// Create a test builder with no jobs
let (agent, scheduler) = ScheduleToolTestBuilder::new().build().await;
// Test list action
let arguments = json!({
"action": "list"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content.text.contains("Scheduled Jobs:"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_list_action_error() {
// Create a test builder with a list error
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior(
"list_scheduled_jobs",
MockBehavior::InternalError("Database error".to_string()),
)
.await
.build()
.await;
// Test list action
let arguments = json!({
"action": "list"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
assert!(err.message.contains("Failed to list jobs"));
assert!(err.message.contains("Database error"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_create_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new().build().await;
// Create a temporary recipe file
let temp_recipe = create_temp_recipe(true, "json");
// Test create action
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap(),
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Successfully created scheduled job"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"add_scheduled_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_create_action_missing_params() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test create action with missing recipe_path
let arguments = json!({
"action": "create",
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
assert!(err.message.contains("Missing 'recipe_path' parameter"));
} else {
panic!("Expected ExecutionError");
}
// Test create action with missing cron_expression
let temp_recipe = create_temp_recipe(true, "json");
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap()
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
assert!(err.message.contains("Missing 'cron_expression' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_create_action_nonexistent_recipe() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test create action with nonexistent recipe
let arguments = json!({
"action": "create",
"recipe_path": "/nonexistent/recipe.json",
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
assert!(err.message.contains("Recipe file not found"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_create_action_invalid_recipe() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Create an invalid recipe file
let temp_recipe = create_temp_recipe(false, "json");
// Test create action with invalid recipe
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap(),
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
assert!(err.message.contains("Invalid JSON recipe"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_create_action_scheduler_error() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior(
"add_scheduled_job",
MockBehavior::AlreadyExists("job1".to_string()),
)
.await
.build()
.await;
// Create a temporary recipe file
let temp_recipe = create_temp_recipe(true, "json");
// Test create action
let arguments = json!({
"action": "create",
"recipe_path": temp_recipe.path.to_str().unwrap(),
"cron_expression": "*/5 * * * * *"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
assert!(err.message.contains("Failed to create job"));
assert!(err.message.contains("job1"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"add_scheduled_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_run_now_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test run_now action
let arguments = json!({
"action": "run_now",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Successfully started job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"run_now".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_run_now_action_missing_job_id() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test run_now action with missing job_id
let arguments = json!({
"action": "run_now"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
assert!(err.message.contains("Missing 'job_id' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_run_now_action_nonexistent_job() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior("run_now", MockBehavior::NotFound("nonexistent".to_string()))
.await
.build()
.await;
// Test run_now action with nonexistent job
let arguments = json!({
"action": "run_now",
"job_id": "nonexistent"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert!(err.message.contains("Failed to run job"));
assert!(err.message.contains("nonexistent"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"run_now".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_pause_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test pause action
let arguments = json!({
"action": "pause",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content.text.contains("Successfully paused job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"pause_schedule".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_pause_action_missing_job_id() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test pause action with missing job_id
let arguments = json!({
"action": "pause"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert!(err.message.contains("Missing 'job_id' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_pause_action_running_job() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_scheduler_behavior(
"pause_schedule",
MockBehavior::JobCurrentlyRunning("job1".to_string()),
)
.await
.build()
.await;
// Test pause action with a running job
let arguments = json!({
"action": "pause",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert!(err.message.contains("Failed to pause job"));
assert!(err.message.contains("job1"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"pause_schedule".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_unpause_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test unpause action
let arguments = json!({
"action": "unpause",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Successfully unpaused job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"unpause_schedule".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_delete_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test delete action
let arguments = json!({
"action": "delete",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Successfully deleted job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"remove_scheduled_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_kill_action() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_running_job("job1")
.await
.build()
.await;
// Test kill action
let arguments = json!({
"action": "kill",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Successfully killed running job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"kill_running_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_kill_action_not_running() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test kill action with a job that's not running
let arguments = json!({
"action": "kill",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert!(err.message.contains("Failed to kill job"));
} else {
panic!("Expected ExecutionError");
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"kill_running_job".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_inspect_action_running() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_running_job("job1")
.await
.build()
.await;
// Test inspect action
let arguments = json!({
"action": "inspect",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Job 'job1' is currently running"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"get_running_job_info".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_inspect_action_not_running() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test inspect action with a job that's not running
let arguments = json!({
"action": "inspect",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("Job 'job1' is not currently running"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"get_running_job_info".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_sessions_action() {
// Create test session metadata
let sessions = vec![
(
"1234567890_session1".to_string(),
create_test_session_metadata(5, "/tmp"),
),
(
"0987654321_session2".to_string(),
create_test_session_metadata(10, "/home"),
),
];
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_sessions_data("job1", sessions)
.await
.build()
.await;
// Test sessions action
let arguments = json!({
"action": "sessions",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content.text.contains("Sessions for job 'job1'"));
assert!(text_content.text.contains("session1"));
assert!(text_content.text.contains("session2"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"sessions".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_sessions_action_with_limit() {
// Create test session metadata
let sessions = vec![
(
"1234567890_session1".to_string(),
create_test_session_metadata(5, "/tmp"),
),
(
"0987654321_session2".to_string(),
create_test_session_metadata(10, "/home"),
),
(
"5555555555_session3".to_string(),
create_test_session_metadata(15, "/usr"),
),
];
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.with_sessions_data("job1", sessions)
.await
.build()
.await;
// Test sessions action with limit
let arguments = json!({
"action": "sessions",
"job_id": "job1",
"limit": 2
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"sessions".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_sessions_action_empty() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test sessions action with no sessions
let arguments = json!({
"action": "sessions",
"job_id": "job1"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
if let Some(text_content) = content[0].as_text() {
assert!(text_content
.text
.contains("No sessions found for job 'job1'"));
}
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"sessions".to_string()));
}
#[tokio::test]
async fn test_schedule_tool_session_content_action_missing_session_id() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test session_content action with missing session_id
let arguments = json!({
"action": "session_content"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert!(err.message.contains("Missing 'session_id' parameter"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_unknown_action() {
let (agent, _) = ScheduleToolTestBuilder::new().build().await;
// Test unknown action
let arguments = json!({
"action": "unknown_action"
});
let result = agent
.handle_schedule_management(arguments, "test_req".to_string())
.await;
assert!(result.is_err());
if let Err(err) = result {
assert!(err.message.contains("Unknown action"));
} else {
panic!("Expected ExecutionError");
}
}
#[tokio::test]
async fn test_schedule_tool_dispatch() {
let (agent, scheduler) = ScheduleToolTestBuilder::new()
.with_existing_job("job1", "*/5 * * * * *")
.await
.build()
.await;
// Test that the tool is properly dispatched through dispatch_tool_call
let tool_call = CallToolRequestParam {
name: PLATFORM_MANAGE_SCHEDULE_TOOL_NAME.into(),
arguments: Some(object!({
"action": "list"
})),
};
let (request_id, result) = agent
.dispatch_tool_call(tool_call, "test_dispatch".to_string(), None, None)
.await;
assert_eq!(request_id, "test_dispatch");
assert!(result.is_ok());
let tool_result = result.unwrap();
// The result should be a future that resolves to the tool output
let output = tool_result.result.await;
assert!(output.is_ok());
// Verify the scheduler was called
let calls = scheduler.get_calls().await;
assert!(calls.contains(&"list_scheduled_jobs".to_string()));
}
+1 -1
View File
@@ -345,7 +345,6 @@ impl ScheduleToolTestBuilder {
paused: false,
current_session_id: None,
process_start_time: None,
execution_mode: Some("background".to_string()),
};
{
let mut jobs = self.scheduler.jobs.lock().await;
@@ -397,5 +396,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
conversation: None,
message_count,
user_recipe_values: None,
session_type: Default::default(),
}
}
+12 -4
View File
@@ -4127,10 +4127,6 @@
"currently_running": {
"type": "boolean"
},
"execution_mode": {
"type": "string",
"nullable": true
},
"id": {
"type": "string"
},
@@ -4226,6 +4222,9 @@
"type": "string",
"nullable": true
},
"session_type": {
"$ref": "#/components/schemas/SessionType"
},
"total_tokens": {
"type": "integer",
"format": "int32",
@@ -4344,6 +4343,15 @@
}
}
},
"SessionType": {
"type": "string",
"enum": [
"user",
"scheduled",
"sub_agent",
"hidden"
]
},
"SessionsQuery": {
"type": "object",
"properties": {
+30 -10
View File
@@ -328,6 +328,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -697,6 +698,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -743,6 +745,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1182,6 +1185,7 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@@ -2731,6 +2735,7 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@@ -5960,8 +5965,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6250,6 +6254,7 @@
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6291,6 +6296,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6301,6 +6307,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6447,6 +6454,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -6848,6 +6856,7 @@
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"fflate": "^0.8.2",
@@ -7097,6 +7106,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7736,6 +7746,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -9071,8 +9082,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -9130,6 +9140,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^22.7.7",
@@ -10145,6 +10156,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10604,6 +10616,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@@ -12819,6 +12832,7 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@@ -14043,7 +14057,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -14064,6 +14077,7 @@
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
@@ -16438,6 +16452,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -16526,7 +16541,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -16542,7 +16556,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -16859,6 +16872,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16868,6 +16882,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16889,8 +16904,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -17839,6 +17853,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -18832,7 +18847,8 @@
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -19451,6 +19467,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19836,6 +19853,7 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -19949,6 +19967,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -20654,6 +20673,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+3 -1
View File
@@ -667,7 +667,6 @@ export type ScheduledJob = {
cron: string;
current_session_id?: string | null;
currently_running?: boolean;
execution_mode?: string | null;
id: string;
last_run?: string | null;
paused?: boolean;
@@ -689,6 +688,7 @@ export type Session = {
output_tokens?: number | null;
recipe?: Recipe | null;
schedule_id?: string | null;
session_type?: SessionType;
total_tokens?: number | null;
updated_at: string;
user_recipe_values?: {
@@ -725,6 +725,8 @@ export type SessionListResponse = {
sessions: Array<Session>;
};
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden';
export type SessionsQuery = {
limit?: number;
};