Skip to main content

phi_core/agents/
system_prompt.rs

1//! SystemPromptStrategy — structured system prompt composition.
2//!
3//! The system prompt is structured as **ordered blocks** with token budgets.
4//! Three entities form a reference chain:
5//!
6//! 1. **SystemPromptStrategy** — defines the structure template (block names, order, max_length)
7//! 2. **SystemPrompt** — fills content into a strategy's blocks (text or file paths)
8//! 3. **AgentProfile.system_prompt** — references a SystemPrompt instance (or is raw text)
9//!
10//! # Example
11//!
12//! ```
13//! use phi_core::agents::system_prompt::*;
14//! use std::collections::HashMap;
15//! use std::path::Path;
16//!
17//! // Define a strategy template
18//! let strategy = CustomPromptStrategy {
19//!     blocks: vec![
20//!         PromptBlockDef { name: "identity".into(), order: 0, max_length: 500 },
21//!         PromptBlockDef { name: "instructions".into(), order: 1, max_length: 2000 },
22//!     ],
23//! };
24//!
25//! // Fill content into the template
26//! let mut blocks = HashMap::new();
27//! blocks.insert("identity".into(), "You are Phi, an expert coder.".into());
28//! blocks.insert("instructions".into(), "Write clean, well-tested code.".into());
29//!
30//! let prompt = SystemPrompt {
31//!     id: "coder".into(),
32//!     description: Some("Coding agent prompt".into()),
33//!     strategy_ref: "agent_layout".into(),
34//!     blocks,
35//! };
36//!
37//! let result = prompt.compose(&strategy, Path::new(".")).unwrap();
38//! assert!(result.contains("Phi"));
39//! assert!(result.contains("well-tested"));
40//! ```
41
42use serde::{Deserialize, Serialize};
43use std::collections::HashMap;
44use std::path::Path;
45
46// ── Block definition ────────────────────────────────────────────────────────
47
48/// A block definition within a SystemPromptStrategy (structure template).
49///
50/// Defines a named slot in the system prompt with an ordering and token budget.
51/// The actual content is provided by a `SystemPrompt` instance.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct PromptBlockDef {
54    /// Block name (e.g., "identity", "instructions", "tools", "constraints").
55    pub name: String,
56    /// Assembly order — lower numbers appear first in the final prompt.
57    pub order: u32,
58    /// Maximum character budget for this block. Content exceeding this is truncated.
59    pub max_length: usize,
60}
61
62// ── Strategy trait ──────────────────────────────────────────────────────────
63
64/// Defines the structure template for a system prompt.
65///
66/// A strategy is reusable — multiple `SystemPrompt` instances can share one strategy,
67/// each providing different content for the same block structure.
68pub trait SystemPromptStrategy: Send + Sync {
69    /// Return block definitions (structure only, no content).
70    fn block_defs(&self) -> &[PromptBlockDef];
71}
72
73// ── Strategy implementations ────────────────────────────────────────────────
74
75/// User-defined strategy with custom block definitions.
76pub 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
86/// Predefined 4-block layout for general agents.
87///
88/// | Block | Order | Max Length |
89/// |-------|-------|------------|
90/// | identity | 0 | 500 |
91/// | instructions | 1 | 2000 |
92/// | tools | 2 | 1000 |
93/// | constraints | 3 | 500 |
94pub 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
133/// Predefined 2-block layout for simple agents.
134///
135/// | Block | Order | Max Length |
136/// |-------|-------|------------|
137/// | identity | 0 | 1000 |
138/// | task | 1 | 3000 |
139pub 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// ── SystemPrompt instance ───────────────────────────────────────────────────
169
170/// A concrete system prompt instance: content mapped to a strategy's blocks.
171///
172/// Each block value is either:
173/// - **Inline text**: `"You are an expert coder."`
174/// - **Relative file path**: `"file:prompts/identity.md"` — resolves from agent workspace
175/// - **Absolute file path**: `"file:/etc/phi/identity.md"` — resolves as-is
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct SystemPrompt {
178    /// Unique id (uses `{{...}}` reference protocol in config).
179    pub id: String,
180    /// Description for existence check queries (with `%` references).
181    pub description: Option<String>,
182    /// Reference to the strategy that defines this prompt's structure.
183    pub strategy_ref: String,
184    /// Block name → content mapping.
185    pub blocks: HashMap<String, String>,
186}
187
188impl SystemPrompt {
189    /// Compose the final system prompt text by resolving blocks against the strategy.
190    ///
191    /// - Sorts blocks by strategy order
192    /// - Resolves `"file:path"` references (relative paths use `working_dir`)
193    /// - Truncates each block to its `max_length`
194    /// - Concatenates with double newlines
195    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
214// ── Helpers ─────────────────────────────────────────────────────────────────
215
216/// Resolve block content: if prefixed with "file:", read from disk.
217/// Relative paths resolve from `working_dir`.
218fn 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
232/// Truncate a string to at most `max_chars` characters.
233fn 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        // b is missing
361        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}