feat: add /plan command in CLI to invoke reasoner with plan system prompt (#1616)

This commit is contained in:
Salman Mohammed
2025-03-20 10:10:01 -04:00
committed by GitHub
parent 3a4866cb7d
commit e273f8ebce
10 changed files with 369 additions and 51 deletions
+43
View File
@@ -15,6 +15,8 @@ pub enum InputResult {
ListPrompts(Option<String>), ListPrompts(Option<String>),
PromptCommand(PromptCommandOptions), PromptCommand(PromptCommandOptions),
GooseMode(String), GooseMode(String),
Plan(PlanCommandOptions),
EndPlan,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -24,6 +26,11 @@ pub struct PromptCommandOptions {
pub arguments: HashMap<String, String>, pub arguments: HashMap<String, String>,
} }
#[derive(Debug)]
pub struct PlanCommandOptions {
pub message_text: String,
}
pub fn get_input( pub fn get_input(
editor: &mut Editor<GooseCompleter, rustyline::history::DefaultHistory>, editor: &mut Editor<GooseCompleter, rustyline::history::DefaultHistory>,
) -> Result<InputResult> { ) -> Result<InputResult> {
@@ -72,6 +79,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
const CMD_EXTENSION: &str = "/extension "; const CMD_EXTENSION: &str = "/extension ";
const CMD_BUILTIN: &str = "/builtin "; const CMD_BUILTIN: &str = "/builtin ";
const CMD_MODE: &str = "/mode "; const CMD_MODE: &str = "/mode ";
const CMD_PLAN: &str = "/plan";
const CMD_ENDPLAN: &str = "/endplan";
match input { match input {
"/exit" | "/quit" => Some(InputResult::Exit), "/exit" | "/quit" => Some(InputResult::Exit),
@@ -111,6 +120,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
s if s.starts_with(CMD_MODE) => { s if s.starts_with(CMD_MODE) => {
Some(InputResult::GooseMode(s[CMD_MODE.len()..].to_string())) 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, _ => None,
} }
} }
@@ -168,6 +179,14 @@ fn parse_prompt_command(args: &str) -> Option<InputResult> {
Some(InputResult::PromptCommand(options)) Some(InputResult::PromptCommand(options))
} }
fn parse_plan_command(input: String) -> Option<InputResult> {
let options = PlanCommandOptions {
message_text: input.trim().to_string(),
};
Some(InputResult::Plan(options))
}
fn print_help() { fn print_help() {
println!( println!(
"Available commands: "Available commands:
@@ -178,6 +197,12 @@ fn print_help() {
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension /prompts [--extension <name>] - List all available prompts, optionally filtered by extension
/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt /prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
/mode <name> - Set the goose mode to use ('auto', 'approve', 'chat') /mode <name> - Set the goose mode to use ('auto', 'approve', 'chat')
/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.
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 /? or /help - Display this help message
Navigation: Navigation:
@@ -370,4 +395,22 @@ mod tests {
panic!("Expected PromptCommand"); 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"),
}
}
} }
+187 -11
View File
@@ -6,6 +6,7 @@ mod prompt;
mod thinking; mod thinking;
pub use builder::build_session; pub use builder::build_session;
use goose::providers::base::Provider;
pub use goose::session::Identifier; pub use goose::session::Identifier;
use anyhow::Result; use anyhow::Result;
@@ -28,6 +29,11 @@ use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tokio; use tokio;
pub enum RunMode {
Normal,
Plan,
}
pub struct Session { pub struct Session {
agent: Box<dyn Agent>, agent: Box<dyn Agent>,
messages: Vec<Message>, messages: Vec<Message>,
@@ -35,6 +41,7 @@ pub struct Session {
// Cache for completion data - using std::sync for thread safety without async // Cache for completion data - using std::sync for thread safety without async
completion_cache: Arc<std::sync::RwLock<CompletionCache>>, completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
debug: bool, // New field for debug mode debug: bool, // New field for debug mode
run_mode: RunMode,
} }
// Cache structure for completion data // 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<Box<dyn Provider>>,
) -> Result<PlannerResponseType> {
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 { impl Session {
pub fn new(agent: Box<dyn Agent>, session_file: PathBuf, debug: bool) -> Self { pub fn new(agent: Box<dyn Agent>, session_file: PathBuf, debug: bool) -> Self {
let messages = match session::read_messages(&session_file) { let messages = match session::read_messages(&session_file) {
@@ -70,6 +113,7 @@ impl Session {
session_file, session_file,
completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())), completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())),
debug, debug,
run_mode: RunMode::Normal,
} }
} }
@@ -265,20 +309,35 @@ impl Session {
loop { loop {
match input::get_input(&mut editor)? { match input::get_input(&mut editor)? {
input::InputResult::Message(content) => { input::InputResult::Message(content) => {
save_history(&mut editor); match self.run_mode {
RunMode::Normal => {
save_history(&mut editor);
self.messages.push(Message::user().with_text(&content)); self.messages.push(Message::user().with_text(&content));
// Get the provider from the agent for description generation // Get the provider from the agent for description generation
let provider = self.agent.provider().await; let provider = self.agent.provider().await;
// Persist messages with provider for automatic description generation // Persist messages with provider for automatic description generation
session::persist_messages(&self.session_file, &self.messages, Some(provider)) session::persist_messages(
.await?; &self.session_file,
&self.messages,
Some(provider),
)
.await?;
output::show_thinking(); output::show_thinking();
self.process_agent_response(true).await?; self.process_agent_response(true).await?;
output::hide_thinking(); 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::Exit => break,
input::InputResult::AddExtension(cmd) => { input::InputResult::AddExtension(cmd) => {
@@ -345,7 +404,27 @@ impl Session {
config config
.set_param("GOOSE_MODE", Value::String(mode.to_string())) .set_param("GOOSE_MODE", Value::String(mode.to_string()))
.unwrap(); .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; continue;
} }
input::InputResult::PromptCommand(opts) => { input::InputResult::PromptCommand(opts) => {
@@ -419,6 +498,72 @@ impl Session {
Ok(()) Ok(())
} }
async fn plan_with_reasoner_model(
&mut self,
plan_messages: Vec<Message>,
reasoner: Box<dyn Provider + Send + Sync>,
) -> 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 /// Process a single message and exit
pub async fn headless(&mut self, message: String) -> Result<()> { pub async fn headless(&mut self, message: String) -> Result<()> {
self.process_message(message).await self.process_message(message).await
@@ -650,3 +795,34 @@ impl Session {
Ok(metadata.total_tokens) Ok(metadata.total_tokens)
} }
} }
fn get_reasoner() -> Result<Box<dyn Provider + Send + Sync>, 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)
}
+27
View File
@@ -126,6 +126,33 @@ pub fn render_message(message: &Message, debug: bool) {
println!(); 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) { fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) {
match &req.tool_call { match &req.tool_call {
Ok(call) => match call.name.as_str() { Ok(call) => match call.name.as_str() {
+3
View File
@@ -63,6 +63,9 @@ pub trait Agent: Send + Sync {
/// Returns the prompt text that would be used as user input /// Returns the prompt text that would be used as user input
async fn get_prompt(&self, name: &str, arguments: Value) -> Result<GetPromptResult>; async fn get_prompt(&self, name: &str, arguments: Value) -> Result<GetPromptResult>;
/// Get the plan prompt, which will be used with the planner (reasoner) model
async fn get_plan_prompt(&self) -> anyhow::Result<String>;
/// Get a reference to the provider used by this agent /// Get a reference to the provider used by this agent
async fn provider(&self) -> Arc<Box<dyn Provider>>; async fn provider(&self) -> Arc<Box<dyn Provider>>;
} }
+17 -1
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, instrument}; 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::config::Config;
use crate::prompt_template; use crate::prompt_template;
use crate::providers::base::Provider; use crate::providers::base::Provider;
@@ -83,6 +83,14 @@ fn normalize(input: String) -> String {
result.to_lowercase() result.to_lowercase()
} }
pub fn get_parameter_names(tool: &Tool) -> Vec<String> {
tool.input_schema
.get("properties")
.and_then(|props| props.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default()
}
impl Capabilities { impl Capabilities {
/// Create a new Capabilities with the specified provider /// Create a new Capabilities with the specified provider
pub fn new(provider: Box<dyn Provider>) -> Self { pub fn new(provider: Box<dyn Provider>) -> Self {
@@ -296,6 +304,14 @@ impl Capabilities {
Ok(result) Ok(result)
} }
/// Get the extension prompt including client instructions
pub async fn get_planning_prompt(&self, tools_info: Vec<ToolInfo>) -> 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 /// Get the extension prompt including client instructions
pub async fn get_system_prompt(&self) -> String { pub async fn get_system_prompt(&self) -> String {
let mut context: HashMap<&str, Value> = HashMap::new(); let mut context: HashMap<&str, Value> = HashMap::new();
+18
View File
@@ -192,3 +192,21 @@ impl ExtensionInfo {
} }
} }
} }
/// Information about the tool used for building prompts
#[derive(Clone, Debug, Serialize)]
pub struct ToolInfo {
name: String,
description: String,
parameters: Vec<String>,
}
impl ToolInfo {
pub fn new(name: &str, description: &str, parameters: Vec<String>) -> Self {
Self {
name: name.to_string(),
description: description.to_string(),
parameters,
}
}
}
+15
View File
@@ -8,6 +8,8 @@ use tokio::sync::Mutex;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
use super::agent::SessionConfig; use super::agent::SessionConfig;
use super::capabilities::get_parameter_names;
use super::extension::ToolInfo;
use super::Agent; use super::Agent;
use crate::agents::capabilities::Capabilities; use crate::agents::capabilities::Capabilities;
use crate::agents::extension::{ExtensionConfig, ExtensionResult}; use crate::agents::extension::{ExtensionConfig, ExtensionResult};
@@ -243,6 +245,19 @@ impl Agent for ReferenceAgent {
Err(anyhow!("Prompt '{}' not found", name)) Err(anyhow!("Prompt '{}' not found", name))
} }
async fn get_plan_prompt(&self) -> anyhow::Result<String> {
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<Box<dyn Provider>> { async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await; let capabilities = self.capabilities.lock().await;
capabilities.provider() capabilities.provider()
+15
View File
@@ -10,7 +10,9 @@ use tokio::sync::Mutex;
use tracing::{debug, error, instrument, warn}; use tracing::{debug, error, instrument, warn};
use super::agent::SessionConfig; use super::agent::SessionConfig;
use super::capabilities::get_parameter_names;
use super::detect_read_only_tools; use super::detect_read_only_tools;
use super::extension::ToolInfo;
use super::Agent; use super::Agent;
use crate::agents::capabilities::Capabilities; use crate::agents::capabilities::Capabilities;
use crate::agents::extension::{ExtensionConfig, ExtensionResult}; use crate::agents::extension::{ExtensionConfig, ExtensionResult};
@@ -457,6 +459,19 @@ impl Agent for SummarizeAgent {
Err(anyhow!("Prompt '{}' not found", name)) Err(anyhow!("Prompt '{}' not found", name))
} }
async fn get_plan_prompt(&self) -> anyhow::Result<String> {
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<Box<dyn Provider>> { async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await; let capabilities = self.capabilities.lock().await;
capabilities.provider() capabilities.provider()
+15 -1
View File
@@ -10,8 +10,9 @@ use tracing::{debug, error, instrument, warn};
use super::agent::SessionConfig; use super::agent::SessionConfig;
use super::detect_read_only_tools; use super::detect_read_only_tools;
use super::extension::ToolInfo;
use super::Agent; 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::extension::{ExtensionConfig, ExtensionResult};
use crate::agents::ToolPermissionStore; use crate::agents::ToolPermissionStore;
use crate::config::Config; use crate::config::Config;
@@ -511,6 +512,19 @@ impl Agent for TruncateAgent {
Err(anyhow!("Prompt '{}' not found", name)) Err(anyhow!("Prompt '{}' not found", name))
} }
async fn get_plan_prompt(&self) -> anyhow::Result<String> {
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<Box<dyn Provider>> { async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await; let capabilities = self.capabilities.lock().await;
capabilities.provider() capabilities.provider()
+29 -38
View File
@@ -1,41 +1,32 @@
You prepare plans for an agent system. You will receive the current system You are a specialized "planner" AI. Your task is to analyze the users request from the chat messages and create either:
status as well as in an incoming request from the human. Your plan will be used by an AI agent, 1. A detailed step-by-step plan (if you have enough information) on behalf of user that another "executor" AI agent can follow, or
who is taking actions on behalf of the human. 2. A list of clarifying questions (if you do not have enough information) prompting the user to reply with the needed clarifications
The agent currently has access to the following tools
{% if (tools is defined) and tools %} ## Available Tools
{% for tool in tools %} {% for tool in tools %}
{{tool.name}}: {{tool.description}}{% endfor %} **{{tool.name}}**
Description: {{tool.description}}
Parameters: {{tool.parameters}}
If the request is simple, such as a greeting or a request for information or advice, the plan can simply be: {% endfor %}
"reply to the user". {% else %}
No tools are defined.
However for anything more complex, reflect on the available tools and describe a step by step {% endif %}
solution that the agent can follow using their tools. ## Guidelines
1. Check for clarity and feasibility
Your plan needs to use the following format, but can have any number of tasks. - If the users 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.
```json 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.
{"description": "the first task here"}, - Number the steps, and explicitly note any dependencies between steps (e.g., “Use the output from Step 3 as input for Step 4”).
{"description": "the second task here"}, - 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 conversations full history.
- Therefore, restate any relevant background, instructions, or prior conversation details needed to execute the plan successfully.
# Examples 4. One-time response
- You can respond only once.
These examples show the format you should follow. *Do not reply with any other text, just the json plan* - 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.
```json 5. Keep it action oriented and clear
[ - In your final output (whether plan or questions), be concise yet thorough.
{"description": "reply to the user"}, - The goal is to enable the executor AI to proceed confidently, without further ambiguity.
]
```
```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"},
]
```