1use std::collections::HashMap;
2
3use crate::PromptContext;
4use crate::plugin::PromptContribution;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum PromptBuiltin {
9 MainAgentIntro,
10 ExecutionInstructions,
11 CoreGuidance,
12}
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum PromptSlot {
17 Intro,
18 Execution,
19 CliAutonomousIntro,
20 CliAutonomousExecution,
21 CliRlmExecution,
22 Guidance,
23 ProjectInstructions,
24 RuntimeContext,
25 Environment,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum PromptTemplateEntry {
31 Text { content: String },
32 Builtin { builtin: PromptBuiltin },
33 Slot { slot: PromptSlot },
34}
35
36impl PromptTemplateEntry {
37 pub fn text(content: impl Into<String>) -> Self {
38 Self::Text {
39 content: content.into(),
40 }
41 }
42
43 pub fn builtin(builtin: PromptBuiltin) -> Self {
44 Self::Builtin { builtin }
45 }
46
47 pub fn slot(slot: PromptSlot) -> Self {
48 Self::Slot { slot }
49 }
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
53pub struct PromptTemplateSection {
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub title: Option<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub entries: Vec<PromptTemplateEntry>,
58}
59
60impl PromptTemplateSection {
61 pub fn new(title: Option<String>, entries: Vec<PromptTemplateEntry>) -> Self {
62 Self { title, entries }
63 }
64
65 pub fn untitled(entries: Vec<PromptTemplateEntry>) -> Self {
66 Self {
67 title: None,
68 entries,
69 }
70 }
71
72 pub fn titled(title: impl Into<String>, entries: Vec<PromptTemplateEntry>) -> Self {
73 Self {
74 title: Some(title.into()),
75 entries,
76 }
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
81pub struct PromptTemplate {
82 pub sections: Vec<PromptTemplateSection>,
83}
84
85impl PromptTemplate {
86 pub fn new(sections: Vec<PromptTemplateSection>) -> Self {
87 Self { sections }
88 }
89
90 pub fn render(&self, prompt: &PromptContext) -> String {
91 let contributions = grouped_contributions(prompt);
92 self.sections
93 .iter()
94 .filter_map(|section| render_section(section, prompt, &contributions))
95 .collect::<Vec<_>>()
96 .join("\n\n")
97 }
98}
99
100impl Default for PromptTemplate {
101 fn default() -> Self {
102 default_prompt_template()
103 }
104}
105
106#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107pub struct PromptLayer {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub template: Option<PromptTemplate>,
110 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
111 pub slots: HashMap<PromptSlot, PromptSlotLayer>,
112}
113
114impl PromptLayer {
115 pub fn new() -> Self {
116 Self::default()
117 }
118
119 pub fn is_empty(&self) -> bool {
120 self.template.is_none() && self.slots.is_empty()
121 }
122
123 pub fn with_template(template: PromptTemplate) -> Self {
124 Self {
125 template: Some(template),
126 slots: HashMap::new(),
127 }
128 }
129
130 pub fn prompt_template(mut self, template: PromptTemplate) -> Self {
131 self.template = Some(template);
132 self
133 }
134
135 pub fn clear_template(mut self) -> Self {
136 self.template = None;
137 self
138 }
139
140 pub fn add_contribution(&mut self, contribution: PromptContribution) {
141 self.slots
142 .entry(contribution.slot)
143 .or_default()
144 .contributions
145 .push(contribution);
146 }
147
148 pub fn with_contribution(mut self, contribution: PromptContribution) -> Self {
149 self.add_contribution(contribution);
150 self
151 }
152
153 pub fn replace_slot(
154 &mut self,
155 slot: PromptSlot,
156 contributions: impl IntoIterator<Item = PromptContribution>,
157 ) {
158 self.slots.insert(
159 slot,
160 PromptSlotLayer {
161 reset: true,
162 contributions: normalize_slot_contributions(slot, contributions),
163 },
164 );
165 }
166
167 pub fn with_replaced_slot(
168 mut self,
169 slot: PromptSlot,
170 contributions: impl IntoIterator<Item = PromptContribution>,
171 ) -> Self {
172 self.replace_slot(slot, contributions);
173 self
174 }
175
176 pub fn clear_slot(&mut self, slot: PromptSlot) {
177 self.slots.insert(
178 slot,
179 PromptSlotLayer {
180 reset: true,
181 contributions: Vec::new(),
182 },
183 );
184 }
185
186 pub fn with_cleared_slot(mut self, slot: PromptSlot) -> Self {
187 self.clear_slot(slot);
188 self
189 }
190}
191
192#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
193pub struct PromptSlotLayer {
194 #[serde(default)]
195 pub reset: bool,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub contributions: Vec<PromptContribution>,
198}
199
200#[derive(Clone, Debug, PartialEq, Eq)]
201pub struct ResolvedPromptLayer {
202 pub template: PromptTemplate,
203 pub contributions: Vec<PromptContribution>,
204}
205
206pub fn resolve_prompt_layers<'a>(
207 layers: impl IntoIterator<Item = &'a PromptLayer>,
208) -> ResolvedPromptLayer {
209 let mut template = default_prompt_template();
210 let mut contributions = Vec::new();
211 for layer in layers {
212 if let Some(next_template) = &layer.template {
213 template = next_template.clone();
214 }
215 for (slot, slot_layer) in &layer.slots {
216 if slot_layer.reset {
217 contributions
218 .retain(|contribution: &PromptContribution| contribution.slot != *slot);
219 }
220 contributions.extend(normalize_slot_contributions(
221 *slot,
222 slot_layer.contributions.iter().cloned(),
223 ));
224 }
225 }
226 ResolvedPromptLayer {
227 template,
228 contributions,
229 }
230}
231
232fn normalize_slot_contributions(
233 slot: PromptSlot,
234 contributions: impl IntoIterator<Item = PromptContribution>,
235) -> Vec<PromptContribution> {
236 contributions
237 .into_iter()
238 .map(|mut contribution| {
239 contribution.slot = slot;
240 contribution
241 })
242 .collect()
243}
244
245pub fn default_prompt_template() -> PromptTemplate {
246 PromptTemplate::new(vec![
247 PromptTemplateSection::untitled(vec![
248 PromptTemplateEntry::builtin(PromptBuiltin::MainAgentIntro),
249 PromptTemplateEntry::slot(PromptSlot::Intro),
250 ]),
251 PromptTemplateSection::titled(
252 "Execution",
253 vec![
254 PromptTemplateEntry::builtin(PromptBuiltin::ExecutionInstructions),
255 PromptTemplateEntry::slot(PromptSlot::Execution),
256 ],
257 ),
258 PromptTemplateSection::titled(
259 "Guidance",
260 vec![
261 PromptTemplateEntry::builtin(PromptBuiltin::CoreGuidance),
262 PromptTemplateEntry::slot(PromptSlot::ProjectInstructions),
263 PromptTemplateEntry::slot(PromptSlot::Guidance),
264 ],
265 ),
266 PromptTemplateSection::titled(
267 "Environment",
268 vec![
269 PromptTemplateEntry::slot(PromptSlot::RuntimeContext),
270 PromptTemplateEntry::slot(PromptSlot::Environment),
271 ],
272 ),
273 ])
274}
275
276pub const MAIN_AGENT_INTRO: &str = "You are an AI coding assistant piloting the lash harness.";
277
278const CORE_GUIDANCE_BASE: &[&str] = &[
285 "- Be concise. Avoid filler, hedging, and performative tone.",
286 "- Do not restate a conclusion you already stated. Once a fix location is identified, act on it in the same turn.",
287 "- Prefer the simplest correct solution over cleverness or unnecessary abstraction.",
288];
289
290const CORE_GUIDANCE_INTERACTIVE_ONLY: &str =
291 "- Take initiative when the user's intent is clear. Ask only when progress is blocked.";
292
293pub fn render_core_guidance(prompt: &PromptContext) -> String {
294 let mut bullets: Vec<&str> = CORE_GUIDANCE_BASE.to_vec();
295 if prompt.has_tool("ask") {
296 bullets.insert(1, CORE_GUIDANCE_INTERACTIVE_ONLY);
300 }
301 bullets.join("\n")
302}
303
304fn grouped_contributions<'a>(
305 prompt: &'a PromptContext,
306) -> HashMap<PromptSlot, Vec<&'a PromptContribution>> {
307 let mut grouped: HashMap<PromptSlot, Vec<&'a PromptContribution>> = HashMap::new();
308 for contribution in prompt.contributions.iter() {
309 grouped
310 .entry(contribution.slot)
311 .or_default()
312 .push(contribution);
313 }
314 for entries in grouped.values_mut() {
315 entries.sort_by_key(|contribution| contribution.priority);
316 }
317 grouped
318}
319
320fn render_section(
321 section: &PromptTemplateSection,
322 prompt: &PromptContext,
323 contributions: &HashMap<PromptSlot, Vec<&PromptContribution>>,
324) -> Option<String> {
325 let mut parts = Vec::new();
326 for entry in §ion.entries {
327 match entry {
328 PromptTemplateEntry::Text { content } => push_text(&mut parts, content),
329 PromptTemplateEntry::Builtin { builtin } => {
330 push_text(&mut parts, &render_builtin(*builtin, prompt))
331 }
332 PromptTemplateEntry::Slot { slot } => {
333 if let Some(entries) = contributions.get(slot) {
334 for contribution in entries {
335 if let Some(rendered) = render_contribution(contribution) {
336 parts.push(rendered);
337 }
338 }
339 }
340 }
341 }
342 }
343
344 if parts.is_empty() {
345 return None;
346 }
347
348 let mut rendered = Vec::new();
349 if let Some(title) = section
350 .title
351 .as_deref()
352 .map(str::trim)
353 .filter(|s| !s.is_empty())
354 {
355 rendered.push(format!("## {title}"));
356 }
357 rendered.extend(parts);
358 Some(rendered.join("\n\n"))
359}
360
361fn push_text(parts: &mut Vec<String>, text: &str) {
362 let trimmed = text.trim();
363 if !trimmed.is_empty() {
364 parts.push(trimmed.to_string());
365 }
366}
367
368fn render_builtin(builtin: PromptBuiltin, prompt: &PromptContext) -> String {
369 match builtin {
370 PromptBuiltin::MainAgentIntro => MAIN_AGENT_INTRO.to_string(),
371 PromptBuiltin::ExecutionInstructions => prompt.execution_prompt.to_string(),
372 PromptBuiltin::CoreGuidance => render_core_guidance(prompt),
373 }
374}
375
376fn render_contribution(contribution: &PromptContribution) -> Option<String> {
377 let content = contribution.content.trim();
378 if content.is_empty() {
379 return None;
380 }
381 match contribution
382 .title
383 .as_deref()
384 .map(str::trim)
385 .filter(|title| !title.is_empty())
386 {
387 Some(title) => Some(format!("### {title}\n\n{content}")),
388 None => Some(content.to_string()),
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn prompt(mode: crate::ExecutionMode) -> PromptContext {
397 PromptContext {
398 mode,
399 execution_prompt: std::sync::Arc::from("mode execution"),
400 ..PromptContext::default()
401 }
402 }
403
404 #[test]
405 fn default_template_renders_builtin_sections() {
406 let mut ctx = prompt(crate::ExecutionMode::new("test_mode"));
407 ctx.tool_names = std::sync::Arc::new(vec!["ask".to_string()]);
408 let text = default_prompt_template().render(&ctx);
409 assert!(text.contains(MAIN_AGENT_INTRO));
410 assert!(text.contains("## Execution"));
411 assert!(text.contains("mode execution"));
412 assert!(text.contains("## Guidance"));
413 assert!(text.contains("Ask only when progress is blocked"));
415 }
416
417 #[test]
418 fn core_guidance_drops_ask_line_when_ask_tool_absent() {
419 let ctx = prompt(crate::ExecutionMode::new("test_mode"));
424 assert!(!ctx.has_tool("ask"));
425 let rendered = render_core_guidance(&ctx);
426 assert!(rendered.contains("Be concise"));
427 assert!(rendered.contains("Prefer the simplest correct solution"));
428 assert!(!rendered.contains("Ask only when progress is blocked"));
429 }
430
431 #[test]
432 fn core_guidance_keeps_ask_line_when_ask_tool_present() {
433 let mut ctx = prompt(crate::ExecutionMode::new("test_mode"));
434 ctx.tool_names = std::sync::Arc::new(vec!["ask".to_string()]);
435 let rendered = render_core_guidance(&ctx);
436 assert!(rendered.contains("Ask only when progress is blocked"));
437 }
438
439 #[test]
440 fn template_renders_slot_contributions_in_order() {
441 let mut prompt = prompt(crate::ExecutionMode::new("test_mode"));
442 prompt.contributions = vec![
443 PromptContribution::guidance("Second Guide", "Second details.").with_priority(10),
444 PromptContribution::guidance("First Guide", "First details.").with_priority(0),
445 ]
446 .into();
447 let text = default_prompt_template().render(&prompt);
448 assert!(text.contains("### First Guide"));
449 assert!(text.contains("### Second Guide"));
450 assert!(text.find("### First Guide").unwrap() < text.find("### Second Guide").unwrap());
451 }
452
453 #[test]
454 fn template_can_omit_builtin_guidance_and_keep_plugin_guidance() {
455 let template = PromptTemplate::new(vec![PromptTemplateSection::titled(
456 "Guidance",
457 vec![PromptTemplateEntry::slot(PromptSlot::Guidance)],
458 )]);
459 let mut prompt = prompt(crate::ExecutionMode::new("test_mode"));
460 prompt.contributions =
461 vec![PromptContribution::guidance("Custom", "More guidance.")].into();
462 let text = template.render(&prompt);
463 assert!(text.contains("## Guidance"));
464 assert!(text.contains("### Custom"));
465 assert!(!text.contains("Be concise. Avoid filler"));
468 }
469
470 #[test]
471 fn template_can_place_project_instructions_separately() {
472 let template = PromptTemplate::new(vec![
473 PromptTemplateSection::titled(
474 "Rules",
475 vec![PromptTemplateEntry::slot(PromptSlot::ProjectInstructions)],
476 ),
477 PromptTemplateSection::titled(
478 "Guidance",
479 vec![PromptTemplateEntry::slot(PromptSlot::Guidance)],
480 ),
481 ]);
482 let mut prompt = prompt(crate::ExecutionMode::new("test_mode"));
483 prompt.contributions = vec![
484 PromptContribution::project_instructions("Repo rules"),
485 PromptContribution::guidance("Shell", "Use exec_command."),
486 ]
487 .into();
488 let text = template.render(&prompt);
489 assert!(text.contains("## Rules"));
490 assert!(text.contains("Repo rules"));
491 assert!(text.contains("## Guidance"));
492 assert!(text.contains("### Shell"));
493 }
494
495 #[test]
496 fn empty_sections_are_skipped() {
497 let template = PromptTemplate::new(vec![PromptTemplateSection::titled(
498 "Environment",
499 vec![PromptTemplateEntry::slot(PromptSlot::Environment)],
500 )]);
501 let text = template.render(&prompt(crate::ExecutionMode::new("test_mode")));
502 assert!(text.is_empty());
503 }
504}