Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
83 changes: 81 additions & 2 deletions crates/goose-cli/src/session/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub enum InputResult {
ListPrompts(Option<String>),
PromptCommand(PromptCommandOptions),
GooseMode(String),
Plan(PlanCommandOptions),
}

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

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

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

match input {
"/exit" | "/quit" => Some(InputResult::Exit),
Expand Down Expand Up @@ -111,6 +119,7 @@ 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()),
_ => None,
}
}
Expand Down Expand Up @@ -168,16 +177,51 @@ fn parse_prompt_command(args: &str) -> Option<InputResult> {
Some(InputResult::PromptCommand(options))
}

fn parse_plan_command(input: String) -> Option<InputResult> {
let mut options = PlanCommandOptions {
model: String::new(),
message_text: String::new(),
};

let parts: Vec<String> = shlex::split(&input).unwrap_or_default();

if parts.is_empty() {
println!("For the /plan command, you must provide message text but none was provided.");
println!("Usage: /plan --model=<model> <message_text>");
return None;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you just type /plan and hit enter you get this message and then the agent loop continues:

( O)> /plan
For the /plan command, you must provide message text but none was provided.
Usage: /plan --model=<model> <message_text>
◓  Honking thoughtfully...     
# Planning Session

I'd be happy to help you with planning. Let me guide you through a planning session where we can outline your goals, tasks, or projects.

## How I Can Help
...

Probably want to return Some(InputResult::Retry) to put them back into the prompt

}

if parts[0].starts_with("--model=") {
options.model = parts[0]
.strip_prefix("--model=")
.unwrap_or_default()
.to_string();
}

// start at index 0 if no model is provided, else start at index 1 & join the rest
options.message_text = parts[if options.model.is_empty() { 0 } else { 1 }..].join(" ");

if options.message_text.is_empty() {
println!("For the /plan command, you must provide message text but none was provided.");
println!("Usage: /plan --model=<model> <message_text>");
return None;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

}

Some(InputResult::Plan(options))
}

fn print_help() {
println!(
"Available commands:
/exit or /quit - Exit the session
/t - Toggle Light/Dark/Ansi theme
/extension <command> - Add a stdio extension (format: ENV1=val1 command args...)
/builtin <names> - Add builtin extensions by name (comma-separated)
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension
/prompts [--extension <n>] - List all available prompts, optionally filtered by extension

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: why n from name? n almost implies a number/index to me

i guess /prompt <n> just under it is not using name either though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was probably copilot making random change

/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
/mode <name> - Set the goose mode to use ('auto', 'approve', 'chat')
/mode <n> - Set the goose mode to use ('auto', 'approve', 'chat')
/plan --model=<model> <message_text> - Create a plan based on the current messages.
Model options: o1, o3-mini-high, o3-mini, claude-3-7. Default model is o1-high.
/? or /help - Display this help message

Navigation:
Expand Down Expand Up @@ -370,4 +414,39 @@ mod tests {
panic!("Expected PromptCommand");
}
}

#[test]
fn test_plan_mode() {
// Test plan mode with no model or text
let result = handle_slash_command("/plan");
assert!(result.is_none());

// Test plan mode with no text
let result = handle_slash_command("/plan --model=claude-3-7");
assert!(result.is_none());

// Test plan mode with model & text
let result = handle_slash_command("/plan --model=claude-3-7 hello world");
assert!(result.is_some());
let options = result.unwrap();
match options {
InputResult::Plan(options) => {
assert_eq!(options.model, "claude-3-7");
assert_eq!(options.message_text, "hello world");
}
_ => panic!("Expected Plan"),
}

// Test plan mode with only 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.model, "");
assert_eq!(options.message_text, "hello world");
}
_ => panic!("Expected Plan"),
}
}
}
64 changes: 64 additions & 0 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,70 @@ impl Session {
println!("Goose mode set to '{}'", mode);
continue;
}
input::InputResult::Plan(options) => {
let model = options.model;
let message_text = options.message_text;

// Copy the messages before the plan
// Run the plan -> prompting a reasoner model to create a plan
let reasoner_provider: String;
let reasoner_model: String;
if model.is_empty() {
reasoner_provider = "openai".to_string();
reasoner_model = "o1-high".to_string();
} else if model.starts_with("o1") || model.starts_with("o3-mini") {
reasoner_provider = "openai".to_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have to validate the effort - high / medium / low only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be validated in the openai provider

reasoner_model = model;
} else if model.starts_with("claude") {
reasoner_provider = "anthropic".to_string();
reasoner_model = "claude-3-7-sonnet-latest".to_string();
// set env var "ANTHROPIC_THINKING_ENABLED" to "true"
std::env::set_var("ANTHROPIC_THINKING_ENABLED", "true");
} else {
println!("Invalid planner model: {}", model);
continue;
}

use goose::model::ModelConfig;
use goose::providers::create;

// TODO: hacky to create a new provider for the planner each time plan is called

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the reasoning model only used for the /plan call? Assuming we switch back to the prior model_config after planning is finished?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes right now its only used for the /plan call

let model_config = ModelConfig::new(reasoner_model.to_string());
let reasoner = create(reasoner_provider.as_str(), model_config)?;

let mut plan_messages = self.messages.clone();
plan_messages.push(Message::user().with_text(&message_text));

let plan_prompt = self.agent.get_plan_prompt().await?;
println!("Plan Prompt: {}\n", plan_prompt); // TODO: remove
let (plan_response, _usage) =
reasoner.complete(&plan_prompt, &plan_messages, &[]).await?;

// Render the plan & ask if user wants to act on it
output::render_message(&plan_response, self.debug);
let confirmed =
cliclack::confirm("Do you want to clear history & act on this plan?")
Comment thread
salman1993 marked this conversation as resolved.
Outdated
.initial_value(true)
.interact()?;

if confirmed {
// clear the messages before the plan
self.messages.clear();
Comment thread
salman1993 marked this conversation as resolved.
Outdated
output::display_session_history_cleared();
// 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();
} 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);
}
}
input::InputResult::PromptCommand(opts) => {
save_history(&mut editor);

Expand Down
4 changes: 4 additions & 0 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,10 @@ pub fn display_greeting() {
println!("\nGoose is running! Enter your instructions, or try asking what goose can do.\n");
}

pub fn display_session_history_cleared() {
println!("\n{}\n", style("Session history cleared.").dim().cyan(),);
}

#[cfg(test)]
mod tests {
use super::*;
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 @@ -66,6 +66,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>>;
}
18 changes: 17 additions & 1 deletion crates/goose/src/agents/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, ProviderUsage};
Expand Down Expand Up @@ -84,6 +84,14 @@ fn normalize(input: String) -> String {
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 {
/// Create a new Capabilities with the specified provider
pub fn new(provider: Box<dyn Provider>) -> Self {
Expand Down Expand Up @@ -322,6 +330,14 @@ impl Capabilities {
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
pub async fn get_system_prompt(&self) -> String {
let mut context: HashMap<&str, Value> = HashMap::new();
Expand Down
18 changes: 18 additions & 0 deletions crates/goose/src/agents/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,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,
}
}
}
4 changes: 4 additions & 0 deletions crates/goose/src/agents/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ impl Agent for ReferenceAgent {
Err(anyhow!("Prompt '{}' not found", name))
}

async fn get_plan_prompt(&self) -> anyhow::Result<String> {
todo!()
}

async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await;
capabilities.provider()
Expand Down
4 changes: 4 additions & 0 deletions crates/goose/src/agents/summarize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,10 @@ impl Agent for SummarizeAgent {
Err(anyhow!("Prompt '{}' not found", name))
}

async fn get_plan_prompt(&self) -> anyhow::Result<String> {
todo!()
}

async fn provider(&self) -> Arc<Box<dyn Provider>> {
let capabilities = self.capabilities.lock().await;
capabilities.provider()
Expand Down
16 changes: 15 additions & 1 deletion crates/goose/src/agents/truncate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -498,6 +499,19 @@ impl Agent for TruncateAgent {
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>> {
let capabilities = self.capabilities.lock().await;
capabilities.provider()
Expand Down
Loading