1use serde::{Deserialize, Serialize};
43use std::collections::HashMap;
44use std::path::Path;
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct PromptBlockDef {
54 pub name: String,
56 pub order: u32,
58 pub max_length: usize,
60}
61
62pub trait SystemPromptStrategy: Send + Sync {
69 fn block_defs(&self) -> &[PromptBlockDef];
71}
72
73pub struct CustomPromptStrategy {
77 pub blocks: Vec<PromptBlockDef>,
78}
79
80impl SystemPromptStrategy for CustomPromptStrategy {
81 fn block_defs(&self) -> &[PromptBlockDef] {
82 &self.blocks
83 }
84}
85
86pub struct AgentPromptStrategy {
95 blocks: Vec<PromptBlockDef>,
96}
97
98impl Default for AgentPromptStrategy {
99 fn default() -> Self {
100 Self {
101 blocks: vec![
102 PromptBlockDef {
103 name: "identity".into(),
104 order: 0,
105 max_length: 500,
106 },
107 PromptBlockDef {
108 name: "instructions".into(),
109 order: 1,
110 max_length: 2000,
111 },
112 PromptBlockDef {
113 name: "tools".into(),
114 order: 2,
115 max_length: 1000,
116 },
117 PromptBlockDef {
118 name: "constraints".into(),
119 order: 3,
120 max_length: 500,
121 },
122 ],
123 }
124 }
125}
126
127impl SystemPromptStrategy for AgentPromptStrategy {
128 fn block_defs(&self) -> &[PromptBlockDef] {
129 &self.blocks
130 }
131}
132
133pub struct MinimalPromptStrategy {
140 blocks: Vec<PromptBlockDef>,
141}
142
143impl Default for MinimalPromptStrategy {
144 fn default() -> Self {
145 Self {
146 blocks: vec![
147 PromptBlockDef {
148 name: "identity".into(),
149 order: 0,
150 max_length: 1000,
151 },
152 PromptBlockDef {
153 name: "task".into(),
154 order: 1,
155 max_length: 3000,
156 },
157 ],
158 }
159 }
160}
161
162impl SystemPromptStrategy for MinimalPromptStrategy {
163 fn block_defs(&self) -> &[PromptBlockDef] {
164 &self.blocks
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct SystemPrompt {
178 pub id: String,
180 pub description: Option<String>,
182 pub strategy_ref: String,
184 pub blocks: HashMap<String, String>,
186}
187
188impl SystemPrompt {
189 pub fn compose(
196 &self,
197 strategy: &dyn SystemPromptStrategy,
198 working_dir: &Path,
199 ) -> Result<String, std::io::Error> {
200 let mut defs: Vec<&PromptBlockDef> = strategy.block_defs().iter().collect();
201 defs.sort_by_key(|d| d.order);
202
203 let mut parts = Vec::new();
204 for def in &defs {
205 if let Some(raw) = self.blocks.get(&def.name) {
206 let content = resolve_content(raw, working_dir)?;
207 parts.push(truncate_to_chars(&content, def.max_length));
208 }
209 }
210 Ok(parts.join("\n\n"))
211 }
212}
213
214fn resolve_content(raw: &str, working_dir: &Path) -> Result<String, std::io::Error> {
219 if let Some(path_str) = raw.strip_prefix("file:") {
220 let path = Path::new(path_str);
221 let full_path = if path.is_absolute() {
222 path.to_path_buf()
223 } else {
224 working_dir.join(path)
225 };
226 std::fs::read_to_string(full_path)
227 } else {
228 Ok(raw.to_string())
229 }
230}
231
232fn truncate_to_chars(s: &str, max_chars: usize) -> String {
234 if s.len() <= max_chars {
235 s.to_string()
236 } else {
237 s.chars().take(max_chars).collect()
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_custom_strategy_block_defs() {
247 let strategy = CustomPromptStrategy {
248 blocks: vec![
249 PromptBlockDef {
250 name: "a".into(),
251 order: 1,
252 max_length: 100,
253 },
254 PromptBlockDef {
255 name: "b".into(),
256 order: 0,
257 max_length: 200,
258 },
259 ],
260 };
261 assert_eq!(strategy.block_defs().len(), 2);
262 }
263
264 #[test]
265 fn test_agent_prompt_strategy_has_4_blocks() {
266 let s = AgentPromptStrategy::default();
267 assert_eq!(s.block_defs().len(), 4);
268 assert_eq!(s.block_defs()[0].name, "identity");
269 assert_eq!(s.block_defs()[3].name, "constraints");
270 }
271
272 #[test]
273 fn test_minimal_prompt_strategy_has_2_blocks() {
274 let s = MinimalPromptStrategy::default();
275 assert_eq!(s.block_defs().len(), 2);
276 assert_eq!(s.block_defs()[0].name, "identity");
277 assert_eq!(s.block_defs()[1].name, "task");
278 }
279
280 #[test]
281 fn test_compose_orders_by_block_order() {
282 let strategy = CustomPromptStrategy {
283 blocks: vec![
284 PromptBlockDef {
285 name: "second".into(),
286 order: 2,
287 max_length: 1000,
288 },
289 PromptBlockDef {
290 name: "first".into(),
291 order: 0,
292 max_length: 1000,
293 },
294 PromptBlockDef {
295 name: "middle".into(),
296 order: 1,
297 max_length: 1000,
298 },
299 ],
300 };
301 let mut blocks = HashMap::new();
302 blocks.insert("first".into(), "AAA".into());
303 blocks.insert("middle".into(), "BBB".into());
304 blocks.insert("second".into(), "CCC".into());
305 let prompt = SystemPrompt {
306 id: "test".into(),
307 description: None,
308 strategy_ref: "test".into(),
309 blocks,
310 };
311 let result = prompt.compose(&strategy, Path::new(".")).unwrap();
312 assert_eq!(result, "AAA\n\nBBB\n\nCCC");
313 }
314
315 #[test]
316 fn test_compose_truncates() {
317 let strategy = CustomPromptStrategy {
318 blocks: vec![PromptBlockDef {
319 name: "a".into(),
320 order: 0,
321 max_length: 5,
322 }],
323 };
324 let mut blocks = HashMap::new();
325 blocks.insert("a".into(), "hello world".into());
326 let prompt = SystemPrompt {
327 id: "test".into(),
328 description: None,
329 strategy_ref: "test".into(),
330 blocks,
331 };
332 let result = prompt.compose(&strategy, Path::new(".")).unwrap();
333 assert_eq!(result, "hello");
334 }
335
336 #[test]
337 fn test_compose_skips_missing_blocks() {
338 let strategy = CustomPromptStrategy {
339 blocks: vec![
340 PromptBlockDef {
341 name: "a".into(),
342 order: 0,
343 max_length: 1000,
344 },
345 PromptBlockDef {
346 name: "b".into(),
347 order: 1,
348 max_length: 1000,
349 },
350 PromptBlockDef {
351 name: "c".into(),
352 order: 2,
353 max_length: 1000,
354 },
355 ],
356 };
357 let mut blocks = HashMap::new();
358 blocks.insert("a".into(), "first".into());
359 blocks.insert("c".into(), "third".into());
360 let prompt = SystemPrompt {
362 id: "test".into(),
363 description: None,
364 strategy_ref: "test".into(),
365 blocks,
366 };
367 let result = prompt.compose(&strategy, Path::new(".")).unwrap();
368 assert_eq!(result, "first\n\nthird");
369 }
370
371 #[test]
372 fn test_compose_reads_file() {
373 let dir = tempfile::tempdir().unwrap();
374 let file_path = dir.path().join("identity.txt");
375 std::fs::write(&file_path, "I am a test agent").unwrap();
376
377 let strategy = CustomPromptStrategy {
378 blocks: vec![PromptBlockDef {
379 name: "identity".into(),
380 order: 0,
381 max_length: 1000,
382 }],
383 };
384 let mut blocks = HashMap::new();
385 blocks.insert("identity".into(), "file:identity.txt".into());
386 let prompt = SystemPrompt {
387 id: "test".into(),
388 description: None,
389 strategy_ref: "test".into(),
390 blocks,
391 };
392 let result = prompt.compose(&strategy, dir.path()).unwrap();
393 assert_eq!(result, "I am a test agent");
394 }
395
396 #[test]
397 fn test_compose_file_not_found() {
398 let strategy = CustomPromptStrategy {
399 blocks: vec![PromptBlockDef {
400 name: "a".into(),
401 order: 0,
402 max_length: 1000,
403 }],
404 };
405 let mut blocks = HashMap::new();
406 blocks.insert("a".into(), "file:nonexistent.txt".into());
407 let prompt = SystemPrompt {
408 id: "test".into(),
409 description: None,
410 strategy_ref: "test".into(),
411 blocks,
412 };
413 let result = prompt.compose(&strategy, Path::new("."));
414 assert!(result.is_err());
415 }
416}