1use std::collections::BTreeSet;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum FragmentBucket {
25 Before,
28 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#[derive(Clone, Debug)]
46pub struct PromptFragment {
47 pub id: String,
50 pub source: String,
53 pub bucket: FragmentBucket,
55 pub requires_tools: Vec<String>,
60 pub requires_caps: Vec<String>,
62 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)] pub fn requiring_caps(mut self, caps: Vec<String>) -> Self {
90 self.requires_caps = caps;
91 self
92 }
93}
94
95#[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#[derive(Clone, Debug, Default)]
109pub struct AssembledPrompt {
110 pub system: Option<String>,
111 pub provenance: Vec<FragmentTrace>,
112}
113
114impl AssembledPrompt {
115 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#[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
150pub 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 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 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 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}