vv_agent/
context_providers.rs1use std::collections::BTreeMap;
2
3use serde_json::Value;
4use sha2::{Digest, Sha256};
5
6use crate::types::Metadata;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct ContextFragment {
10 pub id: String,
11 pub text: String,
12 pub stable: bool,
13 pub priority: i32,
14 pub source: Option<String>,
15 pub metadata: Metadata,
16}
17
18impl ContextFragment {
19 pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
20 Self {
21 id: id.into(),
22 text: text.into(),
23 stable: false,
24 priority: 100,
25 source: None,
26 metadata: Metadata::new(),
27 }
28 }
29
30 pub fn stable(mut self, stable: bool) -> Self {
31 self.stable = stable;
32 self
33 }
34
35 pub fn priority(mut self, priority: i32) -> Self {
36 self.priority = priority;
37 self
38 }
39
40 pub fn source(mut self, source: impl Into<String>) -> Self {
41 self.source = Some(source.into());
42 self
43 }
44
45 pub fn cache_hint(mut self, cache_hint: impl Into<String>) -> Self {
46 self.metadata
47 .insert("cache_hint".to_string(), Value::String(cache_hint.into()));
48 self
49 }
50
51 pub fn metadata(mut self, key: impl Into<String>, value: Value) -> Self {
52 self.metadata.insert(key.into(), value);
53 self
54 }
55}
56
57pub struct ContextRequest<'a> {
58 pub agent_name: &'a str,
59 pub input: &'a str,
60 pub max_prompt_chars: Option<usize>,
61 pub metadata: Metadata,
62}
63
64impl<'a> ContextRequest<'a> {
65 pub fn new(agent_name: &'a str, input: &'a str) -> Self {
66 Self {
67 agent_name,
68 input,
69 max_prompt_chars: None,
70 metadata: Metadata::new(),
71 }
72 }
73
74 pub fn for_test(agent_name: &'a str, input: &'a str) -> Self {
75 Self::new(agent_name, input)
76 }
77
78 pub fn max_prompt_chars(mut self, max_prompt_chars: usize) -> Self {
79 self.max_prompt_chars = Some(max_prompt_chars);
80 self
81 }
82
83 pub fn metadata(mut self, key: impl Into<String>, value: Value) -> Self {
84 self.metadata.insert(key.into(), value);
85 self
86 }
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub struct ContextSection {
91 pub id: String,
92 pub text: String,
93 pub stable: bool,
94 pub metadata: Metadata,
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct ContextBundle {
99 pub prompt: String,
100 pub sections: Vec<ContextSection>,
101 pub stable_hash: String,
102 pub sources: BTreeMap<String, String>,
103 pub omitted_section_ids: Vec<String>,
104}
105
106pub trait ContextProvider: Send + Sync {
107 fn fragments(&self, request: &ContextRequest<'_>)
108 -> Result<Vec<ContextFragment>, ContextError>;
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ContextError {
113 message: String,
114}
115
116impl ContextError {
117 pub fn new(message: impl Into<String>) -> Self {
118 Self {
119 message: message.into(),
120 }
121 }
122}
123
124impl std::fmt::Display for ContextError {
125 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 formatter.write_str(&self.message)
127 }
128}
129
130impl std::error::Error for ContextError {}
131
132pub fn collect_context_fragments(
133 request: &ContextRequest<'_>,
134 providers: &[std::sync::Arc<dyn ContextProvider>],
135) -> Result<Vec<ContextFragment>, ContextError> {
136 let mut fragments = Vec::new();
137 for provider in providers {
138 fragments.extend(provider.fragments(request)?);
139 }
140 Ok(fragments)
141}
142
143pub fn assemble_context_fragments(
144 request: &ContextRequest<'_>,
145 mut fragments: Vec<ContextFragment>,
146) -> Result<ContextBundle, ContextError> {
147 fragments.sort_by(|left, right| {
148 left.priority
149 .cmp(&right.priority)
150 .then_with(|| right.stable.cmp(&left.stable))
151 .then_with(|| left.id.cmp(&right.id))
152 });
153
154 let mut prompt_parts = Vec::new();
155 let mut sections = Vec::new();
156 let mut stable_parts = Vec::new();
157 let mut sources = BTreeMap::new();
158 let mut omitted_section_ids = Vec::new();
159
160 for fragment in fragments {
161 let text = fragment.text.trim().to_string();
162 if text.is_empty() {
163 continue;
164 }
165 let candidate_len = if prompt_parts.is_empty() {
166 text.len()
167 } else {
168 prompt_parts.join("\n\n").len() + 2 + text.len()
169 };
170 if request
171 .max_prompt_chars
172 .is_some_and(|max_chars| candidate_len > max_chars)
173 {
174 omitted_section_ids.push(fragment.id);
175 continue;
176 }
177 if let Some(source) = fragment.source.as_ref() {
178 sources.insert(fragment.id.clone(), source.clone());
179 }
180 if fragment.stable {
181 stable_parts.push(text.clone());
182 }
183 prompt_parts.push(text.clone());
184 sections.push(ContextSection {
185 id: fragment.id,
186 text,
187 stable: fragment.stable,
188 metadata: fragment.metadata,
189 });
190 }
191
192 Ok(ContextBundle {
193 prompt: prompt_parts.join("\n\n"),
194 sections,
195 stable_hash: sha256_hex(stable_parts.join("").as_bytes()),
196 sources,
197 omitted_section_ids,
198 })
199}
200
201fn sha256_hex(bytes: &[u8]) -> String {
202 let mut hasher = Sha256::new();
203 hasher.update(bytes);
204 hex_lower(&hasher.finalize())
205}
206
207fn hex_lower(bytes: &[u8]) -> String {
208 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
209}