Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions crates/goose-cli/src/session/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub enum InputResult {
ListPrompts(Option<String>),
PromptCommand(PromptCommandOptions),
GooseMode(String),
Plan(PlanCommandOptions),
EndPlan,
}

#[derive(Debug)]
Expand All @@ -24,6 +26,11 @@ pub struct PromptCommandOptions {
pub arguments: HashMap<String, String>,
}

#[derive(Debug)]
pub struct PlanCommandOptions {
pub message_text: String,
}

pub fn get_input(
editor: &mut Editor<GooseCompleter, rustyline::history::DefaultHistory>,
) -> Result<InputResult> {
Expand Down Expand Up @@ -72,6 +79,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_PLAN: &str = "/plan";
const CMD_ENDPLAN: &str = "/endplan";

match input {
"/exit" | "/quit" => Some(InputResult::Exit),
Expand Down Expand Up @@ -111,6 +120,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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,
}
}
Expand Down Expand Up @@ -168,6 +179,14 @@ fn parse_prompt_command(args: &str) -> Option<InputResult> {
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() {
println!(
"Available commands:
Expand All @@ -178,6 +197,12 @@ 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 ('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

Navigation:
Expand Down Expand Up @@ -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"),
}
}
}
206 changes: 191 additions & 15 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,13 +29,19 @@ use std::sync::Arc;
use std::time::Instant;
use tokio;

pub enum RunMode {
Normal,
Plan,
}

pub struct Session {
agent: Box<dyn Agent>,
messages: Vec<Message>,
session_file: PathBuf,
// Cache for completion data - using std::sync for thread safety without async
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
debug: bool, // New field for debug mode
run_mode: RunMode,
}

// Cache structure for completion data
Expand All @@ -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 {
pub fn new(agent: Box<dyn Agent>, session_file: PathBuf, debug: bool) -> Self {
let messages = match session::read_messages(&session_file) {
Expand All @@ -70,6 +113,7 @@ impl Session {
session_file,
completion_cache: Arc::new(std::sync::RwLock::new(CompletionCache::new())),
debug,
run_mode: RunMode::Normal,
}
}

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -418,6 +497,72 @@ impl Session {
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
pub async fn headless(&mut self, message: String) -> Result<()> {
self.process_message(message).await
Expand Down Expand Up @@ -649,3 +794,34 @@ impl Session {
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 changes: 27 additions & 0 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
async fn provider(&self) -> Arc<Box<dyn Provider>>;
}
Loading
Loading