Skip to main content

talos_evolution/
hook.rs

1//! Evolution `HookHandler` — wires the I008 self-evolution engine (ADR-001)
2//! into any agent run path via the I009 hook system (see
3//! [`talos_plugin::handler::HookHandler`]).
4//!
5//! One registered handler covers all three CLI run paths (print / interactive /
6//! tui) uniformly: the agent's [`run_inner`][1] fires the subscribed events
7//! once per turn, the same way for every path, so per-Agent registration
8//! guarantees no double-firing. The pre-#I008 concern that "evolution must
9//! attach once at a future `AppServerSession` seam" is satisfied at the hook
10//! layer instead; see [ADR-005 → "Hook-Driven
11//! Evolution"](../docs/decisions/005-tui-event-architecture.md#hook-driven-evolution-2026-06-01-pre-i008-re-scope).
12//!
13//! Capability mapping to the four-phase learning loop (ADR-001):
14//!
15//! | Phase | Hook event(s) |
16//! |-------|---------------|
17//! | Observe | `OnProviderError` (objective error), `BeforeProviderCall` (user-correction heuristic) |
18//! | Accumulate | Handler-internal `Mutex<TurnObserver>` (reset on `TurnStart`) |
19//! | Extract | `PatternExtractor::extract_from_observation` at flush time |
20//! | Apply | `OnSystemPromptBuilt` + `HookResult::Modify` returns augmented prompt |
21//! | Ingest | Flush in `TurnComplete` (overridden timeout = 5s for SQLite write) |
22//!
23//! [1]: talos_agent::Agent::run_inner
24
25use 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
58/// Hook handler implementing the I008 self-evolution loop. Registered per-Agent
59/// in the [`HookRegistry`][1] alongside `LoggingHandler`.
60///
61/// The same `Arc<EvolutionHookHandler>` instance is reused across all turns
62/// for the Agent's lifetime; accumulation is stateful via interior
63/// mutability (`Mutex<TurnObserver>` + `Mutex<KnowledgeStore>`).
64///
65/// [1]: talos_plugin::registry::HookRegistry
66pub struct EvolutionHookHandler {
67    store: Mutex<KnowledgeStore>,
68    config: EvolutionConfig,
69    observer: Mutex<TurnObserver>,
70}
71
72impl EvolutionHookHandler {
73    /// Build a handler backed by the given store, config, and session ID.
74    #[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    /// Open the default knowledge store at `~/.talos/index.db`, creating the
84    /// `.talos` directory if it does not exist.
85    ///
86    /// Returns `Ok(None)` when the home directory cannot be resolved, allowing
87    /// the caller to continue without evolution rather than aborting the run.
88    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                // The 'static bound on HookResult::Modify(HookEvent<'static>) forces
195                // us to leak the augmented prompt. One small permanent allocation
196                // per turn (typically a few KB). The long-term fix is an additive
197                // HookResult::ModifyOwned variant (tracked separately, out of
198                // I008 scope; see ADR-005 → "Hook-Driven Evolution").
199                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}