Skip to main content

ainl_context_compiler/
summarizer.rs

1//! Summarizer trait + structured anchored-summary types.
2//!
3//! M1 ships the *trait surface* and the data types so callers can already pass `Some(summarizer)`;
4//! the actual `LlmDriverSummarizer` adapter (in `openfang-runtime/src/context_summarizer.rs`)
5//! lands in M2. With no summarizer injected, the orchestrator transparently runs at Tier 0
6//! (heuristic compression only).
7//!
8//! The structured-section design is taken from Factory.ai's anchored iterative summarization
9//! pattern: a fixed schema of sections that the LLM repeatedly re-populates, instead of a free-form
10//! summary that drifts across turns.
11
12use serde::{Deserialize, Serialize};
13use std::error::Error;
14use std::fmt;
15
16use crate::segment::Segment;
17
18/// One section of an [`AnchoredSummary`].
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AnchoredSummarySection {
21    /// Stable section id (e.g. `"intent"`, `"decisions"`).
22    pub id: String,
23    /// Human-readable label for dashboards.
24    pub label: String,
25    /// Section content. May be empty.
26    pub content: String,
27}
28
29/// Structured running summary that anchors to fixed sections across turns.
30///
31/// The Factory.ai pattern: rather than re-writing a free-form blob each compaction, the
32/// summarizer is given the prior summary plus the dropped segments and asked to update each named
33/// section. This is what keeps coherence over 30+ turn conversations without drift.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct AnchoredSummary {
36    /// Schema version for forward compatibility.
37    pub schema_version: u32,
38    /// The fixed section list. Order is meaningful for prompt assembly.
39    pub sections: Vec<AnchoredSummarySection>,
40    /// Total token estimate (kept on the struct so dashboards don't recompute).
41    pub token_estimate: usize,
42    /// Iteration counter — bumped each time the summarizer re-anchors.
43    pub iteration: u32,
44}
45
46impl AnchoredSummary {
47    /// Current schema version for `AnchoredSummary` payloads.
48    pub const SCHEMA_VERSION: u32 = 1;
49
50    /// Default fixed-section schema (Factory.ai-derived).
51    #[must_use]
52    pub fn empty() -> Self {
53        Self {
54            schema_version: Self::SCHEMA_VERSION,
55            sections: vec![
56                AnchoredSummarySection {
57                    id: "intent".into(),
58                    label: "Intent".into(),
59                    content: String::new(),
60                },
61                AnchoredSummarySection {
62                    id: "decisions".into(),
63                    label: "Decisions".into(),
64                    content: String::new(),
65                },
66                AnchoredSummarySection {
67                    id: "files_touched".into(),
68                    label: "Files touched".into(),
69                    content: String::new(),
70                },
71                AnchoredSummarySection {
72                    id: "pending_tasks".into(),
73                    label: "Pending tasks".into(),
74                    content: String::new(),
75                },
76                AnchoredSummarySection {
77                    id: "current_state".into(),
78                    label: "Current state".into(),
79                    content: String::new(),
80                },
81            ],
82            token_estimate: 0,
83            iteration: 0,
84        }
85    }
86
87    /// Whether all sections are empty (no content yet).
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.sections.iter().all(|s| s.content.trim().is_empty())
91    }
92
93    /// Render the summary as a single text block for prompt injection.
94    #[must_use]
95    pub fn to_prompt_text(&self) -> String {
96        let mut out = String::new();
97        for section in &self.sections {
98            if section.content.trim().is_empty() {
99                continue;
100            }
101            if !out.is_empty() {
102                out.push_str("\n\n");
103            }
104            out.push_str("## ");
105            out.push_str(&section.label);
106            out.push('\n');
107            out.push_str(section.content.trim());
108        }
109        out
110    }
111}
112
113/// Errors a [`Summarizer`] implementation may return.
114#[derive(Debug)]
115pub enum SummarizerError {
116    /// The summarizer call timed out.
117    Timeout,
118    /// Network / transport failure.
119    Transport(String),
120    /// Parsing the LLM's structured response failed.
121    Parse(String),
122    /// Catch-all.
123    Other(String),
124}
125
126impl fmt::Display for SummarizerError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Timeout => f.write_str("summarizer timed out"),
130            Self::Transport(m) => write!(f, "summarizer transport: {m}"),
131            Self::Parse(m) => write!(f, "summarizer parse: {m}"),
132            Self::Other(m) => write!(f, "summarizer: {m}"),
133        }
134    }
135}
136
137impl Error for SummarizerError {}
138
139impl SummarizerError {
140    /// Short stable kind tag for telemetry.
141    #[must_use]
142    pub fn kind(&self) -> &'static str {
143        match self {
144            Self::Timeout => "timeout",
145            Self::Transport(_) => "transport",
146            Self::Parse(_) => "parse",
147            Self::Other(_) => "other",
148        }
149    }
150}
151
152/// Trait implemented by the M2 `openfang-runtime` adapter (`LlmDriverSummarizer`) — wraps the
153/// existing `LlmDriver` so the compiler crate stays openfang-free.
154///
155/// Returns `Result<AnchoredSummary, SummarizerError>` so the orchestrator can auto-degrade to
156/// Tier 0 on any failure without bringing down the turn.
157///
158/// Marked `Send + Sync` so a single summarizer instance can be shared via `Arc`.
159pub trait Summarizer: Send + Sync {
160    /// Re-anchor `existing_summary` (or build from scratch when `None`) using the dropped
161    /// `segments`. Implementations should be idempotent across retries.
162    ///
163    /// The trait method is intentionally **synchronous** at the interface level so M1 can ship
164    /// without an async runtime dependency in this crate. The host adapter wraps any async work
165    /// (e.g. `LlmDriver` HTTP) using `tokio::runtime::Handle::block_on` or by exposing a
166    /// non-async wrapper.
167    fn summarize(
168        &self,
169        segments: &[Segment],
170        existing_summary: Option<&AnchoredSummary>,
171    ) -> Result<AnchoredSummary, SummarizerError>;
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn empty_summary_renders_nothing() {
180        let s = AnchoredSummary::empty();
181        assert!(s.is_empty());
182        assert_eq!(s.to_prompt_text(), "");
183    }
184
185    #[test]
186    fn populated_summary_renders_sections() {
187        let mut s = AnchoredSummary::empty();
188        s.sections[0].content = "Build the context compiler.".into();
189        s.sections[1].content = "Reuse ainl-compression for fine pruning.".into();
190        let text = s.to_prompt_text();
191        assert!(text.contains("## Intent"));
192        assert!(text.contains("Build the context compiler."));
193        assert!(text.contains("## Decisions"));
194        assert!(!text.contains("## Files touched")); // empty section omitted
195    }
196
197    #[test]
198    fn summarizer_error_kinds_unique() {
199        let kinds = [
200            SummarizerError::Timeout.kind(),
201            SummarizerError::Transport("x".into()).kind(),
202            SummarizerError::Parse("x".into()).kind(),
203            SummarizerError::Other("x".into()).kind(),
204        ];
205        for i in 0..kinds.len() {
206            for j in (i + 1)..kinds.len() {
207                assert_ne!(kinds[i], kinds[j]);
208            }
209        }
210    }
211
212    #[test]
213    fn json_roundtrip() {
214        let mut s = AnchoredSummary::empty();
215        s.sections[0].content = "hello".into();
216        s.token_estimate = 3;
217        s.iteration = 1;
218        let j = serde_json::to_value(&s).unwrap();
219        let back: AnchoredSummary = serde_json::from_value(j).unwrap();
220        assert_eq!(s, back);
221    }
222}