1use std::sync::Mutex;
26use std::time::Duration;
27
28use async_trait::async_trait;
29use talos_core::message::Message;
30use talos_plugin::event::{HookEvent, HookEventKind};
31use talos_plugin::handler::{HookContext, HookHandler, HookResult};
32
33use crate::adapter::BehaviorAdapter;
34use crate::extractor::PatternExtractor;
35use crate::observer::TurnObserver;
36use crate::store::KnowledgeStore;
37use crate::{EvolutionConfig, EvolutionResult};
38
39const ERROR_INTENSITY: f64 = 1.0;
40const CORRECTION_INTENSITY: f64 = 0.8;
41
42const CORRECTION_MARKERS: &[&str] = &[
43 "don't",
44 "do not",
45 "instead",
46 "actually",
47 "that's wrong",
48 "thats wrong",
49 "is wrong",
50 "should be",
51 "no, ",
52 "不对",
53 "不要",
54 "别",
55 "错了",
56];
57
58pub struct EvolutionHookHandler {
67 store: Mutex<KnowledgeStore>,
68 config: EvolutionConfig,
69 observer: Mutex<TurnObserver>,
70}
71
72impl EvolutionHookHandler {
73 #[must_use]
75 pub fn new(store: KnowledgeStore, config: EvolutionConfig, session_id: Option<String>) -> Self {
76 Self {
77 store: Mutex::new(store),
78 config,
79 observer: Mutex::new(TurnObserver::new(session_id)),
80 }
81 }
82
83 pub fn open_default(
89 config: EvolutionConfig,
90 session_id: Option<String>,
91 ) -> EvolutionResult<Option<Self>> {
92 let Some(home) = dirs::home_dir() else {
93 return Ok(None);
94 };
95 let dir = home.join(".talos").join("evolution");
96 std::fs::create_dir_all(&dir)?;
97 let db_path = dir.join("knowledge.db");
98 let store = KnowledgeStore::open(db_path.to_str().unwrap_or_default())?;
99
100 let purged = store.delete_oversized_patterns(config.max_output_bytes)?;
101 if purged > 0 {
102 tracing::info!(
103 count = purged,
104 "evolution: purged oversized patterns on open"
105 );
106 }
107
108 Ok(Some(Self::new(store, config, session_id)))
109 }
110
111 fn evolution_context(&self) -> String {
112 let store = self.store.lock().expect("evolution store poisoned");
113 BehaviorAdapter::new(&store, self.config.clone()).get_evolution_context()
114 }
115
116 fn flush(&self) {
117 let observations = {
118 let mut observer = self.observer.lock().expect("evolution observer poisoned");
119 observer.drain_observations()
120 };
121 if observations.is_empty() {
122 return;
123 }
124
125 let store = self.store.lock().expect("evolution store poisoned");
126 for obs in &observations {
127 if let Err(e) = store.insert_observation(obs) {
128 tracing::warn!(error = %e, "evolution: failed to persist observation");
129 continue;
130 }
131 let Some(candidate) = PatternExtractor::extract_from_observation(obs) else {
132 continue;
133 };
134 let existing = match store.get_all_patterns() {
135 Ok(p) => p,
136 Err(e) => {
137 tracing::warn!(error = %e, "evolution: failed to load existing patterns");
138 continue;
139 }
140 };
141 let matched = existing.into_iter().find(|p| {
142 p.category == candidate.category && p.content_hash == candidate.content_hash
143 });
144 let result = match matched {
145 Some(mut pattern) => {
146 PatternExtractor::merge_evidence(&mut pattern, std::slice::from_ref(obs));
147 store.update_pattern(&pattern)
148 }
149 None => store.insert_pattern(&candidate),
150 };
151 if let Err(e) = result {
152 tracing::warn!(error = %e, "evolution: failed to update pattern");
153 }
154 }
155 }
156}
157
158#[async_trait]
159impl HookHandler for EvolutionHookHandler {
160 fn name(&self) -> &str {
161 "evolution"
162 }
163
164 fn subscribed(&self) -> &'static [HookEventKind] {
165 &[
166 HookEventKind::TurnStart,
167 HookEventKind::OnSystemPromptBuilt,
168 HookEventKind::BeforeProviderCall,
169 HookEventKind::OnProviderError,
170 HookEventKind::OnTextDelta,
171 HookEventKind::OnToolResultObserved,
172 HookEventKind::AfterToolCall,
173 HookEventKind::OnTurnEnd,
174 HookEventKind::TurnComplete,
175 ]
176 }
177
178 fn timeout(&self) -> Duration {
179 Duration::from_secs(5)
180 }
181
182 async fn on_event(&self, _ctx: &HookContext, event: &mut HookEvent<'_>) -> HookResult {
183 match event {
184 HookEvent::TurnStart { .. } => {
185 let mut observer = self.observer.lock().expect("evolution observer poisoned");
186 observer.start_turn();
187 HookResult::Continue
188 }
189 HookEvent::OnSystemPromptBuilt { prompt } => {
190 let context = self.evolution_context();
191 if context.is_empty() {
192 return HookResult::Continue;
193 }
194 let augmented = format!("{prompt}\n\n{context}");
200 HookResult::Modify(HookEvent::OnSystemPromptBuilt {
201 prompt: Box::leak(augmented.into_boxed_str()),
202 })
203 }
204 HookEvent::BeforeProviderCall { messages } => {
205 if let Some(text) = messages.iter().find_map(|m| match m {
206 Message::User { content } => Some(content.as_str()),
207 _ => None,
208 }) && let Some((intensity, _marker)) = detect_correction_with_marker(text)
209 {
210 let mut observer = self.observer.lock().expect("evolution observer poisoned");
211 let context = if let Some(marker_pos) =
212 TurnObserver::find_marker(text, CORRECTION_MARKERS)
213 {
214 TurnObserver::capture_window(
215 text,
216 marker_pos,
217 self.config.max_context_bytes.min(500),
218 )
219 } else {
220 #[allow(deprecated)]
221 TurnObserver::truncate_context(
222 text.to_string(),
223 self.config.max_context_bytes,
224 )
225 };
226 observer.record_correction(context, intensity);
227 }
228 HookResult::Continue
229 }
230 HookEvent::OnProviderError { error } => {
231 let message = format!("{error:?}");
232 let mut observer = self.observer.lock().expect("evolution observer poisoned");
233 #[allow(deprecated)]
234 let truncated =
235 TurnObserver::truncate_context(message, self.config.max_context_bytes);
236 observer.record_error(truncated, ERROR_INTENSITY);
237 HookResult::Continue
238 }
239 HookEvent::TurnComplete { .. } => {
240 self.flush();
241 HookResult::Continue
242 }
243 HookEvent::OnTurnEnd { .. }
244 | HookEvent::OnTextDelta { .. }
245 | HookEvent::OnToolResultObserved { .. }
246 | HookEvent::AfterToolCall { .. } => HookResult::Continue,
247 _ => HookResult::Continue,
248 }
249 }
250}
251
252#[cfg(test)]
253#[allow(warnings)]
254fn detect_correction(input: &str) -> Option<f64> {
255 detect_correction_with_marker(input).map(|(intensity, _)| intensity)
256}
257
258fn detect_correction_with_marker(input: &str) -> Option<(f64, &str)> {
259 let lower = input.to_lowercase();
260 for marker in CORRECTION_MARKERS {
261 if lower.contains(&marker.to_lowercase()) {
262 return Some((CORRECTION_INTENSITY, marker));
263 }
264 }
265 None
266}
267
268#[cfg(test)]
269#[allow(warnings)]
270mod tests {
271 use super::*;
272 use crate::{Pattern, SignalType};
273 use std::path::PathBuf;
274 use talos_core::provider::ProviderError;
275
276 use talos_plugin::event::{TurnId, TurnStatus};
277
278 fn handler() -> EvolutionHookHandler {
279 let store = KnowledgeStore::open_memory().expect("in-memory store");
280 EvolutionHookHandler::new(store, EvolutionConfig::default(), Some("test".into()))
281 }
282
283 fn ctx() -> HookContext {
284 HookContext::new(TurnId::new(), PathBuf::from("."))
285 }
286
287 #[tokio::test]
288 async fn turn_start_then_turn_complete_with_no_observations_is_noop() {
289 let h = handler();
290 let c = ctx();
291 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
292 .await;
293 h.on_event(
294 &c,
295 &mut HookEvent::TurnComplete {
296 turn_id: c.turn_id,
297 status: TurnStatus::Success,
298 },
299 )
300 .await;
301 let store = h.store.lock().expect("store poisoned");
302 assert!(
303 store
304 .get_observations()
305 .expect("operation should succeed")
306 .is_empty()
307 );
308 }
309
310 #[tokio::test]
311 async fn provider_error_records_observation_and_persists() {
312 let h = handler();
313 let c = ctx();
314 let err = ProviderError::ServerError("test failure".into());
315 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
316 .await;
317 h.on_event(&c, &mut HookEvent::OnProviderError { error: &err })
318 .await;
319 h.on_event(
320 &c,
321 &mut HookEvent::TurnComplete {
322 turn_id: c.turn_id,
323 status: TurnStatus::ProviderError,
324 },
325 )
326 .await;
327
328 let store = h.store.lock().expect("store poisoned");
329 let observations = store.get_observations().expect("operation should succeed");
330 assert_eq!(observations.len(), 1);
331 assert_eq!(observations[0].signal_type, SignalType::Error);
332 }
333
334 #[tokio::test]
335 async fn repeated_errors_accumulate_into_injectable_pattern() {
336 let h = handler();
337 let c = ctx();
338 for _ in 0..3 {
339 let err = ProviderError::ServerError("compilation failed".into());
340 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
341 .await;
342 h.on_event(&c, &mut HookEvent::OnProviderError { error: &err })
343 .await;
344 h.on_event(
345 &c,
346 &mut HookEvent::TurnComplete {
347 turn_id: c.turn_id,
348 status: TurnStatus::ProviderError,
349 },
350 )
351 .await;
352 }
353 let context = h.evolution_context();
354 assert!(
355 context.contains("Learned Patterns"),
356 "third identical error should produce an injectable pattern, got: {context:?}"
357 );
358 }
359
360 #[tokio::test]
361 async fn distinct_errors_do_not_merge() {
362 let h = handler();
363 let c = ctx();
364 for message in ["alpha", "beta", "gamma"] {
365 let err = ProviderError::ServerError(message.into());
366 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
367 .await;
368 h.on_event(&c, &mut HookEvent::OnProviderError { error: &err })
369 .await;
370 h.on_event(
371 &c,
372 &mut HookEvent::TurnComplete {
373 turn_id: c.turn_id,
374 status: TurnStatus::ProviderError,
375 },
376 )
377 .await;
378 }
379 assert!(h.evolution_context().is_empty());
380 }
381
382 #[tokio::test]
383 async fn on_system_prompt_built_returns_continue_when_no_patterns() {
384 let h = handler();
385 let c = ctx();
386 let mut event = HookEvent::OnSystemPromptBuilt {
387 prompt: "base prompt",
388 };
389 let outcome = h.on_event(&c, &mut event).await;
390 assert!(matches!(outcome, HookResult::Continue));
391 }
392
393 #[tokio::test]
394 async fn on_system_prompt_built_returns_modify_with_context() {
395 let h = handler();
396 {
397 let store = h.store.lock().expect("store poisoned");
398 let mut pattern = Pattern::new(
399 "Prefer local state".into(),
400 "Avoid global mutable state in library code".into(),
401 "preference".into(),
402 );
403 pattern.confidence = 0.9;
404 pattern.evidence_count = 5;
405 store
406 .insert_pattern(&pattern)
407 .expect("operation should succeed");
408 }
409
410 let c = ctx();
411 let mut event = HookEvent::OnSystemPromptBuilt { prompt: "BASE" };
412 let outcome = h.on_event(&c, &mut event).await;
413 match outcome {
414 HookResult::Modify(HookEvent::OnSystemPromptBuilt { prompt }) => {
415 assert!(
416 prompt.contains("BASE"),
417 "augmented prompt must keep the original prefix"
418 );
419 assert!(prompt.contains("Advisory Learned Patterns"));
420 assert!(prompt.contains("Avoid global mutable state"));
421 }
422 other => panic!("expected Modify, got {other:?}"),
423 }
424 }
425
426 #[tokio::test]
427 async fn before_provider_call_detects_user_correction() {
428 let h = handler();
429 let c = ctx();
430 let messages = vec![Message::User {
431 content: "No, don't do that, use a HashMap instead".into(),
432 }];
433 h.on_event(
434 &c,
435 &mut HookEvent::BeforeProviderCall {
436 messages: &messages,
437 },
438 )
439 .await;
440 h.on_event(
441 &c,
442 &mut HookEvent::TurnComplete {
443 turn_id: c.turn_id,
444 status: TurnStatus::Success,
445 },
446 )
447 .await;
448 let store = h.store.lock().expect("store poisoned");
449 let observations = store.get_observations().expect("operation should succeed");
450 assert_eq!(observations.len(), 1);
451 assert_eq!(observations[0].signal_type, SignalType::Correction);
452 }
453
454 #[tokio::test]
455 async fn before_provider_call_ignores_non_correction_input() {
456 let h = handler();
457 let c = ctx();
458 let messages = vec![Message::User {
459 content: "Please continue with the plan.".into(),
460 }];
461 h.on_event(
462 &c,
463 &mut HookEvent::BeforeProviderCall {
464 messages: &messages,
465 },
466 )
467 .await;
468 h.on_event(
469 &c,
470 &mut HookEvent::TurnComplete {
471 turn_id: c.turn_id,
472 status: TurnStatus::Success,
473 },
474 )
475 .await;
476 let store = h.store.lock().expect("store poisoned");
477 assert!(
478 store
479 .get_observations()
480 .expect("operation should succeed")
481 .is_empty()
482 );
483 }
484
485 #[tokio::test]
486 async fn flush_resets_observer_for_next_turn() {
487 let h = handler();
488 let c = ctx();
489 let err = ProviderError::ServerError("first turn".into());
490 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
491 .await;
492 h.on_event(&c, &mut HookEvent::OnProviderError { error: &err })
493 .await;
494 h.on_event(
495 &c,
496 &mut HookEvent::TurnComplete {
497 turn_id: c.turn_id,
498 status: TurnStatus::ProviderError,
499 },
500 )
501 .await;
502
503 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
504 .await;
505 h.on_event(
506 &c,
507 &mut HookEvent::TurnComplete {
508 turn_id: c.turn_id,
509 status: TurnStatus::Success,
510 },
511 )
512 .await;
513 let store = h.store.lock().expect("store poisoned");
514 let observations = store.get_observations().expect("operation should succeed");
515 assert_eq!(observations.len(), 1, "second turn added nothing");
516 }
517
518 #[test]
519 fn detect_correction_matches_known_markers() {
520 assert!(detect_correction("No, don't do that").is_some());
521 assert!(detect_correction("Actually use a HashMap instead").is_some());
522 assert!(detect_correction("这样不对").is_some());
523 assert!(detect_correction("please continue with the plan").is_none());
524 assert!(detect_correction("write a function").is_none());
525 }
526
527 #[test]
528 fn subscribed_kinds_match_audit() {
529 let h = handler();
530 let kinds = h.subscribed();
531 assert!(kinds.contains(&HookEventKind::TurnStart));
532 assert!(kinds.contains(&HookEventKind::OnSystemPromptBuilt));
533 assert!(kinds.contains(&HookEventKind::BeforeProviderCall));
534 assert!(kinds.contains(&HookEventKind::OnProviderError));
535 assert!(kinds.contains(&HookEventKind::TurnComplete));
536 }
537
538 #[test]
539 fn handler_is_send_and_sync() {
540 fn assert_send_sync<T: Send + Sync>() {}
541 assert_send_sync::<EvolutionHookHandler>();
542 }
543
544 #[test]
545 fn timeout_allows_sqlite_flush() {
546 let h = handler();
547 assert!(h.timeout() >= Duration::from_secs(1));
548 }
549
550 #[tokio::test]
551 async fn test_hook_truncates_correction_context_before_recording() {
552 let store = KnowledgeStore::open_memory().expect("in-memory store");
553 let mut config = EvolutionConfig::default();
554 config.max_context_bytes = 100;
555 let h = EvolutionHookHandler::new(store, config, Some("test".into()));
556
557 let c = ctx();
558 let big_text = "No, don't do that, use a HashMap instead of this very long explanation about data structures and why they matter";
559 let messages = vec![Message::User {
560 content: big_text.into(),
561 }];
562 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
563 .await;
564 h.on_event(
565 &c,
566 &mut HookEvent::BeforeProviderCall {
567 messages: &messages,
568 },
569 )
570 .await;
571 h.on_event(
572 &c,
573 &mut HookEvent::TurnComplete {
574 turn_id: c.turn_id,
575 status: TurnStatus::Success,
576 },
577 )
578 .await;
579
580 let store = h.store.lock().expect("store poisoned");
581 let observations = store.get_observations().expect("operation should succeed");
582 assert_eq!(observations.len(), 1);
583 assert!(
584 observations[0].context.len() <= 100,
585 "context {} exceeds max 100 bytes",
586 observations[0].context.len()
587 );
588 }
589
590 #[tokio::test]
591 async fn test_hook_dedup_via_content_hash_collapses_near_duplicates() {
592 let h = handler();
593 let c = ctx();
594
595 for _ in 0..3 {
596 let err = ProviderError::ServerError("compilation failed".into());
597 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
598 .await;
599 h.on_event(&c, &mut HookEvent::OnProviderError { error: &err })
600 .await;
601 h.on_event(
602 &c,
603 &mut HookEvent::TurnComplete {
604 turn_id: c.turn_id,
605 status: TurnStatus::ProviderError,
606 },
607 )
608 .await;
609 }
610
611 let store = h.store.lock().expect("store poisoned");
612 let patterns = store.get_all_patterns().expect("operation should succeed");
613 assert_eq!(
614 patterns.len(),
615 1,
616 "identical errors should collapse via content hash, got {} patterns",
617 patterns.len()
618 );
619 assert_eq!(
620 patterns[0].evidence_count, 3,
621 "evidence should accumulate to 3"
622 );
623 }
624
625 #[test]
626 fn test_hook_migration_purges_oversized_on_open() {
627 let dir = tempfile::tempdir().expect("tempdir");
628 let db_path = dir.path().join("knowledge.db");
629
630 let store = KnowledgeStore::open(db_path.to_str().expect("operation should succeed"))
631 .expect("open store");
632
633 let mut pattern = Pattern::new("Big".to_string(), "x".repeat(10_000), "test".to_string());
634 pattern.confidence = 0.9;
635 pattern.evidence_count = 5;
636 store
637 .insert_pattern(&pattern)
638 .expect("operation should succeed");
639
640 let pattern_id = pattern.id.clone();
641 drop(store);
642
643 let store = KnowledgeStore::open(db_path.to_str().expect("operation should succeed"))
644 .expect("reopen store");
645 let purged = store.delete_oversized_patterns(4096).expect("purge");
646 assert_eq!(purged, 1, "one oversized pattern should be deactivated");
647
648 let patterns = store.get_all_patterns().expect("operation should succeed");
649 assert_eq!(patterns.len(), 1);
650 assert!(
651 !patterns[0].active,
652 "oversized pattern should be deactivated after migration"
653 );
654 assert_eq!(patterns[0].id, pattern_id, "row should still exist");
655 }
656
657 #[tokio::test]
658 async fn test_hook_capture_window_5mb_input_chinese_marker() {
659 let store = KnowledgeStore::open_memory().expect("in-memory store");
660 let h = EvolutionHookHandler::new(store, EvolutionConfig::default(), Some("test".into()));
661
662 let c = ctx();
663 let prefix = "system_prompt content ".repeat(200_000);
664 let big_text = format!("{}{}", prefix, "不要用 sed");
665 let messages = vec![Message::User {
666 content: big_text.into(),
667 }];
668 h.on_event(&c, &mut HookEvent::TurnStart { turn_id: c.turn_id })
669 .await;
670 h.on_event(
671 &c,
672 &mut HookEvent::BeforeProviderCall {
673 messages: &messages,
674 },
675 )
676 .await;
677 h.on_event(
678 &c,
679 &mut HookEvent::TurnComplete {
680 turn_id: c.turn_id,
681 status: TurnStatus::Success,
682 },
683 )
684 .await;
685
686 let store = h.store.lock().expect("store poisoned");
687 let observations = store.get_observations().expect("operation should succeed");
688 assert_eq!(observations.len(), 1);
689 assert!(
690 observations[0].context.len() < 500,
691 "context {} bytes exceeds 500 for 5MB input",
692 observations[0].context.len()
693 );
694 assert!(
695 observations[0].context.contains("不要用 sed"),
696 "context must contain '不要用 sed', got: {:?}",
697 observations[0].context
698 );
699 }
700}