mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: add /model slash command to CLI for session model switching (#8747)
Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Generated
+1
@@ -4605,6 +4605,7 @@ dependencies = [
|
||||
"comfy-table",
|
||||
"console",
|
||||
"dotenvy",
|
||||
"env-lock",
|
||||
"etcetera 0.11.0",
|
||||
"futures",
|
||||
"goose",
|
||||
|
||||
@@ -108,6 +108,7 @@ native-tls = [
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
env-lock.workspace = true
|
||||
tempfile = { workspace = true }
|
||||
test-case = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -129,7 +129,6 @@ impl GooseCompleter {
|
||||
let skills = list_installed_skills(Some(&cwd));
|
||||
let skill_names: Vec<String> = skills.iter().map(|s| s.name.clone()).collect();
|
||||
|
||||
// Complete the last letter being typed (e.g. "/skills coding in<tab>")
|
||||
let last = line.rsplit_once(' ').map_or("", |(_, w)| w);
|
||||
let pos = line.len() - last.len();
|
||||
|
||||
@@ -146,6 +145,11 @@ impl GooseCompleter {
|
||||
Ok((pos, candidates))
|
||||
}
|
||||
|
||||
/// Complete model names for the /model command.
|
||||
fn complete_model_names(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
|
||||
Ok((line.len(), vec![]))
|
||||
}
|
||||
|
||||
/// Complete slash commands
|
||||
fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
|
||||
// Define available slash commands
|
||||
@@ -160,6 +164,7 @@ impl GooseCompleter {
|
||||
"/prompts",
|
||||
"/prompt",
|
||||
"/mode",
|
||||
"/model",
|
||||
"/recipe",
|
||||
"/skills",
|
||||
];
|
||||
@@ -396,6 +401,10 @@ impl Completer for GooseCompleter {
|
||||
}
|
||||
}
|
||||
|
||||
if line.starts_with("/model") {
|
||||
return self.complete_model_names(line);
|
||||
}
|
||||
|
||||
if line.starts_with("/mode") {
|
||||
return self.complete_mode_flags(line);
|
||||
}
|
||||
@@ -574,6 +583,20 @@ mod tests {
|
||||
assert_eq!(candidates.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_model_names() {
|
||||
let cache = create_test_cache();
|
||||
let completer = GooseCompleter::new(cache);
|
||||
|
||||
let (pos, candidates) = completer.complete_model_names("/model ").unwrap();
|
||||
assert_eq!(pos, "/model ".len());
|
||||
assert!(candidates.is_empty());
|
||||
|
||||
let (pos, candidates) = completer.complete_model_names("/model gpt").unwrap();
|
||||
assert_eq!(pos, "/model gpt".len());
|
||||
assert!(candidates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_prompt_names() {
|
||||
let cache = create_test_cache();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub enum InputResult {
|
||||
ListPrompts(Option<String>),
|
||||
PromptCommand(PromptCommandOptions),
|
||||
GooseMode(String),
|
||||
Model(Option<String>),
|
||||
Plan(PlanCommandOptions),
|
||||
EndPlan,
|
||||
Clear,
|
||||
@@ -196,6 +197,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
const CMD_EXTENSION: &str = "/extension ";
|
||||
const CMD_BUILTIN: &str = "/builtin ";
|
||||
const CMD_MODE: &str = "/mode ";
|
||||
const CMD_MODEL: &str = "/model";
|
||||
const CMD_MODEL_WITH_SPACE: &str = "/model ";
|
||||
const CMD_PLAN: &str = "/plan";
|
||||
const CMD_ENDPLAN: &str = "/endplan";
|
||||
const CMD_CLEAR: &str = "/clear";
|
||||
@@ -261,6 +264,19 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
s if s.starts_with(CMD_MODE) => Some(InputResult::GooseMode(
|
||||
s.get(CMD_MODE.len()..).unwrap_or("").to_string(),
|
||||
)),
|
||||
s if s == CMD_MODEL => Some(InputResult::Model(None)),
|
||||
s if s.starts_with(CMD_MODEL_WITH_SPACE) => {
|
||||
let model = s
|
||||
.get(CMD_MODEL_WITH_SPACE.len()..)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if model.is_empty() {
|
||||
Some(InputResult::Model(None))
|
||||
} else {
|
||||
Some(InputResult::Model(Some(model)))
|
||||
}
|
||||
}
|
||||
s if s.starts_with(CMD_PLAN) => {
|
||||
parse_plan_command(s.get(CMD_PLAN.len()..).unwrap_or("").trim().to_string())
|
||||
}
|
||||
@@ -399,6 +415,7 @@ fn print_help() {
|
||||
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension
|
||||
/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
|
||||
/mode <name> - Set the goose mode to use ({modes})
|
||||
/model [name] - Show the current model, or switch models for this session while keeping the same provider
|
||||
/plan <message_text> - Enters 'plan' mode with optional message. Create a plan based on the current messages and asks user if they want to act on it.
|
||||
If user acts on the plan, goose mode is set to 'auto' and returns to 'normal' goose mode.
|
||||
To warm up goose before using '/plan', we recommend setting '/mode approve' & putting appropriate context into goose.
|
||||
@@ -498,6 +515,21 @@ mod tests {
|
||||
panic!("Expected AddBuiltin");
|
||||
}
|
||||
|
||||
// Test model command
|
||||
assert!(matches!(
|
||||
handle_slash_command("/model"),
|
||||
Some(InputResult::Model(None))
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_slash_command("/model "),
|
||||
Some(InputResult::Model(None))
|
||||
));
|
||||
if let Some(InputResult::Model(Some(model))) = handle_slash_command("/model gpt-4.1") {
|
||||
assert_eq!(model, "gpt-4.1");
|
||||
} else {
|
||||
panic!("Expected Model");
|
||||
}
|
||||
|
||||
// Test unknown commands
|
||||
assert!(handle_slash_command("/unknown").is_none());
|
||||
}
|
||||
|
||||
@@ -608,6 +608,10 @@ impl CliSession {
|
||||
history.save(editor);
|
||||
self.handle_goose_mode(&mode).await?;
|
||||
}
|
||||
InputResult::Model(model) => {
|
||||
history.save(editor);
|
||||
self.handle_model(model.as_deref()).await?;
|
||||
}
|
||||
InputResult::Plan(options) => {
|
||||
self.handle_plan_mode(options).await?;
|
||||
}
|
||||
@@ -795,6 +799,73 @@ impl CliSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_model(&self, model: Option<&str>) -> Result<()> {
|
||||
let provider = self.agent.provider().await?;
|
||||
let current_provider_name = provider.get_name().to_string();
|
||||
let current_model_config = provider.get_model_config();
|
||||
let current_model_name = current_model_config.model_name.clone();
|
||||
|
||||
if model.is_none() {
|
||||
output::goose_mode_message(&format!(
|
||||
"Current session model: '{}' (provider '{}')",
|
||||
current_model_name, current_provider_name
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let model_name = model.unwrap_or_default().trim();
|
||||
if model_name.is_empty() {
|
||||
output::render_error("Model name cannot be empty");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if current_provider_name.ends_with("-acp") {
|
||||
output::render_error(
|
||||
"Session model switching is not supported for ACP providers in the CLI.",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if provider.manages_own_context() {
|
||||
output::render_error(&format!(
|
||||
"Session model switching is not supported for provider '{}' because it manages its own conversation context.",
|
||||
current_provider_name
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let new_model_config =
|
||||
build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?;
|
||||
|
||||
if new_model_config.model_name == current_model_config.model_name
|
||||
&& new_model_config.thinking_effort() == current_model_config.thinking_effort()
|
||||
{
|
||||
output::goose_mode_message(&format!(
|
||||
"Session already using model '{}' for provider '{}'",
|
||||
current_model_name, current_provider_name
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let extensions = self.agent.get_extension_configs().await;
|
||||
let new_provider =
|
||||
goose::providers::create(¤t_provider_name, new_model_config, extensions)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?;
|
||||
|
||||
self.agent
|
||||
.update_provider(new_provider, &self.session_id)
|
||||
.await?;
|
||||
|
||||
let mode = self.agent.goose_mode().await;
|
||||
self.agent.update_goose_mode(mode, &self.session_id).await?;
|
||||
output::goose_mode_message(&format!(
|
||||
"Session model switched from '{}' to '{}' for provider '{}'",
|
||||
current_model_name, model_name, current_provider_name
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_plan_mode(&mut self, options: input::PlanCommandOptions) -> Result<()> {
|
||||
self.run_mode = RunMode::Plan;
|
||||
output::render_enter_plan_mode();
|
||||
@@ -2091,11 +2162,28 @@ fn format_elapsed_time(duration: std::time::Duration) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_switched_model_config(
|
||||
provider_name: &str,
|
||||
model_name: &str,
|
||||
current_model_config: &goose::model::ModelConfig,
|
||||
) -> Result<goose::model::ModelConfig> {
|
||||
goose::model::ModelConfig::new(model_name)
|
||||
.map(|config| {
|
||||
config
|
||||
.with_canonical_limits(provider_name)
|
||||
.with_temperature(current_model_config.temperature)
|
||||
.with_toolshim(current_model_config.toolshim)
|
||||
.with_toolshim_model(current_model_config.toolshim_model.clone())
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create model configuration: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use goose::agents::extension::Envs;
|
||||
use goose::config::ExtensionConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use test_case::test_case;
|
||||
|
||||
@@ -2219,6 +2307,74 @@ mod tests {
|
||||
assert!(CliSession::parse_stdio_extension("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_switched_model_config_rebuilds_target_model_settings() {
|
||||
let _guard = env_lock::lock_env([
|
||||
("GOOSE_MAX_TOKENS", None::<&str>),
|
||||
("GOOSE_TEMPERATURE", None::<&str>),
|
||||
("GOOSE_CONTEXT_LIMIT", None::<&str>),
|
||||
("GOOSE_TOOLSHIM", None::<&str>),
|
||||
("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>),
|
||||
]);
|
||||
|
||||
let current_model_config = goose::model::ModelConfig {
|
||||
model_name: "gpt-4o".to_string(),
|
||||
context_limit: Some(128_000),
|
||||
temperature: Some(0.25),
|
||||
max_tokens: Some(16_384),
|
||||
toolshim: true,
|
||||
toolshim_model: Some("qwen2.5-coder".to_string()),
|
||||
fast_model_config: None,
|
||||
request_params: Some(HashMap::from([(
|
||||
"anthropic_beta".to_string(),
|
||||
serde_json::json!(["output-128k-2025-02-19"]),
|
||||
)])),
|
||||
reasoning: Some(false),
|
||||
};
|
||||
|
||||
let switched =
|
||||
build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap();
|
||||
let expected = goose::model::ModelConfig::new_or_fail("gpt-5.4")
|
||||
.with_canonical_limits("openai")
|
||||
.with_temperature(Some(0.25))
|
||||
.with_toolshim(true)
|
||||
.with_toolshim_model(Some("qwen2.5-coder".to_string()));
|
||||
|
||||
assert_eq!(switched.model_name, expected.model_name);
|
||||
assert_eq!(switched.context_limit, expected.context_limit);
|
||||
assert_eq!(switched.max_tokens, expected.max_tokens);
|
||||
assert_eq!(switched.request_params, expected.request_params);
|
||||
assert_eq!(switched.reasoning, expected.reasoning);
|
||||
assert_eq!(switched.temperature, Some(0.25));
|
||||
assert!(switched.toolshim);
|
||||
assert_eq!(switched.toolshim_model.as_deref(), Some("qwen2.5-coder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_switched_model_config_detects_effort_suffix_change() {
|
||||
let _guard = env_lock::lock_env([
|
||||
("GOOSE_MAX_TOKENS", None::<&str>),
|
||||
("GOOSE_TEMPERATURE", None::<&str>),
|
||||
("GOOSE_CONTEXT_LIMIT", None::<&str>),
|
||||
("GOOSE_TOOLSHIM", None::<&str>),
|
||||
("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>),
|
||||
("GOOSE_THINKING_EFFORT", None::<&str>),
|
||||
]);
|
||||
|
||||
let current =
|
||||
goose::model::ModelConfig::new_or_fail("gpt-5.4-high").with_canonical_limits("openai");
|
||||
assert_eq!(current.model_name, "gpt-5.4");
|
||||
assert_eq!(
|
||||
current.thinking_effort(),
|
||||
Some(goose::model::ThinkingEffort::High)
|
||||
);
|
||||
|
||||
let switched = build_switched_model_config("openai", "gpt-5.4", ¤t).unwrap();
|
||||
|
||||
assert_eq!(switched.model_name, current.model_name);
|
||||
assert_ne!(switched.thinking_effort(), current.thinking_effort());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_command_args_windows_paths() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user