Skip to main content

harn_vm/llm/
prompt.rs

1//! Unified prompt-fragment assembly.
2//!
3//! A system prompt is the deterministic reduction of an ordered list of
4//! [`PromptFragment`]s. Host-provided system-prompt parts (`system_preamble`,
5//! `system_prefix`, …), the agent's per-turn "primary" system text, rendered
6//! system reminders, and capability-gated tool guidance all flow through the
7//! same model and the same [`assemble`] reducer. There is no parallel
8//! string-concatenation path: this module is the single source of truth for
9//! how the system string is built.
10//!
11//! Every fragment is recorded in the returned [`AssembledPrompt::provenance`]
12//! — included or excluded, with the reason — so the final prompt is fully
13//! auditable: you can answer "why is this sentence here?" and "what would the
14//! prompt look like without tool X?" without reverse-engineering a concat.
15
16use std::collections::BTreeSet;
17
18/// Which side of the primary system block a fragment lands on.
19///
20/// This mirrors the historical two-vector (`before`/`after`) mechanic so the
21/// assembled bytes are stable: all included `Before` fragments in declaration
22/// order, then all included `After` fragments in declaration order.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum FragmentBucket {
25    /// Emitted before/around the primary system text and reminders
26    /// (preamble / prefix / context / parts / primary / reminders).
27    Before,
28    /// Emitted after the primary block (appendix / suffix region).
29    After,
30}
31
32impl FragmentBucket {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::Before => "before",
36            Self::After => "after",
37        }
38    }
39}
40
41/// One contributor to the system prompt.
42///
43/// `body` is already rendered (templates run upstream, in `.harn` or in the
44/// reminder pipeline). `assemble` trims the body and skips empty fragments.
45#[derive(Clone, Debug)]
46pub struct PromptFragment {
47    /// Stable, unique-ish identifier, e.g. `host:system_preamble`,
48    /// `primary`, `reminder`, `tool:todo.guidance`.
49    pub id: String,
50    /// Who contributed it, for provenance grouping (`host:*`, `primary`,
51    /// `reminder`, `tool:<name>`, `stdlib:*`).
52    pub source: String,
53    /// Ordering bucket relative to the primary block.
54    pub bucket: FragmentBucket,
55    /// Included only if every named tool is present in the active tool set.
56    /// This is the capability gate: a fragment that says "always update the
57    /// TODO tracker" carries `requires_tools: ["todo"]` and disappears when
58    /// the tool is not registered — instruction and tool can never drift.
59    pub requires_tools: Vec<String>,
60    /// Included only if every named capability flag is set.
61    pub requires_caps: Vec<String>,
62    /// Pre-rendered text. Trimmed by `assemble`; empty bodies are excluded.
63    pub body: String,
64}
65
66impl PromptFragment {
67    pub fn new(
68        id: impl Into<String>,
69        source: impl Into<String>,
70        bucket: FragmentBucket,
71        body: impl Into<String>,
72    ) -> Self {
73        Self {
74            id: id.into(),
75            source: source.into(),
76            bucket,
77            requires_tools: Vec::new(),
78            requires_caps: Vec::new(),
79            body: body.into(),
80        }
81    }
82
83    pub fn requiring_tools(mut self, tools: Vec<String>) -> Self {
84        self.requires_tools = tools;
85        self
86    }
87
88    #[allow(dead_code)] // used by capability-gated fragments (Wave 1+)
89    pub fn requiring_caps(mut self, caps: Vec<String>) -> Self {
90        self.requires_caps = caps;
91        self
92    }
93}
94
95/// Provenance for one fragment: whether it made the prompt and why.
96#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
97pub struct FragmentTrace {
98    pub id: String,
99    pub source: String,
100    pub bucket: &'static str,
101    pub included: bool,
102    pub reason: String,
103    pub bytes: usize,
104}
105
106/// Result of [`assemble`]: the system string (if any) plus full provenance
107/// for every fragment that was considered.
108#[derive(Clone, Debug, Default)]
109pub struct AssembledPrompt {
110    pub system: Option<String>,
111    pub provenance: Vec<FragmentTrace>,
112}
113
114impl AssembledPrompt {
115    /// Provenance serialized for the `prompt_explain` builtin / CLI and for
116    /// transcript audit metadata.
117    pub fn provenance_json(&self) -> serde_json::Value {
118        serde_json::json!({
119            "fragments": self.provenance,
120            "included": self.provenance.iter().filter(|t| t.included).count(),
121            "excluded": self.provenance.iter().filter(|t| !t.included).count(),
122        })
123    }
124}
125
126/// Inputs that gate fragment inclusion: which tools and capability flags are
127/// active for this assembly.
128#[derive(Default, Debug)]
129pub struct AssembleCtx {
130    pub tool_names: BTreeSet<String>,
131    pub caps: BTreeSet<String>,
132}
133
134impl AssembleCtx {
135    fn missing_tool<'a>(&self, frag: &'a PromptFragment) -> Option<&'a str> {
136        frag.requires_tools
137            .iter()
138            .find(|tool| !self.tool_names.contains(*tool))
139            .map(String::as_str)
140    }
141
142    fn missing_cap<'a>(&self, frag: &'a PromptFragment) -> Option<&'a str> {
143        frag.requires_caps
144            .iter()
145            .find(|cap| !self.caps.contains(*cap))
146            .map(String::as_str)
147    }
148}
149
150/// Reduce fragments to the final system string, recording provenance for
151/// every fragment in declaration order.
152///
153/// Ordering is faithful to the legacy `before`/`after` mechanic: included
154/// `Before` fragments in declaration order, then included `After` fragments
155/// in declaration order, joined with a blank line. Bodies are trimmed; empty
156/// (or gated-out) fragments are excluded but still recorded with a reason.
157pub fn assemble(fragments: &[PromptFragment], ctx: &AssembleCtx) -> AssembledPrompt {
158    let mut provenance = Vec::with_capacity(fragments.len());
159    let mut before: Vec<String> = Vec::new();
160    let mut after: Vec<String> = Vec::new();
161
162    for frag in fragments {
163        let trimmed = frag.body.trim();
164        let (included, reason) = if trimmed.is_empty() {
165            (false, "empty body".to_string())
166        } else if let Some(tool) = ctx.missing_tool(frag) {
167            (false, format!("requires tool `{tool}` (not available)"))
168        } else if let Some(cap) = ctx.missing_cap(frag) {
169            (false, format!("requires capability `{cap}` (not set)"))
170        } else if !frag.requires_tools.is_empty() {
171            (
172                true,
173                format!("tool(s) present: {}", frag.requires_tools.join(", ")),
174            )
175        } else if !frag.requires_caps.is_empty() {
176            (
177                true,
178                format!("capabilit(ies) present: {}", frag.requires_caps.join(", ")),
179            )
180        } else {
181            (true, "unconditional".to_string())
182        };
183
184        provenance.push(FragmentTrace {
185            id: frag.id.clone(),
186            source: frag.source.clone(),
187            bucket: frag.bucket.as_str(),
188            included,
189            reason,
190            bytes: if included { trimmed.len() } else { 0 },
191        });
192
193        if included {
194            match frag.bucket {
195                FragmentBucket::Before => before.push(trimmed.to_string()),
196                FragmentBucket::After => after.push(trimmed.to_string()),
197            }
198        }
199    }
200
201    before.extend(after);
202    let system = if before.is_empty() {
203        None
204    } else {
205        Some(before.join("\n\n"))
206    };
207    AssembledPrompt { system, provenance }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn frag(id: &str, bucket: FragmentBucket, body: &str) -> PromptFragment {
215        PromptFragment::new(id, id, bucket, body)
216    }
217
218    #[test]
219    fn before_then_after_join_is_blank_line_separated() {
220        let frags = vec![
221            frag("parts", FragmentBucket::Before, "parts"),
222            frag("appendix", FragmentBucket::After, "appendix"),
223            frag("base", FragmentBucket::Before, "base"),
224            frag("reminder", FragmentBucket::Before, "reminder"),
225        ];
226        let out = assemble(&frags, &AssembleCtx::default());
227        // Before fragments in declaration order, then After fragments.
228        assert_eq!(
229            out.system.as_deref(),
230            Some("parts\n\nbase\n\nreminder\n\nappendix")
231        );
232    }
233
234    #[test]
235    fn empty_and_whitespace_bodies_are_excluded_with_reason() {
236        let frags = vec![
237            frag("a", FragmentBucket::Before, "  \n  "),
238            frag("b", FragmentBucket::Before, "kept"),
239        ];
240        let out = assemble(&frags, &AssembleCtx::default());
241        assert_eq!(out.system.as_deref(), Some("kept"));
242        assert!(!out.provenance[0].included);
243        assert_eq!(out.provenance[0].reason, "empty body");
244        assert!(out.provenance[1].included);
245    }
246
247    #[test]
248    fn requires_tools_gates_inclusion() {
249        let gated = PromptFragment::new(
250            "todo.guidance",
251            "tool:todo",
252            FragmentBucket::Before,
253            "update the tracker",
254        )
255        .requiring_tools(vec!["todo".to_string()]);
256        // Tool absent: excluded.
257        let out = assemble(&[gated.clone()], &AssembleCtx::default());
258        assert_eq!(out.system, None);
259        assert!(!out.provenance[0].included);
260        assert!(out.provenance[0].reason.contains("requires tool `todo`"));
261        // Tool present: included.
262        let ctx = AssembleCtx {
263            tool_names: BTreeSet::from(["todo".to_string()]),
264            ..Default::default()
265        };
266        let out = assemble(&[gated], &ctx);
267        assert_eq!(out.system.as_deref(), Some("update the tracker"));
268        assert!(out.provenance[0].included);
269        assert!(out.provenance[0].reason.contains("tool(s) present: todo"));
270    }
271
272    #[test]
273    fn empty_fragment_set_yields_none() {
274        let out = assemble(&[], &AssembleCtx::default());
275        assert_eq!(out.system, None);
276        assert!(out.provenance.is_empty());
277    }
278}