1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
4use serde_json::Value;
5
6use super::hash::sha256_hex;
7
8#[derive(Debug, Clone, PartialEq, Serialize)]
9pub struct PromptSection {
10 pub id: String,
11 pub text: String,
12 pub stable: bool,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub source: Option<String>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub cache_hint: Option<String>,
17 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
18 pub metadata: BTreeMap<String, Value>,
19}
20
21#[derive(Deserialize)]
22#[serde(deny_unknown_fields)]
23struct PromptSectionWire {
24 id: String,
25 text: String,
26 stable: bool,
27 #[serde(default)]
28 source: Option<String>,
29 #[serde(default)]
30 cache_hint: Option<String>,
31 #[serde(default)]
32 metadata: BTreeMap<String, Value>,
33}
34
35impl<'de> Deserialize<'de> for PromptSection {
36 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37 where
38 D: Deserializer<'de>,
39 {
40 let wire = PromptSectionWire::deserialize(deserializer)?;
41 Self::try_new(wire.id, wire.text, wire.stable)
42 .and_then(|section| {
43 Ok(Self {
44 source: normalize_optional_strict(wire.source, "source")?,
45 cache_hint: normalize_optional_strict(wire.cache_hint, "cache_hint")?,
46 metadata: wire.metadata,
47 ..section
48 })
49 })
50 .and_then(|section| section.validate().map(|()| section))
51 .map_err(D::Error::custom)
52 }
53}
54
55impl PromptSection {
56 pub fn new(id: impl Into<String>, text: impl Into<String>, stable: bool) -> Self {
57 Self {
58 id: trim_ascii_whitespace(&id.into()).to_string(),
59 text: trim_ascii_whitespace(&text.into()).to_string(),
60 stable,
61 source: None,
62 cache_hint: None,
63 metadata: BTreeMap::new(),
64 }
65 }
66
67 pub fn try_new(
68 id: impl Into<String>,
69 text: impl Into<String>,
70 stable: bool,
71 ) -> Result<Self, String> {
72 let section = Self::new(id, text, stable);
73 section.validate()?;
74 Ok(section)
75 }
76
77 pub fn source(mut self, source: impl Into<String>) -> Self {
78 self.source = normalize_optional(Some(source.into()));
79 self
80 }
81
82 pub fn cache_hint(mut self, cache_hint: impl Into<String>) -> Self {
83 self.cache_hint = normalize_optional(Some(cache_hint.into()));
84 self
85 }
86
87 pub fn metadata(mut self, key: impl Into<String>, value: Value) -> Self {
88 self.metadata.insert(key.into(), value);
89 self
90 }
91
92 pub fn validate(&self) -> Result<(), String> {
93 if trim_ascii_whitespace(&self.id).is_empty() {
94 return Err("prompt section id must be non-empty".to_string());
95 }
96 if trim_ascii_whitespace(&self.text).is_empty() {
97 return Err("prompt section text must be non-empty".to_string());
98 }
99 for (name, value) in [
100 ("source", self.source.as_deref()),
101 ("cache_hint", self.cache_hint.as_deref()),
102 ] {
103 if value.is_some_and(|value| trim_ascii_whitespace(value).is_empty()) {
104 return Err(format!(
105 "prompt section {name} must be omitted or non-empty"
106 ));
107 }
108 }
109 Ok(())
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize)]
114pub struct PromptBundle {
115 pub sections: Vec<PromptSection>,
116 pub stable_hash: String,
117}
118
119#[derive(Deserialize)]
120#[serde(deny_unknown_fields)]
121struct PromptBundleWire {
122 sections: Vec<PromptSection>,
123 stable_hash: String,
124}
125
126impl<'de> Deserialize<'de> for PromptBundle {
127 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128 where
129 D: Deserializer<'de>,
130 {
131 let wire = PromptBundleWire::deserialize(deserializer)?;
132 let bundle = Self {
133 sections: wire.sections,
134 stable_hash: wire.stable_hash,
135 };
136 bundle.validate().map_err(D::Error::custom)?;
137 Ok(bundle)
138 }
139}
140
141impl PromptBundle {
142 pub fn new(sections: Vec<PromptSection>) -> Result<Self, String> {
143 if sections.is_empty() {
144 return Err("prompt bundle sections must be non-empty".to_string());
145 }
146 validate_sections(§ions)?;
147 let stable_hash = stable_hash(§ions)?;
148 Ok(Self {
149 sections,
150 stable_hash,
151 })
152 }
153
154 pub fn from_instruction_text(text: impl Into<String>) -> Result<Self, String> {
155 Self::new(vec![PromptSection::try_new(
156 "agent_instructions",
157 text,
158 true,
159 )?
160 .source("agent.instructions")])
161 }
162
163 pub fn with_session_memory_context(
168 &self,
169 session_memory_context: impl AsRef<str>,
170 ) -> Result<Self, String> {
171 let context = trim_ascii_whitespace(session_memory_context.as_ref());
172 if context.is_empty()
173 || self
174 .sections
175 .iter()
176 .any(|section| section.id == "session_memory")
177 {
178 return Ok(self.clone());
179 }
180
181 let mut sections = self.sections.clone();
182 let insertion_index = sections
183 .iter()
184 .position(|section| section.id == "current_time")
185 .unwrap_or(sections.len());
186 sections.insert(
187 insertion_index,
188 PromptSection::new("session_memory", context, false).source("session.memory"),
189 );
190 Self::new(sections)
191 }
192
193 pub fn flatten(&self) -> String {
194 self.sections
195 .iter()
196 .map(|section| section.text.as_str())
197 .collect::<Vec<_>>()
198 .join("\n\n")
199 }
200
201 pub fn validate(&self) -> Result<(), String> {
202 if self.sections.is_empty() {
203 return Err("prompt bundle sections must be non-empty".to_string());
204 }
205 validate_sections(&self.sections)?;
206 let expected = stable_hash(&self.sections)?;
207 if self.stable_hash != expected {
208 return Err("prompt bundle stable_hash does not match stable sections".to_string());
209 }
210 Ok(())
211 }
212
213 pub fn to_value(&self) -> Value {
214 serde_json::to_value(self).expect("PromptBundle contains JSON values")
215 }
216
217 pub fn from_value(value: &Value) -> Result<Self, String> {
218 serde_json::from_value(value.clone()).map_err(|error| error.to_string())
219 }
220}
221
222fn validate_sections(sections: &[PromptSection]) -> Result<(), String> {
223 let mut ids = BTreeSet::new();
224 for section in sections {
225 section.validate()?;
226 if !ids.insert(section.id.as_str()) {
227 return Err(format!("duplicate prompt section id: {}", section.id));
228 }
229 }
230 Ok(())
231}
232
233fn stable_hash(sections: &[PromptSection]) -> Result<String, String> {
234 let stable = sections
235 .iter()
236 .filter(|section| section.stable)
237 .collect::<Vec<_>>();
238 let bytes = serde_json_canonicalizer::to_vec(&stable)
239 .map_err(|error| format!("prompt stable sections cannot be canonicalized: {error}"))?;
240 Ok(sha256_hex(&bytes))
241}
242
243fn normalize_optional(value: Option<String>) -> Option<String> {
244 value.and_then(|value| {
245 let value = trim_ascii_whitespace(&value).to_string();
246 (!value.is_empty()).then_some(value)
247 })
248}
249
250fn normalize_optional_strict(
251 value: Option<String>,
252 field_name: &str,
253) -> Result<Option<String>, String> {
254 match value {
255 None => Ok(None),
256 Some(value) => {
257 let value = trim_ascii_whitespace(&value).to_string();
258 if value.is_empty() {
259 Err(format!(
260 "prompt section {field_name} must be omitted or non-empty"
261 ))
262 } else {
263 Ok(Some(value))
264 }
265 }
266 }
267}
268
269fn trim_ascii_whitespace(value: &str) -> &str {
270 value.trim_matches(|character| matches!(character, '\u{0009}'..='\u{000D}' | '\u{0020}'))
271}