diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index b020fe7a..83cc474e 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1,8 +1,9 @@ use anyhow::Result; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell as ClapShell}; -use goose::config::{Config, ExtensionConfig}; +use goose::config::Config; use goose::posthog::get_telemetry_choice; +use goose::recipe::Recipe; use goose_mcp::mcp_server_runner::{serve, McpCommand}; use goose_mcp::{ AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer, @@ -25,7 +26,7 @@ use crate::commands::schedule::{ 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 crate::session::{build_session, SessionBuilderConfig}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; use goose_bench::bench_config::BenchRunConfig; @@ -941,18 +942,9 @@ enum CliProviderVariant { #[derive(Debug)] pub struct InputConfig { pub contents: Option, - pub extensions_override: Option>, pub additional_system_prompt: Option, } -#[derive(Debug)] -pub struct RecipeInfo { - pub session_settings: Option, - pub sub_recipes: Option>, - pub final_output_response: Option, - pub retry_config: Option, -} - fn get_command_name(command: &Option) -> &'static str { match command { Some(Command::Configure {}) => "configure", @@ -1093,9 +1085,8 @@ async fn handle_interactive_session( extensions: extension_opts.extensions, streamable_http_extensions: extension_opts.streamable_http_extensions, builtins: extension_opts.builtins, - extensions_override: None, + recipe: None, additional_system_prompt: None, - settings: None, provider: None, model: None, debug: session_opts.debug, @@ -1104,9 +1095,6 @@ async fn handle_interactive_session( scheduled_job_id: None, interactive: true, quiet: false, - sub_recipes: None, - final_output_response: None, - retry_config: None, output_format: "text".to_string(), }) .await; @@ -1163,7 +1151,7 @@ async fn log_session_completion( fn parse_run_input( input_opts: &InputOptions, quiet: bool, -) -> Result)>> { +) -> Result)>> { match ( &input_opts.instructions, &input_opts.input_text, @@ -1177,7 +1165,6 @@ fn parse_run_input( Ok(Some(( InputConfig { contents: Some(contents), - extensions_override: None, additional_system_prompt: input_opts.system.clone(), }, None, @@ -1194,7 +1181,6 @@ fn parse_run_input( Ok(Some(( InputConfig { contents: Some(contents), - extensions_override: None, additional_system_prompt: None, }, None, @@ -1203,7 +1189,6 @@ fn parse_run_input( (_, Some(text), _) => Ok(Some(( InputConfig { contents: Some(text.clone()), - extensions_override: None, additional_system_prompt: input_opts.system.clone(), }, None, @@ -1247,13 +1232,13 @@ fn parse_run_input( "Recipe execution started" ); - let (input_config, recipe_info) = extract_recipe_info_from_cli( + let (input_config, recipe) = extract_recipe_info_from_cli( recipe_name.clone(), input_opts.params.clone(), input_opts.additional_sub_recipes.clone(), quiet, )?; - Ok(Some((input_config, Some(recipe_info)))) + Ok(Some((input_config, Some(recipe)))) } (None, None, None) => { eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin."); @@ -1277,7 +1262,7 @@ async fn handle_run_command( let parsed = parse_run_input(&input_opts, output_opts.quiet)?; - let Some((input_config, recipe_info)) = parsed else { + let Some((input_config, recipe)) = parsed else { return Ok(()); }; @@ -1302,11 +1287,8 @@ async fn handle_run_command( extensions: extension_opts.extensions, streamable_http_extensions: extension_opts.streamable_http_extensions, builtins: extension_opts.builtins, - extensions_override: input_config.extensions_override, + recipe: recipe.clone(), additional_system_prompt: input_config.additional_system_prompt, - settings: recipe_info - .as_ref() - .and_then(|r| r.session_settings.clone()), provider: model_opts.provider, model: model_opts.model, debug: session_opts.debug, @@ -1315,11 +1297,6 @@ async fn handle_run_command( scheduled_job_id: run_behavior.scheduled_job_id, interactive: run_behavior.interactive, quiet: output_opts.quiet, - sub_recipes: recipe_info.as_ref().and_then(|r| r.sub_recipes.clone()), - final_output_response: recipe_info - .as_ref() - .and_then(|r| r.final_output_response.clone()), - retry_config: recipe_info.as_ref().and_then(|r| r.retry_config.clone()), output_format: output_opts.output_format, }) .await; @@ -1328,11 +1305,7 @@ async fn handle_run_command( session.interactive(input_config.contents).await } else if let Some(contents) = input_config.contents { let session_start = std::time::Instant::now(); - let session_type = if recipe_info.is_some() { - "recipe" - } else { - "run" - }; + let session_type = if recipe.is_some() { "recipe" } else { "run" }; tracing::info!( counter.goose.session_starts = 1, @@ -1438,9 +1411,8 @@ async fn handle_default_session() -> Result<()> { extensions: Vec::new(), streamable_http_extensions: Vec::new(), builtins: Vec::new(), - extensions_override: None, + recipe: None, additional_system_prompt: None, - settings: None::, provider: None, model: None, debug: false, @@ -1449,9 +1421,6 @@ async fn handle_default_session() -> Result<()> { scheduled_job_id: None, interactive: true, quiet: false, - sub_recipes: None, - final_output_response: None, - retry_config: None, output_format: "text".to_string(), }) .await; diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index 7d4522ff..bac2ef88 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -41,9 +41,8 @@ pub async fn agent_generator( extensions: requirements.external, streamable_http_extensions: requirements.streamable_http, builtins: requirements.builtin, - extensions_override: None, + recipe: None, additional_system_prompt: None, - settings: None, provider: None, model: None, debug: false, @@ -52,9 +51,6 @@ pub async fn agent_generator( scheduled_job_id: None, max_turns: None, quiet: false, - sub_recipes: None, - final_output_response: None, - retry_config: None, output_format: "text".to_string(), }) .await; diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index 02546c0f..aac9e91c 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -1,31 +1,29 @@ use std::path::PathBuf; use anyhow::{anyhow, Result}; -use goose::recipe::SubRecipe; +use goose::recipe::{Recipe, SubRecipe}; +use crate::cli::InputConfig; use crate::recipes::print_recipe::print_recipe_info; use crate::recipes::recipe::load_recipe; use crate::recipes::search_recipe::load_recipe_file; -use crate::{ - cli::{InputConfig, RecipeInfo}, - session::SessionSettings, -}; pub fn extract_recipe_info_from_cli( recipe_name: String, params: Vec<(String, String)>, additional_sub_recipes: Vec, quiet: bool, -) -> Result<(InputConfig, RecipeInfo)> { - let recipe = load_recipe(&recipe_name, params.clone()).unwrap_or_else(|err| { +) -> Result<(InputConfig, Recipe)> { + let mut recipe = load_recipe(&recipe_name, params.clone()).unwrap_or_else(|err| { eprintln!("{}: {}", console::style("Error").red().bold(), err); std::process::exit(1); }); if !quiet { print_recipe_info(&recipe, params); } - let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default(); + if !additional_sub_recipes.is_empty() { + let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default(); for sub_recipe_name in additional_sub_recipes { match load_recipe_file(&sub_recipe_name) { Ok(recipe_file) => { @@ -49,25 +47,15 @@ pub fn extract_recipe_info_from_cli( } } } + recipe.sub_recipes = Some(all_sub_recipes); } + let input_config = InputConfig { - contents: recipe.prompt.filter(|s| !s.trim().is_empty()), - extensions_override: recipe.extensions, - additional_system_prompt: recipe.instructions, + contents: recipe.prompt.clone().filter(|s| !s.trim().is_empty()), + additional_system_prompt: recipe.instructions.clone(), }; - let recipe_info = RecipeInfo { - session_settings: recipe.settings.map(|s| SessionSettings { - goose_provider: s.goose_provider, - goose_model: s.goose_model, - temperature: s.temperature, - }), - sub_recipes: Some(all_sub_recipes), - final_output_response: recipe.response, - retry_config: recipe.retry, - }; - - Ok((input_config, recipe_info)) + Ok((input_config, recipe)) } fn extract_recipe_name(recipe_identifier: &str) -> String { @@ -98,18 +86,18 @@ mod tests { let params = vec![("name".to_string(), "my_value".to_string())]; let recipe_name = recipe_path.to_str().unwrap().to_string(); - let (input_config, recipe_info) = + let (input_config, recipe) = extract_recipe_info_from_cli(recipe_name, params, Vec::new(), false).unwrap(); - let settings = recipe_info.session_settings; - let sub_recipes = recipe_info.sub_recipes; - let response = recipe_info.final_output_response; + let settings = recipe.settings; + let sub_recipes = recipe.sub_recipes; + let response = recipe.response; assert_eq!(input_config.contents, Some("test_prompt".to_string())); assert_eq!( input_config.additional_system_prompt, Some("test_instructions my_value".to_string()) ); - assert!(input_config.extensions_override.is_none()); + assert!(recipe.extensions.is_none()); assert!(settings.is_some()); let settings = settings.unwrap(); @@ -162,19 +150,19 @@ mod tests { sub_recipe2_path.to_string_lossy().to_string(), ]; - let (input_config, recipe_info) = + let (input_config, recipe) = extract_recipe_info_from_cli(recipe_name, params, additional_sub_recipes, false) .unwrap(); - let settings = recipe_info.session_settings; - let sub_recipes = recipe_info.sub_recipes; - let response = recipe_info.final_output_response; + let settings = recipe.settings; + let sub_recipes = recipe.sub_recipes; + let response = recipe.response; assert_eq!(input_config.contents, Some("test_prompt".to_string())); assert_eq!( input_config.additional_system_prompt, Some("test_instructions my_value".to_string()) ); - assert!(input_config.extensions_override.is_none()); + assert!(recipe.extensions.is_none()); assert!(settings.is_some()); let settings = settings.unwrap(); diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index a6c13235..4ecc96ea 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -1,23 +1,77 @@ use super::output; use super::CliSession; use console::style; -use goose::agents::types::{RetryConfig, SessionConfig}; use goose::agents::Agent; +use goose::config::get_enabled_extensions; +use goose::config::resolve_extensions_for_new_session; use goose::config::{ - extensions::get_extension_by_name, get_all_extensions, get_enabled_extensions, Config, - ExtensionConfig, + extensions::get_extension_by_name, get_all_extensions, Config, ExtensionConfig, }; use goose::providers::create; -use goose::recipe::{Response, SubRecipe}; - +use goose::recipe::Recipe; use goose::session::session_manager::SessionType; use goose::session::{EnabledExtensionsState, ExtensionState}; use rustyline::EditMode; -use std::collections::HashSet; +use std::collections::BTreeSet; use std::process; use std::sync::Arc; use tokio::task::JoinSet; +const EXTENSION_HINT_MAX_LEN: usize = 5; + +fn truncate_with_ellipsis(s: &str, max_len: usize) -> String { + let truncated: String = s.chars().take(max_len).collect(); + if s.chars().count() > max_len { + format!("{}…", truncated) + } else { + truncated + } +} + +fn parse_cli_flag_extensions( + extensions: &[String], + streamable_http_extensions: &[String], + builtins: &[String], +) -> Vec<(String, ExtensionConfig)> { + let mut extensions_to_load = Vec::new(); + + for (idx, ext_str) in extensions.iter().enumerate() { + match CliSession::parse_stdio_extension(ext_str) { + Ok(config) => { + let hint = truncate_with_ellipsis(ext_str, EXTENSION_HINT_MAX_LEN); + let label = format!("stdio #{}({})", idx + 1, hint); + extensions_to_load.push((label, config)); + } + Err(e) => { + eprintln!( + "{}", + style(format!( + "Warning: Invalid --extension value '{}' ({}); ignoring", + ext_str, e + )) + .yellow() + ); + } + } + } + + for (idx, ext_str) in streamable_http_extensions.iter().enumerate() { + let config = CliSession::parse_streamable_http_extension(ext_str); + let hint = truncate_with_ellipsis(ext_str, EXTENSION_HINT_MAX_LEN); + let label = format!("http #{}({})", idx + 1, hint); + extensions_to_load.push((label, config)); + } + + for builtin_str in builtins { + let configs = CliSession::parse_builtin_extensions(builtin_str); + for config in configs { + extensions_to_load.push((config.name(), config)); + } + } + + extensions_to_load +} + /// Configuration for building a new Goose session /// /// This struct contains all the parameters needed to create a new session, @@ -36,12 +90,10 @@ pub struct SessionBuilderConfig { pub streamable_http_extensions: Vec, /// List of builtin extension commands to add pub builtins: Vec, - /// List of extensions to enable, enable only this set and ignore configured ones - pub extensions_override: Option>, + /// Recipe for the session + pub recipe: Option, /// Any additional system prompt to append to the default pub additional_system_prompt: Option, - /// Settings to override the global Goose settings - pub settings: Option, /// Provider override from CLI arguments pub provider: Option, /// Model override from CLI arguments @@ -58,12 +110,6 @@ pub struct SessionBuilderConfig { pub interactive: bool, /// Quiet mode - suppress non-response output pub quiet: bool, - /// Sub-recipes to add to the session - pub sub_recipes: Option>, - /// Final output expected response - pub final_output_response: Option, - /// Retry configuration for automated validation and recovery - pub retry_config: Option, /// Output format (text, json) pub output_format: String, } @@ -79,9 +125,8 @@ impl Default for SessionBuilderConfig { extensions: Vec::new(), streamable_http_extensions: Vec::new(), builtins: Vec::new(), - extensions_override: None, + recipe: None, additional_system_prompt: None, - settings: None, provider: None, model: None, debug: false, @@ -90,9 +135,6 @@ impl Default for SessionBuilderConfig { scheduled_job_id: None, interactive: false, quiet: false, - sub_recipes: None, - final_output_response: None, - retry_config: None, output_format: "text".to_string(), } } @@ -203,6 +245,85 @@ async fn offer_extension_debugging_help( Ok(()) } +async fn load_extensions( + agent: Agent, + extensions_to_load: Vec<(String, ExtensionConfig)>, + provider_for_debug: Arc, + interactive: bool, +) -> Arc { + let mut set = JoinSet::new(); + let agent_ptr = Arc::new(agent); + + let mut waiting_ids: BTreeSet = (0..extensions_to_load.len()).collect(); + for (id, (_label, extension)) in extensions_to_load.iter().enumerate() { + let agent_ptr = agent_ptr.clone(); + let cfg = extension.clone(); + set.spawn(async move { (id, agent_ptr.add_extension(cfg).await) }); + } + + let get_message = |waiting_ids: &BTreeSet| { + let labels: Vec = waiting_ids + .iter() + .map(|id| { + extensions_to_load + .get(*id) + .map(|e| e.0.clone()) + .unwrap_or_default() + }) + .collect(); + format!( + "starting {} extensions: {}", + waiting_ids.len(), + labels.join(", ") + ) + }; + + let spinner = cliclack::spinner(); + spinner.start(get_message(&waiting_ids)); + + let mut offer_debug: Vec<(usize, anyhow::Error)> = Vec::new(); + while let Some(result) = set.join_next().await { + match result { + Ok((id, Ok(_))) => { + waiting_ids.remove(&id); + spinner.set_message(get_message(&waiting_ids)); + } + Ok((id, Err(e))) => offer_debug.push((id, e.into())), + Err(e) => tracing::error!("failed to add extension: {}", e), + } + } + + spinner.clear(); + + for (id, err) in offer_debug { + let label = extensions_to_load + .get(id) + .map(|e| e.0.clone()) + .unwrap_or_default(); + eprintln!( + "{}", + style(format!( + "Warning: Failed to start extension '{}' ({}), continuing without it", + label, err + )) + .yellow() + ); + + if let Err(debug_err) = offer_extension_debugging_help( + &label, + &err.to_string(), + Arc::clone(&provider_for_debug), + interactive, + ) + .await + { + eprintln!("Note: Could not start debugging session: {}", debug_err); + } + } + + agent_ptr +} + fn check_missing_extensions_or_exit(saved_extensions: &[ExtensionConfig], interactive: bool) { let missing: Vec<_> = saved_extensions .iter() @@ -242,13 +363,6 @@ fn check_missing_extensions_or_exit(saved_extensions: &[ExtensionConfig], intera } } -#[derive(Clone, Debug, Default)] -pub struct SessionSettings { - pub goose_model: Option, - pub goose_provider: Option, - pub temperature: Option, -} - pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { goose::posthog::set_session_context("cli", session_config.resume); @@ -269,27 +383,20 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { (None, None) }; + let recipe = session_config.recipe.as_ref(); + let recipe_settings = recipe.and_then(|r| r.settings.as_ref()); + let provider_name = session_config .provider .or(saved_provider) - .or_else(|| { - session_config - .settings - .as_ref() - .and_then(|s| s.goose_provider.clone()) - }) + .or_else(|| recipe_settings.and_then(|s| s.goose_provider.clone())) .or_else(|| config.get_goose_provider().ok()) .expect("No provider configured. Run 'goose configure' first"); let model_name = session_config .model .or_else(|| saved_model_config.as_ref().map(|mc| mc.model_name.clone())) - .or_else(|| { - session_config - .settings - .as_ref() - .and_then(|s| s.goose_model.clone()) - }) + .or_else(|| recipe_settings.and_then(|s| s.goose_model.clone())) .or_else(|| config.get_goose_model().ok()) .expect("No model configured. Run 'goose configure' first"); @@ -299,12 +406,12 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { .is_some_and(|mc| mc.model_name == model_name) { let mut config = saved_model_config.unwrap(); - if let Some(temp) = session_config.settings.as_ref().and_then(|s| s.temperature) { + if let Some(temp) = recipe_settings.and_then(|s| s.temperature) { config = config.with_temperature(Some(temp)); } config } else { - let temperature = session_config.settings.as_ref().and_then(|s| s.temperature); + let temperature = recipe_settings.and_then(|s| s.temperature); goose::model::ModelConfig::new(&model_name) .unwrap_or_else(|e| { output::render_error(&format!("Failed to create model configuration: {}", e)); @@ -315,8 +422,8 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { agent .apply_recipe_components( - session_config.sub_recipes, - session_config.final_output_response, + recipe.and_then(|r| r.sub_recipes.clone()), + recipe.and_then(|r| r.response.clone()), true, ) .await; @@ -440,94 +547,42 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { eprintln!("{}", style(format!("Warning: {}", warning)).yellow()); } - // If we get extensions_override, only run those extensions and none other - let extensions_to_run: Vec<_> = if let Some(extensions) = session_config.extensions_override { - extensions.into_iter().collect() - } else if session_config.resume { - match agent + let configured_extensions: Vec = if session_config.resume { + agent .config .session_manager .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, - session_config.interactive, - ); - saved_state.extensions - } else { - get_enabled_extensions() - } - } - _ => get_enabled_extensions(), - } + .ok() + .and_then(|s| EnabledExtensionsState::from_extension_data(&s.extension_data)) + .map(|state| { + check_missing_extensions_or_exit(&state.extensions, session_config.interactive); + state.extensions + }) + .unwrap_or_else(get_enabled_extensions) } else { - get_enabled_extensions() + resolve_extensions_for_new_session(recipe.and_then(|r| r.extensions.as_deref()), None) }; - let mut set = JoinSet::new(); - let agent_ptr = Arc::new(agent); + let cli_flag_extensions_to_load = parse_cli_flag_extensions( + &session_config.extensions, + &session_config.streamable_http_extensions, + &session_config.builtins, + ); - let mut waiting_on = HashSet::new(); - for extension in extensions_to_run { - waiting_on.insert(extension.name()); - let agent_ptr = agent_ptr.clone(); - set.spawn(async move { - ( - extension.name(), - agent_ptr.add_extension(extension.clone()).await, - ) - }); - } + let mut extensions_to_load: Vec<(String, ExtensionConfig)> = configured_extensions + .iter() + .map(|cfg| (cfg.name(), cfg.clone())) + .collect(); + extensions_to_load.extend(cli_flag_extensions_to_load); - let get_message = |waiting_on: &HashSet| { - let mut names: Vec<_> = waiting_on.iter().cloned().collect(); - names.sort(); - format!("starting {} extensions: {}", names.len(), names.join(", ")) - }; - - let spinner = cliclack::spinner(); - spinner.start(get_message(&waiting_on)); - - let mut offer_debug = Vec::new(); - while let Some(result) = set.join_next().await { - match result { - Ok((name, Ok(_))) => { - waiting_on.remove(&name); - spinner.set_message(get_message(&waiting_on)); - } - Ok((name, Err(e))) => offer_debug.push((name, e)), - Err(e) => tracing::error!("failed to add extension: {}", e), - } - } - - spinner.clear(); - - for (name, err) in offer_debug { - eprintln!( - "{}", - style(format!( - "Warning: Failed to start extension '{}' ({}), continuing without it", - name, err - )) - .yellow() - ); - - if let Err(debug_err) = offer_extension_debugging_help( - &name, - &err.to_string(), - Arc::clone(&provider_for_display), - session_config.interactive, - ) - .await - { - eprintln!("Note: Could not start debugging session: {}", debug_err); - } - } + let agent_ptr = load_extensions( + agent, + extensions_to_load, + Arc::clone(&provider_for_display), + session_config.interactive, + ) + .await; // Determine editor mode let edit_mode = config @@ -544,113 +599,24 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { let debug_mode = session_config.debug || config.get_param("GOOSE_DEBUG").unwrap_or(false); - // Create new session - let mut session = CliSession::new( + let session = CliSession::new( Arc::try_unwrap(agent_ptr).unwrap_or_else(|_| panic!("There should be no more references")), session_id.clone(), debug_mode, session_config.scheduled_job_id.clone(), session_config.max_turns, edit_mode, - session_config.retry_config.clone(), + recipe.and_then(|r| r.retry.clone()), session_config.output_format.clone(), ) .await; - // Add stdio extensions if provided - for extension_str in session_config.extensions { - if let Err(e) = session.add_extension(extension_str.clone()).await { - eprintln!( - "{}", - style(format!( - "Warning: Failed to start stdio extension '{}' ({}), continuing without it", - extension_str, e - )) - .yellow() - ); - - // Offer debugging help - if let Err(debug_err) = offer_extension_debugging_help( - &extension_str, - &e.to_string(), - Arc::clone(&provider_for_display), - session_config.interactive, - ) - .await - { - eprintln!("Note: Could not start debugging session: {}", debug_err); - } - } - } - - // Add streamable HTTP extensions if provided - for extension_str in session_config.streamable_http_extensions { - if let Err(e) = session - .add_streamable_http_extension(extension_str.clone()) - .await - { - eprintln!( - "{}", - style(format!( - "Warning: Failed to start streamable HTTP extension '{}' ({}), continuing without it", - extension_str, e - )) - .yellow() - ); - - // Offer debugging help - if let Err(debug_err) = offer_extension_debugging_help( - &extension_str, - &e.to_string(), - Arc::clone(&provider_for_display), - session_config.interactive, - ) - .await - { - eprintln!("Note: Could not start debugging session: {}", debug_err); - } - } - } - - // Add builtin extensions - for builtin in session_config.builtins { - if let Err(e) = session.add_builtin(builtin.clone()).await { - eprintln!( - "{}", - style(format!( - "Warning: Failed to start builtin extension '{}' ({}), continuing without it", - builtin, e - )) - .yellow() - ); - - // Offer debugging help - if let Err(debug_err) = offer_extension_debugging_help( - &builtin, - &e.to_string(), - Arc::clone(&provider_for_display), - session_config.interactive, - ) - .await - { - eprintln!("Note: Could not start debugging session: {}", debug_err); - } - } - } - - 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) + .persist_extension_state(&session_id.clone()) .await { - tracing::warn!("Failed to save initial extension state: {}", e); + tracing::warn!("Failed to save extension state: {}", e); } // Add CLI-specific system prompt extension @@ -697,9 +663,8 @@ mod tests { extensions: vec!["echo test".to_string()], streamable_http_extensions: vec!["http://localhost:8080/mcp".to_string()], builtins: vec!["developer".to_string()], - extensions_override: None, + recipe: None, additional_system_prompt: Some("Test prompt".to_string()), - settings: None, provider: None, model: None, debug: true, @@ -708,9 +673,6 @@ mod tests { scheduled_job_id: None, interactive: true, quiet: false, - sub_recipes: None, - final_output_response: None, - retry_config: None, output_format: "text".to_string(), }; @@ -735,7 +697,7 @@ mod tests { assert!(config.extensions.is_empty()); assert!(config.streamable_http_extensions.is_empty()); assert!(config.builtins.is_empty()); - assert!(config.extensions_override.is_none()); + assert!(config.recipe.is_none()); assert!(config.additional_system_prompt.is_none()); assert!(!config.debug); assert!(config.max_tool_repetitions.is_none()); @@ -743,7 +705,6 @@ mod tests { assert!(config.scheduled_job_id.is_none()); assert!(!config.interactive); assert!(!config.quiet); - assert!(config.final_output_response.is_none()); } #[tokio::test] @@ -760,4 +721,16 @@ mod tests { assert_eq!(extension_name, "test-extension"); assert_eq!(error_message, "test error"); } + + #[test] + fn test_truncate_with_ellipsis() { + assert_eq!(truncate_with_ellipsis("abc", 5), "abc"); + + assert_eq!(truncate_with_ellipsis("abcde", 5), "abcde"); + + assert_eq!(truncate_with_ellipsis("abcdef", 5), "abcde…"); + assert_eq!(truncate_with_ellipsis("hello world", 5), "hello…"); + + assert_eq!(truncate_with_ellipsis("", 5), ""); + } } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 4bee1f98..0ee42f40 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -18,7 +18,7 @@ use tokio::signal::ctrl_c; use tokio_util::task::AbortOnDropHandle; pub use self::export::message_to_markdown; -pub use builder::{build_session, SessionBuilderConfig, SessionSettings}; +pub use builder::{build_session, SessionBuilderConfig}; use console::Color; use goose::agents::AgentEvent; use goose::permission::permission_confirmation::PrincipalType; @@ -255,12 +255,9 @@ impl CliSession { &self.session_id } - /// Add a stdio extension to the session - /// - /// # Arguments - /// * `extension_command` - Full command string including environment variables - /// Format: "ENV1=val1 ENV2=val2 command args..." - pub async fn add_extension(&mut self, extension_command: String) -> Result<()> { + /// Parse a stdio extension command string into an ExtensionConfig + /// Format: "ENV1=val1 ENV2=val2 command args..." + pub fn parse_stdio_extension(extension_command: &str) -> Result { let mut parts: Vec<&str> = extension_command.split_whitespace().collect(); let mut envs = HashMap::new(); @@ -279,94 +276,91 @@ impl CliSession { let cmd = parts.remove(0).to_string(); - let config = ExtensionConfig::Stdio { + Ok(ExtensionConfig::Stdio { name: String::new(), cmd, args: parts.iter().map(|s| s.to_string()).collect(), envs: Envs::new(envs), env_keys: Vec::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), - // TODO: should set timeout timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: None, available_tools: Vec::new(), - }; - - self.agent - .add_extension(config) - .await - .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; - - // Invalidate the completion cache when a new extension is added - self.invalidate_completion_cache().await; - - Ok(()) + }) } - /// Add a streamable HTTP extension to the session - /// - /// # Arguments - /// * `extension_url` - URL of the server - pub async fn add_streamable_http_extension(&mut self, extension_url: String) -> Result<()> { - let config = ExtensionConfig::StreamableHttp { + pub fn parse_streamable_http_extension(extension_url: &str) -> ExtensionConfig { + ExtensionConfig::StreamableHttp { name: String::new(), - uri: extension_url, + uri: extension_url.to_string(), envs: Envs::new(HashMap::new()), env_keys: Vec::new(), headers: HashMap::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), - // TODO: should set timeout timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: None, available_tools: Vec::new(), - }; + } + } + + /// Parse builtin extension names (comma-separated) into ExtensionConfigs + pub fn parse_builtin_extensions(builtin_name: &str) -> Vec { + builtin_name + .split(',') + .map(|name| { + let extension_name = name.trim(); + if PLATFORM_EXTENSIONS.contains_key(extension_name) { + ExtensionConfig::Platform { + name: extension_name.to_string(), + bundled: None, + description: extension_name.to_string(), + available_tools: Vec::new(), + } + } else { + ExtensionConfig::Builtin { + name: extension_name.to_string(), + display_name: None, + timeout: None, + bundled: None, + description: extension_name.to_string(), + available_tools: Vec::new(), + } + } + }) + .collect() + } + + async fn add_and_persist_extensions(&mut self, configs: Vec) -> Result<()> { + for config in configs { + self.agent + .add_extension(config) + .await + .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; + } self.agent - .add_extension(config) + .persist_extension_state(&self.session_id) .await - .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; + .map_err(|e| anyhow::anyhow!("Failed to save extension state: {}", e))?; - // Invalidate the completion cache when a new extension is added self.invalidate_completion_cache().await; Ok(()) } - /// Add a builtin extension to the session - /// - /// # Arguments - /// * `builtin_name` - Name of the builtin extension(s), comma separated + pub async fn add_extension(&mut self, extension_command: String) -> Result<()> { + let config = Self::parse_stdio_extension(&extension_command)?; + self.add_and_persist_extensions(vec![config]).await + } + + pub async fn add_streamable_http_extension(&mut self, extension_url: String) -> Result<()> { + let config = Self::parse_streamable_http_extension(&extension_url); + self.add_and_persist_extensions(vec![config]).await + } + pub async fn add_builtin(&mut self, builtin_name: String) -> Result<()> { - for name in builtin_name.split(',') { - let extension_name = name.trim(); - - let config = if PLATFORM_EXTENSIONS.contains_key(extension_name) { - ExtensionConfig::Platform { - name: extension_name.to_string(), - bundled: None, - description: name.to_string(), - available_tools: Vec::new(), - } - } else { - ExtensionConfig::Builtin { - name: extension_name.to_string(), - display_name: None, - timeout: None, - bundled: None, - description: name.to_string(), - available_tools: Vec::new(), - } - }; - self.agent - .add_extension(config) - .await - .map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?; - } - - // Invalidate the completion cache when a new extension is added - self.invalidate_completion_cache().await; - - Ok(()) + let configs = Self::parse_builtin_extensions(&builtin_name); + self.add_and_persist_extensions(configs).await } pub async fn list_prompts( diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index b8c92fc5..7e3503c0 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -15,6 +15,7 @@ use goose::goose_apps::{fetch_mcp_apps, GooseApp, McpAppCache}; use base64::Engine; use goose::agents::ExtensionConfig; +use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, GooseMode}; use goose::model::ModelConfig; use goose::prompt_template::render_global_file; @@ -225,9 +226,11 @@ async fn start_agent( } })?; - // Initialize session with extensions (either overrides from hub or global defaults) + let recipe_extensions = original_recipe + .as_ref() + .and_then(|r| r.extensions.as_deref()); let extensions_to_use = - extension_overrides.unwrap_or_else(goose::config::get_enabled_extensions); + resolve_extensions_for_new_session(recipe_extensions, extension_overrides); let mut extension_data = session.extension_data.clone(); let extensions_state = EnabledExtensionsState::new(extensions_to_use); if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 0528553b..c3f4b6cd 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -155,3 +155,18 @@ pub fn get_warnings() -> Vec { } warnings } + +pub fn resolve_extensions_for_new_session( + recipe_extensions: Option<&[ExtensionConfig]>, + override_extensions: Option>, +) -> Vec { + if let Some(exts) = recipe_extensions { + return exts.to_vec(); + } + + if let Some(exts) = override_extensions { + return exts; + } + + get_enabled_extensions() +} diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index f7e3b8ad..0599cfb8 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -15,8 +15,8 @@ pub use declarative_providers::DeclarativeProviderConfig; pub use experiments::ExperimentManager; pub use extensions::{ get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name, - get_warnings, is_extension_enabled, remove_extension, set_extension, set_extension_enabled, - ExtensionEntry, + get_warnings, is_extension_enabled, remove_extension, resolve_extensions_for_new_session, + set_extension, set_extension_enabled, ExtensionEntry, }; pub use goose_mode::GooseMode; pub use permission::PermissionManager; diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 07ad93f9..b4520d80 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken; use crate::agents::AgentEvent; use crate::agents::{Agent, SessionConfig}; use crate::config::paths::Paths; -use crate::config::Config; +use crate::config::{resolve_extensions_for_new_session, Config}; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::posthog; @@ -743,10 +743,9 @@ async fn execute_job( let agent_provider = create(&provider_name, model_config).await?; - if let Some(ref extensions) = recipe.extensions { - for ext in extensions { - agent.add_extension(ext.clone()).await?; - } + let extensions = resolve_extensions_for_new_session(recipe.extensions.as_deref(), None); + for ext in extensions { + agent.add_extension(ext.clone()).await?; } let session = agent