Skip to main content

agent_works/prompt/
fragment.rs

1//! Core trait and composition logic for prompt fragments.
2
3/// Context passed to each fragment during rendering.
4pub struct FragmentContext<'a> {
5    /// Tool definitions as JSON (OpenAI function-calling format).
6    pub tool_definitions: &'a [serde_json::Value],
7    /// Current session ID.
8    pub session_id: &'a str,
9}
10
11/// Composable prompt fragment — each fragment owns one concern.
12///
13/// # Priority convention
14///
15/// | Range  | Typical use                     |
16/// |--------|----------------------------------|
17/// | 10–19  | Core role/personality            |
18/// | 20–29  | Thinking methodology             |
19/// | 50–59  | Safety rules, environment info   |
20/// | 60–69  | Workflow instructions            |
21/// | 70–79  | Tool descriptions                |
22/// | 80–89  | Feature-specific (multi-agent)   |
23/// | 90–99  | Network environment, memory      |
24///
25/// Lower priority renders first (closer to the top of the system prompt).
26pub trait PromptFragment: Send + Sync + dyn_clone::DynClone {
27    /// Human-readable name for debugging.
28    fn name(&self) -> &str;
29
30    /// Lower = rendered first. Default: 100.
31    fn priority(&self) -> i32 {
32        100
33    }
34
35    /// Render this fragment's content. Return `None` to skip.
36    fn render(&self, ctx: &FragmentContext) -> Option<String>;
37}
38
39dyn_clone::clone_trait_object!(PromptFragment);
40
41/// Sort fragments by priority (ascending) and concatenate with double newlines.
42///
43/// Fragments that return `None` from `render()` are skipped.
44/// Empty result returns an empty string.
45pub fn compose_fragments(fragments: &[Box<dyn PromptFragment>], ctx: &FragmentContext) -> String {
46    let mut sorted: Vec<&Box<dyn PromptFragment>> = fragments.iter().collect();
47    sorted.sort_by_key(|f| f.priority());
48    sorted
49        .iter()
50        .filter_map(|f| f.render(ctx))
51        .collect::<Vec<_>>()
52        .join("\n\n")
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[derive(Clone)]
60    struct AlphaFragment;
61    impl PromptFragment for AlphaFragment {
62        fn name(&self) -> &str {
63            "alpha"
64        }
65        fn priority(&self) -> i32 {
66            20
67        }
68        fn render(&self, _ctx: &FragmentContext) -> Option<String> {
69            Some("ALPHA".to_string())
70        }
71    }
72
73    #[derive(Clone)]
74    struct BetaFragment;
75    impl PromptFragment for BetaFragment {
76        fn name(&self) -> &str {
77            "beta"
78        }
79        fn priority(&self) -> i32 {
80            10
81        }
82        fn render(&self, _ctx: &FragmentContext) -> Option<String> {
83            Some("BETA".to_string())
84        }
85    }
86
87    #[derive(Clone)]
88    struct SkipFragment;
89    impl PromptFragment for SkipFragment {
90        fn name(&self) -> &str {
91            "skip"
92        }
93        fn render(&self, _ctx: &FragmentContext) -> Option<String> {
94            None
95        }
96    }
97
98    #[test]
99    fn compose_sorts_by_priority() {
100        let fragments: Vec<Box<dyn PromptFragment>> = vec![
101            Box::new(AlphaFragment), // priority 20
102            Box::new(BetaFragment),  // priority 10
103        ];
104        let ctx = FragmentContext {
105            tool_definitions: &[],
106            session_id: "test",
107        };
108        let result = compose_fragments(&fragments, &ctx);
109        // Beta (10) should come before Alpha (20)
110        assert_eq!(result, "BETA\n\nALPHA");
111    }
112
113    #[test]
114    fn compose_skips_none() {
115        let fragments: Vec<Box<dyn PromptFragment>> = vec![
116            Box::new(AlphaFragment),
117            Box::new(SkipFragment),
118            Box::new(BetaFragment),
119        ];
120        let ctx = FragmentContext {
121            tool_definitions: &[],
122            session_id: "test",
123        };
124        let result = compose_fragments(&fragments, &ctx);
125        assert_eq!(result, "BETA\n\nALPHA");
126    }
127
128    #[test]
129    fn compose_empty_fragments() {
130        let fragments: Vec<Box<dyn PromptFragment>> = vec![];
131        let ctx = FragmentContext {
132            tool_definitions: &[],
133            session_id: "test",
134        };
135        let result = compose_fragments(&fragments, &ctx);
136        assert!(result.is_empty());
137    }
138
139    #[test]
140    fn compose_all_none_returns_empty() {
141        let fragments: Vec<Box<dyn PromptFragment>> =
142            vec![Box::new(SkipFragment), Box::new(SkipFragment)];
143        let ctx = FragmentContext {
144            tool_definitions: &[],
145            session_id: "test",
146        };
147        let result = compose_fragments(&fragments, &ctx);
148        assert!(result.is_empty());
149    }
150
151    #[test]
152    fn default_priority_is_100() {
153        #[derive(Clone)]
154        struct DefaultPriorityFragment;
155        impl PromptFragment for DefaultPriorityFragment {
156            fn name(&self) -> &str {
157                "default"
158            }
159            fn render(&self, _ctx: &FragmentContext) -> Option<String> {
160                Some("default".to_string())
161            }
162        }
163        assert_eq!(DefaultPriorityFragment.priority(), 100);
164    }
165}