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