1use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::source::{Contribution, ContributionContent, Source, SourceError};
19use crate::types::{BriefContext, Priority, Role, SourceId};
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum HistoryEntry {
31 Text {
33 role: Role,
35 content: String,
37 },
38 ToolCall {
40 id: String,
42 name: String,
44 args: Value,
46 },
47 ToolResult {
49 id: String,
51 content: String,
53 },
54}
55
56impl HistoryEntry {
57 pub fn estimated_tokens(&self) -> usize {
59 match self {
60 HistoryEntry::Text { content, .. } => content.len().div_ceil(4),
61 HistoryEntry::ToolCall { name, args, .. } => {
62 let args_len = serde_json::to_string(args).map(|s| s.len()).unwrap_or(0);
63 (name.len() + args_len).div_ceil(4)
64 }
65 HistoryEntry::ToolResult { content, .. } => content.len().div_ceil(4),
66 }
67 }
68}
69
70#[async_trait]
83pub trait HistoryStore: Send + Sync {
84 async fn recent(&self, limit: usize) -> Vec<HistoryEntry>;
86}
87
88#[async_trait]
89impl HistoryStore for Vec<HistoryEntry> {
90 async fn recent(&self, limit: usize) -> Vec<HistoryEntry> {
93 let start = self.len().saturating_sub(limit);
94 self[start..].to_vec()
95 }
96}
97
98#[derive(Debug, Clone)]
105pub struct HistorySource<H: HistoryStore> {
106 id: SourceId,
107 store: H,
108 limit: usize,
109}
110
111impl<H: HistoryStore> HistorySource<H> {
112 pub fn new(store: H, limit: usize) -> Self {
119 HistorySource {
120 id: SourceId::new("history"),
121 store,
122 limit,
123 }
124 }
125
126 pub fn with_id(mut self, id: impl Into<SourceId>) -> Self {
128 self.id = id.into();
129 self
130 }
131
132 pub fn limit(&self) -> usize {
134 self.limit
135 }
136}
137
138#[async_trait]
139impl<H: HistoryStore> Source for HistorySource<H> {
140 fn id(&self) -> SourceId {
141 self.id.clone()
142 }
143
144 fn priority(&self) -> Priority {
145 Priority::Normal
146 }
147
148 async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
149 if self.limit == 0 {
150 return Err(SourceError::Skipped("history limit is 0".into()));
151 }
152 let entries = self.store.recent(self.limit).await;
153 if entries.is_empty() {
154 return Err(SourceError::Skipped("history store is empty".into()));
155 }
156
157 let total = entries.len() as f32;
158 Ok(entries
159 .into_iter()
160 .enumerate()
161 .map(|(idx, entry)| {
162 let recency = (idx as f32 + 1.0) / total;
164 let importance = 0.2 + 0.2 * recency;
165 let est = entry.estimated_tokens();
166 let content = match entry {
167 HistoryEntry::Text { role, content } => {
168 ContributionContent::Text { role, content }
169 }
170 HistoryEntry::ToolCall { id, name, args } => {
171 ContributionContent::ToolCall { id, name, args }
172 }
173 HistoryEntry::ToolResult { id, content } => {
174 ContributionContent::ToolResult { id, content }
175 }
176 };
177 Contribution {
178 content,
179 estimated_tokens: est,
180 importance,
181 redactable: true,
182 tags: vec!["history".into()],
183 }
184 })
185 .collect())
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::types::TokenBudget;
193 use serde_json::json;
194
195 fn entry(role: Role, content: &str) -> HistoryEntry {
196 HistoryEntry::Text {
197 role,
198 content: content.into(),
199 }
200 }
201
202 #[tokio::test]
203 async fn vec_store_returns_tail_in_order() {
204 let log: Vec<HistoryEntry> = vec![
205 entry(Role::User, "oldest"),
206 entry(Role::Assistant, "middle"),
207 entry(Role::User, "newest"),
208 ];
209 let got = HistoryStore::recent(&log, 2).await;
210 assert_eq!(got.len(), 2);
211 match &got[0] {
212 HistoryEntry::Text { content, .. } => assert_eq!(content, "middle"),
213 _ => panic!(),
214 }
215 match &got[1] {
216 HistoryEntry::Text { content, .. } => assert_eq!(content, "newest"),
217 _ => panic!(),
218 }
219 }
220
221 #[tokio::test]
222 async fn vec_store_limit_above_len_returns_all() {
223 let log: Vec<HistoryEntry> = vec![entry(Role::User, "only")];
224 let got = HistoryStore::recent(&log, 99).await;
225 assert_eq!(got.len(), 1);
226 }
227
228 #[tokio::test]
229 async fn source_emits_one_contribution_per_entry() {
230 let log: Vec<HistoryEntry> = vec![
231 entry(Role::User, "hi"),
232 entry(Role::Assistant, "hello!"),
233 entry(Role::User, "more"),
234 ];
235 let src = HistorySource::new(log, 10);
236 let ctx = BriefContext::new(TokenBudget::default());
237 let cs = src.contribute(&ctx).await.expect("ok");
238 assert_eq!(cs.len(), 3);
239 assert!(cs[2].importance > cs[0].importance);
241 assert!(cs.iter().all(|c| c.redactable));
242 assert!(cs.iter().all(|c| c.tags == vec!["history".to_owned()]));
243 }
244
245 #[tokio::test]
246 async fn empty_store_is_skipped() {
247 let src = HistorySource::new(Vec::<HistoryEntry>::new(), 10);
248 let ctx = BriefContext::new(TokenBudget::default());
249 match src.contribute(&ctx).await {
250 Err(SourceError::Skipped(_)) => {}
251 other => panic!("expected Skipped, got {other:?}"),
252 }
253 }
254
255 #[tokio::test]
256 async fn zero_limit_is_skipped() {
257 let log: Vec<HistoryEntry> = vec![entry(Role::User, "hi")];
258 let src = HistorySource::new(log, 0);
259 let ctx = BriefContext::new(TokenBudget::default());
260 match src.contribute(&ctx).await {
261 Err(SourceError::Skipped(_)) => {}
262 other => panic!("expected Skipped, got {other:?}"),
263 }
264 }
265
266 #[tokio::test]
267 async fn tool_call_round_trips_through_history() {
268 let log: Vec<HistoryEntry> = vec![
269 HistoryEntry::ToolCall {
270 id: "call_1".into(),
271 name: "fs.copy".into(),
272 args: json!({"src":"a","dst":"b"}),
273 },
274 HistoryEntry::ToolResult {
275 id: "call_1".into(),
276 content: "{\"ok\":true}".into(),
277 },
278 ];
279 let src = HistorySource::new(log, 10);
280 let ctx = BriefContext::new(TokenBudget::default());
281 let cs = src.contribute(&ctx).await.expect("ok");
282 assert_eq!(cs.len(), 2);
283 match &cs[0].content {
284 ContributionContent::ToolCall { id, name, .. } => {
285 assert_eq!(id, "call_1");
286 assert_eq!(name, "fs.copy");
287 }
288 other => panic!("expected ToolCall, got {other:?}"),
289 }
290 match &cs[1].content {
291 ContributionContent::ToolResult { id, content } => {
292 assert_eq!(id, "call_1");
293 assert_eq!(content, "{\"ok\":true}");
294 }
295 other => panic!("expected ToolResult, got {other:?}"),
296 }
297 }
298
299 #[tokio::test]
300 async fn priority_and_id_defaults() {
301 let src = HistorySource::new(Vec::<HistoryEntry>::new(), 1);
302 assert_eq!(src.priority(), Priority::Normal);
303 assert_eq!(src.id(), SourceId::new("history"));
304 }
305
306 #[tokio::test]
307 async fn with_id_overrides_default() {
308 let src = HistorySource::new(Vec::<HistoryEntry>::new(), 1).with_id("chat_log");
309 assert_eq!(src.id(), SourceId::new("chat_log"));
310 }
311
312 #[test]
313 fn history_entry_round_trips_through_serde() {
314 let e = HistoryEntry::ToolCall {
315 id: "c1".into(),
316 name: "t".into(),
317 args: json!({"x":1}),
318 };
319 let json = serde_json::to_string(&e).expect("serialize");
320 let back: HistoryEntry = serde_json::from_str(&json).expect("deserialize");
321 assert_eq!(e, back);
322 }
323}