Skip to main content

talos_evolution/
lib.rs

1//! Talos Evolution — self-evolution engine for agent behavior adaptation.
2//!
3//! Implements a 4-phase learning loop per ADR-001:
4//! 1. Observe: Capture signals during agent execution
5//! 2. Extract: Identify patterns from observations
6//! 3. Store: Persist patterns with confidence scores
7//! 4. Apply: Inject high-confidence patterns into system prompt
8
9pub mod adapter;
10pub mod extractor;
11pub mod hook;
12pub mod observer;
13pub mod store;
14
15pub use hook::EvolutionHookHandler;
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21/// Error type for the evolution crate.
22#[derive(Debug, Error)]
23pub enum EvolutionError {
24    /// Filesystem operation failed.
25    #[error("filesystem operation failed: {0}")]
26    Io(#[from] std::io::Error),
27    /// Knowledge store operation failed.
28    #[error("knowledge store operation failed: {0}")]
29    Store(#[from] StoreError),
30}
31
32/// Errors originating from the SQLite knowledge store.
33#[derive(Debug, Error)]
34pub enum StoreError {
35    /// A database operation failed.
36    #[error("database operation failed: {0}")]
37    Database(String),
38}
39
40impl From<rusqlite::Error> for StoreError {
41    fn from(err: rusqlite::Error) -> Self {
42        StoreError::Database(err.to_string())
43    }
44}
45
46impl From<rusqlite::Error> for EvolutionError {
47    fn from(err: rusqlite::Error) -> Self {
48        EvolutionError::Store(StoreError::from(err))
49    }
50}
51
52/// Result type for evolution operations.
53pub type EvolutionResult<T> = std::result::Result<T, EvolutionError>;
54
55// ─── MenteDB-aligned types (I021-S1) ────────────────────────────────────────
56
57/// The kind of learning signal captured during agent execution.
58/// Four base variants per the MenteDB cognitive-feedback blueprint
59/// (`docs/reference/REFERENCE-PROJECTS.md` §17).
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
61pub enum SignalKind {
62    /// User corrected the agent's behavior ("don't do that", "use X instead")
63    Correction,
64    /// Agent encountered an error (tool failure, provider error, etc.)
65    Error,
66    /// User expressed satisfaction or approval
67    Satisfaction,
68    /// Agent identified inefficiency in its own behavior
69    Inefficiency,
70}
71
72impl std::fmt::Display for SignalKind {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            SignalKind::Correction => write!(f, "Correction"),
76            SignalKind::Error => write!(f, "Error"),
77            SignalKind::Satisfaction => write!(f, "Satisfaction"),
78            SignalKind::Inefficiency => write!(f, "Inefficiency"),
79        }
80    }
81}
82
83/// A single learning signal captured during agent execution.
84///
85/// Per the MenteDB blueprint, `context` is a **small window** (typically
86/// < 500 bytes) centered on the marker phrase that triggered the signal,
87/// NOT the full user message.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct Signal {
90    /// What kind of signal this is
91    pub kind: SignalKind,
92    /// Signal intensity (0.0 – 1.0)
93    pub intensity: f32,
94    /// Small context window around the marker phrase (typically < 500 bytes)
95    pub context: String,
96    /// Which tool was involved, if applicable
97    pub tool_name: Option<String>,
98}
99
100/// Outcome of a single agent turn.
101#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
102pub enum TurnOutcome {
103    /// Turn completed successfully
104    Success,
105    /// Turn completed but with partial results
106    PartialSuccess,
107    /// Turn failed (error, provider failure, etc.)
108    Failure,
109    /// User corrected the agent's output during this turn
110    UserCorrected,
111}
112
113/// Record of a tool invocation during a turn.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct ToolUsage {
116    /// Name of the tool that was called
117    pub tool_name: String,
118    /// Hash of the tool arguments (for dedup / comparison)
119    pub arguments_hash: u64,
120    /// Brief summary of the tool result
121    pub result_summary: String,
122}
123
124/// Per-turn observation that aggregates multiple signals with turn-level metadata.
125///
126/// This is the MenteDB-aligned replacement for the legacy [`Observation`] type.
127/// A `TurnObservation` is the parent (per-turn), containing child [`Signal`]s
128/// (per-event) plus tool usage, outcome, and timing data.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct TurnObservation {
131    /// Signals captured during this turn
132    pub signals: Vec<Signal>,
133    /// Tools that were used during this turn
134    pub tools_used: Vec<ToolUsage>,
135    /// How the turn ended
136    pub outcome: TurnOutcome,
137    /// Turn duration in milliseconds
138    pub duration_ms: u64,
139    /// Session this turn belongs to
140    pub session_id: uuid::Uuid,
141    /// Turn number within the session
142    pub turn_number: u32,
143}
144
145impl TurnObservation {
146    /// Create a new `TurnObservation` with the given turn-level metadata.
147    pub fn new(
148        session_id: uuid::Uuid,
149        turn_number: u32,
150        outcome: TurnOutcome,
151        duration_ms: u64,
152    ) -> Self {
153        Self {
154            signals: Vec::new(),
155            tools_used: Vec::new(),
156            outcome,
157            duration_ms,
158            session_id,
159            turn_number,
160        }
161    }
162
163    /// Add a signal to this turn observation.
164    pub fn add_signal(&mut self, signal: Signal) {
165        self.signals.push(signal);
166    }
167
168    /// Record a tool usage for this turn.
169    pub fn add_tool_usage(&mut self, usage: ToolUsage) {
170        self.tools_used.push(usage);
171    }
172}
173
174// ─── Legacy types (backward-compatible, kept for migration) ─────────────────
175
176/// A signal captured during agent execution.
177///
178/// **Deprecated**: Use [`Signal`] + [`TurnObservation`] instead.
179/// This type is retained for backward compatibility with the pre-I021 schema.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub enum SignalType {
182    /// User corrected the agent's behavior
183    Correction,
184    /// Agent encountered an error
185    Error,
186    /// User expressed satisfaction
187    Satisfaction,
188    /// Agent identified inefficiency in its own behavior
189    Inefficiency,
190}
191
192/// An observation captured from a single turn.
193///
194/// **Deprecated**: Use [`TurnObservation`] + [`Signal`] instead.
195/// This type is retained for backward compatibility with the pre-I021 schema.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct Observation {
198    /// Unique identifier
199    pub id: String,
200    /// Type of signal
201    pub signal_type: SignalType,
202    /// Intensity of the signal (0.0 - 1.0)
203    pub intensity: f64,
204    /// Context description
205    pub context: String,
206    /// When the observation was made
207    pub timestamp: DateTime<Utc>,
208    /// Session ID where this was observed
209    pub session_id: Option<String>,
210    /// Turn number within the session
211    pub turn_number: Option<u32>,
212}
213
214/// A pattern extracted from multiple observations.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Pattern {
217    /// Unique identifier
218    pub id: String,
219    /// Human-readable description of the pattern
220    pub description: String,
221    /// Natural language instruction to inject into system prompt
222    pub instruction: String,
223    /// Confidence score (0.0 - 1.0)
224    pub confidence: f64,
225    /// Number of observations supporting this pattern
226    pub evidence_count: u32,
227    /// When the pattern was first observed
228    pub first_observed: DateTime<Utc>,
229    /// When the pattern was last updated
230    pub last_updated: DateTime<Utc>,
231    /// Category of the pattern (e.g., "preference", "error_avoidance", "efficiency")
232    pub category: String,
233    /// Whether this pattern is active (can be injected into prompts)
234    pub active: bool,
235    /// Normalized fingerprint for content-based dedup: "{category}|{first 1KB of instruction}"
236    pub content_hash: String,
237    // ─── I021-S3: MenteDB-aligned fields ────────────────────────────────────
238    /// Structured key identifying this pattern (e.g., "prefer_functional_style")
239    pub key: String,
240    /// Structured value as JSON (replaces free-text instruction at the data level)
241    pub value: serde_json::Value,
242    /// Number of contradicting observations
243    pub contradicting_count: u32,
244    /// When this pattern was last reinforced by a matching signal
245    pub last_reinforced: DateTime<Utc>,
246    /// Session IDs where this pattern was observed (traceability)
247    pub source_sessions: Vec<uuid::Uuid>,
248}
249
250/// A conflict between two patterns.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct Conflict {
253    /// Unique identifier
254    pub id: String,
255    /// ID of the first pattern
256    pub pattern_a_id: String,
257    /// ID of the second pattern
258    pub pattern_b_id: String,
259    /// Description of the conflict
260    pub description: String,
261    /// When the conflict was detected
262    pub detected_at: DateTime<Utc>,
263    /// Whether the conflict has been resolved
264    pub resolved: bool,
265    /// ID of the winning pattern (if resolved)
266    pub winner_id: Option<String>,
267}
268
269/// Configuration for the evolution engine.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct EvolutionConfig {
272    /// Minimum confidence to inject pattern into system prompt
273    pub min_confidence: f64,
274    /// Minimum evidence count to consider a pattern stable
275    pub min_evidence: u32,
276    /// Half-life for time decay in days
277    pub half_life_days: f64,
278    /// Maximum number of patterns to inject into system prompt
279    pub max_patterns: usize,
280    /// Whether to enable automatic pattern extraction
281    pub auto_extract: bool,
282    /// Maximum bytes stored per observation.context (defense layer 1).
283    /// Observations longer than this are truncated with a marker.
284    pub max_context_bytes: usize,
285    /// Maximum bytes injected into system prompt by BehaviorAdapter (defense layer 2).
286    /// Final output is truncated to fit; oversized patterns are dropped first.
287    pub max_output_bytes: usize,
288}
289
290impl Default for EvolutionConfig {
291    fn default() -> Self {
292        Self {
293            min_confidence: 0.7,
294            min_evidence: 3,
295            half_life_days: 70.0,
296            max_patterns: 5,
297            auto_extract: true,
298            max_context_bytes: 4096,
299            max_output_bytes: 8192,
300        }
301    }
302}
303
304impl Observation {
305    /// Create a new observation with the current timestamp.
306    pub fn new(
307        signal_type: SignalType,
308        intensity: f64,
309        context: String,
310        session_id: Option<String>,
311        turn_number: Option<u32>,
312    ) -> Self {
313        Self {
314            id: uuid::Uuid::new_v4().to_string(),
315            signal_type,
316            intensity,
317            context,
318            timestamp: Utc::now(),
319            session_id,
320            turn_number,
321        }
322    }
323
324    /// Calculate the age of this observation in days.
325    pub fn age_days(&self) -> f64 {
326        let now = Utc::now();
327        let duration = now.signed_duration_since(self.timestamp);
328        duration.num_days() as f64
329    }
330}
331
332/// Compute a normalized fingerprint for content-based dedup.
333/// Format: "{category}|{first 1KB of instruction}" hashed via DefaultHasher.
334pub fn compute_content_hash(category: &str, instruction: &str) -> String {
335    use std::collections::hash_map::DefaultHasher;
336    use std::hash::{Hash, Hasher};
337
338    let prefix = if instruction.len() > 1024 {
339        &instruction[..1024]
340    } else {
341        instruction
342    };
343    let fingerprint = format!("{category}|{prefix}");
344
345    let mut hasher = DefaultHasher::new();
346    fingerprint.hash(&mut hasher);
347    format!("{:016x}", hasher.finish())
348}
349
350impl Pattern {
351    /// Create a new pattern with the current timestamp.
352    ///
353    /// Backward-compatible constructor. Derives `key` and `value` from
354    /// `description` and `instruction` for migration purposes.
355    pub fn new(description: String, instruction: String, category: String) -> Self {
356        let now = Utc::now();
357        let content_hash = compute_content_hash(&category, &instruction);
358        let key = category.clone();
359        let value = serde_json::json!({ "instruction": instruction });
360        Self {
361            id: uuid::Uuid::new_v4().to_string(),
362            description,
363            instruction,
364            confidence: 0.0,
365            evidence_count: 0,
366            first_observed: now,
367            last_updated: now,
368            category,
369            active: true,
370            content_hash,
371            key,
372            value,
373            contradicting_count: 0,
374            last_reinforced: now,
375            source_sessions: Vec::new(),
376        }
377    }
378
379    /// Create a pattern with MenteDB-aligned fields.
380    ///
381    /// `description` and `instruction` are derived from `key` + `value`
382    /// rendering, keeping the BehaviorAdapter output format unchanged.
383    pub fn new_with_key(
384        key: String,
385        value: serde_json::Value,
386        category: String,
387        source_session: Option<uuid::Uuid>,
388    ) -> Self {
389        let now = Utc::now();
390        let description = format!("Pattern: {key}");
391        let instruction = value.to_string();
392        let content_hash = compute_content_hash(&category, &instruction);
393        let mut source_sessions = Vec::new();
394        if let Some(sid) = source_session {
395            source_sessions.push(sid);
396        }
397        Self {
398            id: uuid::Uuid::new_v4().to_string(),
399            description,
400            instruction,
401            confidence: 0.0,
402            evidence_count: 0,
403            first_observed: now,
404            last_updated: now,
405            category,
406            active: true,
407            content_hash,
408            key,
409            value,
410            contradicting_count: 0,
411            last_reinforced: now,
412            source_sessions,
413        }
414    }
415
416    /// Calculate the time-decayed confidence based on evidence and age.
417    pub fn decayed_confidence(&self, half_life_days: f64) -> f64 {
418        let age = self.last_updated.signed_duration_since(self.first_observed);
419        let days = age.num_days() as f64;
420        let decay = (-0.693 * days / half_life_days).exp();
421        self.confidence * decay
422    }
423}
424
425#[cfg(test)]
426#[allow(warnings)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_observation_new() {
432        let obs = Observation::new(
433            SignalType::Correction,
434            0.8,
435            "User said to use functional style".to_string(),
436            Some("session-1".to_string()),
437            Some(5),
438        );
439
440        assert_eq!(obs.signal_type, SignalType::Correction);
441        assert_eq!(obs.intensity, 0.8);
442        assert!(obs.id.len() > 0);
443    }
444
445    #[test]
446    fn test_pattern_new() {
447        let pattern = Pattern::new(
448            "Prefer functional style".to_string(),
449            "Use functional programming patterns when writing Rust code".to_string(),
450            "preference".to_string(),
451        );
452
453        assert_eq!(pattern.confidence, 0.0);
454        assert_eq!(pattern.evidence_count, 0);
455        assert!(pattern.active);
456    }
457
458    #[test]
459    fn test_pattern_decay() {
460        let mut pattern = Pattern::new(
461            "Test pattern".to_string(),
462            "Test instruction".to_string(),
463            "test".to_string(),
464        );
465        pattern.confidence = 0.8;
466
467        // Fresh pattern should have full confidence
468        let decayed = pattern.decayed_confidence(70.0);
469        assert!((decayed - 0.8).abs() < 0.01);
470    }
471
472    #[test]
473    fn test_evolution_config_default() {
474        let config = EvolutionConfig::default();
475        assert_eq!(config.min_confidence, 0.7);
476        assert_eq!(config.min_evidence, 3);
477        assert_eq!(config.half_life_days, 70.0);
478    }
479
480    #[test]
481    fn test_evolution_config_default_has_byte_caps() {
482        let config = EvolutionConfig::default();
483        assert_eq!(config.max_context_bytes, 4096);
484        assert_eq!(config.max_output_bytes, 8192);
485    }
486
487    #[test]
488    fn test_evolution_config_max_context_bytes_default_4kb() {
489        let config = EvolutionConfig::default();
490        assert_eq!(config.max_context_bytes, 4 * 1024);
491    }
492
493    #[test]
494    fn test_evolution_config_max_output_bytes_default_8kb() {
495        let config = EvolutionConfig::default();
496        assert_eq!(config.max_output_bytes, 8 * 1024);
497    }
498
499    // ─── I021-S1: New MenteDB-aligned type tests ────────────────────────────
500
501    #[test]
502    fn test_signal_roundtrip_preserves_all_fields() {
503        let signal = Signal {
504            kind: SignalKind::Correction,
505            intensity: 0.85,
506            context: "不要用 sed".to_string(),
507            tool_name: Some("bash".to_string()),
508        };
509
510        // Serialize and deserialize roundtrip
511        let json = serde_json::to_string(&signal).expect("serialize Signal");
512        let restored: Signal = serde_json::from_str(&json).expect("deserialize Signal");
513
514        assert_eq!(restored.kind, SignalKind::Correction);
515        assert!((restored.intensity - 0.85).abs() < f32::EPSILON);
516        assert_eq!(restored.context, "不要用 sed");
517        assert_eq!(restored.tool_name, Some("bash".to_string()));
518    }
519
520    #[test]
521    fn test_turn_observation_multi_signal_flush() {
522        let session_id = uuid::Uuid::new_v4();
523        let mut turn = TurnObservation::new(session_id, 3, TurnOutcome::Success, 1500);
524
525        turn.add_signal(Signal {
526            kind: SignalKind::Correction,
527            intensity: 0.9,
528            context: "use HashMap".to_string(),
529            tool_name: None,
530        });
531        turn.add_signal(Signal {
532            kind: SignalKind::Inefficiency,
533            intensity: 0.4,
534            context: "took 10 steps".to_string(),
535            tool_name: Some("bash".to_string()),
536        });
537        turn.add_tool_usage(ToolUsage {
538            tool_name: "read".to_string(),
539            arguments_hash: 42,
540            result_summary: "file contents".to_string(),
541        });
542
543        assert_eq!(turn.signals.len(), 2);
544        assert_eq!(turn.tools_used.len(), 1);
545        assert_eq!(turn.outcome, TurnOutcome::Success);
546        assert_eq!(turn.duration_ms, 1500);
547        assert_eq!(turn.session_id, session_id);
548        assert_eq!(turn.turn_number, 3);
549
550        // Roundtrip
551        let json = serde_json::to_string(&turn).expect("serialize TurnObservation");
552        let restored: TurnObservation =
553            serde_json::from_str(&json).expect("deserialize TurnObservation");
554        assert_eq!(restored.signals.len(), 2);
555        assert_eq!(restored.signals[0].kind, SignalKind::Correction);
556        assert_eq!(restored.signals[1].kind, SignalKind::Inefficiency);
557        assert_eq!(restored.tools_used[0].tool_name, "read");
558    }
559
560    #[test]
561    fn test_signal_kind_display() {
562        assert_eq!(format!("{}", SignalKind::Correction), "Correction");
563        assert_eq!(format!("{}", SignalKind::Error), "Error");
564        assert_eq!(format!("{}", SignalKind::Satisfaction), "Satisfaction");
565        assert_eq!(format!("{}", SignalKind::Inefficiency), "Inefficiency");
566    }
567
568    #[test]
569    fn test_tool_usage_roundtrip() {
570        let usage = ToolUsage {
571            tool_name: "write".to_string(),
572            arguments_hash: 12345,
573            result_summary: "wrote 50 bytes".to_string(),
574        };
575
576        let json = serde_json::to_string(&usage).expect("serialize ToolUsage");
577        let restored: ToolUsage = serde_json::from_str(&json).expect("deserialize ToolUsage");
578
579        assert_eq!(restored.tool_name, "write");
580        assert_eq!(restored.arguments_hash, 12345);
581        assert_eq!(restored.result_summary, "wrote 50 bytes");
582    }
583
584    #[test]
585    fn test_pattern_roundtrip_with_mentedb_fields() {
586        let session_id = uuid::Uuid::new_v4();
587        let mut pattern = Pattern::new(
588            "Prefer functional style".to_string(),
589            "Use functional programming patterns".to_string(),
590            "preference".to_string(),
591        );
592        pattern.key = "prefer_functional_style".to_string();
593        pattern.value = serde_json::json!({ "style": "functional", "language": "rust" });
594        pattern.contradicting_count = 2;
595        pattern.last_reinforced = Utc::now();
596        pattern.source_sessions = vec![session_id];
597
598        let json = serde_json::to_string(&pattern).expect("serialize Pattern");
599        let restored: Pattern = serde_json::from_str(&json).expect("deserialize Pattern");
600
601        assert_eq!(restored.key, "prefer_functional_style");
602        assert_eq!(restored.value["style"], "functional");
603        assert_eq!(restored.contradicting_count, 2);
604        assert_eq!(restored.source_sessions.len(), 1);
605        assert_eq!(restored.source_sessions[0], session_id);
606    }
607}