feat: add recipes, a custom goose agent configuration (#2115)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use console::style;
|
||||
use goose::agents::extension::ExtensionError;
|
||||
use goose::agents::Agent;
|
||||
use goose::config::{Config, ExtensionConfigManager};
|
||||
use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
|
||||
use goose::session;
|
||||
use goose::session::Identifier;
|
||||
use mcp_client::transport::Error as McpClientError;
|
||||
@@ -10,14 +10,31 @@ use std::process;
|
||||
use super::output;
|
||||
use super::Session;
|
||||
|
||||
pub async fn build_session(
|
||||
identifier: Option<Identifier>,
|
||||
resume: bool,
|
||||
extensions: Vec<String>,
|
||||
remote_extensions: Vec<String>,
|
||||
builtins: Vec<String>,
|
||||
debug: bool,
|
||||
) -> Session {
|
||||
/// Configuration for building a new Goose session
|
||||
///
|
||||
/// This struct contains all the parameters needed to create a new session,
|
||||
/// including session identification, extension configuration, and debug settings.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct SessionBuilderConfig {
|
||||
/// Optional identifier for the session (name or path)
|
||||
pub identifier: Option<Identifier>,
|
||||
/// Whether to resume an existing session
|
||||
pub resume: bool,
|
||||
/// List of stdio extension commands to add
|
||||
pub extensions: Vec<String>,
|
||||
/// List of remote extension commands to add
|
||||
pub remote_extensions: Vec<String>,
|
||||
/// List of builtin extension commands to add
|
||||
pub builtins: Vec<String>,
|
||||
/// List of extensions to enable, enable only this set and ignore configured ones
|
||||
pub extensions_override: Option<Vec<ExtensionConfig>>,
|
||||
/// Any additional system prompt to append to the default
|
||||
pub additional_system_prompt: Option<String>,
|
||||
/// Enable debug printing
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
|
||||
// Load config and get provider/model
|
||||
let config = Config::global();
|
||||
|
||||
@@ -36,8 +53,8 @@ pub async fn build_session(
|
||||
let mut agent = Agent::new(provider);
|
||||
|
||||
// Handle session file resolution and resuming
|
||||
let session_file = if resume {
|
||||
if let Some(identifier) = identifier {
|
||||
let session_file = if session_config.resume {
|
||||
if let Some(identifier) = session_config.identifier {
|
||||
let session_file = session::get_path(identifier);
|
||||
if !session_file.exists() {
|
||||
output::render_error(&format!(
|
||||
@@ -60,7 +77,7 @@ pub async fn build_session(
|
||||
}
|
||||
} else {
|
||||
// Create new session with provided name/path or generated name
|
||||
let id = match identifier {
|
||||
let id = match session_config.identifier {
|
||||
Some(identifier) => identifier,
|
||||
None => Identifier::Name(session::generate_session_id()),
|
||||
};
|
||||
@@ -69,7 +86,7 @@ pub async fn build_session(
|
||||
session::get_path(id)
|
||||
};
|
||||
|
||||
if resume {
|
||||
if session_config.resume {
|
||||
// Read the session metadata
|
||||
let metadata = session::read_metadata(&session_file).unwrap_or_else(|e| {
|
||||
output::render_error(&format!("Failed to read session metadata: {}", e));
|
||||
@@ -92,34 +109,38 @@ pub async fn build_session(
|
||||
|
||||
// Setup extensions for the agent
|
||||
// Extensions need to be added after the session is created because we change directory when resuming a session
|
||||
for extension in ExtensionConfigManager::get_all().expect("should load extensions") {
|
||||
if extension.enabled {
|
||||
let config = extension.config.clone();
|
||||
agent
|
||||
.add_extension(config.clone())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let err = match e {
|
||||
ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => {
|
||||
inner
|
||||
}
|
||||
_ => e.to_string(),
|
||||
};
|
||||
println!("Failed to start extension: {}, {:?}", config.name(), err);
|
||||
println!(
|
||||
"Please check extension configuration for {}.",
|
||||
config.name()
|
||||
);
|
||||
process::exit(1);
|
||||
});
|
||||
// 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 {
|
||||
ExtensionConfigManager::get_all()
|
||||
.expect("should load extensions")
|
||||
.into_iter()
|
||||
.filter(|ext| ext.enabled)
|
||||
.map(|ext| ext.config)
|
||||
.collect()
|
||||
};
|
||||
|
||||
for extension in extensions_to_run {
|
||||
if let Err(e) = agent.add_extension(extension.clone()).await {
|
||||
let err = match e {
|
||||
ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => inner,
|
||||
_ => e.to_string(),
|
||||
};
|
||||
eprintln!("Failed to start extension: {}, {:?}", extension.name(), err);
|
||||
eprintln!(
|
||||
"Please check extension configuration for {}.",
|
||||
extension.name()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session
|
||||
let mut session = Session::new(agent, session_file.clone(), debug);
|
||||
let mut session = Session::new(agent, session_file.clone(), session_config.debug);
|
||||
|
||||
// Add extensions if provided
|
||||
for extension_str in extensions {
|
||||
for extension_str in session_config.extensions {
|
||||
if let Err(e) = session.add_extension(extension_str).await {
|
||||
eprintln!("Failed to start extension: {}", e);
|
||||
process::exit(1);
|
||||
@@ -127,7 +148,7 @@ pub async fn build_session(
|
||||
}
|
||||
|
||||
// Add remote extensions if provided
|
||||
for extension_str in remote_extensions {
|
||||
for extension_str in session_config.remote_extensions {
|
||||
if let Err(e) = session.add_remote_extension(extension_str).await {
|
||||
eprintln!("Failed to start extension: {}", e);
|
||||
process::exit(1);
|
||||
@@ -135,7 +156,7 @@ pub async fn build_session(
|
||||
}
|
||||
|
||||
// Add builtin extensions
|
||||
for builtin in builtins {
|
||||
for builtin in session_config.builtins {
|
||||
if let Err(e) = session.add_builtin(builtin).await {
|
||||
eprintln!("Failed to start builtin extension: {}", e);
|
||||
process::exit(1);
|
||||
@@ -148,6 +169,10 @@ pub async fn build_session(
|
||||
.extend_system_prompt(super::prompt::get_cli_prompt())
|
||||
.await;
|
||||
|
||||
if let Some(additional_prompt) = session_config.additional_system_prompt {
|
||||
session.agent.extend_system_prompt(additional_prompt).await;
|
||||
}
|
||||
|
||||
// Only override system prompt if a system override exists
|
||||
let system_prompt_file: Option<String> = config.get_param("GOOSE_SYSTEM_PROMPT_FILE_PATH").ok();
|
||||
if let Some(ref path) = system_prompt_file {
|
||||
@@ -156,6 +181,6 @@ pub async fn build_session(
|
||||
session.agent.override_system_prompt(override_prompt).await;
|
||||
}
|
||||
|
||||
output::display_session_info(resume, &provider_name, &model, &session_file);
|
||||
output::display_session_info(session_config.resume, &provider_name, &model, &session_file);
|
||||
session
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ impl GooseCompleter {
|
||||
"/prompts",
|
||||
"/prompt",
|
||||
"/mode",
|
||||
"/recipe",
|
||||
];
|
||||
|
||||
// Find commands that match the prefix
|
||||
|
||||
@@ -17,6 +17,7 @@ pub enum InputResult {
|
||||
GooseMode(String),
|
||||
Plan(PlanCommandOptions),
|
||||
EndPlan,
|
||||
Recipe(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -89,6 +90,7 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
const CMD_MODE: &str = "/mode ";
|
||||
const CMD_PLAN: &str = "/plan";
|
||||
const CMD_ENDPLAN: &str = "/endplan";
|
||||
const CMD_RECIPE: &str = "/recipe";
|
||||
|
||||
match input {
|
||||
"/exit" | "/quit" => Some(InputResult::Exit),
|
||||
@@ -130,10 +132,36 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
}
|
||||
s if s.starts_with(CMD_PLAN) => parse_plan_command(s[CMD_PLAN.len()..].trim().to_string()),
|
||||
s if s == CMD_ENDPLAN => Some(InputResult::EndPlan),
|
||||
s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_recipe_command(s: &str) -> Option<InputResult> {
|
||||
const CMD_RECIPE: &str = "/recipe";
|
||||
|
||||
if s == CMD_RECIPE {
|
||||
// No filepath provided, use default
|
||||
return Some(InputResult::Recipe(None));
|
||||
}
|
||||
|
||||
// Extract the filepath from the command
|
||||
let filepath = s[CMD_RECIPE.len()..].trim();
|
||||
|
||||
if filepath.is_empty() {
|
||||
return Some(InputResult::Recipe(None));
|
||||
}
|
||||
|
||||
// Validate that the filepath ends with .yaml
|
||||
if !filepath.to_lowercase().ends_with(".yaml") {
|
||||
println!("{}", console::style("Filepath must end with .yaml").red());
|
||||
return Some(InputResult::Retry);
|
||||
}
|
||||
|
||||
// Return the filepath for validation in the handler
|
||||
Some(InputResult::Recipe(Some(filepath.to_string())))
|
||||
}
|
||||
|
||||
fn parse_prompts_command(args: &str) -> Option<InputResult> {
|
||||
let parts: Vec<String> = shlex::split(args).unwrap_or_default();
|
||||
|
||||
@@ -211,6 +239,8 @@ fn print_help() {
|
||||
The model is used based on $GOOSE_PLANNER_PROVIDER and $GOOSE_PLANNER_MODEL environment variables.
|
||||
If no model is set, the default model is used.
|
||||
/endplan - Exit plan mode and return to 'normal' goose mode.
|
||||
/recipe [filepath] - Generate a recipe from the current conversation and save it to the specified filepath (must end with .yaml).
|
||||
If no filepath is provided, it will be saved to ./recipe.yaml.
|
||||
/? or /help - Display this help message
|
||||
|
||||
Navigation:
|
||||
@@ -421,4 +451,27 @@ mod tests {
|
||||
_ => panic!("Expected Plan"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipe_command() {
|
||||
// Test recipe with no filepath
|
||||
if let Some(InputResult::Recipe(filepath)) = handle_slash_command("/recipe") {
|
||||
assert!(filepath.is_none());
|
||||
} else {
|
||||
panic!("Expected Recipe");
|
||||
}
|
||||
|
||||
// Test recipe with filepath
|
||||
if let Some(InputResult::Recipe(filepath)) =
|
||||
handle_slash_command("/recipe /path/to/file.yaml")
|
||||
{
|
||||
assert_eq!(filepath, Some("/path/to/file.yaml".to_string()));
|
||||
} else {
|
||||
panic!("Expected recipe with filepath");
|
||||
}
|
||||
|
||||
// Test recipe with invalid extension
|
||||
let result = handle_slash_command("/recipe /path/to/file.txt");
|
||||
assert!(matches!(result, Some(InputResult::Retry)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ mod output;
|
||||
mod prompt;
|
||||
mod thinking;
|
||||
|
||||
pub use builder::build_session;
|
||||
pub use builder::{build_session, SessionBuilderConfig};
|
||||
use goose::permission::permission_confirmation::PrincipalType;
|
||||
use goose::permission::Permission;
|
||||
use goose::permission::PermissionConfirmation;
|
||||
use goose::providers::base::Provider;
|
||||
pub use goose::session::Identifier;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use completion::GooseCompleter;
|
||||
use etcetera::choose_app_strategy;
|
||||
use etcetera::AppStrategy;
|
||||
@@ -21,6 +21,7 @@ use goose::agents::{Agent, SessionConfig};
|
||||
use goose::config::Config;
|
||||
use goose::message::{Message, MessageContent};
|
||||
use goose::session;
|
||||
use input::InputResult;
|
||||
use mcp_core::handler::ToolError;
|
||||
use mcp_core::prompt::PromptMessage;
|
||||
|
||||
@@ -466,64 +467,40 @@ impl Session {
|
||||
}
|
||||
input::InputResult::PromptCommand(opts) => {
|
||||
save_history(&mut editor);
|
||||
self.handle_prompt_command(opts).await?;
|
||||
}
|
||||
InputResult::Recipe(filepath_opt) => {
|
||||
println!("{}", console::style("Generating Recipe").green());
|
||||
|
||||
// name is required
|
||||
if opts.name.is_empty() {
|
||||
output::render_error("Prompt name argument is required");
|
||||
continue;
|
||||
}
|
||||
output::show_thinking();
|
||||
let recipe = self.agent.create_recipe(self.messages.clone()).await;
|
||||
output::hide_thinking();
|
||||
|
||||
if opts.info {
|
||||
match self.get_prompt_info(&opts.name).await? {
|
||||
Some(info) => output::render_prompt_info(&info),
|
||||
None => {
|
||||
output::render_error(&format!("Prompt '{}' not found", opts.name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Convert the arguments HashMap to a Value
|
||||
let arguments = serde_json::to_value(opts.arguments)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to serialize arguments: {}", e))?;
|
||||
|
||||
match self.get_prompt(&opts.name, arguments).await {
|
||||
Ok(messages) => {
|
||||
let start_len = self.messages.len();
|
||||
let mut valid = true;
|
||||
for (i, prompt_message) in messages.into_iter().enumerate() {
|
||||
let msg = Message::from(prompt_message);
|
||||
// ensure we get a User - Assistant - User type pattern
|
||||
let expected_role = if i % 2 == 0 {
|
||||
mcp_core::Role::User
|
||||
} else {
|
||||
mcp_core::Role::Assistant
|
||||
};
|
||||
|
||||
if msg.role != expected_role {
|
||||
output::render_error(&format!(
|
||||
"Expected {:?} message at position {}, but found {:?}",
|
||||
expected_role, i, msg.role
|
||||
));
|
||||
valid = false;
|
||||
// get rid of everything we added to messages
|
||||
self.messages.truncate(start_len);
|
||||
break;
|
||||
}
|
||||
|
||||
if msg.role == mcp_core::Role::User {
|
||||
output::render_message(&msg, self.debug);
|
||||
}
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
if valid {
|
||||
output::show_thinking();
|
||||
self.process_agent_response(true).await?;
|
||||
output::hide_thinking();
|
||||
match recipe {
|
||||
Ok(recipe) => {
|
||||
// Use provided filepath or default
|
||||
let filepath_str = filepath_opt.as_deref().unwrap_or("recipe.yaml");
|
||||
match self.save_recipe(&recipe, filepath_str) {
|
||||
Ok(path) => println!(
|
||||
"{}",
|
||||
console::style(format!("Saved recipe to {}", path.display()))
|
||||
.green()
|
||||
),
|
||||
Err(e) => {
|
||||
println!("{}", console::style(e).red());
|
||||
}
|
||||
}
|
||||
Err(e) => output::render_error(&e.to_string()),
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{}: {:?}",
|
||||
console::style("Failed to generate recipe").red(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,6 +832,110 @@ impl Session {
|
||||
let metadata = self.get_metadata()?;
|
||||
Ok(metadata.total_tokens)
|
||||
}
|
||||
|
||||
/// Handle prompt command execution
|
||||
async fn handle_prompt_command(&mut self, opts: input::PromptCommandOptions) -> Result<()> {
|
||||
// name is required
|
||||
if opts.name.is_empty() {
|
||||
output::render_error("Prompt name argument is required");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if opts.info {
|
||||
match self.get_prompt_info(&opts.name).await? {
|
||||
Some(info) => output::render_prompt_info(&info),
|
||||
None => output::render_error(&format!("Prompt '{}' not found", opts.name)),
|
||||
}
|
||||
} else {
|
||||
// Convert the arguments HashMap to a Value
|
||||
let arguments = serde_json::to_value(opts.arguments)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to serialize arguments: {}", e))?;
|
||||
|
||||
match self.get_prompt(&opts.name, arguments).await {
|
||||
Ok(messages) => {
|
||||
let start_len = self.messages.len();
|
||||
let mut valid = true;
|
||||
for (i, prompt_message) in messages.into_iter().enumerate() {
|
||||
let msg = Message::from(prompt_message);
|
||||
// ensure we get a User - Assistant - User type pattern
|
||||
let expected_role = if i % 2 == 0 {
|
||||
mcp_core::Role::User
|
||||
} else {
|
||||
mcp_core::Role::Assistant
|
||||
};
|
||||
|
||||
if msg.role != expected_role {
|
||||
output::render_error(&format!(
|
||||
"Expected {:?} message at position {}, but found {:?}",
|
||||
expected_role, i, msg.role
|
||||
));
|
||||
valid = false;
|
||||
// get rid of everything we added to messages
|
||||
self.messages.truncate(start_len);
|
||||
break;
|
||||
}
|
||||
|
||||
if msg.role == mcp_core::Role::User {
|
||||
output::render_message(&msg, self.debug);
|
||||
}
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
if valid {
|
||||
output::show_thinking();
|
||||
self.process_agent_response(true).await?;
|
||||
output::hide_thinking();
|
||||
}
|
||||
}
|
||||
Err(e) => output::render_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save a recipe to a file
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `recipe` - The recipe to save
|
||||
/// * `filepath_str` - The path to save the recipe to
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<PathBuf, String>` - The path the recipe was saved to or an error message
|
||||
fn save_recipe(
|
||||
&self,
|
||||
recipe: &goose::recipe::Recipe,
|
||||
filepath_str: &str,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let path_buf = PathBuf::from(filepath_str);
|
||||
let mut path = path_buf.clone();
|
||||
|
||||
// Update the final path if it's relative
|
||||
if path_buf.is_relative() {
|
||||
// If the path is relative, resolve it relative to the current working directory
|
||||
let cwd = std::env::current_dir().context("Failed to get current directory")?;
|
||||
path = cwd.join(&path_buf);
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Directory '{}' does not exist",
|
||||
parent.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Try creating the file
|
||||
let file = std::fs::File::create(path.as_path())
|
||||
.context(format!("Failed to create file '{}'", path.display()))?;
|
||||
|
||||
// Write YAML
|
||||
serde_yaml::to_writer(file, recipe).context("Failed to save recipe")?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_reasoner() -> Result<Arc<dyn Provider>, anyhow::Error> {
|
||||
|
||||
Reference in New Issue
Block a user