Skip to main content

cel_brief/sources/
receipt.rs

1//! [`ReceiptSource`] — surfaces a run's recent execution receipts into the brief.
2//!
3//! A runtime can append each run-scoped execution receipt to
4//! `<runs_dir>/<run_id>.jsonl`. This source reads the last `limit` for a given
5//! run and contributes a compact "recent actions" summary, so the next turn's
6//! brief reflects what the agent just did and whether effects were observed.
7
8use std::path::{Path, PathBuf};
9
10use async_trait::async_trait;
11
12use crate::source::{Contribution, ContributionContent, Source, SourceError};
13use crate::types::{BriefContext, Priority, SourceId};
14
15/// Contributes a run's recent execution receipts as a compact System summary.
16pub struct ReceiptSource {
17    id: SourceId,
18    runs_dir: PathBuf,
19    run_id: String,
20    limit: usize,
21}
22
23impl ReceiptSource {
24    /// Read receipts for `run_id` from `runs_dir` (`<runs_dir>/<run_id>.jsonl`),
25    /// surfacing the last `limit`.
26    pub fn new(runs_dir: impl Into<PathBuf>, run_id: impl Into<String>, limit: usize) -> Self {
27        Self {
28            id: SourceId::new("receipts"),
29            runs_dir: runs_dir.into(),
30            run_id: run_id.into(),
31            limit,
32        }
33    }
34
35    /// Convenience constructor pointing at `~/.cel/brief/runs`.
36    /// Returns `None` when `$HOME` is unset.
37    pub fn for_run(run_id: impl Into<String>, limit: usize) -> Option<Self> {
38        default_runs_dir().map(|dir| Self::new(dir, run_id, limit))
39    }
40}
41
42#[async_trait]
43impl Source for ReceiptSource {
44    fn id(&self) -> SourceId {
45        self.id.clone()
46    }
47
48    fn priority(&self) -> Priority {
49        Priority::Normal
50    }
51
52    async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
53        let receipts = read_recent(&self.runs_dir, &self.run_id, self.limit);
54        if receipts.is_empty() {
55            return Ok(Vec::new());
56        }
57        let mut text = format!("Recent actions this run ({}):", self.run_id);
58        for r in &receipts {
59            text.push('\n');
60            text.push_str(&format_receipt(r));
61        }
62        let estimated_tokens = text.len() / 4;
63        Ok(vec![Contribution {
64            content: ContributionContent::System { text },
65            estimated_tokens,
66            importance: 0.6,
67            redactable: true,
68            tags: vec!["receipts".to_string(), "recent".to_string()],
69        }])
70    }
71}
72
73fn read_recent(runs_dir: &Path, run_id: &str, limit: usize) -> Vec<serde_json::Value> {
74    let path = runs_dir.join(format!("{}.jsonl", sanitize_run_id(run_id)));
75    let Ok(content) = std::fs::read_to_string(&path) else {
76        return Vec::new();
77    };
78    let mut all: Vec<serde_json::Value> = content
79        .lines()
80        .filter(|l| !l.trim().is_empty())
81        .filter_map(|l| serde_json::from_str(l).ok())
82        .collect();
83    if all.len() > limit {
84        all = all.split_off(all.len() - limit);
85    }
86    all
87}
88
89fn format_receipt(r: &serde_json::Value) -> String {
90    let s = |k: &str| r.get(k).and_then(|v| v.as_str()).unwrap_or("?");
91    let route = r
92        .get("route")
93        .and_then(|v| v.get("route"))
94        .and_then(|v| v.as_str())
95        .unwrap_or("?");
96    let effect = r
97        .get("observed_effect")
98        .and_then(|v| v.get("status"))
99        .and_then(|v| v.as_str())
100        .unwrap_or("-");
101    let dur = r.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0);
102    let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
103    format!(
104        "- {} via {} → {} (effect: {}, {}ms) {}",
105        s("action_kind"),
106        route,
107        s("status"),
108        effect,
109        dur,
110        target
111    )
112}
113
114fn default_runs_dir() -> Option<PathBuf> {
115    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cel").join("brief").join("runs"))
116}
117
118/// File-safe run id used by the JSONL receipt source.
119fn sanitize_run_id(run_id: &str) -> String {
120    run_id
121        .chars()
122        .map(|c| {
123            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
124                c
125            } else {
126                '_'
127            }
128        })
129        .collect()
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::types::{BriefContext, TokenBudget};
136
137    fn ctx() -> BriefContext {
138        BriefContext::new(TokenBudget::new(1000, 0))
139    }
140
141    fn write_run(dir: &Path, run_id: &str, lines: &[&str]) {
142        std::fs::create_dir_all(dir).unwrap();
143        std::fs::write(dir.join(format!("{run_id}.jsonl")), lines.join("\n")).unwrap();
144    }
145
146    #[tokio::test]
147    async fn surfaces_recent_receipts() {
148        let dir = std::env::temp_dir().join("cel_brief_receipt_src_a");
149        let _ = std::fs::remove_dir_all(&dir);
150        write_run(
151            &dir,
152            "run1",
153            &[
154                r#"{"receipt_id":"r1","action_kind":"set_value","route":{"route":"cdp"},"observed_effect":{"status":"observed"},"status":"ok","duration_ms":42,"target":"dom:input:x"}"#,
155                r#"{"receipt_id":"r2","action_kind":"click","route":{"route":"cdp"},"observed_effect":{"status":"timed_out"},"status":"timed_out","duration_ms":2000,"target":"dom:button:y"}"#,
156            ],
157        );
158        let src = ReceiptSource::new(&dir, "run1", 8);
159        let out = src.contribute(&ctx()).await.unwrap();
160        assert_eq!(out.len(), 1);
161        let ContributionContent::System { text } = &out[0].content else {
162            panic!("expected a system contribution");
163        };
164        assert!(text.contains("set_value via cdp → ok"));
165        assert!(text.contains("click via cdp → timed_out"));
166        assert!(text.contains("effect: observed"));
167        let _ = std::fs::remove_dir_all(&dir);
168    }
169
170    #[tokio::test]
171    async fn empty_when_no_file() {
172        let dir = std::env::temp_dir().join("cel_brief_receipt_src_missing");
173        let _ = std::fs::remove_dir_all(&dir);
174        let src = ReceiptSource::new(&dir, "nope", 8);
175        assert!(src.contribute(&ctx()).await.unwrap().is_empty());
176    }
177
178    #[tokio::test]
179    async fn respects_limit() {
180        let dir = std::env::temp_dir().join("cel_brief_receipt_src_limit");
181        let _ = std::fs::remove_dir_all(&dir);
182        let lines: Vec<String> = (0..10)
183            .map(|i| {
184                format!(
185                    r#"{{"action_kind":"click","route":{{"route":"cdp"}},"status":"ok","duration_ms":{i}}}"#
186                )
187            })
188            .collect();
189        let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
190        write_run(&dir, "run2", &refs);
191        let src = ReceiptSource::new(&dir, "run2", 3);
192        let out = src.contribute(&ctx()).await.unwrap();
193        let ContributionContent::System { text } = &out[0].content else {
194            panic!("expected a system contribution");
195        };
196        // 1 header line + 3 receipt lines.
197        assert_eq!(text.lines().count(), 4);
198        let _ = std::fs::remove_dir_all(&dir);
199    }
200}