mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 07:04:21 +01:00
feat: add recipes, a custom goose agent configuration (#2115)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2404,6 +2404,7 @@ version = "1.0.17"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"bat",
|
"bat",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ tracing-appender = "0.2"
|
|||||||
once_cell = "1.20.2"
|
once_cell = "1.20.2"
|
||||||
shlex = "1.3.0"
|
shlex = "1.3.0"
|
||||||
async-trait = "0.1.86"
|
async-trait = "0.1.86"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["wincred"] }
|
winapi = { version = "0.3", features = ["wincred"] }
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
use goose::config::Config;
|
use goose::config::{Config, ExtensionConfig};
|
||||||
|
|
||||||
use crate::commands::bench::agent_generator;
|
use crate::commands::bench::agent_generator;
|
||||||
use crate::commands::configure::handle_configure;
|
use crate::commands::configure::handle_configure;
|
||||||
use crate::commands::info::handle_info;
|
use crate::commands::info::handle_info;
|
||||||
use crate::commands::mcp::run_server;
|
use crate::commands::mcp::run_server;
|
||||||
|
use crate::commands::recipe::{handle_deeplink, handle_validate};
|
||||||
use crate::commands::session::handle_session_list;
|
use crate::commands::session::handle_session_list;
|
||||||
use crate::logging::setup_logging;
|
use crate::logging::setup_logging;
|
||||||
|
use crate::recipe::load_recipe;
|
||||||
use crate::session;
|
use crate::session;
|
||||||
use crate::session::build_session;
|
use crate::session::{build_session, SessionBuilderConfig};
|
||||||
use goose_bench::bench_config::BenchRunConfig;
|
use goose_bench::bench_config::BenchRunConfig;
|
||||||
use goose_bench::runners::bench_runner::BenchRunner;
|
use goose_bench::runners::bench_runner::BenchRunner;
|
||||||
use goose_bench::runners::eval_runner::EvalRunner;
|
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)]
|
#[derive(Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Configure Goose settings
|
/// 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...'",
|
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
|
action = clap::ArgAction::Append
|
||||||
)]
|
)]
|
||||||
extension: Vec<String>,
|
extensions: Vec<String>,
|
||||||
|
|
||||||
/// Add remote extensions with a URL
|
/// Add remote extensions with a URL
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -180,7 +201,7 @@ enum Command {
|
|||||||
long_help = "Add remote extensions from a URL. Can be specified multiple times. Format: 'url...'",
|
long_help = "Add remote extensions from a URL. Can be specified multiple times. Format: 'url...'",
|
||||||
action = clap::ArgAction::Append
|
action = clap::ArgAction::Append
|
||||||
)]
|
)]
|
||||||
remote_extension: Vec<String>,
|
remote_extensions: Vec<String>,
|
||||||
|
|
||||||
/// Add builtin extensions by name
|
/// Add builtin extensions by name
|
||||||
#[arg(
|
#[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",
|
long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated",
|
||||||
value_delimiter = ','
|
value_delimiter = ','
|
||||||
)]
|
)]
|
||||||
builtin: Vec<String>,
|
builtins: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Execute commands from an instruction file
|
/// Execute commands from an instruction file
|
||||||
@@ -202,7 +223,8 @@ enum Command {
|
|||||||
long,
|
long,
|
||||||
value_name = "FILE",
|
value_name = "FILE",
|
||||||
help = "Path to instruction file containing commands. Use - for stdin.",
|
help = "Path to instruction file containing commands. Use - for stdin.",
|
||||||
conflicts_with = "input_text"
|
conflicts_with = "input_text",
|
||||||
|
conflicts_with = "recipe"
|
||||||
)]
|
)]
|
||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
|
|
||||||
@@ -213,10 +235,23 @@ enum Command {
|
|||||||
value_name = "TEXT",
|
value_name = "TEXT",
|
||||||
help = "Input text to provide to Goose directly",
|
help = "Input text to provide to Goose directly",
|
||||||
long_help = "Input text containing commands for Goose. Use this in lieu of the instructions argument.",
|
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>,
|
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
|
/// Continue in interactive mode after processing input
|
||||||
#[arg(
|
#[arg(
|
||||||
short = 's',
|
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...'",
|
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
|
action = clap::ArgAction::Append
|
||||||
)]
|
)]
|
||||||
extension: Vec<String>,
|
extensions: Vec<String>,
|
||||||
|
|
||||||
/// Add remote extensions
|
/// Add remote extensions
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -265,7 +300,7 @@ enum Command {
|
|||||||
long_help = "Add remote extensions. Can be specified multiple times. Format: 'url...'",
|
long_help = "Add remote extensions. Can be specified multiple times. Format: 'url...'",
|
||||||
action = clap::ArgAction::Append
|
action = clap::ArgAction::Append
|
||||||
)]
|
)]
|
||||||
remote_extension: Vec<String>,
|
remote_extensions: Vec<String>,
|
||||||
|
|
||||||
/// Add builtin extensions by name
|
/// Add builtin extensions by name
|
||||||
#[arg(
|
#[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",
|
long_help = "Add one or more builtin extensions that are bundled with goose by specifying their names, comma-separated",
|
||||||
value_delimiter = ','
|
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
|
/// Update the Goose CLI version
|
||||||
@@ -308,6 +350,12 @@ enum CliProviderVariant {
|
|||||||
Ollama,
|
Ollama,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InputConfig {
|
||||||
|
contents: Option<String>,
|
||||||
|
extensions_override: Option<Vec<ExtensionConfig>>,
|
||||||
|
additional_system_prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn cli() -> Result<()> {
|
pub async fn cli() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -328,9 +376,9 @@ pub async fn cli() -> Result<()> {
|
|||||||
identifier,
|
identifier,
|
||||||
resume,
|
resume,
|
||||||
debug,
|
debug,
|
||||||
extension,
|
extensions,
|
||||||
remote_extension,
|
remote_extensions,
|
||||||
builtin,
|
builtins,
|
||||||
}) => {
|
}) => {
|
||||||
return match command {
|
return match command {
|
||||||
Some(SessionCommand::List { verbose, format }) => {
|
Some(SessionCommand::List { verbose, format }) => {
|
||||||
@@ -339,14 +387,16 @@ pub async fn cli() -> Result<()> {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Run session command by default
|
// Run session command by default
|
||||||
let mut session = build_session(
|
let mut session = build_session(SessionBuilderConfig {
|
||||||
identifier.map(extract_identifier),
|
identifier: identifier.map(extract_identifier),
|
||||||
resume,
|
resume,
|
||||||
extension,
|
extensions,
|
||||||
remote_extension,
|
remote_extensions,
|
||||||
builtin,
|
builtins,
|
||||||
|
extensions_override: None,
|
||||||
|
additional_system_prompt: None,
|
||||||
debug,
|
debug,
|
||||||
)
|
})
|
||||||
.await;
|
.await;
|
||||||
setup_logging(
|
setup_logging(
|
||||||
session.session_file().file_stem().and_then(|s| s.to_str()),
|
session.session_file().file_stem().and_then(|s| s.to_str()),
|
||||||
@@ -360,44 +410,74 @@ pub async fn cli() -> Result<()> {
|
|||||||
Some(Command::Run {
|
Some(Command::Run {
|
||||||
instructions,
|
instructions,
|
||||||
input_text,
|
input_text,
|
||||||
|
recipe,
|
||||||
interactive,
|
interactive,
|
||||||
identifier,
|
identifier,
|
||||||
resume,
|
resume,
|
||||||
debug,
|
debug,
|
||||||
extension,
|
extensions,
|
||||||
remote_extension,
|
remote_extensions,
|
||||||
builtin,
|
builtins,
|
||||||
}) => {
|
}) => {
|
||||||
let contents = match (instructions, input_text) {
|
let input_config = match (instructions, input_text, recipe) {
|
||||||
(Some(file), _) if file == "-" => {
|
(Some(file), _, _) if file == "-" => {
|
||||||
let mut stdin = String::new();
|
let mut input = String::new();
|
||||||
std::io::stdin()
|
std::io::stdin()
|
||||||
.read_to_string(&mut stdin)
|
.read_to_string(&mut input)
|
||||||
.expect("Failed to read from stdin");
|
.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| {
|
}
|
||||||
|
(Some(file), _, _) => {
|
||||||
|
let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Instruction file not found — did you mean to use goose run --text?\n{}",
|
"Instruction file not found — did you mean to use goose run --text?\n{}",
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}),
|
});
|
||||||
(None, Some(text)) => text,
|
InputConfig {
|
||||||
(None, None) => {
|
contents: Some(contents),
|
||||||
eprintln!("Error: Must provide either --instructions (-i) or --text (-t). Use -i - for stdin.");
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut session = build_session(
|
let mut session = build_session(SessionBuilderConfig {
|
||||||
identifier.map(extract_identifier),
|
identifier: identifier.map(extract_identifier),
|
||||||
resume,
|
resume,
|
||||||
extension,
|
extensions,
|
||||||
remote_extension,
|
remote_extensions,
|
||||||
builtin,
|
builtins,
|
||||||
|
extensions_override: input_config.extensions_override,
|
||||||
|
additional_system_prompt: input_config.additional_system_prompt,
|
||||||
debug,
|
debug,
|
||||||
)
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
setup_logging(
|
setup_logging(
|
||||||
@@ -406,9 +486,12 @@ pub async fn cli() -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
if interactive {
|
if interactive {
|
||||||
session.interactive(Some(contents)).await?;
|
session.interactive(input_config.contents).await?;
|
||||||
} else {
|
} else if let Some(contents) = input_config.contents {
|
||||||
session.headless(contents).await?;
|
session.headless(contents).await?;
|
||||||
|
} else {
|
||||||
|
eprintln!("Error: no text provided for prompt in headless mode");
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -432,13 +515,24 @@ pub async fn cli() -> Result<()> {
|
|||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Some(Command::Recipe { command }) => {
|
||||||
|
match command {
|
||||||
|
RecipeCommand::Validate { file } => {
|
||||||
|
handle_validate(file)?;
|
||||||
|
}
|
||||||
|
RecipeCommand::Deeplink { file } => {
|
||||||
|
handle_deeplink(file)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
return if !Config::global().exists() {
|
return if !Config::global().exists() {
|
||||||
let _ = handle_configure().await;
|
let _ = handle_configure().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// Run session command by default
|
// 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(
|
setup_logging(
|
||||||
session.session_file().file_stem().and_then(|s| s.to_str()),
|
session.session_file().file_stem().and_then(|s| s.to_str()),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::session::build_session;
|
use crate::session::build_session;
|
||||||
|
use crate::session::SessionBuilderConfig;
|
||||||
use crate::{logging, session, Session};
|
use crate::{logging, session, Session};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use goose::message::Message;
|
use goose::message::Message;
|
||||||
@@ -30,14 +31,16 @@ pub async fn agent_generator(
|
|||||||
) -> BenchAgent {
|
) -> BenchAgent {
|
||||||
let identifier = Some(session::Identifier::Name(session_id));
|
let identifier = Some(session::Identifier::Name(session_id));
|
||||||
|
|
||||||
let base_session = build_session(
|
let base_session = build_session(SessionBuilderConfig {
|
||||||
identifier,
|
identifier,
|
||||||
false,
|
resume: false,
|
||||||
requirements.external,
|
extensions: requirements.external,
|
||||||
requirements.remote,
|
remote_extensions: requirements.remote,
|
||||||
requirements.builtin,
|
builtins: requirements.builtin,
|
||||||
false,
|
extensions_override: None,
|
||||||
)
|
additional_system_prompt: None,
|
||||||
|
debug: false,
|
||||||
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// package session obj into benchmark-compatible struct
|
// package session obj into benchmark-compatible struct
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ pub mod bench;
|
|||||||
pub mod configure;
|
pub mod configure;
|
||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod recipe;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|||||||
60
crates/goose-cli/src/commands/recipe.rs
Normal file
60
crates/goose-cli/src/commands/recipe.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use once_cell::sync::Lazy;
|
|||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod recipe;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
|
|||||||
70
crates/goose-cli/src/recipe.rs
Normal file
70
crates/goose-cli/src/recipe.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use console::style;
|
use console::style;
|
||||||
use goose::agents::extension::ExtensionError;
|
use goose::agents::extension::ExtensionError;
|
||||||
use goose::agents::Agent;
|
use goose::agents::Agent;
|
||||||
use goose::config::{Config, ExtensionConfigManager};
|
use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
|
||||||
use goose::session;
|
use goose::session;
|
||||||
use goose::session::Identifier;
|
use goose::session::Identifier;
|
||||||
use mcp_client::transport::Error as McpClientError;
|
use mcp_client::transport::Error as McpClientError;
|
||||||
@@ -10,14 +10,31 @@ use std::process;
|
|||||||
use super::output;
|
use super::output;
|
||||||
use super::Session;
|
use super::Session;
|
||||||
|
|
||||||
pub async fn build_session(
|
/// Configuration for building a new Goose session
|
||||||
identifier: Option<Identifier>,
|
///
|
||||||
resume: bool,
|
/// This struct contains all the parameters needed to create a new session,
|
||||||
extensions: Vec<String>,
|
/// including session identification, extension configuration, and debug settings.
|
||||||
remote_extensions: Vec<String>,
|
#[derive(Default, Clone, Debug)]
|
||||||
builtins: Vec<String>,
|
pub struct SessionBuilderConfig {
|
||||||
debug: bool,
|
/// Optional identifier for the session (name or path)
|
||||||
) -> Session {
|
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
|
// Load config and get provider/model
|
||||||
let config = Config::global();
|
let config = Config::global();
|
||||||
|
|
||||||
@@ -36,8 +53,8 @@ pub async fn build_session(
|
|||||||
let mut agent = Agent::new(provider);
|
let mut agent = Agent::new(provider);
|
||||||
|
|
||||||
// Handle session file resolution and resuming
|
// Handle session file resolution and resuming
|
||||||
let session_file = if resume {
|
let session_file = if session_config.resume {
|
||||||
if let Some(identifier) = identifier {
|
if let Some(identifier) = session_config.identifier {
|
||||||
let session_file = session::get_path(identifier);
|
let session_file = session::get_path(identifier);
|
||||||
if !session_file.exists() {
|
if !session_file.exists() {
|
||||||
output::render_error(&format!(
|
output::render_error(&format!(
|
||||||
@@ -60,7 +77,7 @@ pub async fn build_session(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new session with provided name/path or generated name
|
// Create new session with provided name/path or generated name
|
||||||
let id = match identifier {
|
let id = match session_config.identifier {
|
||||||
Some(identifier) => identifier,
|
Some(identifier) => identifier,
|
||||||
None => Identifier::Name(session::generate_session_id()),
|
None => Identifier::Name(session::generate_session_id()),
|
||||||
};
|
};
|
||||||
@@ -69,7 +86,7 @@ pub async fn build_session(
|
|||||||
session::get_path(id)
|
session::get_path(id)
|
||||||
};
|
};
|
||||||
|
|
||||||
if resume {
|
if session_config.resume {
|
||||||
// Read the session metadata
|
// Read the session metadata
|
||||||
let metadata = session::read_metadata(&session_file).unwrap_or_else(|e| {
|
let metadata = session::read_metadata(&session_file).unwrap_or_else(|e| {
|
||||||
output::render_error(&format!("Failed to read session metadata: {}", 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
|
// Setup extensions for the agent
|
||||||
// Extensions need to be added after the session is created because we change directory when resuming a session
|
// 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 we get extensions_override, only run those extensions and none other
|
||||||
if extension.enabled {
|
let extensions_to_run: Vec<_> = if let Some(extensions) = session_config.extensions_override {
|
||||||
let config = extension.config.clone();
|
extensions.into_iter().collect()
|
||||||
agent
|
} else {
|
||||||
.add_extension(config.clone())
|
ExtensionConfigManager::get_all()
|
||||||
.await
|
.expect("should load extensions")
|
||||||
.unwrap_or_else(|e| {
|
.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 {
|
let err = match e {
|
||||||
ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => {
|
ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => inner,
|
||||||
inner
|
|
||||||
}
|
|
||||||
_ => e.to_string(),
|
_ => e.to_string(),
|
||||||
};
|
};
|
||||||
println!("Failed to start extension: {}, {:?}", config.name(), err);
|
eprintln!("Failed to start extension: {}, {:?}", extension.name(), err);
|
||||||
println!(
|
eprintln!(
|
||||||
"Please check extension configuration for {}.",
|
"Please check extension configuration for {}.",
|
||||||
config.name()
|
extension.name()
|
||||||
);
|
);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new session
|
// 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
|
// 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 {
|
if let Err(e) = session.add_extension(extension_str).await {
|
||||||
eprintln!("Failed to start extension: {}", e);
|
eprintln!("Failed to start extension: {}", e);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
@@ -127,7 +148,7 @@ pub async fn build_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add remote extensions if provided
|
// 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 {
|
if let Err(e) = session.add_remote_extension(extension_str).await {
|
||||||
eprintln!("Failed to start extension: {}", e);
|
eprintln!("Failed to start extension: {}", e);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
@@ -135,7 +156,7 @@ pub async fn build_session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add builtin extensions
|
// Add builtin extensions
|
||||||
for builtin in builtins {
|
for builtin in session_config.builtins {
|
||||||
if let Err(e) = session.add_builtin(builtin).await {
|
if let Err(e) = session.add_builtin(builtin).await {
|
||||||
eprintln!("Failed to start builtin extension: {}", e);
|
eprintln!("Failed to start builtin extension: {}", e);
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
@@ -148,6 +169,10 @@ pub async fn build_session(
|
|||||||
.extend_system_prompt(super::prompt::get_cli_prompt())
|
.extend_system_prompt(super::prompt::get_cli_prompt())
|
||||||
.await;
|
.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
|
// Only override system prompt if a system override exists
|
||||||
let system_prompt_file: Option<String> = config.get_param("GOOSE_SYSTEM_PROMPT_FILE_PATH").ok();
|
let system_prompt_file: Option<String> = config.get_param("GOOSE_SYSTEM_PROMPT_FILE_PATH").ok();
|
||||||
if let Some(ref path) = system_prompt_file {
|
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;
|
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
|
session
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ impl GooseCompleter {
|
|||||||
"/prompts",
|
"/prompts",
|
||||||
"/prompt",
|
"/prompt",
|
||||||
"/mode",
|
"/mode",
|
||||||
|
"/recipe",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Find commands that match the prefix
|
// Find commands that match the prefix
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub enum InputResult {
|
|||||||
GooseMode(String),
|
GooseMode(String),
|
||||||
Plan(PlanCommandOptions),
|
Plan(PlanCommandOptions),
|
||||||
EndPlan,
|
EndPlan,
|
||||||
|
Recipe(Option<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -89,6 +90,7 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
|||||||
const CMD_MODE: &str = "/mode ";
|
const CMD_MODE: &str = "/mode ";
|
||||||
const CMD_PLAN: &str = "/plan";
|
const CMD_PLAN: &str = "/plan";
|
||||||
const CMD_ENDPLAN: &str = "/endplan";
|
const CMD_ENDPLAN: &str = "/endplan";
|
||||||
|
const CMD_RECIPE: &str = "/recipe";
|
||||||
|
|
||||||
match input {
|
match input {
|
||||||
"/exit" | "/quit" => Some(InputResult::Exit),
|
"/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.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 == CMD_ENDPLAN => Some(InputResult::EndPlan),
|
||||||
|
s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s),
|
||||||
_ => None,
|
_ => 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> {
|
fn parse_prompts_command(args: &str) -> Option<InputResult> {
|
||||||
let parts: Vec<String> = shlex::split(args).unwrap_or_default();
|
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.
|
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.
|
If no model is set, the default model is used.
|
||||||
/endplan - Exit plan mode and return to 'normal' goose mode.
|
/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
|
/? or /help - Display this help message
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
@@ -421,4 +451,27 @@ mod tests {
|
|||||||
_ => panic!("Expected Plan"),
|
_ => 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 prompt;
|
||||||
mod thinking;
|
mod thinking;
|
||||||
|
|
||||||
pub use builder::build_session;
|
pub use builder::{build_session, SessionBuilderConfig};
|
||||||
use goose::permission::permission_confirmation::PrincipalType;
|
use goose::permission::permission_confirmation::PrincipalType;
|
||||||
use goose::permission::Permission;
|
use goose::permission::Permission;
|
||||||
use goose::permission::PermissionConfirmation;
|
use goose::permission::PermissionConfirmation;
|
||||||
use goose::providers::base::Provider;
|
use goose::providers::base::Provider;
|
||||||
pub use goose::session::Identifier;
|
pub use goose::session::Identifier;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use completion::GooseCompleter;
|
use completion::GooseCompleter;
|
||||||
use etcetera::choose_app_strategy;
|
use etcetera::choose_app_strategy;
|
||||||
use etcetera::AppStrategy;
|
use etcetera::AppStrategy;
|
||||||
@@ -21,6 +21,7 @@ use goose::agents::{Agent, SessionConfig};
|
|||||||
use goose::config::Config;
|
use goose::config::Config;
|
||||||
use goose::message::{Message, MessageContent};
|
use goose::message::{Message, MessageContent};
|
||||||
use goose::session;
|
use goose::session;
|
||||||
|
use input::InputResult;
|
||||||
use mcp_core::handler::ToolError;
|
use mcp_core::handler::ToolError;
|
||||||
use mcp_core::prompt::PromptMessage;
|
use mcp_core::prompt::PromptMessage;
|
||||||
|
|
||||||
@@ -466,64 +467,40 @@ impl Session {
|
|||||||
}
|
}
|
||||||
input::InputResult::PromptCommand(opts) => {
|
input::InputResult::PromptCommand(opts) => {
|
||||||
save_history(&mut editor);
|
save_history(&mut editor);
|
||||||
|
self.handle_prompt_command(opts).await?;
|
||||||
// name is required
|
|
||||||
if opts.name.is_empty() {
|
|
||||||
output::render_error("Prompt name argument is required");
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
InputResult::Recipe(filepath_opt) => {
|
||||||
|
println!("{}", console::style("Generating Recipe").green());
|
||||||
|
|
||||||
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();
|
output::show_thinking();
|
||||||
self.process_agent_response(true).await?;
|
let recipe = self.agent.create_recipe(self.messages.clone()).await;
|
||||||
output::hide_thinking();
|
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()?;
|
let metadata = self.get_metadata()?;
|
||||||
Ok(metadata.total_tokens)
|
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> {
|
fn get_reasoner() -> Result<Arc<dyn Provider>, anyhow::Error> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tracing::{debug, error, instrument, warn};
|
use tracing::{debug, error, instrument, warn};
|
||||||
@@ -21,6 +22,7 @@ use crate::providers::errors::ProviderError;
|
|||||||
use crate::providers::toolshim::{
|
use crate::providers::toolshim::{
|
||||||
augment_message_with_tool_calls, modify_system_prompt_for_tool_json, OllamaInterpreter,
|
augment_message_with_tool_calls, modify_system_prompt_for_tool_json, OllamaInterpreter,
|
||||||
};
|
};
|
||||||
|
use crate::recipe::{Author, Recipe};
|
||||||
use crate::session;
|
use crate::session;
|
||||||
use crate::token_counter::TokenCounter;
|
use crate::token_counter::TokenCounter;
|
||||||
use crate::truncate::{truncate_messages, OldestFirstTruncation};
|
use crate::truncate::{truncate_messages, OldestFirstTruncation};
|
||||||
@@ -787,4 +789,116 @@ impl Agent {
|
|||||||
tracing::error!("Failed to send tool result: {}", e);
|
tracing::error!("Failed to send tool result: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_recipe(&self, mut messages: Vec<Message>) -> Result<Recipe> {
|
||||||
|
let mut extension_manager = self.extension_manager.lock().await;
|
||||||
|
let extensions_info = extension_manager.get_extensions_info().await;
|
||||||
|
let system_prompt = self
|
||||||
|
.prompt_manager
|
||||||
|
.build_system_prompt(extensions_info, self.frontend_instructions.clone());
|
||||||
|
|
||||||
|
let recipe_prompt = self.prompt_manager.get_recipe_prompt().await;
|
||||||
|
let tools = extension_manager.get_prefixed_tools().await?;
|
||||||
|
|
||||||
|
messages.push(Message::user().with_text(recipe_prompt));
|
||||||
|
|
||||||
|
let (result, _usage) = self
|
||||||
|
.provider
|
||||||
|
.complete(&system_prompt, &messages, &tools)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let content = result.as_concat_text();
|
||||||
|
|
||||||
|
// the response may be contained in ```json ```, strip that before parsing json
|
||||||
|
let re = Regex::new(r"(?s)^```[^\n]*\n(.*?)\n```$").unwrap();
|
||||||
|
let clean_content = re
|
||||||
|
.captures(&content)
|
||||||
|
.and_then(|caps| caps.get(1).map(|m| m.as_str()))
|
||||||
|
.unwrap_or(&content)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// try to parse json response from the LLM
|
||||||
|
let (instructions, activities) =
|
||||||
|
if let Ok(json_content) = serde_json::from_str::<Value>(&clean_content) {
|
||||||
|
let instructions = json_content
|
||||||
|
.get("instructions")
|
||||||
|
.ok_or_else(|| anyhow!("Missing 'instructions' in json response"))?
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow!("instructions' is not a string"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let activities = json_content
|
||||||
|
.get("activities")
|
||||||
|
.ok_or_else(|| anyhow!("Missing 'activities' in json response"))?
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow!("'activities' is not an array'"))?
|
||||||
|
.iter()
|
||||||
|
.map(|act| {
|
||||||
|
act.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or(anyhow!("'activities' array element is not a string"))
|
||||||
|
})
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
(instructions, activities)
|
||||||
|
} else {
|
||||||
|
// If we can't get valid JSON, try string parsing
|
||||||
|
// Use split_once to get the content after "Instructions:".
|
||||||
|
let after_instructions = content
|
||||||
|
.split_once("instructions:")
|
||||||
|
.map(|(_, rest)| rest)
|
||||||
|
.unwrap_or(&content);
|
||||||
|
|
||||||
|
// Split once more to separate instructions from activities.
|
||||||
|
let (instructions_part, activities_text) = after_instructions
|
||||||
|
.split_once("activities:")
|
||||||
|
.unwrap_or((after_instructions, ""));
|
||||||
|
|
||||||
|
let instructions = instructions_part
|
||||||
|
.trim_end_matches(|c: char| c.is_whitespace() || c == '#')
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let activities_text = activities_text.trim();
|
||||||
|
|
||||||
|
// Regex to remove bullet markers or numbers with an optional dot.
|
||||||
|
let bullet_re = Regex::new(r"^[•\-\*\d]+\.?\s*").expect("Invalid regex");
|
||||||
|
|
||||||
|
// Process each line in the activities section.
|
||||||
|
let activities: Vec<String> = activities_text
|
||||||
|
.lines()
|
||||||
|
.map(|line| bullet_re.replace(line, "").to_string())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(instructions, activities)
|
||||||
|
};
|
||||||
|
|
||||||
|
let extensions = ExtensionConfigManager::get_all().unwrap_or_default();
|
||||||
|
let extension_configs: Vec<_> = extensions
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.enabled)
|
||||||
|
.map(|e| e.config.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let author = Author {
|
||||||
|
contact: std::env::var("USER")
|
||||||
|
.or_else(|_| std::env::var("USERNAME"))
|
||||||
|
.ok(),
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let recipe = Recipe::builder()
|
||||||
|
.title("Custom recipe from chat")
|
||||||
|
.description("a custom recipe instance from this chat session")
|
||||||
|
.instructions(instructions)
|
||||||
|
.activities(activities)
|
||||||
|
.extensions(extension_configs)
|
||||||
|
.author(author)
|
||||||
|
.build()
|
||||||
|
.expect("valid recipe");
|
||||||
|
|
||||||
|
Ok(recipe)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,4 +92,10 @@ impl PromptManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the recipe prompt
|
||||||
|
pub async fn get_recipe_prompt(&self) -> String {
|
||||||
|
let context: HashMap<&str, Value> = HashMap::new();
|
||||||
|
prompt_template::render_global_file("recipe.md", &context).expect("Prompt should render")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod model;
|
|||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod prompt_template;
|
pub mod prompt_template;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
|
pub mod recipe;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod token_counter;
|
pub mod token_counter;
|
||||||
pub mod tracing;
|
pub mod tracing;
|
||||||
|
|||||||
17
crates/goose/src/prompts/recipe.md
Normal file
17
crates/goose/src/prompts/recipe.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Based on our conversation so far, could you create:
|
||||||
|
|
||||||
|
1. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Make the instructions generic, and higher-level so that can be re-used across various similar tasks. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required.
|
||||||
|
|
||||||
|
2. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic
|
||||||
|
|
||||||
|
Format your response in _VALID_ json, with one key being `instructions` which contains a string, and the other key `activities` as an array of strings.
|
||||||
|
For example, perhaps we have been discussing fruit and you might write:
|
||||||
|
|
||||||
|
{
|
||||||
|
"instructions": "Using web searches we find pictures of fruit, and always check what language to reply in.",
|
||||||
|
"activities": [
|
||||||
|
"Show pics of apples",
|
||||||
|
"say a random fruit",
|
||||||
|
"share a fruit fact"
|
||||||
|
]
|
||||||
|
}
|
||||||
210
crates/goose/src/recipe/mod.rs
Normal file
210
crates/goose/src/recipe/mod.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
use crate::agents::extension::ExtensionConfig;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
fn default_version() -> String {
|
||||||
|
"1.0.0".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Recipe represents a personalized, user-generated agent configuration that defines
|
||||||
|
/// specific behaviors and capabilities within the Goose system.
|
||||||
|
///
|
||||||
|
/// # Fields
|
||||||
|
///
|
||||||
|
/// ## Required Fields
|
||||||
|
/// * `version` - Semantic version of the Recipe file format (defaults to "1.0.0")
|
||||||
|
/// * `title` - Short, descriptive name of the Recipe
|
||||||
|
/// * `description` - Detailed description explaining the Recipe's purpose and functionality
|
||||||
|
/// * `Instructions` - Instructions that defines the Recipe's behavior
|
||||||
|
///
|
||||||
|
/// ## Optional Fields
|
||||||
|
/// * `prompt` - the initial prompt to the session to start with
|
||||||
|
/// * `extensions` - List of extension configurations required by the Recipe
|
||||||
|
/// * `context` - Supplementary context information for the Recipe
|
||||||
|
/// * `activities` - Activity labels that appear when loading the Recipe
|
||||||
|
/// * `author` - Information about the Recipe's creator and metadata
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use goose::recipe::Recipe;
|
||||||
|
///
|
||||||
|
/// // Using the builder pattern
|
||||||
|
/// let recipe = Recipe::builder()
|
||||||
|
/// .title("Example Agent")
|
||||||
|
/// .description("An example Recipe configuration")
|
||||||
|
/// .instructions("Act as a helpful assistant")
|
||||||
|
/// .build()
|
||||||
|
/// .expect("Missing required fields");
|
||||||
|
///
|
||||||
|
/// // Or using struct initialization
|
||||||
|
/// let recipe = Recipe {
|
||||||
|
/// version: "1.0.0".to_string(),
|
||||||
|
/// title: "Example Agent".to_string(),
|
||||||
|
/// description: "An example Recipe configuration".to_string(),
|
||||||
|
/// instructions: "Act as a helpful assistant".to_string(),
|
||||||
|
/// prompt: None,
|
||||||
|
/// extensions: None,
|
||||||
|
/// context: None,
|
||||||
|
/// activities: None,
|
||||||
|
/// author: None,
|
||||||
|
/// };
|
||||||
|
/// ```
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Recipe {
|
||||||
|
// Required fields
|
||||||
|
#[serde(default = "default_version")]
|
||||||
|
pub version: String, // version of the file format, sem ver
|
||||||
|
|
||||||
|
pub title: String, // short title of the recipe
|
||||||
|
|
||||||
|
pub description: String, // a longer description of the recipe
|
||||||
|
|
||||||
|
pub instructions: String, // the instructions for the model
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt: Option<String>, // the prompt to start the session with
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<Vec<String>>, // any additional context
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub activities: Option<Vec<String>>, // the activity pills that show up when loading the
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub author: Option<Author>, // any additional author information
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Author {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contact: Option<String>, // creator/contact information of the recipe
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<String>, // any additional metadata for the author
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for creating Recipe instances
|
||||||
|
pub struct RecipeBuilder {
|
||||||
|
// Required fields with default values
|
||||||
|
version: String,
|
||||||
|
title: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
instructions: Option<String>,
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
prompt: Option<String>,
|
||||||
|
extensions: Option<Vec<ExtensionConfig>>,
|
||||||
|
context: Option<Vec<String>>,
|
||||||
|
activities: Option<Vec<String>>,
|
||||||
|
author: Option<Author>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recipe {
|
||||||
|
/// Creates a new RecipeBuilder to construct a Recipe instance
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use goose::recipe::Recipe;
|
||||||
|
///
|
||||||
|
/// let recipe = Recipe::builder()
|
||||||
|
/// .title("My Recipe")
|
||||||
|
/// .description("A helpful assistant")
|
||||||
|
/// .instructions("Act as a helpful assistant")
|
||||||
|
/// .build()
|
||||||
|
/// .expect("Failed to build Recipe: missing required fields");
|
||||||
|
/// ```
|
||||||
|
pub fn builder() -> RecipeBuilder {
|
||||||
|
RecipeBuilder {
|
||||||
|
version: default_version(),
|
||||||
|
title: None,
|
||||||
|
description: None,
|
||||||
|
instructions: None,
|
||||||
|
prompt: None,
|
||||||
|
extensions: None,
|
||||||
|
context: None,
|
||||||
|
activities: None,
|
||||||
|
author: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecipeBuilder {
|
||||||
|
/// Sets the version of the Recipe
|
||||||
|
pub fn version(mut self, version: impl Into<String>) -> Self {
|
||||||
|
self.version = version.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the title of the Recipe (required)
|
||||||
|
pub fn title(mut self, title: impl Into<String>) -> Self {
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the description of the Recipe (required)
|
||||||
|
pub fn description(mut self, description: impl Into<String>) -> Self {
|
||||||
|
self.description = Some(description.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the instructions for the Recipe (required)
|
||||||
|
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
|
||||||
|
self.instructions = Some(instructions.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
|
||||||
|
self.prompt = Some(prompt.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the extensions for the Recipe
|
||||||
|
pub fn extensions(mut self, extensions: Vec<ExtensionConfig>) -> Self {
|
||||||
|
self.extensions = Some(extensions);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the context for the Recipe
|
||||||
|
pub fn context(mut self, context: Vec<String>) -> Self {
|
||||||
|
self.context = Some(context);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the activities for the Recipe
|
||||||
|
pub fn activities(mut self, activities: Vec<String>) -> Self {
|
||||||
|
self.activities = Some(activities);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the author information for the Recipe
|
||||||
|
pub fn author(mut self, author: Author) -> Self {
|
||||||
|
self.author = Some(author);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the Recipe instance
|
||||||
|
///
|
||||||
|
/// Returns an error if any required fields are missing
|
||||||
|
pub fn build(self) -> Result<Recipe, &'static str> {
|
||||||
|
let title = self.title.ok_or("Title is required")?;
|
||||||
|
let description = self.description.ok_or("Description is required")?;
|
||||||
|
let instructions = self.instructions.ok_or("Instructions are required")?;
|
||||||
|
|
||||||
|
Ok(Recipe {
|
||||||
|
version: self.version,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
prompt: self.prompt,
|
||||||
|
extensions: self.extensions,
|
||||||
|
context: self.context,
|
||||||
|
activities: self.activities,
|
||||||
|
author: self.author,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user