Skip to main content

zeph_context/
compression_feedback.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pure detection and classification helpers for ACON compression guidelines (#1647).
5//!
6//! This module contains the stateless functions that detect context loss after
7//! compaction and classify what was likely lost. The `Agent`-level integration
8//! (logging to `SQLite`, reading `self.*` fields) lives in `zeph-core`.
9
10use std::sync::LazyLock;
11
12use regex::Regex;
13
14/// Explicit uncertainty phrases — signals the agent is unaware of something.
15///
16/// Pattern set 1: must match for detection to fire.
17static UNCERTAINTY_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
18    vec![
19        Regex::new(
20            r"(?i)\b(i\s+(don'?t|no\s+longer)\s+have\s+(access\s+to|information\s+(about|on|regarding)))\b",
21        )
22        .unwrap(),
23        Regex::new(r"(?i)\b(i\s+(wasn'?t|haven'?t\s+been)\s+(provided|given)\s+with)\b").unwrap(),
24        Regex::new(
25            r"(?i)\b(i\s+don'?t\s+(recall|remember)\s+(any|the|what|which)\s+(previous|earlier|prior|specific))\b",
26        )
27        .unwrap(),
28        Regex::new(
29            r"(?i)\b(i'?m\s+not\s+sure\s+what\s+(we|was|had)\s+(discussed|covered|decided|established))\b",
30        )
31        .unwrap(),
32        Regex::new(r"(?i)\b(could\s+you\s+(remind|tell)\s+me\s+(what|again|about))\b").unwrap(),
33    ]
34});
35
36/// Prior-context reference phrases — signals the response references something that
37/// should have been in the compressed context.
38///
39/// Pattern set 2: must ALSO match for detection to fire.
40static PRIOR_CONTEXT_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
41    vec![
42        Regex::new(
43            r"(?i)\b(earlier\s+in\s+(our|this)\s+(conversation|session|chat|discussion))\b",
44        )
45        .unwrap(),
46        Regex::new(
47            r"(?i)\b((previously|before)\s+(you\s+(mentioned|said|told|described|shared)|we\s+(discussed|covered|established|decided)))\b",
48        )
49        .unwrap(),
50        Regex::new(
51            r"(?i)\b(in\s+(our|the)\s+(earlier|previous|prior|past)\s+(exchange|discussion|conversation|messages|context))\b",
52        )
53        .unwrap(),
54        Regex::new(
55            r"(?i)\b(based\s+on\s+what\s+(you|we)\s+(told|said|discussed|shared)(\s+\w+)?\s+(earlier|before|previously))\b",
56        )
57        .unwrap(),
58    ]
59});
60
61/// Classify which content category was likely lost in a compaction failure.
62///
63/// Returns one of: `tool_output`, `assistant_reasoning`, `user_context`, `unknown`.
64///
65/// Classification is performed on the compaction summary text, not on the
66/// post-summary LLM response.
67#[must_use]
68pub fn classify_failure_category(compressed_context: &str) -> &'static str {
69    let has_tool_markers = compressed_context.contains("[tool output")
70        || compressed_context.contains("ToolOutput")
71        || compressed_context.contains("ToolResult")
72        || compressed_context.contains("[archived:")
73        || compressed_context.contains("read_overflow");
74
75    let has_user_markers = compressed_context.contains("[user]:")
76        || compressed_context
77            .matches("[user]:")
78            .count()
79            .saturating_mul(2)
80            > compressed_context.matches("[assistant]:").count();
81
82    let has_assistant_markers =
83        compressed_context.contains("[assistant]:") && !has_tool_markers && !has_user_markers;
84
85    if has_tool_markers {
86        "tool_output"
87    } else if has_assistant_markers {
88        "assistant_reasoning"
89    } else if has_user_markers {
90        "user_context"
91    } else {
92        "unknown"
93    }
94}
95
96/// Detect whether `response` contains signals of context loss after compaction.
97///
98/// Returns `Some(reason)` only when ALL of:
99/// 1. `had_compaction` is `true`
100/// 2. At least one uncertainty phrase matches
101/// 3. At least one prior-context reference phrase also matches
102///
103/// This two-signal requirement minimizes false positives. Neither pattern set
104/// alone is reliable; together they are highly specific.
105///
106/// # Returns
107///
108/// - `None` when `had_compaction` is `false`.
109/// - `None` when the response does not match both signal categories.
110/// - `Some(reason)` with a description string when context loss is detected.
111#[must_use]
112pub fn detect_compression_failure(response: &str, had_compaction: bool) -> Option<String> {
113    if !had_compaction {
114        return None;
115    }
116
117    let uncertainty_match = UNCERTAINTY_PATTERNS
118        .iter()
119        .find_map(|p| p.find(response).map(|m| m.as_str().to_string()));
120
121    let prior_ctx_match = PRIOR_CONTEXT_PATTERNS
122        .iter()
123        .find_map(|p| p.find(response).map(|m| m.as_str().to_string()));
124
125    match (uncertainty_match, prior_ctx_match) {
126        (Some(u), Some(p)) => Some(format!(
127            "context loss signals detected: uncertainty='{u}', prior-ref='{p}'"
128        )),
129        _ => None,
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    // ── True positives: both signals present ──────────────────────────────────
138
139    #[test]
140    fn detects_dont_have_access_with_prior_ref() {
141        let response = "I don't have access to the information about the file path. Earlier in our conversation you mentioned a specific path.";
142        assert!(
143            detect_compression_failure(response, true).is_some(),
144            "should detect context loss"
145        );
146    }
147
148    #[test]
149    fn detects_wasnt_provided_with_previously() {
150        let response = "I wasn't provided with that information. Previously you mentioned the database schema.";
151        assert!(detect_compression_failure(response, true).is_some());
152    }
153
154    #[test]
155    fn detects_dont_recall_prior_specific_with_earlier() {
156        let response = "I don't recall the specific error from before. In our earlier discussion you shared the stack trace.";
157        assert!(detect_compression_failure(response, true).is_some());
158    }
159
160    #[test]
161    fn detects_not_sure_what_we_discussed_with_prior() {
162        let response = "I'm not sure what we discussed about the API design. Based on what you told me earlier, it was REST-based.";
163        assert!(detect_compression_failure(response, true).is_some());
164    }
165
166    // ── False negatives when had_compaction=false ─────────────────────────────
167
168    #[test]
169    fn no_detection_when_no_compaction() {
170        let response =
171            "I don't have access to that. Earlier in our conversation you mentioned the path.";
172        assert!(
173            detect_compression_failure(response, false).is_none(),
174            "must not fire without compaction"
175        );
176    }
177
178    // ── False positives: only one signal → should NOT fire ────────────────────
179
180    #[test]
181    fn no_detection_with_only_uncertainty() {
182        let response = "I don't recall the specific previous details.";
183        assert!(
184            detect_compression_failure(response, true).is_none(),
185            "uncertainty alone must not fire without a prior-context anchor phrase"
186        );
187    }
188
189    #[test]
190    fn no_detection_normal_conversation_reference() {
191        let response = "As mentioned earlier, the function takes two arguments.";
192        assert!(
193            detect_compression_failure(response, true).is_none(),
194            "normal conversational reference must not trigger"
195        );
196    }
197
198    #[test]
199    fn no_detection_llm_asking_clarifying_question() {
200        let response = "Could you tell me more about what you'd like the function to do?";
201        assert!(
202            detect_compression_failure(response, true).is_none(),
203            "clarifying question without prior-context ref must not fire"
204        );
205    }
206
207    #[test]
208    fn no_detection_legitimate_i_dont_see_previous() {
209        let response =
210            "I don't see any previous error logs in your message. Could you paste them here?";
211        assert!(
212            detect_compression_failure(response, true).is_none(),
213            "legitimate 'I don't see' without prior-context ref must not fire"
214        );
215    }
216
217    #[test]
218    fn no_detection_empty_response() {
219        assert!(detect_compression_failure("", true).is_none());
220        assert!(detect_compression_failure("", false).is_none());
221    }
222
223    #[test]
224    fn returns_reason_string_with_matches() {
225        let response = "I don't have access to information about that. Previously you mentioned the config file.";
226        let reason = detect_compression_failure(response, true).expect("should detect");
227        assert!(
228            reason.contains("uncertainty="),
229            "reason must include uncertainty match"
230        );
231        assert!(
232            reason.contains("prior-ref="),
233            "reason must include prior-ref match"
234        );
235    }
236
237    // ── classify_failure_category() unit tests ────────────────────────────────
238
239    #[test]
240    fn classify_tool_output_by_tool_output_marker() {
241        let ctx = "[tool output]: file listing returned 42 items";
242        assert_eq!(classify_failure_category(ctx), "tool_output");
243    }
244
245    #[test]
246    fn classify_tool_output_by_archived_marker() {
247        let ctx = "[archived:550e8400-e29b-41d4-a716-446655440000 — tool: shell — 1024 bytes]";
248        assert_eq!(classify_failure_category(ctx), "tool_output");
249    }
250
251    #[test]
252    fn classify_tool_output_by_tooloutput_struct_name() {
253        let ctx = "ToolOutput { body: \"...\", tool_name: \"shell\" }";
254        assert_eq!(classify_failure_category(ctx), "tool_output");
255    }
256
257    #[test]
258    fn classify_assistant_reasoning_pure_assistant_context() {
259        let ctx = "[assistant]: Let me think about this step by step.\n\
260                   [assistant]: First, we need to consider the constraints.";
261        assert_eq!(classify_failure_category(ctx), "assistant_reasoning");
262    }
263
264    #[test]
265    fn classify_user_context_dominant_user_turns() {
266        let ctx = "[user]: what is X?\n[user]: and also Y?\n[user]: and Z?\n[assistant]: ...";
267        assert_eq!(classify_failure_category(ctx), "user_context");
268    }
269
270    #[test]
271    fn classify_tool_output_wins_over_user_markers() {
272        let ctx = "[user]: please run the command\n[tool output]: exit 0";
273        assert_eq!(
274            classify_failure_category(ctx),
275            "tool_output",
276            "tool_output must take priority when both markers are present"
277        );
278    }
279
280    #[test]
281    fn classify_unknown_for_empty_context() {
282        assert_eq!(classify_failure_category(""), "unknown");
283    }
284
285    #[test]
286    fn classify_unknown_for_context_without_markers() {
287        let ctx = "This is a generic summary without any role markers or tool output.";
288        assert_eq!(classify_failure_category(ctx), "unknown");
289    }
290}