diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 795516250bc5..9e826ba3597a 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -15,6 +15,8 @@ pub enum InputResult { ListPrompts(Option), PromptCommand(PromptCommandOptions), GooseMode(String), + Plan(PlanCommandOptions), + EndPlan, } #[derive(Debug)] @@ -24,6 +26,11 @@ pub struct PromptCommandOptions { pub arguments: HashMap, } +#[derive(Debug)] +pub struct PlanCommandOptions { + pub message_text: String, +} + pub fn get_input( editor: &mut Editor, ) -> Result { @@ -72,6 +79,8 @@ fn handle_slash_command(input: &str) -> Option { const CMD_EXTENSION: &str = "/extension "; const CMD_BUILTIN: &str = "/builtin "; const CMD_MODE: &str = "/mode "; + const CMD_PLAN: &str = "/plan"; + const CMD_ENDPLAN: &str = "/endplan"; match input { "/exit" | "/quit" => Some(InputResult::Exit), @@ -111,6 +120,8 @@ fn handle_slash_command(input: &str) -> Option { s if s.starts_with(CMD_MODE) => { Some(InputResult::GooseMode(s[CMD_MODE.len()..].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), _ => None, } } @@ -168,6 +179,14 @@ fn parse_prompt_command(args: &str) -> Option { Some(InputResult::PromptCommand(options)) } +fn parse_plan_command(input: String) -> Option { + let options = PlanCommandOptions { + message_text: input.trim().to_string(), + }; + + Some(InputResult::Plan(options)) +} + fn print_help() { println!( "Available commands: @@ -178,6 +197,12 @@ fn print_help() { /prompts [--extension ] - List all available prompts, optionally filtered by extension /prompt [--info] [key=value...] - Get prompt info or execute a prompt /mode - Set the goose mode to use ('auto', 'approve', 'chat') +/plan - 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. + 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. /? or /help - Display this help message Navigation: @@ -370,4 +395,22 @@ mod tests { panic!("Expected PromptCommand"); } } + + #[test] + fn test_plan_mode() { + // Test plan mode with no text + let result = handle_slash_command("/plan"); + assert!(result.is_some()); + + // Test plan mode with text + let result = handle_slash_command("/plan hello world"); + assert!(result.is_some()); + let options = result.unwrap(); + match options { + InputResult::Plan(options) => { + assert_eq!(options.message_text, "hello world"); + } + _ => panic!("Expected Plan"), + } + } } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 196dd6cbfd3e..97f896a0c970 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -6,6 +6,7 @@ mod prompt; mod thinking; pub use builder::build_session; +use goose::providers::base::Provider; pub use goose::session::Identifier; use anyhow::Result; @@ -28,6 +29,11 @@ use std::sync::Arc; use std::time::Instant; use tokio; +pub enum RunMode { + Normal, + Plan, +} + pub struct Session { agent: Box, messages: Vec, @@ -35,6 +41,7 @@ pub struct Session { // Cache for completion data - using std::sync for thread safety without async completion_cache: Arc>, debug: bool, // New field for debug mode + run_mode: RunMode, } // Cache structure for completion data @@ -54,6 +61,42 @@ impl CompletionCache { } } +pub enum PlannerResponseType { + Plan, + ClarifyingQuestions, +} + +/// Decide if the planner's reponse is a plan or a clarifying question +/// +/// This function is called after the planner has generated a response +/// to the user's message. The response is either a plan or a clarifying +/// question. +pub async fn classify_planner_response( + message_text: String, + provider: Arc>, +) -> Result { + let prompt = format!("The text below is the output from an AI model which can either provide a plan or list of clarifying questions. Based on the text below, decide if the output is a \"plan\" or \"clarifying questions\".\n---\n{message_text}"); + + // Generate the description + let message = Message::user().with_text(&prompt); + let (result, _usage) = provider + .complete( + "Reply only with the classification label: \"plan\" or \"clarifying questions\"", + &[message], + &[], + ) + .await?; + + // println!("classify_planner_response: {result:?}\n"); // TODO: remove + + let predicted = result.as_concat_text(); + if predicted.to_lowercase().contains("plan") { + Ok(PlannerResponseType::Plan) + } else { + Ok(PlannerResponseType::ClarifyingQuestions) + } +} + impl Session { pub fn new(agent: Box, session_file: PathBuf, debug: bool) -> Self { let messages = match session::read_messages(&session_file) { @@ -70,6 +113,7 @@ impl Session { session_file, completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())), debug, + run_mode: RunMode::Normal, } } @@ -264,20 +308,35 @@ impl Session { loop { match input::get_input(&mut editor)? { input::InputResult::Message(content) => { - save_history(&mut editor); - - self.messages.push(Message::user().with_text(&content)); - - // Get the provider from the agent for description generation - let provider = self.agent.provider().await; - - // Persist messages with provider for automatic description generation - session::persist_messages(&self.session_file, &self.messages, Some(provider)) - .await?; - - output::show_thinking(); - self.process_agent_response(true).await?; - output::hide_thinking(); + match self.run_mode { + RunMode::Normal => { + save_history(&mut editor); + + self.messages.push(Message::user().with_text(&content)); + + // Get the provider from the agent for description generation + let provider = self.agent.provider().await; + + // Persist messages with provider for automatic description generation + session::persist_messages( + &self.session_file, + &self.messages, + Some(provider), + ) + .await?; + + output::show_thinking(); + self.process_agent_response(true).await?; + output::hide_thinking(); + } + RunMode::Plan => { + let mut plan_messages = self.messages.clone(); + plan_messages.push(Message::user().with_text(&content)); + let reasoner = get_reasoner()?; + self.plan_with_reasoner_model(plan_messages, reasoner) + .await?; + } + } } input::InputResult::Exit => break, input::InputResult::AddExtension(cmd) => { @@ -344,7 +403,27 @@ impl Session { config .set_param("GOOSE_MODE", Value::String(mode.to_string())) .unwrap(); - println!("Goose mode set to '{}'", mode); + output::goose_mode_message(&format!("Goose mode set to '{}'", mode)); + continue; + } + input::InputResult::Plan(options) => { + self.run_mode = RunMode::Plan; + output::render_enter_plan_mode(); + + let message_text = options.message_text; + if message_text.is_empty() { + continue; + } + let mut plan_messages = self.messages.clone(); + plan_messages.push(Message::user().with_text(&message_text)); + + let reasoner = get_reasoner()?; + self.plan_with_reasoner_model(plan_messages, reasoner) + .await?; + } + input::InputResult::EndPlan => { + self.run_mode = RunMode::Normal; + output::render_exit_plan_mode(); continue; } input::InputResult::PromptCommand(opts) => { @@ -418,6 +497,72 @@ impl Session { Ok(()) } + async fn plan_with_reasoner_model( + &mut self, + plan_messages: Vec, + reasoner: Box, + ) -> Result<(), anyhow::Error> { + let plan_prompt = self.agent.get_plan_prompt().await?; + output::show_thinking(); + let (plan_response, _usage) = reasoner.complete(&plan_prompt, &plan_messages, &[]).await?; + output::render_message(&plan_response, self.debug); + output::hide_thinking(); + let planner_response_type = + classify_planner_response(plan_response.as_concat_text(), self.agent.provider().await) + .await?; + + match planner_response_type { + PlannerResponseType::Plan => { + println!(); + let should_act = + cliclack::confirm("Do you want to clear message history & act on this plan?") + .initial_value(true) + .interact()?; + if should_act { + output::render_act_on_plan(); + self.run_mode = RunMode::Normal; + // set goose mode: auto if that isn't already the case + let config = Config::global(); + let curr_goose_mode = + config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()); + if curr_goose_mode != "auto" { + config + .set_param("GOOSE_MODE", Value::String("auto".to_string())) + .unwrap(); + } + + // clear the messages before acting on the plan + self.messages.clear(); + // add the plan response as a user message + let plan_message = Message::user().with_text(plan_response.as_concat_text()); + self.messages.push(plan_message); + // act on the plan + output::show_thinking(); + self.process_agent_response(true).await?; + output::hide_thinking(); + + // Reset run & goose mode + if curr_goose_mode != "auto" { + config + .set_param("GOOSE_MODE", Value::String(curr_goose_mode.to_string())) + .unwrap(); + } + } else { + // add the plan response (assistant message) & carry the conversation forward + // in the next round, the user might wanna slightly modify the plan + self.messages.push(plan_response); + } + } + PlannerResponseType::ClarifyingQuestions => { + // add the plan response (assistant message) & carry the conversation forward + // in the next round, the user will answer the clarifying questions + self.messages.push(plan_response); + } + } + + Ok(()) + } + /// Process a single message and exit pub async fn headless(&mut self, message: String) -> Result<()> { self.process_message(message).await @@ -649,3 +794,34 @@ impl Session { Ok(metadata.total_tokens) } } + +fn get_reasoner() -> Result, anyhow::Error> { + use goose::model::ModelConfig; + use goose::providers::create; + + let (reasoner_provider, reasoner_model) = match ( + std::env::var("GOOSE_PLANNER_PROVIDER"), + std::env::var("GOOSE_PLANNER_MODEL"), + ) { + (Ok(provider), Ok(model)) => (provider, model), + _ => { + println!( + "WARNING: GOOSE_PLANNER_PROVIDER or GOOSE_PLANNER_MODEL is not set. \ + Using default model from config..." + ); + let config = Config::global(); + let provider = config + .get_param("GOOSE_PROVIDER") + .expect("No provider configured. Run 'goose configure' first"); + let model = config + .get_param("GOOSE_MODEL") + .expect("No model configured. Run 'goose configure' first"); + (provider, model) + } + }; + + let model_config = ModelConfig::new(reasoner_model); + let reasoner = create(&reasoner_provider, model_config)?; + + Ok(reasoner) +} diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index d60df271b979..a86e062fbc65 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -126,6 +126,33 @@ pub fn render_message(message: &Message, debug: bool) { println!(); } +pub fn render_enter_plan_mode() { + println!( + "\n{} {}\n", + style("Entering plan mode.").green().bold(), + style("You can provide instructions to create a plan and then act on it. To exit early, type /endplan") + .green() + .dim() + ); +} + +pub fn render_act_on_plan() { + println!( + "\n{}\n", + style("Exiting plan mode and acting on the above plan") + .green() + .bold(), + ); +} + +pub fn render_exit_plan_mode() { + println!("\n{}\n", style("Exiting plan mode.").green().bold()); +} + +pub fn goose_mode_message(text: &str) { + println!("\n{}", style(text).yellow(),); +} + fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { match &req.tool_call { Ok(call) => match call.name.as_str() { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index fbde767c9a0d..76ea04cbe0ed 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -63,6 +63,9 @@ pub trait Agent: Send + Sync { /// Returns the prompt text that would be used as user input async fn get_prompt(&self, name: &str, arguments: Value) -> Result; + /// Get the plan prompt, which will be used with the planner (reasoner) model + async fn get_plan_prompt(&self) -> anyhow::Result; + /// Get a reference to the provider used by this agent async fn provider(&self) -> Arc>; } diff --git a/crates/goose/src/agents/capabilities.rs b/crates/goose/src/agents/capabilities.rs index 44627b3baff0..9691ab45be34 100644 --- a/crates/goose/src/agents/capabilities.rs +++ b/crates/goose/src/agents/capabilities.rs @@ -10,7 +10,7 @@ use std::time::Duration; use tokio::sync::Mutex; use tracing::{debug, instrument}; -use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult}; +use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo}; use crate::config::Config; use crate::prompt_template; use crate::providers::base::Provider; @@ -83,6 +83,14 @@ fn normalize(input: String) -> String { result.to_lowercase() } +pub fn get_parameter_names(tool: &Tool) -> Vec { + tool.input_schema + .get("properties") + .and_then(|props| props.as_object()) + .map(|props| props.keys().cloned().collect()) + .unwrap_or_default() +} + impl Capabilities { /// Create a new Capabilities with the specified provider pub fn new(provider: Box) -> Self { @@ -291,6 +299,14 @@ impl Capabilities { Ok(result) } + /// Get the extension prompt including client instructions + pub async fn get_planning_prompt(&self, tools_info: Vec) -> String { + let mut context: HashMap<&str, Value> = HashMap::new(); + context.insert("tools", serde_json::to_value(tools_info).unwrap()); + + prompt_template::render_global_file("plan.md", &context).expect("Prompt should render") + } + /// Get the extension prompt including client instructions pub async fn get_system_prompt(&self) -> String { let mut context: HashMap<&str, Value> = HashMap::new(); diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 2887d1321c12..62c5a1484844 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -190,3 +190,21 @@ impl ExtensionInfo { } } } + +/// Information about the tool used for building prompts +#[derive(Clone, Debug, Serialize)] +pub struct ToolInfo { + name: String, + description: String, + parameters: Vec, +} + +impl ToolInfo { + pub fn new(name: &str, description: &str, parameters: Vec) -> Self { + Self { + name: name.to_string(), + description: description.to_string(), + parameters, + } + } +} diff --git a/crates/goose/src/agents/reference.rs b/crates/goose/src/agents/reference.rs index 324df39b956c..812987b19ac2 100644 --- a/crates/goose/src/agents/reference.rs +++ b/crates/goose/src/agents/reference.rs @@ -8,6 +8,8 @@ use tokio::sync::Mutex; use tracing::{debug, instrument}; use super::agent::SessionConfig; +use super::capabilities::get_parameter_names; +use super::extension::ToolInfo; use super::Agent; use crate::agents::capabilities::Capabilities; use crate::agents::extension::{ExtensionConfig, ExtensionResult}; @@ -243,6 +245,19 @@ impl Agent for ReferenceAgent { Err(anyhow!("Prompt '{}' not found", name)) } + async fn get_plan_prompt(&self) -> anyhow::Result { + let mut capabilities = self.capabilities.lock().await; + let tools = capabilities.get_prefixed_tools().await?; + let tools_info = tools + .into_iter() + .map(|tool| ToolInfo::new(&tool.name, &tool.description, get_parameter_names(&tool))) + .collect(); + + let plan_prompt = capabilities.get_planning_prompt(tools_info).await; + + Ok(plan_prompt) + } + async fn provider(&self) -> Arc> { let capabilities = self.capabilities.lock().await; capabilities.provider() diff --git a/crates/goose/src/agents/summarize.rs b/crates/goose/src/agents/summarize.rs index 078572664895..6c0da8896e93 100644 --- a/crates/goose/src/agents/summarize.rs +++ b/crates/goose/src/agents/summarize.rs @@ -10,7 +10,9 @@ use tokio::sync::Mutex; use tracing::{debug, error, instrument, warn}; use super::agent::SessionConfig; +use super::capabilities::get_parameter_names; use super::detect_read_only_tools; +use super::extension::ToolInfo; use super::Agent; use crate::agents::capabilities::Capabilities; use crate::agents::extension::{ExtensionConfig, ExtensionResult}; @@ -457,6 +459,19 @@ impl Agent for SummarizeAgent { Err(anyhow!("Prompt '{}' not found", name)) } + async fn get_plan_prompt(&self) -> anyhow::Result { + let mut capabilities = self.capabilities.lock().await; + let tools = capabilities.get_prefixed_tools().await?; + let tools_info = tools + .into_iter() + .map(|tool| ToolInfo::new(&tool.name, &tool.description, get_parameter_names(&tool))) + .collect(); + + let plan_prompt = capabilities.get_planning_prompt(tools_info).await; + + Ok(plan_prompt) + } + async fn provider(&self) -> Arc> { let capabilities = self.capabilities.lock().await; capabilities.provider() diff --git a/crates/goose/src/agents/truncate.rs b/crates/goose/src/agents/truncate.rs index ae44332083fc..bab25ed89bdc 100644 --- a/crates/goose/src/agents/truncate.rs +++ b/crates/goose/src/agents/truncate.rs @@ -10,8 +10,9 @@ use tracing::{debug, error, instrument, warn}; use super::agent::SessionConfig; use super::detect_read_only_tools; +use super::extension::ToolInfo; use super::Agent; -use crate::agents::capabilities::Capabilities; +use crate::agents::capabilities::{get_parameter_names, Capabilities}; use crate::agents::extension::{ExtensionConfig, ExtensionResult}; use crate::agents::ToolPermissionStore; use crate::config::Config; @@ -511,6 +512,19 @@ impl Agent for TruncateAgent { Err(anyhow!("Prompt '{}' not found", name)) } + async fn get_plan_prompt(&self) -> anyhow::Result { + let mut capabilities = self.capabilities.lock().await; + let tools = capabilities.get_prefixed_tools().await?; + let tools_info = tools + .into_iter() + .map(|tool| ToolInfo::new(&tool.name, &tool.description, get_parameter_names(&tool))) + .collect(); + + let plan_prompt = capabilities.get_planning_prompt(tools_info).await; + + Ok(plan_prompt) + } + async fn provider(&self) -> Arc> { let capabilities = self.capabilities.lock().await; capabilities.provider() diff --git a/crates/goose/src/prompts/plan.md b/crates/goose/src/prompts/plan.md index f7146fa26c08..af8764a85112 100644 --- a/crates/goose/src/prompts/plan.md +++ b/crates/goose/src/prompts/plan.md @@ -1,41 +1,32 @@ -You prepare plans for an agent system. You will receive the current system -status as well as in an incoming request from the human. Your plan will be used by an AI agent, -who is taking actions on behalf of the human. - -The agent currently has access to the following tools +You are a specialized "planner" AI. Your task is to analyze the user’s request from the chat messages and create either: +1. A detailed step-by-step plan (if you have enough information) on behalf of user that another "executor" AI agent can follow, or +2. A list of clarifying questions (if you do not have enough information) prompting the user to reply with the needed clarifications +{% if (tools is defined) and tools %} ## Available Tools {% for tool in tools %} -{{tool.name}}: {{tool.description}}{% endfor %} - -If the request is simple, such as a greeting or a request for information or advice, the plan can simply be: -"reply to the user". - -However for anything more complex, reflect on the available tools and describe a step by step -solution that the agent can follow using their tools. - -Your plan needs to use the following format, but can have any number of tasks. - -```json -[ - {"description": "the first task here"}, - {"description": "the second task here"}, -] -``` - -# Examples - -These examples show the format you should follow. *Do not reply with any other text, just the json plan* - -```json -[ - {"description": "reply to the user"}, -] -``` - -```json -[ - {"description": "create a directory 'demo'"}, - {"description": "write a file at 'demo/fibonacci.py' with a function fibonacci implementation"}, - {"description": "run python demo/fibonacci.py"}, -] -``` +**{{tool.name}}** +Description: {{tool.description}} +Parameters: {{tool.parameters}} + +{% endfor %} +{% else %} +No tools are defined. +{% endif %} +## Guidelines +1. Check for clarity and feasibility + - If the user’s request is ambiguous, incomplete, or requires more information, respond only with all your clarifying questions in a concise list. + - If available tools are inadequate to complete the request, outline the gaps and suggest next steps or ask for additional tools or guidance. +2. Create a detailed plan + - Once you have sufficient clarity, produce a step-by-step plan that covers all actions the executor AI must take. + - Number the steps, and explicitly note any dependencies between steps (e.g., “Use the output from Step 3 as input for Step 4”). + - Include any conditional or branching logic needed (e.g., “If X occurs, do Y; otherwise, do Z”). +3. Provide essential context + - The executor AI will see only your final plan (as a user message) or your questions (as an assistant message) and will not have access to this conversation’s full history. + - Therefore, restate any relevant background, instructions, or prior conversation details needed to execute the plan successfully. +4. One-time response + - You can respond only once. + - If you respond with a plan, it will appear as a user message in a fresh conversation for the executor AI, effectively clearing out the previous context. + - If you respond with clarifying questions, it will appear as an assistant message in this same conversation, prompting the user to reply with the needed clarifications. +5. Keep it action oriented and clear + - In your final output (whether plan or questions), be concise yet thorough. + - The goal is to enable the executor AI to proceed confidently, without further ambiguity.