Skip to main content

lash_sansio/session_model/
prompt.rs

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
275/// Core guidance delivered in the `## Guidance` section. Rendered
276/// through [`render_core_guidance`] rather than inlined as a `const`
277/// so we can drop interactive-only advice when the session has no
278/// `ask` tool (autonomous `--print` runs, benchmarks, etc.). Rules that
279/// depend on being able to talk to a user only make sense when that
280/// channel exists.
281const 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        // Insert after the "Be concise" lead-in so the interactive-
294        // only rule sits alongside the other core directives instead
295        // of at the end.
296        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 &section.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        // Interactive context: the "ask when blocked" guidance is in play.
410        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        // Autonomous `--print` / benchmark sessions filter out the
416        // `ask` tool, so the guidance line telling the model "Ask
417        // only when progress is blocked" would contradict the
418        // run-time constraint. `render_core_guidance` must drop it.
419        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        // Template with no `CoreGuidance` builtin omits the baked-in
462        // guidance lines — only plugin contributions should land.
463        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}