Skip to main content

cel_brief/sources/
history.rs

1//! [`HistoryStore`] trait + [`HistorySource`] — past-N-turns window source.
2//!
3//! Most agents keep a transcript of prior turns somewhere — in memory, on
4//! disk, in a SQL database. [`HistoryStore`] is the small trait that lets
5//! cel-brief read from any of them without caring how it's stored. The
6//! companion [`HistorySource`] turns the last N entries into
7//! [`Contribution`]s every turn at [`Priority::Normal`].
8//!
9//! The trait stays minimal on purpose: one async method that returns up to
10//! `limit` recent entries in oldest-first order. Tool calls and tool results
11//! are first-class so a model's prior decision-then-execution loop survives
12//! the round trip.
13
14use 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/// A single entry in an agent's turn log.
22///
23/// One [`HistoryEntry`] becomes one [`crate::types::BriefMessage`] (modulo
24/// budget pruning) in the assembled brief. The variants intentionally mirror
25/// the subset of [`crate::source::ContributionContent`] that makes sense for
26/// transcript replay; system prompts and tool *schemas* belong to other
27/// sources.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum HistoryEntry {
31    /// Plain text turn (user said X, assistant said Y).
32    Text {
33        /// Role this entry is attributed to.
34        role: Role,
35        /// Message body.
36        content: String,
37    },
38    /// A tool invocation the model previously emitted.
39    ToolCall {
40        /// Provider-issued tool-call ID.
41        id: String,
42        /// Tool name.
43        name: String,
44        /// JSON arguments the model passed.
45        args: Value,
46    },
47    /// The result returned for a prior tool call.
48    ToolResult {
49        /// Tool-call ID this result responds to.
50        id: String,
51        /// Serialised result content.
52        content: String,
53    },
54}
55
56impl HistoryEntry {
57    /// Default token estimate for this entry — `length / 4`.
58    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/// Read-only access to an agent's turn log.
71///
72/// Implementations should:
73/// - Return entries in **oldest-first** order so they slot into the brief in
74///   chronological order.
75/// - Cap the result at `limit` if their store has more than that many.
76/// - Return an empty vec rather than an error when the store is empty.
77///
78/// The trait is generic over backend (in-memory `Vec`, SQLite table, Redis
79/// list, …) and over what counts as a "turn". A computer-use agent might
80/// store one entry per `ToolCall` + `ToolResult` pair; a chat agent stores
81/// `Text` entries per message.
82#[async_trait]
83pub trait HistoryStore: Send + Sync {
84    /// Return up to `limit` most-recent entries in oldest-first order.
85    async fn recent(&self, limit: usize) -> Vec<HistoryEntry>;
86}
87
88#[async_trait]
89impl HistoryStore for Vec<HistoryEntry> {
90    /// Convenience impl — handy for tests and small fixed transcripts. The
91    /// vec is treated as oldest-first; the tail (most recent) is taken.
92    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/// A [`Source`] that injects the last N entries from a [`HistoryStore`].
99///
100/// Normal priority. The default importance is `0.4`, declining linearly
101/// toward `0.2` for the oldest entry in the window — newer turns survive
102/// pruning longer. Entries are emitted as redactable contributions so
103/// governance can scrub PII before they reach the model.
104#[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    /// Construct a history source with the default ID `"history"` and the
113    /// given window size.
114    ///
115    /// A `limit` of `0` is allowed but the source will always emit zero
116    /// contributions — register `HistorySource` only when you actually want
117    /// transcript replay.
118    pub fn new(store: H, limit: usize) -> Self {
119        HistorySource {
120            id: SourceId::new("history"),
121            store,
122            limit,
123        }
124    }
125
126    /// Override the default [`SourceId`].
127    pub fn with_id(mut self, id: impl Into<SourceId>) -> Self {
128        self.id = id.into();
129        self
130    }
131
132    /// The configured window size.
133    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                // Recency-weighted importance: oldest = 0.2, newest = 0.4.
163                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        // Newer entries should have higher importance than older ones.
240        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}