Skip to content

Commit 2f083b8

Browse files
DOsingaDouwe Osinga
andauthored
User configurable templates (#6420)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
1 parent 5a04ee8 commit 2f083b8

23 files changed

Lines changed: 1046 additions & 301 deletions

crates/goose-server/src/openapi.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,10 @@ derive_utoipa!(Icon as IconSchema);
353353
super::routes::config_management::check_provider,
354354
super::routes::config_management::set_config_provider,
355355
super::routes::config_management::get_pricing,
356+
super::routes::prompts::get_prompts,
357+
super::routes::prompts::get_prompt,
358+
super::routes::prompts::save_prompt,
359+
super::routes::prompts::reset_prompt,
356360
super::routes::agent::start_agent,
357361
super::routes::agent::resume_agent,
358362
super::routes::agent::stop_agent,
@@ -427,6 +431,10 @@ derive_utoipa!(Icon as IconSchema);
427431
super::routes::config_management::PricingQuery,
428432
super::routes::config_management::PricingResponse,
429433
super::routes::config_management::PricingData,
434+
super::routes::prompts::PromptsListResponse,
435+
super::routes::prompts::PromptContentResponse,
436+
super::routes::prompts::SavePromptRequest,
437+
goose::prompt_template::Template,
430438
super::routes::action_required::ConfirmToolActionRequest,
431439
super::routes::reply::ChatRequest,
432440
super::routes::session::ImportSessionRequest,

crates/goose-server/src/routes/agent.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use goose::agents::ExtensionConfig;
1818
use goose::config::resolve_extensions_for_new_session;
1919
use goose::config::{Config, GooseMode};
2020
use goose::model::ModelConfig;
21-
use goose::prompt_template::render_global_file;
21+
use goose::prompt_template::render_template;
2222
use goose::providers::create;
2323
use goose::recipe::Recipe;
2424
use goose::recipe_deeplink;
@@ -418,7 +418,7 @@ async fn update_from_session(
418418
})?;
419419
let context: HashMap<&str, Value> = HashMap::new();
420420
let desktop_prompt =
421-
render_global_file("desktop_prompt.md", &context).expect("Prompt should render");
421+
render_template("desktop_prompt.md", &context).expect("Prompt should render");
422422
let mut update_prompt = desktop_prompt;
423423
if let Some(recipe) = session.recipe {
424424
match build_recipe_with_parameter_values(
@@ -691,7 +691,7 @@ async fn restart_agent_internal(
691691

692692
let context: HashMap<&str, Value> = HashMap::new();
693693
let desktop_prompt =
694-
render_global_file("desktop_prompt.md", &context).expect("Prompt should render");
694+
render_template("desktop_prompt.md", &context).expect("Prompt should render");
695695
let mut update_prompt = desktop_prompt;
696696

697697
if let Some(ref recipe) = session.recipe {

crates/goose-server/src/routes/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod config_management;
55
pub mod errors;
66
pub mod mcp_app_proxy;
77
pub mod mcp_ui_proxy;
8+
pub mod prompts;
89
pub mod recipe;
910
pub mod recipe_utils;
1011
pub mod reply;
@@ -29,6 +30,7 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
2930
.merge(agent::routes(state.clone()))
3031
.merge(audio::routes(state.clone()))
3132
.merge(config_management::routes(state.clone()))
33+
.merge(prompts::routes())
3234
.merge(recipe::routes(state.clone()))
3335
.merge(session::routes(state.clone()))
3436
.merge(schedule::routes(state.clone()))
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use axum::{
2+
extract::Path,
3+
routing::{delete, get, put},
4+
Json, Router,
5+
};
6+
use goose::prompt_template::{
7+
get_template, list_templates, reset_template, save_template, Template,
8+
};
9+
use http::StatusCode;
10+
use serde::{Deserialize, Serialize};
11+
use utoipa::ToSchema;
12+
13+
#[derive(Serialize, ToSchema)]
14+
pub struct PromptsListResponse {
15+
pub prompts: Vec<Template>,
16+
}
17+
18+
#[derive(Serialize, ToSchema)]
19+
pub struct PromptContentResponse {
20+
pub name: String,
21+
pub content: String,
22+
pub default_content: String,
23+
pub is_customized: bool,
24+
}
25+
26+
#[derive(Deserialize, ToSchema)]
27+
pub struct SavePromptRequest {
28+
pub content: String,
29+
}
30+
31+
#[utoipa::path(
32+
get,
33+
path = "/config/prompts",
34+
responses(
35+
(status = 200, description = "List of all available prompts", body = PromptsListResponse)
36+
)
37+
)]
38+
pub async fn get_prompts() -> Json<PromptsListResponse> {
39+
Json(PromptsListResponse {
40+
prompts: list_templates(),
41+
})
42+
}
43+
44+
#[utoipa::path(
45+
get,
46+
path = "/config/prompts/{name}",
47+
params(
48+
("name" = String, Path, description = "Prompt template name (e.g., system.md)")
49+
),
50+
responses(
51+
(status = 200, description = "Prompt content retrieved successfully", body = PromptContentResponse),
52+
(status = 404, description = "Prompt not found")
53+
)
54+
)]
55+
pub async fn get_prompt(
56+
Path(name): Path<String>,
57+
) -> Result<Json<PromptContentResponse>, StatusCode> {
58+
let template = get_template(&name).ok_or(StatusCode::NOT_FOUND)?;
59+
60+
let content = template
61+
.user_content
62+
.as_ref()
63+
.unwrap_or(&template.default_content);
64+
65+
Ok(Json(PromptContentResponse {
66+
name: template.name,
67+
content: content.clone(),
68+
default_content: template.default_content,
69+
is_customized: template.is_customized,
70+
}))
71+
}
72+
73+
#[utoipa::path(
74+
put,
75+
path = "/config/prompts/{name}",
76+
params(
77+
("name" = String, Path, description = "Prompt template name (e.g., system.md)")
78+
),
79+
request_body = SavePromptRequest,
80+
responses(
81+
(status = 200, description = "Prompt saved successfully", body = String),
82+
(status = 404, description = "Prompt not found"),
83+
(status = 500, description = "Failed to save prompt")
84+
)
85+
)]
86+
pub async fn save_prompt(
87+
Path(name): Path<String>,
88+
Json(request): Json<SavePromptRequest>,
89+
) -> Result<Json<String>, StatusCode> {
90+
save_template(&name, &request.content).map_err(|e| {
91+
if e.kind() == std::io::ErrorKind::NotFound {
92+
StatusCode::NOT_FOUND
93+
} else {
94+
tracing::error!("Failed to save prompt {}: {}", name, e);
95+
StatusCode::INTERNAL_SERVER_ERROR
96+
}
97+
})?;
98+
99+
Ok(Json(format!("Saved prompt: {}", name)))
100+
}
101+
102+
#[utoipa::path(
103+
delete,
104+
path = "/config/prompts/{name}",
105+
params(
106+
("name" = String, Path, description = "Prompt template name (e.g., system.md)")
107+
),
108+
responses(
109+
(status = 200, description = "Prompt reset to default successfully", body = String),
110+
(status = 404, description = "Prompt not found"),
111+
(status = 500, description = "Failed to reset prompt")
112+
)
113+
)]
114+
pub async fn reset_prompt(Path(name): Path<String>) -> Result<Json<String>, StatusCode> {
115+
reset_template(&name).map_err(|e| {
116+
if e.kind() == std::io::ErrorKind::NotFound {
117+
StatusCode::NOT_FOUND
118+
} else {
119+
tracing::error!("Failed to reset prompt {}: {}", name, e);
120+
StatusCode::INTERNAL_SERVER_ERROR
121+
}
122+
})?;
123+
124+
Ok(Json(format!("Reset prompt to default: {}", name)))
125+
}
126+
127+
pub fn routes() -> Router {
128+
Router::new()
129+
.route("/config/prompts", get(get_prompts))
130+
.route("/config/prompts/{name}", get(get_prompt))
131+
.route("/config/prompts/{name}", put(save_prompt))
132+
.route("/config/prompts/{name}", delete(reset_prompt))
133+
}

crates/goose-server/src/routes/recipe_utils.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::state::AppState;
1010
use anyhow::Result;
1111
use axum::http::StatusCode;
1212
use goose::agents::Agent;
13-
use goose::prompt_template::render_global_file;
13+
use goose::prompt_template::render_template;
1414
use goose::recipe::build_recipe::{build_recipe_from_template, RecipeError};
1515
use goose::recipe::local_recipes::{get_recipe_library_dir, list_local_recipes};
1616
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
@@ -173,6 +173,6 @@ pub async fn apply_recipe_to_agent(
173173
recipe.instructions.as_ref().map(|instructions| {
174174
let mut context: HashMap<&str, Value> = HashMap::new();
175175
context.insert("recipe_instructions", Value::String(instructions.clone()));
176-
render_global_file("desktop_recipe_instruction.md", &context).expect("Prompt should render")
176+
render_template("desktop_recipe_instruction.md", &context).expect("Prompt should render")
177177
})
178178
}

crates/goose/src/agents/extension_manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ impl ExtensionManager {
848848
let mut context: HashMap<&str, Value> = HashMap::new();
849849
context.insert("tools", serde_json::to_value(tools_info).unwrap());
850850

851-
prompt_template::render_global_file("plan.md", &context).expect("Prompt should render")
851+
prompt_template::render_template("plan.md", &context).expect("Prompt should render")
852852
}
853853

854854
/// Find and return a reference to the appropriate client for a tool call

crates/goose/src/agents/prompt_manager.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
161161

162162
let base_prompt = if let Some(override_prompt) = &self.manager.system_prompt_override {
163163
let sanitized_override_prompt = sanitize_unicode_tags(override_prompt);
164-
prompt_template::render_inline_once(&sanitized_override_prompt, &context)
164+
prompt_template::render_string(&sanitized_override_prompt, &context)
165165
} else {
166-
prompt_template::render_global_file("system.md", &context)
166+
prompt_template::render_template("system.md", &context)
167167
}
168168
.unwrap_or_else(|_| {
169169
"You are a general-purpose AI agent called goose, created by Block".to_string()
@@ -245,7 +245,7 @@ impl PromptManager {
245245

246246
pub async fn get_recipe_prompt(&self) -> String {
247247
let context: HashMap<&str, Value> = HashMap::new();
248-
prompt_template::render_global_file("recipe.md", &context)
248+
prompt_template::render_template("recipe.md", &context)
249249
.unwrap_or_else(|_| "The recipe prompt is busted. Tell the user.".to_string())
250250
}
251251
}

crates/goose/src/agents/subagent_handler.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::{
22
agents::{subagent_task_config::TaskConfig, Agent, AgentConfig, AgentEvent, SessionConfig},
33
conversation::{message::Message, Conversation},
4-
prompt_template::render_global_file,
4+
prompt_template::render_template,
55
recipe::Recipe,
66
};
77
use anyhow::{anyhow, Result};
@@ -148,7 +148,7 @@ fn get_agent_messages(
148148
.await;
149149

150150
let tools = agent.list_tools(&session_id, None).await;
151-
let subagent_prompt = render_global_file(
151+
let subagent_prompt = render_template(
152152
"subagent_system.md",
153153
&SubagentPromptContext {
154154
max_turns: task_config

crates/goose/src/context_mgmt/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::conversation::message::{ActionRequiredData, MessageMetadata};
22
use crate::conversation::message::{Message, MessageContent};
33
use crate::conversation::{merge_consecutive_messages, Conversation};
4-
use crate::prompt_template::render_global_file;
4+
use crate::prompt_template::render_template;
55
use crate::providers::base::{Provider, ProviderUsage};
66
use crate::providers::errors::ProviderError;
77
use crate::{config::Config, token_counter::create_token_counter};
@@ -294,7 +294,7 @@ async fn do_compact(
294294
messages: messages_text,
295295
};
296296

297-
let system_prompt = render_global_file("summarize_oneshot.md", &context)?;
297+
let system_prompt = render_template("compaction.md", &context)?;
298298

299299
let user_message = Message::user()
300300
.with_text("Please summarize the conversation history provided in the system prompt.");

crates/goose/src/permission/permission_judge.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use crate::config::permission::PermissionLevel;
33
use crate::config::PermissionManager;
44
use crate::conversation::message::{Message, MessageContent, ToolRequest};
55
use crate::conversation::Conversation;
6-
use crate::prompt_template::render_global_file;
6+
use crate::prompt_template::render_template;
77
use crate::providers::base::Provider;
88
use chrono::Utc;
99
use indoc::indoc;
@@ -140,7 +140,7 @@ pub async fn detect_read_only_tools(
140140
let check_messages = create_check_messages(tool_requests);
141141

142142
let context = PermissionJudgeContext {};
143-
let system_prompt = render_global_file("permission_judge.md", &context)
143+
let system_prompt = render_template("permission_judge.md", &context)
144144
.unwrap_or_else(|_| "You are a good analyst and can detect operations whether they have read-only operations.".to_string());
145145

146146
let res = provider

0 commit comments

Comments
 (0)