Skip to main content

vv_agent/prompt/builder/
section.rs

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(&sections)?;
147        let stable_hash = stable_hash(&sections)?;
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    /// Freeze persisted Session Memory into the resolved bundle for one run.
164    ///
165    /// A bundle already containing the section is a frozen checkpoint/run
166    /// bundle, so it must be reused exactly rather than refreshed.
167    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}