Skip to main content

talos_evolution/
observer.rs

1//! TurnObserver — captures signals during agent execution.
2
3use crate::{Observation, SignalType};
4
5/// Captures observations during agent turns.
6pub struct TurnObserver {
7    /// Current session ID
8    session_id: Option<String>,
9    /// Current turn number
10    turn_number: u32,
11    /// Accumulated observations for current turn
12    observations: Vec<Observation>,
13}
14
15impl TurnObserver {
16    /// Create a new TurnObserver.
17    pub fn new(session_id: Option<String>) -> Self {
18        Self {
19            session_id,
20            turn_number: 0,
21            observations: Vec::new(),
22        }
23    }
24
25    /// Find the byte offset of the first matching marker phrase in `text`.
26    ///
27    /// Searches case-insensitively. Returns `Some(byte_offset)` of the match
28    /// start, or `None` if no marker is found.
29    pub fn find_marker(text: &str, markers: &[&str]) -> Option<usize> {
30        let lower = text.to_lowercase();
31        for marker in markers {
32            if let Some(pos) = lower.find(&marker.to_lowercase()) {
33                return Some(pos);
34            }
35        }
36        None
37    }
38
39    /// Extract a context window centered on `marker_pos`.
40    ///
41    /// Returns approximately `window_bytes / 2` bytes before and after the
42    /// marker, with the marker phrase centered. Respects UTF-8 char boundaries.
43    pub fn capture_window(text: &str, marker_pos: usize, window_bytes: usize) -> String {
44        let half = window_bytes / 2;
45        let text_len = text.len();
46
47        let start = marker_pos.saturating_sub(half);
48        let end = (marker_pos + half).min(text_len);
49
50        let start = text
51            .char_indices()
52            .rev()
53            .find(|(i, _)| *i <= start)
54            .map(|(i, _)| i)
55            .unwrap_or(0);
56
57        let end = text
58            .char_indices()
59            .find(|(i, _)| *i >= end)
60            .map(|(i, c)| (i + c.len_utf8()).min(text_len))
61            .unwrap_or(text_len);
62
63        let window = &text[start..end];
64
65        let mut result = String::with_capacity(window.len() + 8);
66        if start > 0 {
67            result.push_str("...");
68        }
69        result.push_str(window);
70        if end < text_len {
71            result.push_str("...");
72        }
73        result
74    }
75
76    /// Truncate context to fit `max_bytes`, appending a marker if truncated.
77    #[deprecated(
78        since = "0.2.0",
79        note = "Use find_marker + capture_window instead. This function keeps the head of the string, losing the actual signal."
80    )]
81    #[allow(deprecated)]
82    pub fn truncate_context(context: String, max_bytes: usize) -> String {
83        if max_bytes == 0 {
84            return format!("... [truncated, original was {} bytes]", context.len());
85        }
86        let byte_len = context.len();
87        if byte_len <= max_bytes {
88            return context;
89        }
90        let marker = format!("... [truncated, original was {byte_len} bytes]");
91        let marker_len = marker.len();
92        if marker_len >= max_bytes {
93            return marker;
94        }
95        let truncate_at = max_bytes - marker_len;
96        let mut result = String::with_capacity(max_bytes);
97        result.push_str(&context[..truncate_at]);
98        result.push_str(&marker);
99        result
100    }
101
102    /// Start a new turn.
103    pub fn start_turn(&mut self) {
104        self.turn_number += 1;
105        self.observations.clear();
106    }
107
108    /// Record a correction signal.
109    pub fn record_correction(&mut self, context: String, intensity: f64) {
110        let obs = Observation::new(
111            SignalType::Correction,
112            intensity.clamp(0.0, 1.0),
113            context,
114            self.session_id.clone(),
115            Some(self.turn_number),
116        );
117        self.observations.push(obs);
118    }
119
120    /// Record an error signal.
121    pub fn record_error(&mut self, context: String, intensity: f64) {
122        let obs = Observation::new(
123            SignalType::Error,
124            intensity.clamp(0.0, 1.0),
125            context,
126            self.session_id.clone(),
127            Some(self.turn_number),
128        );
129        self.observations.push(obs);
130    }
131
132    /// Record a satisfaction signal.
133    pub fn record_satisfaction(&mut self, context: String, intensity: f64) {
134        let obs = Observation::new(
135            SignalType::Satisfaction,
136            intensity.clamp(0.0, 1.0),
137            context,
138            self.session_id.clone(),
139            Some(self.turn_number),
140        );
141        self.observations.push(obs);
142    }
143
144    /// Record an inefficiency signal.
145    pub fn record_inefficiency(&mut self, context: String, intensity: f64) {
146        let obs = Observation::new(
147            SignalType::Inefficiency,
148            intensity.clamp(0.0, 1.0),
149            context,
150            self.session_id.clone(),
151            Some(self.turn_number),
152        );
153        self.observations.push(obs);
154    }
155
156    /// Get all observations for the current turn.
157    pub fn current_observations(&self) -> &[Observation] {
158        &self.observations
159    }
160
161    /// Get the current turn number.
162    pub fn turn_number(&self) -> u32 {
163        self.turn_number
164    }
165
166    /// Get the session ID.
167    pub fn session_id(&self) -> Option<&str> {
168        self.session_id.as_deref()
169    }
170
171    /// Drain observations from the current turn.
172    pub fn drain_observations(&mut self) -> Vec<Observation> {
173        std::mem::take(&mut self.observations)
174    }
175}
176
177#[cfg(test)]
178#[allow(warnings)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_turn_observer_new() {
184        let observer = TurnObserver::new(Some("session-1".to_string()));
185        assert_eq!(observer.turn_number(), 0);
186        assert_eq!(observer.session_id(), Some("session-1"));
187    }
188
189    #[test]
190    fn test_record_signals() {
191        let mut observer = TurnObserver::new(None);
192        observer.start_turn();
193
194        observer.record_correction("User said to use functional style".to_string(), 0.8);
195        observer.record_error("File not found".to_string(), 0.5);
196        observer.record_satisfaction("Good response".to_string(), 0.9);
197        observer.record_inefficiency("Took too many steps".to_string(), 0.3);
198
199        let observations = observer.current_observations();
200        assert_eq!(observations.len(), 4);
201        assert_eq!(observations[0].signal_type, SignalType::Correction);
202        assert_eq!(observations[1].signal_type, SignalType::Error);
203        assert_eq!(observations[2].signal_type, SignalType::Satisfaction);
204        assert_eq!(observations[3].signal_type, SignalType::Inefficiency);
205    }
206
207    #[test]
208    fn test_drain_observations() {
209        let mut observer = TurnObserver::new(None);
210        observer.start_turn();
211        observer.record_correction("test".to_string(), 0.5);
212
213        let drained = observer.drain_observations();
214        assert_eq!(drained.len(), 1);
215        assert!(observer.current_observations().is_empty());
216    }
217
218    #[test]
219    fn test_turn_increment() {
220        let mut observer = TurnObserver::new(None);
221        observer.start_turn();
222        assert_eq!(observer.turn_number(), 1);
223
224        observer.start_turn();
225        assert_eq!(observer.turn_number(), 2);
226    }
227
228    #[test]
229    #[allow(deprecated)]
230    fn test_truncate_context_under_limit_unchanged() {
231        let input = "short text".to_string();
232        let result = TurnObserver::truncate_context(input.clone(), 4096);
233        assert_eq!(result, input);
234    }
235
236    #[test]
237    #[allow(deprecated)]
238    fn test_truncate_context_over_limit_truncated_with_marker() {
239        let input = "a".repeat(5000);
240        let result = TurnObserver::truncate_context(input.clone(), 4096);
241        assert!(result.len() <= 4096);
242        assert!(result.contains("[truncated, original was 5000 bytes]"));
243    }
244
245    #[test]
246    #[allow(deprecated)]
247    fn test_truncate_context_exact_limit_unchanged() {
248        let input = "a".repeat(100);
249        let result = TurnObserver::truncate_context(input.clone(), 100);
250        assert_eq!(result, input);
251    }
252
253    #[test]
254    #[allow(deprecated)]
255    fn test_truncate_context_empty_max_bytes_returns_marker_only() {
256        let input = "some context".to_string();
257        let result = TurnObserver::truncate_context(input, 0);
258        assert!(result.contains("[truncated, original was 12 bytes]"));
259    }
260
261    // ─── I021-S2: find_marker + capture_window tests ────────────────────────
262
263    #[test]
264    fn test_find_marker_returns_byte_offset() {
265        let text = "Hello world, don't do that please";
266        let pos = TurnObserver::find_marker(text, &["don't", "do not"]);
267        assert_eq!(pos, Some(13));
268    }
269
270    #[test]
271    fn test_find_marker_case_insensitive() {
272        let text = "Hello world, DON'T do that please";
273        let pos = TurnObserver::find_marker(text, &["don't"]);
274        assert_eq!(pos, Some(13));
275    }
276
277    #[test]
278    fn test_find_marker_chinese() {
279        let text = "前面很多内容 不要用 sed 后面更多内容";
280        let pos = TurnObserver::find_marker(text, &["不要用 sed"]);
281        assert!(pos.is_some());
282        assert!(text[pos.expect("operation should succeed")..].starts_with("不要用 sed"));
283    }
284
285    #[test]
286    fn test_find_marker_not_found() {
287        let text = "Hello world, please continue";
288        let pos = TurnObserver::find_marker(text, &["don't", "do not"]);
289        assert_eq!(pos, None);
290    }
291
292    #[test]
293    fn test_capture_window_marker_in_center() {
294        let text = "AAAAAAAAAA marker BBBBBBBBBB";
295        let pos = text.find("marker").expect("operation should succeed");
296        let window = TurnObserver::capture_window(text, pos, 20);
297
298        assert!(
299            window.contains("marker"),
300            "window={window:?} len={}",
301            window.len()
302        );
303    }
304
305    #[test]
306    fn test_capture_window_marker_at_start() {
307        let text = "marker BBBBBBBBBBBBBBBBBBBB";
308        let window = TurnObserver::capture_window(text, 0, 20);
309
310        assert!(window.starts_with("marker"));
311        assert!(!window.starts_with("..."));
312    }
313
314    #[test]
315    fn test_capture_window_marker_at_end() {
316        let text = "AAAAAAAAAAAAAAAAAAAA marker";
317        let pos = text.find("marker").expect("operation should succeed");
318        let window = TurnObserver::capture_window(text, pos, 20);
319
320        assert!(window.contains("marker"));
321        assert!(window.ends_with("marker"));
322    }
323
324    #[test]
325    fn test_capture_window_5mb_input_small_output() {
326        let prefix = "x".repeat(5 * 1024 * 1024);
327        let text = format!("{}{}", prefix, "不要用 sed");
328        let pos = text.find("不要用 sed").expect("operation should succeed");
329        let window = TurnObserver::capture_window(&text, pos, 400);
330
331        assert!(
332            window.len() < 500,
333            "window {} bytes exceeds 500 for 5MB input",
334            window.len()
335        );
336        assert!(
337            window.contains("不要用 sed"),
338            "window must contain marker, got: {window:?}"
339        );
340    }
341
342    #[test]
343    fn test_capture_window_respects_utf8_boundaries() {
344        let text = "AAAA 你好世界 marker 你好世界 BBBB";
345        let pos = text.find("marker").expect("operation should succeed");
346        let window = TurnObserver::capture_window(text, pos, 30);
347
348        assert!(window.contains("marker"));
349        assert!(window.is_char_boundary(0));
350    }
351
352    #[test]
353    fn test_capture_window_no_marker_uses_full_text() {
354        let text = "short text";
355        let window = TurnObserver::capture_window(text, 0, 400);
356        assert_eq!(window, "short text");
357    }
358
359    #[test]
360    fn test_capture_window_5mb_with_chinese_tail_contains_marker() {
361        let prefix = "system_prompt content ".repeat(200_000);
362        let text = format!("{}{}", prefix, "不要用 sed");
363        let pos = text.find("不要用 sed").expect("operation should succeed");
364        let window = TurnObserver::capture_window(&text, pos, 400);
365
366        assert!(
367            window.len() < 500,
368            "window {} bytes exceeds 500",
369            window.len()
370        );
371        assert!(
372            window.contains("不要用 sed"),
373            "window must contain '不要用 sed', got: {window:?}"
374        );
375    }
376}