User configurable templates (#6420)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-01-20 17:15:52 -05:00
committed by GitHub
parent 5a04ee8700
commit 2f083b827a
23 changed files with 1052 additions and 307 deletions
+8
View File
@@ -353,6 +353,10 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::check_provider,
super::routes::config_management::set_config_provider,
super::routes::config_management::get_pricing,
super::routes::prompts::get_prompts,
super::routes::prompts::get_prompt,
super::routes::prompts::save_prompt,
super::routes::prompts::reset_prompt,
super::routes::agent::start_agent,
super::routes::agent::resume_agent,
super::routes::agent::stop_agent,
@@ -427,6 +431,10 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::PricingQuery,
super::routes::config_management::PricingResponse,
super::routes::config_management::PricingData,
super::routes::prompts::PromptsListResponse,
super::routes::prompts::PromptContentResponse,
super::routes::prompts::SavePromptRequest,
goose::prompt_template::Template,
super::routes::action_required::ConfirmToolActionRequest,
super::routes::reply::ChatRequest,
super::routes::session::ImportSessionRequest,
+3 -3
View File
@@ -18,7 +18,7 @@ use goose::agents::ExtensionConfig;
use goose::config::resolve_extensions_for_new_session;
use goose::config::{Config, GooseMode};
use goose::model::ModelConfig;
use goose::prompt_template::render_global_file;
use goose::prompt_template::render_template;
use goose::providers::create;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
@@ -418,7 +418,7 @@ async fn update_from_session(
})?;
let context: HashMap<&str, Value> = HashMap::new();
let desktop_prompt =
render_global_file("desktop_prompt.md", &context).expect("Prompt should render");
render_template("desktop_prompt.md", &context).expect("Prompt should render");
let mut update_prompt = desktop_prompt;
if let Some(recipe) = session.recipe {
match build_recipe_with_parameter_values(
@@ -691,7 +691,7 @@ async fn restart_agent_internal(
let context: HashMap<&str, Value> = HashMap::new();
let desktop_prompt =
render_global_file("desktop_prompt.md", &context).expect("Prompt should render");
render_template("desktop_prompt.md", &context).expect("Prompt should render");
let mut update_prompt = desktop_prompt;
if let Some(ref recipe) = session.recipe {
+2
View File
@@ -5,6 +5,7 @@ pub mod config_management;
pub mod errors;
pub mod mcp_app_proxy;
pub mod mcp_ui_proxy;
pub mod prompts;
pub mod recipe;
pub mod recipe_utils;
pub mod reply;
@@ -29,6 +30,7 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
.merge(agent::routes(state.clone()))
.merge(audio::routes(state.clone()))
.merge(config_management::routes(state.clone()))
.merge(prompts::routes())
.merge(recipe::routes(state.clone()))
.merge(session::routes(state.clone()))
.merge(schedule::routes(state.clone()))
+133
View File
@@ -0,0 +1,133 @@
use axum::{
extract::Path,
routing::{delete, get, put},
Json, Router,
};
use goose::prompt_template::{
get_template, list_templates, reset_template, save_template, Template,
};
use http::StatusCode;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct PromptsListResponse {
pub prompts: Vec<Template>,
}
#[derive(Serialize, ToSchema)]
pub struct PromptContentResponse {
pub name: String,
pub content: String,
pub default_content: String,
pub is_customized: bool,
}
#[derive(Deserialize, ToSchema)]
pub struct SavePromptRequest {
pub content: String,
}
#[utoipa::path(
get,
path = "/config/prompts",
responses(
(status = 200, description = "List of all available prompts", body = PromptsListResponse)
)
)]
pub async fn get_prompts() -> Json<PromptsListResponse> {
Json(PromptsListResponse {
prompts: list_templates(),
})
}
#[utoipa::path(
get,
path = "/config/prompts/{name}",
params(
("name" = String, Path, description = "Prompt template name (e.g., system.md)")
),
responses(
(status = 200, description = "Prompt content retrieved successfully", body = PromptContentResponse),
(status = 404, description = "Prompt not found")
)
)]
pub async fn get_prompt(
Path(name): Path<String>,
) -> Result<Json<PromptContentResponse>, StatusCode> {
let template = get_template(&name).ok_or(StatusCode::NOT_FOUND)?;
let content = template
.user_content
.as_ref()
.unwrap_or(&template.default_content);
Ok(Json(PromptContentResponse {
name: template.name,
content: content.clone(),
default_content: template.default_content,
is_customized: template.is_customized,
}))
}
#[utoipa::path(
put,
path = "/config/prompts/{name}",
params(
("name" = String, Path, description = "Prompt template name (e.g., system.md)")
),
request_body = SavePromptRequest,
responses(
(status = 200, description = "Prompt saved successfully", body = String),
(status = 404, description = "Prompt not found"),
(status = 500, description = "Failed to save prompt")
)
)]
pub async fn save_prompt(
Path(name): Path<String>,
Json(request): Json<SavePromptRequest>,
) -> Result<Json<String>, StatusCode> {
save_template(&name, &request.content).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StatusCode::NOT_FOUND
} else {
tracing::error!("Failed to save prompt {}: {}", name, e);
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok(Json(format!("Saved prompt: {}", name)))
}
#[utoipa::path(
delete,
path = "/config/prompts/{name}",
params(
("name" = String, Path, description = "Prompt template name (e.g., system.md)")
),
responses(
(status = 200, description = "Prompt reset to default successfully", body = String),
(status = 404, description = "Prompt not found"),
(status = 500, description = "Failed to reset prompt")
)
)]
pub async fn reset_prompt(Path(name): Path<String>) -> Result<Json<String>, StatusCode> {
reset_template(&name).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StatusCode::NOT_FOUND
} else {
tracing::error!("Failed to reset prompt {}: {}", name, e);
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok(Json(format!("Reset prompt to default: {}", name)))
}
pub fn routes() -> Router {
Router::new()
.route("/config/prompts", get(get_prompts))
.route("/config/prompts/{name}", get(get_prompt))
.route("/config/prompts/{name}", put(save_prompt))
.route("/config/prompts/{name}", delete(reset_prompt))
}
@@ -10,7 +10,7 @@ use crate::state::AppState;
use anyhow::Result;
use axum::http::StatusCode;
use goose::agents::Agent;
use goose::prompt_template::render_global_file;
use goose::prompt_template::render_template;
use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError};
use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes};
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
@@ -173,6 +173,6 @@ pub async fn apply_recipe_to_agent(
recipe.instructions.as_ref().map(|instructions| {
let mut context: HashMap<&str, Value> = HashMap::new();
context.insert("recipe_instructions", Value::String(instructions.clone()));
render_global_file("desktop_recipe_instruction.md", &context).expect("Prompt should render")
render_template("desktop_recipe_instruction.md", &context).expect("Prompt should render")
})
}
+1 -1
View File
@@ -848,7 +848,7 @@ impl ExtensionManager {
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")
prompt_template::render_template("plan.md", &context).expect("Prompt should render")
}
/// Find and return a reference to the appropriate client for a tool call
+3 -3
View File
@@ -161,9 +161,9 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
let base_prompt = if let Some(override_prompt) = &self.manager.system_prompt_override {
let sanitized_override_prompt = sanitize_unicode_tags(override_prompt);
prompt_template::render_inline_once(&sanitized_override_prompt, &context)
prompt_template::render_string(&sanitized_override_prompt, &context)
} else {
prompt_template::render_global_file("system.md", &context)
prompt_template::render_template("system.md", &context)
}
.unwrap_or_else(|_| {
"You are a general-purpose AI agent called goose, created by Block".to_string()
@@ -245,7 +245,7 @@ impl PromptManager {
pub async fn get_recipe_prompt(&self) -> String {
let context: HashMap<&str, Value> = HashMap::new();
prompt_template::render_global_file("recipe.md", &context)
prompt_template::render_template("recipe.md", &context)
.unwrap_or_else(|_| "The recipe prompt is busted. Tell the user.".to_string())
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::{
agents::{subagent_task_config::TaskConfig, Agent, AgentConfig, AgentEvent, SessionConfig},
conversation::{message::Message, Conversation},
prompt_template::render_global_file,
prompt_template::render_template,
recipe::Recipe,
};
use anyhow::{anyhow, Result};
@@ -148,7 +148,7 @@ fn get_agent_messages(
.await;
let tools = agent.list_tools(&session_id, None).await;
let subagent_prompt = render_global_file(
let subagent_prompt = render_template(
"subagent_system.md",
&SubagentPromptContext {
max_turns: task_config
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::conversation::message::{ActionRequiredData, MessageMetadata};
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::{merge_consecutive_messages, Conversation};
use crate::prompt_template::render_global_file;
use crate::prompt_template::render_template;
use crate::providers::base::{Provider, ProviderUsage};
use crate::providers::errors::ProviderError;
use crate::{config::Config, token_counter::create_token_counter};
@@ -294,7 +294,7 @@ async fn do_compact(
messages: messages_text,
};
let system_prompt = render_global_file("summarize_oneshot.md", &context)?;
let system_prompt = render_template("compaction.md", &context)?;
let user_message = Message::user()
.with_text("Please summarize the conversation history provided in the system prompt.");
@@ -3,7 +3,7 @@ use crate::config::permission::PermissionLevel;
use crate::config::PermissionManager;
use crate::conversation::message::{Message, MessageContent, ToolRequest};
use crate::conversation::Conversation;
use crate::prompt_template::render_global_file;
use crate::prompt_template::render_template;
use crate::providers::base::Provider;
use chrono::Utc;
use indoc::indoc;
@@ -140,7 +140,7 @@ pub async fn detect_read_only_tools(
let check_messages = create_check_messages(tool_requests);
let context = PermissionJudgeContext {};
let system_prompt = render_global_file("permission_judge.md", &context)
let system_prompt = render_template("permission_judge.md", &context)
.unwrap_or_else(|_| "You are a good analyst and can detect operations whether they have read-only operations.".to_string());
let res = provider
+201 -207
View File
@@ -1,240 +1,234 @@
use crate::config::paths::Paths;
use include_dir::{include_dir, Dir};
use minijinja::{Environment, Error as MiniJinjaError, Value as MJValue};
use once_cell::sync::Lazy;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
/// This directory will be embedded into the final binary.
/// Typically used to store "core" or "system" prompts.
static CORE_PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/prompts");
/// A global MiniJinja environment storing the "core" prompts.
///
/// - Loaded at startup from the `CORE_PROMPTS_DIR`.
/// - Ideal for "system" templates that don't change often.
/// - *Not* used for extension prompts (which are ephemeral).
static GLOBAL_ENV: Lazy<Arc<RwLock<Environment<'static>>>> = Lazy::new(|| {
static TEMPLATE_REGISTRY: &[(&str, &str)] = &[
(
"system.md",
"Main system prompt that defines goose's personality and behavior",
),
(
"compaction.md",
"Prompt for summarizing conversation history when context limits are reached",
),
(
"subagent_system.md",
"System prompt for subagents spawned to handle specific tasks",
),
(
"recipe.md",
"Prompt for generating recipe files from conversations",
),
(
"permission_judge.md",
"Prompt for analyzing tool operations for read-only detection",
),
(
"desktop_prompt.md",
"Additional context provided when using the desktop application",
),
(
"desktop_recipe_instruction.md",
"Prompt template for recipe instructions in desktop mode",
),
(
"plan.md",
"Prompt used when goose creates step-by-step plans. CLI only",
),
];
/// Information about a template including its content and customization status
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct Template {
pub name: String,
pub description: String,
pub default_content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_content: Option<String>,
pub is_customized: bool,
}
fn user_prompts_dir() -> PathBuf {
Paths::config_dir().join("prompts")
}
fn is_registered(name: &str) -> bool {
TEMPLATE_REGISTRY.iter().any(|(n, _)| *n == name)
}
pub fn render_string<T: Serialize>(
template_str: &str,
context: &T,
) -> Result<String, MiniJinjaError> {
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.add_template("template", template_str)?;
let tmpl = env.get_template("template")?;
let ctx = MJValue::from_serialize(context);
let rendered = tmpl.render(ctx)?;
Ok(rendered.trim().to_string())
}
// Pre-load all core templates from the embedded dir.
for file in CORE_PROMPTS_DIR.files() {
let name = file.path().to_string_lossy().to_string();
let source = String::from_utf8_lossy(file.contents()).to_string();
// Since we're using 'static lifetime for the Environment, we need to ensure
// the strings we add as templates live for the entire program duration.
// We can achieve this by leaking the strings (acceptable for initialization).
let static_name: &'static str = Box::leak(name.into_boxed_str());
let static_source: &'static str = Box::leak(source.into_boxed_str());
if let Err(e) = env.add_template(static_name, static_source) {
tracing::error!("Failed to add template {}: {}", static_name, e);
}
pub fn render_template<T: Serialize>(name: &str, context: &T) -> Result<String, MiniJinjaError> {
if !is_registered(name) {
return Err(MiniJinjaError::new(
minijinja::ErrorKind::TemplateNotFound,
format!("Template '{}' is not registered", name),
));
}
Arc::new(RwLock::new(env))
});
let user_path = user_prompts_dir().join(name);
let template_str = if user_path.exists() {
std::fs::read_to_string(&user_path).map_err(|e| {
MiniJinjaError::new(
minijinja::ErrorKind::InvalidOperation,
format!("Failed to read user template: {}", e),
)
})?
} else {
let file = CORE_PROMPTS_DIR.get_file(name).ok_or_else(|| {
MiniJinjaError::new(
minijinja::ErrorKind::TemplateNotFound,
format!("Built-in template '{}' not found", name),
)
})?;
String::from_utf8_lossy(file.contents()).to_string()
};
/// Renders a prompt from the global environment by name.
///
/// # Arguments
/// * `template_name` - The name of the template (usually the file path or a custom ID).
/// * `context_data` - Data to be inserted into the template (must be `Serialize`).
pub fn render_global_template<T: Serialize>(
template_name: &str,
context_data: &T,
) -> Result<String, MiniJinjaError> {
let env = GLOBAL_ENV.read().expect("GLOBAL_ENV lock poisoned");
let tmpl = env.get_template(template_name)?;
let ctx = MJValue::from_serialize(context_data);
let rendered = tmpl.render(ctx)?;
Ok(rendered.trim().to_string())
render_string(&template_str, context)
}
/// Renders a file from `CORE_PROMPTS_DIR` within the global environment.
///
/// # Arguments
/// * `template_file` - The file path within the embedded directory (e.g. "system.md").
/// * `context_data` - Data to be inserted into the template (must be `Serialize`).
///
/// This function **assumes** the file is already in `CORE_PROMPTS_DIR`. If it wasn't
/// added to the global environment at startup (due to parse errors, etc.), this will error out.
pub fn render_global_file<T: Serialize>(
template_file: impl Into<PathBuf>,
context_data: &T,
) -> Result<String, MiniJinjaError> {
let file_path = template_file.into();
let template_name = file_path.to_string_lossy().to_string();
pub fn get_template(name: &str) -> Option<Template> {
let (_, description) = TEMPLATE_REGISTRY.iter().find(|(n, _)| *n == name)?;
render_global_template(&template_name, context_data)
let default_content = CORE_PROMPTS_DIR
.get_file(name)
.map(|file| String::from_utf8_lossy(file.contents()).to_string())?;
let user_path = user_prompts_dir().join(name);
let user_content = if user_path.exists() {
std::fs::read_to_string(&user_path).ok()
} else {
None
};
let is_customized = user_content.is_some();
Some(Template {
name: name.to_string(),
description: description.to_string(),
default_content,
user_content,
is_customized,
})
}
/// Alias for render_global_file for backward compatibility
pub fn render_global_from_file<T: Serialize>(
template_file: impl Into<PathBuf>,
context_data: &T,
) -> Result<String, MiniJinjaError> {
render_global_file(template_file, context_data)
pub fn save_template(name: &str, content: &str) -> std::io::Result<()> {
if !is_registered(name) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Template '{}' is not registered", name),
));
}
let prompts_dir = user_prompts_dir();
std::fs::create_dir_all(&prompts_dir)?;
let path = prompts_dir.join(name);
std::fs::write(path, content)
}
/// Renders a **one-off ephemeral** template (inline string).
///
/// This does *not* store anything in the global environment and is best for
/// extension prompts or user-supplied templates that are used infrequently.
///
/// # Arguments
/// * `template_str` - The raw template string.
/// * `context_data` - Data to be inserted into the template (must be `Serialize`).
pub fn render_inline_once<T: Serialize>(
template_str: &str,
context_data: &T,
) -> Result<String, MiniJinjaError> {
let mut env = Environment::new();
env.add_template("inline_ephemeral", template_str)?;
let tmpl = env.get_template("inline_ephemeral")?;
let ctx = MJValue::from_serialize(context_data);
let rendered = tmpl.render(ctx)?;
Ok(rendered.trim().to_string())
/// Reset a template to its default by removing the user customization.
pub fn reset_template(name: &str) -> std::io::Result<()> {
if !is_registered(name) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Template '{}' is not registered", name),
));
}
let path = user_prompts_dir().join(name);
if path.exists() {
std::fs::remove_file(path)
} else {
Ok(())
}
}
pub fn list_templates() -> Vec<Template> {
TEMPLATE_REGISTRY
.iter()
.filter_map(|(name, description)| {
let default_content = CORE_PROMPTS_DIR
.get_file(name)
.map(|file| String::from_utf8_lossy(file.contents()).to_string())?;
let user_path = user_prompts_dir().join(name);
let user_content = if user_path.exists() {
std::fs::read_to_string(&user_path).ok()
} else {
None
};
let is_customized = user_content.is_some();
Some(Template {
name: name.to_string(),
description: description.to_string(),
default_content,
user_content,
is_customized,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
/// For convenience in tests, define a small struct or use a HashMap to provide context.
#[derive(Serialize)]
struct TestContext {
name: String,
age: u32,
#[test]
fn test_get_template() {
let template = get_template("system.md");
assert!(template.is_some(), "system.md should be registered");
let template = template.unwrap();
assert_eq!(template.name, "system.md");
assert!(!template.description.is_empty());
assert!(!template.default_content.is_empty());
assert!(!template.is_customized);
}
// A simple function to help us test missing or partial data
fn build_context(name: Option<&str>, age: Option<u32>) -> HashMap<String, serde_json::Value> {
let mut ctx = HashMap::new();
if let Some(n) = name {
ctx.insert("name".to_string(), json!(n));
#[test]
fn test_render_template() {
let context: HashMap<String, String> = HashMap::new();
let result = render_template("system.md", &context);
assert!(result.is_ok(), "Should be able to render system.md");
assert!(!result.unwrap().is_empty());
}
#[test]
fn test_list_templates() {
let templates = list_templates();
assert_eq!(templates.len(), TEMPLATE_REGISTRY.len());
let has_system = templates.iter().any(|t| t.name == "system.md");
assert!(has_system, "system.md should be in the template list");
for template in templates {
assert!(
!template.description.is_empty(),
"Each template should have a description"
);
assert!(
!template.default_content.is_empty(),
"Each template should have content"
);
}
if let Some(a) = age {
ctx.insert("age".to_string(), json!(a));
}
ctx
}
#[test]
fn test_render_inline_once_basic() {
let template_str = "Hello, {{ name }}! You are {{ age }} years old.";
let context = TestContext {
name: "Alice".to_string(),
age: 30,
};
let result = render_inline_once(template_str, &context).unwrap();
assert_eq!(result, "Hello, Alice! You are 30 years old.");
}
#[test]
fn test_render_inline_missing_variable() {
let template_str = "Hello, {{ name }}! You are {{ age }} years old.";
let context = build_context(Some("Alice"), None);
// MiniJinja doesn't fail on missing variables, it renders them as empty strings
// So we should check that it renders successfully but with missing data
let result = render_inline_once(template_str, &context).unwrap();
assert!(result.contains("Hello, Alice! You are years old."));
}
#[test]
fn test_global_file_render() {
// "mock.md" should exist in the embedded CORE_PROMPTS_DIR
// and have placeholders for `name` and `age`.
let context = TestContext {
name: "Alice".to_string(),
age: 30,
};
let result = render_global_file("mock.md", &context).unwrap();
// Assume mock.md content is something like:
// "This prompt is only used for testing.\n\nHello, {{ name }}! You are {{ age }} years old."
assert_eq!(
result,
"This prompt is only used for testing.\n\nHello, Alice! You are 30 years old."
);
}
#[test]
fn test_global_file_not_found() {
let context = TestContext {
name: "Unused".to_string(),
age: 99,
};
let result = render_global_file("non_existent.md", &context);
assert!(result.is_err(), "Should fail because file is missing");
}
#[test]
fn test_inline_complex_object() {
// Example with more complex data.
#[derive(Serialize)]
struct Tool {
name: String,
description: String,
}
#[derive(Serialize)]
struct ToolsContext {
tools: Vec<Tool>,
}
let template_str = "\
### Tool Descriptions
{% for tool in tools %}
- {{ tool.name }}: {{ tool.description }}
{% endfor %}";
let context = ToolsContext {
tools: vec![
Tool {
name: "calculator".to_string(),
description: "Performs basic math operations".to_string(),
},
Tool {
name: "weather".to_string(),
description: "Gets weather information".to_string(),
},
],
};
let rendered = render_inline_once(template_str, &context).unwrap();
let expected = "\
### Tool Descriptions
- calculator: Performs basic math operations
- weather: Gets weather information";
assert_eq!(rendered, expected);
}
#[test]
fn test_inline_with_empty_list() {
let template_str = "\
### Tool Descriptions
{% for tool in tools %}
- {{ tool.name }}: {{ tool.description }}
{% endfor %}";
#[derive(Serialize)]
struct ToolsContext {
tools: Vec<String>, // or a struct if needed
}
let context = ToolsContext { tools: vec![] };
let rendered = render_inline_once(template_str, &context).unwrap();
let expected = "### Tool Descriptions";
assert_eq!(rendered, expected);
}
}
-3
View File
@@ -1,3 +0,0 @@
This prompt is only used for testing.
Hello, {{ name }}! You are {{ age }} years old.
@@ -1,63 +0,0 @@
You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal. goose is being developed as an open-source software project.
IMPORTANT INSTRUCTIONS:
Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved.
If you are not sure about file content or codebase structure, or other information pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. It is important you use tools that can assist with providing the right context.
CRITICAL: The str_replace command in the text_editor tool (when available) should be used most of the time, with the write tool only for new files. ALWAYS check the content of the file before editing. NEVER overwrite the whole content of a file unless directed to, always edit carefully by adding and changing content. Never leave content unfinished with comments like "rest of the file here"
The user may direct or imply that you are to take actions, in this case, it is important to note the following guidelines:
* If you are directed to complete a task, you should see it through.
* Your thinking should be thorough and so it's fine if it's very long. You can think step by step before and after each action you decide to take.
* Only terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
* You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
* Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Your solution must be perfect. If not, continue working on it. When you are validating solutions with tools, it is important to iterate until you get success
* Do not stop and ask the user for confirmation for actions you should be taking to achieve the outcomes directed and with tools available.
The current date is {{current_date_time}}.
goose uses LLM providers with tool calling capability.
Your model may have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10 months prior to the current date.
# Extensions
Extensions allow other applications to provide context to goose. Extensions connect goose to different data sources and tools.
You are capable of dynamically plugging into new extensions and learning how to use them. You solve higher level problems using the tools in these extensions, and can interact with multiple at once.
If the Extension Manager extension is enabled, you can use the search_available_extensions tool to discover additional extensions that can help with your task. To enable or disable extensions, use the manage_extensions tool with the extension_name. You should only enable extensions found from the search_available_extensions tool.
If Extension Manager is not available, you can only work with currently enabled extensions and cannot dynamically load new ones.
{% if (extensions is defined) and extensions %}
Because you dynamically load extensions, your conversation history may refer
to interactions with extensions that are not currently active. The currently
active extensions are below. Each of these extensions provides tools that are
in your tool specification.
{% for extension in extensions %}
## {{extension.name}}
{% if extension.has_resources %}
{{extension.name}} supports resources, you can use platform__read_resource,
and platform__list_resources on this extension.
{% endif %}
{% if extension.instructions %}### Instructions
{{extension.instructions}}{% endif %}
{% endfor %}
{% else %}
No extensions are defined. You should let the user know that they should add extensions.
{% endif %}
# Response Guidelines
- Use Markdown formatting for all responses.
- Follow best practices for Markdown, including:
- Using headers for organization.
- Bullet points for lists.
- Links formatted correctly, either as linked text (e.g., [this is linked text](https://example.com)) or automatic links using angle brackets (e.g., <http://example.com/>).
- For code examples, use fenced code blocks by placing triple backticks (` ``` `) before and after the code. Include the language identifier after the opening backticks (e.g., ` ```python `) to enable syntax highlighting.
- Ensure clarity, conciseness, and proper formatting to enhance readability and usability.
+210
View File
@@ -1014,6 +1014,140 @@
}
}
},
"/config/prompts": {
"get": {
"tags": [
"super::routes::prompts"
],
"operationId": "get_prompts",
"responses": {
"200": {
"description": "List of all available prompts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromptsListResponse"
}
}
}
}
}
}
},
"/config/prompts/{name}": {
"get": {
"tags": [
"super::routes::prompts"
],
"operationId": "get_prompt",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Prompt template name (e.g., system.md)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Prompt content retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromptContentResponse"
}
}
}
},
"404": {
"description": "Prompt not found"
}
}
},
"put": {
"tags": [
"super::routes::prompts"
],
"operationId": "save_prompt",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Prompt template name (e.g., system.md)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SavePromptRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Prompt saved successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Prompt not found"
},
"500": {
"description": "Failed to save prompt"
}
}
},
"delete": {
"tags": [
"super::routes::prompts"
],
"operationId": "reset_prompt",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Prompt template name (e.g., system.md)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Prompt reset to default successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Prompt not found"
},
"500": {
"description": "Failed to reset prompt"
}
}
}
},
"/config/providers": {
"get": {
"tags": [
@@ -4691,6 +4825,43 @@
"Tool"
]
},
"PromptContentResponse": {
"type": "object",
"required": [
"name",
"content",
"default_content",
"is_customized"
],
"properties": {
"content": {
"type": "string"
},
"default_content": {
"type": "string"
},
"is_customized": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"PromptsListResponse": {
"type": "object",
"required": [
"prompts"
],
"properties": {
"prompts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Template"
}
}
}
},
"ProviderDetails": {
"type": "object",
"required": [
@@ -5352,6 +5523,17 @@
}
}
},
"SavePromptRequest": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"type": "string"
}
}
},
"SaveRecipeRequest": {
"type": "object",
"required": [
@@ -5970,6 +6152,34 @@
}
}
},
"Template": {
"type": "object",
"description": "Information about a template including its content and customization status",
"required": [
"name",
"description",
"default_content",
"is_customized"
],
"properties": {
"default_content": {
"type": "string"
},
"description": {
"type": "string"
},
"is_customized": {
"type": "boolean"
},
"name": {
"type": "string"
},
"user_content": {
"type": "string",
"nullable": true
}
}
},
"TextContent": {
"type": "object",
"required": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+134
View File
@@ -617,6 +617,17 @@ export type PricingResponse = {
export type PrincipalType = 'Extension' | 'Tool';
export type PromptContentResponse = {
content: string;
default_content: string;
is_customized: boolean;
name: string;
};
export type PromptsListResponse = {
prompts: Array<Template>;
};
export type ProviderDetails = {
is_configured: boolean;
metadata: ProviderMetadata;
@@ -857,6 +868,10 @@ export type RunNowResponse = {
session_id: string;
};
export type SavePromptRequest = {
content: string;
};
export type SaveRecipeRequest = {
id?: string | null;
recipe: Recipe;
@@ -1041,6 +1056,17 @@ export type TelemetryEventRequest = {
};
};
/**
* Information about a template including its content and customization status
*/
export type Template = {
default_content: string;
description: string;
is_customized: boolean;
name: string;
user_content?: string | null;
};
export type TextContent = {
_meta?: {
[key: string]: unknown;
@@ -2005,6 +2031,114 @@ export type GetPricingResponses = {
export type GetPricingResponse = GetPricingResponses[keyof GetPricingResponses];
export type GetPromptsData = {
body?: never;
path?: never;
query?: never;
url: '/config/prompts';
};
export type GetPromptsResponses = {
/**
* List of all available prompts
*/
200: PromptsListResponse;
};
export type GetPromptsResponse = GetPromptsResponses[keyof GetPromptsResponses];
export type ResetPromptData = {
body?: never;
path: {
/**
* Prompt template name (e.g., system.md)
*/
name: string;
};
query?: never;
url: '/config/prompts/{name}';
};
export type ResetPromptErrors = {
/**
* Prompt not found
*/
404: unknown;
/**
* Failed to reset prompt
*/
500: unknown;
};
export type ResetPromptResponses = {
/**
* Prompt reset to default successfully
*/
200: string;
};
export type ResetPromptResponse = ResetPromptResponses[keyof ResetPromptResponses];
export type GetPromptData = {
body?: never;
path: {
/**
* Prompt template name (e.g., system.md)
*/
name: string;
};
query?: never;
url: '/config/prompts/{name}';
};
export type GetPromptErrors = {
/**
* Prompt not found
*/
404: unknown;
};
export type GetPromptResponses = {
/**
* Prompt content retrieved successfully
*/
200: PromptContentResponse;
};
export type GetPromptResponse = GetPromptResponses[keyof GetPromptResponses];
export type SavePromptData = {
body: SavePromptRequest;
path: {
/**
* Prompt template name (e.g., system.md)
*/
name: string;
};
query?: never;
url: '/config/prompts/{name}';
};
export type SavePromptErrors = {
/**
* Prompt not found
*/
404: unknown;
/**
* Failed to save prompt
*/
500: unknown;
};
export type SavePromptResponses = {
/**
* Prompt saved successfully
*/
200: string;
};
export type SavePromptResponse = SavePromptResponses[keyof SavePromptResponses];
export type ProvidersData = {
body?: never;
path?: never;
+1 -2
View File
@@ -302,9 +302,8 @@ function BaseChatContent({
name: session?.name || 'No Session',
};
// Update the global chat context when session name changes
const lastSetNameRef = useRef<string>('');
useEffect(() => {
const currentSessionName = session?.name;
if (currentSessionName && currentSessionName !== lastSetNameRef.current) {
@@ -0,0 +1,297 @@
import { useState, useEffect } from 'react';
import {
getPrompt,
getPrompts,
PromptContentResponse,
Template,
resetPrompt,
savePrompt,
} from '../../api';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { AlertTriangle, RotateCcw, ArrowLeft } from 'lucide-react';
import { toast } from 'react-toastify';
export default function PromptsSettingsSection() {
const [prompts, setPrompts] = useState<Template[]>([]);
const [selectedPrompt, setSelectedPrompt] = useState<string | null>(null);
const [promptData, setPromptData] = useState<PromptContentResponse | null>(null);
const [content, setContent] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const fetchPrompts = async () => {
try {
const response = await getPrompts();
if (response.data) {
setPrompts(response.data.prompts);
}
} catch (error) {
console.error('Failed to fetch prompts:', error);
toast.error('Failed to load prompts');
}
};
useEffect(() => {
fetchPrompts();
}, []);
useEffect(() => {
if (selectedPrompt) {
const fetchPrompt = async () => {
try {
const response = await getPrompt({ path: { name: selectedPrompt } });
if (response.data) {
setPromptData(response.data);
setContent(response.data.content);
}
} catch (error) {
console.error('Failed to fetch prompt:', error);
toast.error('Failed to load prompt');
}
};
fetchPrompt();
}
}, [selectedPrompt]);
useEffect(() => {
if (promptData) {
setHasChanges(content !== promptData.content);
}
}, [content, promptData]);
const handleResetAll = async () => {
if (
!window.confirm(
'Are you sure you want to reset all prompts to their defaults? This cannot be undone.'
)
) {
return;
}
try {
const customizedPrompts = prompts.filter((p) => p.is_customized);
for (const prompt of customizedPrompts) {
await resetPrompt({ path: { name: prompt.name } });
}
toast.success('All prompts reset to defaults');
fetchPrompts();
} catch (error) {
console.error('Failed to reset all prompts:', error);
toast.error('Failed to reset prompts');
}
};
const handleSave = async () => {
if (!selectedPrompt) return;
try {
await savePrompt({
path: { name: selectedPrompt },
body: { content },
});
toast.success('Prompt saved');
setPromptData((prev) => (prev ? { ...prev, content, is_customized: true } : null));
fetchPrompts();
} catch (error) {
console.error('Failed to save prompt:', error);
toast.error('Failed to save prompt');
}
};
const handleReset = async () => {
if (!selectedPrompt) return;
if (
!window.confirm(
'Are you sure you want to reset this prompt to its default? This cannot be undone.'
)
) {
return;
}
try {
await resetPrompt({ path: { name: selectedPrompt } });
if (promptData) {
setContent(promptData.default_content);
setPromptData({ ...promptData, content: promptData.default_content, is_customized: false });
}
fetchPrompts();
toast.success('Prompt reset to default');
} catch (error) {
console.error('Failed to reset prompt:', error);
toast.error('Failed to reset prompt');
}
};
const handleRestoreDefault = () => {
if (promptData) {
if (hasChanges) {
if (!window.confirm('Replace current content with default? Your changes will be lost.')) {
return;
}
}
setContent(promptData.default_content);
}
};
const handleBack = () => {
if (hasChanges) {
if (!window.confirm('You have unsaved changes. Are you sure you want to go back?')) {
return;
}
}
setSelectedPrompt(null);
setPromptData(null);
setContent('');
};
const hasCustomizedPrompts = prompts.some((p) => p.is_customized);
if (selectedPrompt) {
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="pb-2 rounded-lg">
<CardHeader className="pb-4">
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to List
</Button>
<div className="flex items-center gap-2">
{promptData?.is_customized && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="flex items-center gap-2"
>
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
)}
<Button onClick={handleSave} disabled={!hasChanges} size="sm">
Save
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<CardTitle>Edit: {selectedPrompt}</CardTitle>
{promptData?.is_customized && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-600 dark:text-blue-400">
Customized
</span>
)}
</div>
</CardHeader>
<CardContent className="px-4 space-y-4 flex flex-col h-full">
<div className="text-sm text-text-muted bg-background-subtle p-3 rounded-lg">
<p>
<strong>Tip:</strong> Template variables like{' '}
<code className="bg-background-default px-1 rounded">{'{{ extensions }}'}</code> or{' '}
<code className="bg-background-default px-1 rounded">
{'{% for item in list %}'}
</code>{' '}
are replaced with actual values at runtime. Be careful not to remove required
variables.
</p>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Editing: {selectedPrompt}</label>
{promptData?.is_customized && content !== promptData.default_content && (
<Button
variant="ghost"
size="sm"
onClick={handleRestoreDefault}
className="text-xs"
>
Restore Default
</Button>
)}
</div>
<textarea
value={content}
className="w-full flex-1 min-h-[500px] border rounded-md p-3 text-sm font-mono resize-y bg-background-default text-textStandard border-borderStandard focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setContent(e.target.value)}
placeholder="Enter prompt content..."
spellCheck={false}
/>
</div>
{hasChanges && (
<div className="text-sm text-yellow-600 dark:text-yellow-400">
You have unsaved changes
</div>
)}
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="pb-2 rounded-lg border-yellow-500/50 bg-yellow-500/10">
<CardHeader className="pb-2">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-1" />
<div className="flex-1">
<CardTitle className="text-yellow-600 dark:text-yellow-400">Prompt Editing</CardTitle>
<p className="text-sm text-text-muted mt-2">
Customize the prompts that define goose's behavior in different contexts. These
prompts use Jinja2 templating syntax. Be careful when modifying template variables,
as incorrect changes can break functionality. Please share any improvements with the
community.
</p>
</div>
{hasCustomizedPrompts && (
<Button
variant="outline"
size="sm"
onClick={handleResetAll}
className="flex items-center gap-2 border-yellow-500/50 hover:bg-yellow-500/20"
>
<RotateCcw className="h-4 w-4" />
Reset All
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-4 pt-4">
<div className="space-y-2">
{prompts.map((prompt) => (
<div
key={prompt.name}
className="flex items-center justify-between p-3 rounded-lg border border-border-default hover:bg-background-subtle transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-text-default truncate">{prompt.name}</h4>
{prompt.is_customized && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-600 dark:text-blue-400">
Customized
</span>
)}
</div>
<p className="text-sm text-text-muted mt-0.5 truncate">{prompt.description}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedPrompt(prompt.name)}
className="ml-4"
>
Edit
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -6,9 +6,10 @@ import SessionSharingSection from './sessions/SessionSharingSection';
import ExternalBackendSection from './app/ExternalBackendSection';
import AppSettingsSection from './app/AppSettingsSection';
import ConfigSettings from './config/ConfigSettings';
import PromptsSettingsSection from './PromptsSettingsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare } from 'lucide-react';
import { Bot, Share2, Monitor, MessageSquare, FileText } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import ChatSettingsSection from './chat/ChatSettingsSection';
import { CONFIGURATION_ENABLED } from '../../updates';
@@ -50,6 +51,7 @@ export default function SettingsView({
tools: 'chat',
app: 'app',
chat: 'chat',
prompts: 'prompts',
};
const targetTab = sectionToTab[viewOptions.section];
@@ -120,6 +122,14 @@ export default function SettingsView({
<Share2 className="h-4 w-4" />
Session
</TabsTrigger>
<TabsTrigger
value="prompts"
className="flex gap-2"
data-testid="settings-prompts-tab"
>
<FileText className="h-4 w-4" />
Prompts
</TabsTrigger>
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
<Monitor className="h-4 w-4" />
App
@@ -152,6 +162,13 @@ export default function SettingsView({
</div>
</TabsContent>
<TabsContent
value="prompts"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<PromptsSettingsSection />
</TabsContent>
<TabsContent
value="app"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
@@ -214,20 +214,24 @@ export const SwitchModelModal = ({
results.forEach(({ provider: p, models, error }) => {
const modelList = error
? (p.metadata.known_models?.map(({ name }) => name) || [])
: (models || []);
? p.metadata.known_models?.map(({ name }) => name) || []
: models || [];
if (error) {
errors.push(error);
}
const options: { value: string; label: string; provider: string; providerType: ProviderType }[] =
modelList.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
}));
const options: {
value: string;
label: string;
provider: string;
providerType: ProviderType;
}[] = modelList.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
}));
if (p.metadata.allows_unlisted_models && p.provider_type !== 'Custom') {
options.push({
+2 -4
View File
@@ -215,10 +215,8 @@ export function useChatStream({
// The backend regenerates the name after each of the first 3 user messages
// to refine it as more context becomes available
if (!error && sessionId) {
const userMessageCount = messagesRef.current.filter(
(m) => m.role === 'user'
).length;
const userMessageCount = messagesRef.current.filter((m) => m.role === 'user').length;
// Only refresh for the first 3 user messages
if (userMessageCount <= 3) {
try {