zeph_context/
compression_feedback.rs1use std::sync::LazyLock;
11
12use regex::Regex;
13
14static 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
36static 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#[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#[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 #[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 #[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 #[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 #[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}