feat: add recipes, a custom goose agent configuration (#2115)

This commit is contained in:
Kalvin C
2025-04-09 18:57:24 -07:00
committed by GitHub
parent d84787f144
commit d1c124c28d
17 changed files with 884 additions and 145 deletions
+140 -46
View File
@@ -1,16 +1,18 @@
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use goose::config::Config;
use goose::config::{Config, ExtensionConfig};
use crate::commands::bench::agent_generator;
use crate::commands::configure::handle_configure;
use crate::commands::info::handle_info;
use crate::commands::mcp::run_server;
use crate::commands::recipe::{handle_deeplink, handle_validate};
use crate::commands::session::handle_session_list;
use crate::logging::setup_logging;
use crate::recipe::load_recipe;
use crate::session;
use crate::session::build_session;
use crate::session::{build_session, SessionBuilderConfig};
use goose_bench::bench_config::BenchRunConfig;
use goose_bench::runners::bench_runner::BenchRunner;
use goose_bench::runners::eval_runner::EvalRunner;
@@ -115,6 +117,25 @@ pub enum BenchCommand {
},
}
#[derive(Subcommand)]
enum RecipeCommand {
/// Validate a recipe file
#[command(about = "Validate a recipe file")]
Validate {
/// Path to the recipe file to validate
#[arg(help = "Path to the recipe file to validate")]
file: String,
},
/// Generate a deeplink for a recipe file
#[command(about = "Generate a deeplink for a recipe file")]
Deeplink {
/// Path to the recipe file
#[arg(help = "Path to the recipe file")]
file: String,
},
}
#[derive(Subcommand)]
enum Command {
/// Configure Goose settings
@@ -170,7 +191,7 @@ enum Command {
long_help = "Add stdio extensions from full commands with environment variables. Can be specified multiple times. Format: 'ENV1=val1 ENV2=val2 command args...'",
action = clap::ArgAction::Append
)]
extension: Vec<String>,
extensions: Vec<String>,
/// Add remote extensions with a URL
#[arg(
@@ -180,7 +201,7 @@ enum Command {
long_help = "Add remote extensions from a URL. Can be specified multiple times. Format: 'url...'",
action = clap::ArgAction::Append
)]
remote_extension: Vec<String>,
remote_extensions: Vec<String>,
/// Add builtin extensions by name
#[arg(
@@ -190,7 +211,7 @@ enum Command {
long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated",
value_delimiter = ','
)]
builtin: Vec<String>,
builtins: Vec<String>,
},
/// Execute commands from an instruction file
@@ -202,7 +223,8 @@ enum Command {
long,
value_name = "FILE",
help = "Path to instruction file containing commands. Use - for stdin.",
conflicts_with = "input_text"
conflicts_with = "input_text",
conflicts_with = "recipe"
)]
instructions: Option<String>,
@@ -213,10 +235,23 @@ enum Command {
value_name = "TEXT",
help = "Input text to provide to Goose directly",
long_help = "Input text containing commands for Goose. Use this in lieu of the instructions argument.",
conflicts_with = "instructions"
conflicts_with = "instructions",
conflicts_with = "recipe"
)]
input_text: Option<String>,
/// Path to recipe.yaml file
#[arg(
short = None,
long = "recipe",
value_name = "FILE",
help = "Path to recipe.yaml file",
long_help = "Path to a recipe.yaml file that defines a custom agent configuration",
conflicts_with = "instructions",
conflicts_with = "input_text"
)]
recipe: Option<String>,
/// Continue in interactive mode after processing input
#[arg(
short = 's',
@@ -255,7 +290,7 @@ enum Command {
long_help = "Add stdio extensions from full commands with environment variables. Can be specified multiple times. Format: 'ENV1=val1 ENV2=val2 command args...'",
action = clap::ArgAction::Append
)]
extension: Vec<String>,
extensions: Vec<String>,
/// Add remote extensions
#[arg(
@@ -265,7 +300,7 @@ enum Command {
long_help = "Add remote extensions. Can be specified multiple times. Format: 'url...'",
action = clap::ArgAction::Append
)]
remote_extension: Vec<String>,
remote_extensions: Vec<String>,
/// Add builtin extensions by name
#[arg(
@@ -275,7 +310,14 @@ enum Command {
long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated",
value_delimiter = ','
)]
builtin: Vec<String>,
builtins: Vec<String>,
},
/// Recipe utilities for validation and deeplinking
#[command(about = "Recipe utilities for validation and deeplinking")]
Recipe {
#[command(subcommand)]
command: RecipeCommand,
},
/// Update the Goose CLI version
@@ -308,6 +350,12 @@ enum CliProviderVariant {
Ollama,
}
struct InputConfig {
contents: Option<String>,
extensions_override: Option<Vec<ExtensionConfig>>,
additional_system_prompt: Option<String>,
}
pub async fn cli() -> Result<()> {
let cli = Cli::parse();
@@ -328,9 +376,9 @@ pub async fn cli() -> Result<()> {
identifier,
resume,
debug,
extension,
remote_extension,
builtin,
extensions,
remote_extensions,
builtins,
}) => {
return match command {
Some(SessionCommand::List { verbose, format }) => {
@@ -339,14 +387,16 @@ pub async fn cli() -> Result<()> {
}
None => {
// Run session command by default
let mut session = build_session(
identifier.map(extract_identifier),
let mut session = build_session(SessionBuilderConfig {
identifier: identifier.map(extract_identifier),
resume,
extension,
remote_extension,
builtin,
extensions,
remote_extensions,
builtins,
extensions_override: None,
additional_system_prompt: None,
debug,
)
})
.await;
setup_logging(
session.session_file().file_stem().and_then(|s| s.to_str()),
@@ -360,44 +410,74 @@ pub async fn cli() -> Result<()> {
Some(Command::Run {
instructions,
input_text,
recipe,
interactive,
identifier,
resume,
debug,
extension,
remote_extension,
builtin,
extensions,
remote_extensions,
builtins,
}) => {
let contents = match (instructions, input_text) {
(Some(file), _) if file == "-" => {
let mut stdin = String::new();
let input_config = match (instructions, input_text, recipe) {
(Some(file), _, _) if file == "-" => {
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut stdin)
.read_to_string(&mut input)
.expect("Failed to read from stdin");
stdin
InputConfig {
contents: Some(input),
extensions_override: None,
additional_system_prompt: None,
}
}
(Some(file), _) => std::fs::read_to_string(&file).unwrap_or_else(|err| {
eprintln!(
"Instruction file not found — did you mean to use goose run --text?\n{}",
err
);
std::process::exit(1);
}),
(None, Some(text)) => text,
(None, None) => {
eprintln!("Error: Must provide either --instructions (-i) or --text (-t). Use -i - for stdin.");
(Some(file), _, _) => {
let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| {
eprintln!(
"Instruction file not found — did you mean to use goose run --text?\n{}",
err
);
std::process::exit(1);
});
InputConfig {
contents: Some(contents),
extensions_override: None,
additional_system_prompt: None,
}
}
(_, Some(text), _) => InputConfig {
contents: Some(text),
extensions_override: None,
additional_system_prompt: None,
},
(_, _, Some(file)) => {
let recipe = load_recipe(&file, true).unwrap_or_else(|err| {
eprintln!("{}: {}", console::style("Error").red().bold(), err);
std::process::exit(1);
});
InputConfig {
contents: recipe.prompt,
extensions_override: recipe.extensions,
additional_system_prompt: Some(recipe.instructions),
}
}
(None, None, None) => {
eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe (-r). Use -i - for stdin.");
std::process::exit(1);
}
};
let mut session = build_session(
identifier.map(extract_identifier),
let mut session = build_session(SessionBuilderConfig {
identifier: identifier.map(extract_identifier),
resume,
extension,
remote_extension,
builtin,
extensions,
remote_extensions,
builtins,
extensions_override: input_config.extensions_override,
additional_system_prompt: input_config.additional_system_prompt,
debug,
)
})
.await;
setup_logging(
@@ -406,9 +486,12 @@ pub async fn cli() -> Result<()> {
)?;
if interactive {
session.interactive(Some(contents)).await?;
} else {
session.interactive(input_config.contents).await?;
} else if let Some(contents) = input_config.contents {
session.headless(contents).await?;
} else {
eprintln!("Error: no text provided for prompt in headless mode");
std::process::exit(1);
}
return Ok(());
@@ -432,13 +515,24 @@ pub async fn cli() -> Result<()> {
}
return Ok(());
}
Some(Command::Recipe { command }) => {
match command {
RecipeCommand::Validate { file } => {
handle_validate(file)?;
}
RecipeCommand::Deeplink { file } => {
handle_deeplink(file)?;
}
}
return Ok(());
}
None => {
return if !Config::global().exists() {
let _ = handle_configure().await;
Ok(())
} else {
// Run session command by default
let mut session = build_session(None, false, vec![], vec![], vec![], false).await;
let mut session = build_session(SessionBuilderConfig::default()).await;
setup_logging(
session.session_file().file_stem().and_then(|s| s.to_str()),
None,
+10 -7
View File
@@ -1,4 +1,5 @@
use crate::session::build_session;
use crate::session::SessionBuilderConfig;
use crate::{logging, session, Session};
use async_trait::async_trait;
use goose::message::Message;
@@ -30,14 +31,16 @@ pub async fn agent_generator(
) -> BenchAgent {
let identifier = Some(session::Identifier::Name(session_id));
let base_session = build_session(
let base_session = build_session(SessionBuilderConfig {
identifier,
false,
requirements.external,
requirements.remote,
requirements.builtin,
false,
)
resume: false,
extensions: requirements.external,
remote_extensions: requirements.remote,
builtins: requirements.builtin,
extensions_override: None,
additional_system_prompt: None,
debug: false,
})
.await;
// package session obj into benchmark-compatible struct
+1
View File
@@ -2,5 +2,6 @@ pub mod bench;
pub mod configure;
pub mod info;
pub mod mcp;
pub mod recipe;
pub mod session;
pub mod update;
+60
View File
@@ -0,0 +1,60 @@
use anyhow::Result;
use base64::Engine;
use console::style;
use std::path::Path;
use crate::recipe::load_recipe;
/// Validates a recipe file
///
/// # Arguments
///
/// * `file_path` - Path to the recipe file to validate
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_validate<P: AsRef<Path>>(file_path: P) -> Result<()> {
// Load and validate the recipe file
match load_recipe(&file_path, false) {
Ok(_) => {
println!("{} recipe file is valid", style("").green().bold());
Ok(())
}
Err(err) => {
println!("{} {}", style("").red().bold(), err);
Err(err)
}
}
}
/// Generates a deeplink for a recipe file
///
/// # Arguments
///
/// * `file_path` - Path to the recipe file
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_deeplink<P: AsRef<Path>>(file_path: P) -> Result<()> {
// Load the recipe file first to validate it
match load_recipe(&file_path, false) {
Ok(recipe) => {
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
println!(
"{} Generated deeplink for: {}",
style("").green().bold(),
recipe.title
);
println!("goose://recipe?config={}", deeplink);
}
Ok(())
}
Err(err) => {
println!("{} {}", style("").red().bold(), err);
Err(err)
}
}
}
+1
View File
@@ -3,6 +3,7 @@ use once_cell::sync::Lazy;
pub mod cli;
pub mod commands;
pub mod logging;
pub mod recipe;
pub mod session;
// Re-export commonly used types
+70
View File
@@ -0,0 +1,70 @@
use anyhow::{Context, Result};
use console::style;
use std::path::Path;
use goose::recipe::Recipe;
/// Loads and validates a recipe from a YAML or JSON file
///
/// # Arguments
///
/// * `path` - Path to the recipe file (YAML or JSON)
/// * `log` - whether to log information about the recipe or not
///
/// # Returns
///
/// The parsed recipe struct if successful
///
/// # Errors
///
/// Returns an error if:
/// - The file doesn't exist
/// - The file can't be read
/// - The YAML/JSON is invalid
/// - The required fields are missing
pub fn load_recipe<P: AsRef<Path>>(path: P, log: bool) -> Result<Recipe> {
let path = path.as_ref();
// Check if file exists
if !path.exists() {
return Err(anyhow::anyhow!("recipe file not found: {}", path.display()));
}
// Read file content
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read recipe file: {}", path.display()))?;
// Determine file format based on extension and parse accordingly
let recipe: Recipe = if let Some(extension) = path.extension() {
match extension.to_str().unwrap_or("").to_lowercase().as_str() {
"json" => serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON recipe file: {}", path.display()))?,
"yaml" => serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML recipe file: {}", path.display()))?,
_ => {
return Err(anyhow::anyhow!(
"Unsupported file format for recipe file: {}. Expected .yaml or .json",
path.display()
))
}
}
} else {
return Err(anyhow::anyhow!(
"File has no extension: {}. Expected .yaml or .json",
path.display()
));
};
if log {
// Display information about the loaded recipe
println!(
"{} {}",
style("Loading recipe:").green().bold(),
style(&recipe.title).green()
);
println!("{} {}", style("Description:").dim(), &recipe.description);
println!(); // Add a blank line for spacing
}
Ok(recipe)
}
+63 -38
View File
@@ -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
+53
View File
@@ -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)));
}
}
+135 -54
View File
@@ -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> {