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