Skip to main content

roboticus_agent/
memory.rs

1use roboticus_core::config::MemoryConfig;
2use tracing::{debug, warn};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct MemoryBudgets {
6    pub working: usize,
7    pub episodic: usize,
8    pub semantic: usize,
9    pub procedural: usize,
10    pub relationship: usize,
11}
12
13pub struct MemoryBudgetManager {
14    config: MemoryConfig,
15}
16
17impl MemoryBudgetManager {
18    pub fn new(config: MemoryConfig) -> Self {
19        Self { config }
20    }
21
22    /// Distributes `total_tokens` across the five memory tiers based on config percentages.
23    /// Any remainder from rounding is added to the working memory tier.
24    pub fn allocate_budgets(&self, total_tokens: usize) -> MemoryBudgets {
25        let working = pct(total_tokens, self.config.working_budget_pct);
26        let episodic = pct(total_tokens, self.config.episodic_budget_pct);
27        let semantic = pct(total_tokens, self.config.semantic_budget_pct);
28        let procedural = pct(total_tokens, self.config.procedural_budget_pct);
29        let relationship = pct(total_tokens, self.config.relationship_budget_pct);
30
31        let allocated = working + episodic + semantic + procedural + relationship;
32        let rollover = total_tokens.saturating_sub(allocated);
33
34        MemoryBudgets {
35            working: working + rollover,
36            episodic,
37            semantic,
38            procedural,
39            relationship,
40        }
41    }
42}
43
44fn pct(total: usize, percent: f64) -> usize {
45    ((total as f64) * percent / 100.0).floor() as usize
46}
47
48// ── Post-turn memory ingestion ──────────────────────────────────
49
50/// Classifies the type of a conversational turn for memory routing.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum TurnType {
53    Reasoning,
54    ToolUse,
55    Creative,
56    Financial,
57    Social,
58}
59
60/// Classifies a turn based on user + assistant content and tool results.
61pub fn classify_turn(
62    user_msg: &str,
63    assistant_msg: &str,
64    tool_results: &[(String, String)],
65) -> TurnType {
66    if !tool_results.is_empty() {
67        return TurnType::ToolUse;
68    }
69    // BUG-08: Only check user_msg for financial keywords, and require >= 2 matches
70    // to avoid false-positives (e.g. "balance" in generic error messages).
71    let user_lower = user_msg.to_lowercase();
72    let financial_keywords = [
73        "transfer",
74        "balance",
75        "wallet",
76        "payment",
77        "usdc",
78        "send funds",
79    ];
80    let financial_hits = financial_keywords
81        .iter()
82        .filter(|kw| user_lower.contains(*kw))
83        .count();
84    if financial_hits >= 2 {
85        return TurnType::Financial;
86    }
87    let combined = format!("{user_msg} {assistant_msg}").to_lowercase();
88    if combined.contains("hello")
89        || combined.contains("thanks")
90        || combined.contains("please")
91        || combined.contains("how are you")
92    {
93        return TurnType::Social;
94    }
95    if combined.contains("write a")
96        || combined.contains("create a")
97        || combined.contains("design a")
98        || combined.contains("compose a")
99        || combined.contains("draw")
100        || combined.contains("generate a")
101    {
102        return TurnType::Creative;
103    }
104    TurnType::Reasoning
105}
106
107/// Ingests a completed turn into the appropriate memory tiers.
108///
109/// # Silent Degradation
110///
111/// This function returns `()` by design: each `db.store_*()` call is
112/// independently wrapped in `if let Err(e) = ... { warn!(...) }`, so any
113/// combination of memory-tier writes can fail without aborting the turn.
114/// Tools whose output is derivable — the agent can re-run them to get
115/// current data. Storing their output creates stale facts that the agent
116/// later cites as truth (e.g., "23 tasks" when there are now 19).
117fn is_derivable_tool(name: &str) -> bool {
118    matches!(
119        name,
120        "list_directory"
121            | "list-subagent-roster"
122            | "get_subagent_status"
123            | "get_runtime_context"
124            | "get_memory_stats"
125            | "get_channel_health"
126            | "list-open-tasks"
127            | "list-available-skills"
128            | "task-status"
129            | "get_wallet_balance"
130            | "read_file"
131    ) || name.starts_with("orchestrate-subagents")
132}
133
134/// This is intentional -- memory ingestion runs in a background
135/// `tokio::spawn` and must not block the response path.  A future
136/// improvement could return a count of failed operations for
137/// observability (see BUG-060 in the bug ledger).
138pub fn ingest_turn(
139    db: &roboticus_db::Database,
140    session_id: &str,
141    user_msg: &str,
142    assistant_msg: &str,
143    tool_results: &[(String, String)],
144) {
145    let turn_type = classify_turn(user_msg, assistant_msg, tool_results);
146
147    // Working memory: update active goals/context
148    let summary = if assistant_msg.len() > 200 {
149        &assistant_msg[..assistant_msg.floor_char_boundary(200)]
150    } else {
151        assistant_msg
152    };
153    if let Err(e) = roboticus_db::memory::store_working(db, session_id, "turn_summary", summary, 3)
154    {
155        warn!(error = %e, "failed to store working memory");
156    }
157
158    ingest_relationship_memory(db, session_id, user_msg, summary, turn_type);
159
160    // Episodic: record significant events (tool use, financial operations).
161    // Don't-store-derivable: skip read-only introspection tools whose output
162    // can be re-derived by calling the tool again. Store the ACTION taken
163    // (e.g., "delegated to sentinel"), not the OBSERVATION (e.g., "5 files
164    // found"). This prevents stale-fact hallucination.
165    match turn_type {
166        TurnType::ToolUse => {
167            for (tool_name, result) in tool_results {
168                // Skip derivable tool outputs — these can be re-queried
169                if is_derivable_tool(tool_name) {
170                    debug!(
171                        tool = tool_name,
172                        "skipping derivable tool output from memory"
173                    );
174                    continue;
175                }
176                // Store action description, not full output
177                let event = if result.len() > 200 {
178                    format!(
179                        "Executed '{tool_name}' (result: {}...)",
180                        &result[..result.floor_char_boundary(150)]
181                    )
182                } else {
183                    format!("Executed '{tool_name}': {result}")
184                };
185                // Dedup: skip if identical content already exists in episodic memory
186                if roboticus_db::memory::episodic_content_exists(db, &event) {
187                    debug!(tool = tool_name, "skipping duplicate episodic entry");
188                    continue;
189                }
190                if let Err(e) = roboticus_db::memory::store_episodic(db, "tool_use", &event, 7) {
191                    warn!(error = %e, "failed to store episodic tool_use memory");
192                }
193            }
194        }
195        TurnType::Financial => {
196            let event = format!("Financial interaction: {summary}");
197            if let Err(e) = roboticus_db::memory::store_episodic(db, "financial", &event, 8) {
198                warn!(error = %e, "failed to store episodic financial memory");
199            }
200        }
201        _ => {}
202    }
203
204    // Semantic: extract factual information from responses longer than a threshold
205    if assistant_msg.len() > 100
206        && (turn_type == TurnType::Reasoning || turn_type == TurnType::Creative)
207    {
208        let key_prefix = format!("session:{session_id}:");
209        let key = format!("{key_prefix}{}", uuid::Uuid::new_v4());
210        match roboticus_db::memory::store_semantic(db, "learned", &key, summary, 0.6) {
211            Ok(semantic_id) => {
212                if let Err(e) = roboticus_db::memory::mark_semantic_stale_by_category_and_key_prefix(
213                    db,
214                    "learned",
215                    &key_prefix,
216                    &semantic_id,
217                    "superseded_by_newer_session_summary",
218                ) {
219                    warn!(error = %e, session_id, "failed to mark older semantic memories stale");
220                }
221            }
222            Err(e) => warn!(error = %e, "failed to store semantic memory"),
223        }
224    }
225
226    // Procedural: track tool success/failure
227    if turn_type == TurnType::ToolUse {
228        for (tool_name, result) in tool_results {
229            if is_tool_failure(result) {
230                if let Err(e) = roboticus_db::memory::record_procedural_failure(db, tool_name) {
231                    warn!(error = %e, tool = %tool_name, "failed to record procedural failure");
232                }
233            } else if let Err(e) = roboticus_db::memory::record_procedural_success(db, tool_name) {
234                warn!(error = %e, tool = %tool_name, "failed to record procedural success");
235            }
236        }
237    }
238}
239
240/// Heuristic: does the tool result text indicate a failure?
241///
242/// Checks for common error prefixes and patterns in tool output.  We lean
243/// toward *not* marking ambiguous results as failures (false negatives are
244/// cheaper than false positives in the procedural memory tier).
245fn is_tool_failure(result: &str) -> bool {
246    let lower = result.to_lowercase();
247    let trimmed = lower.trim_start();
248
249    // Explicit error/failure prefixes
250    if trimmed.starts_with("error:")
251        || trimmed.starts_with("error -")
252        || trimmed.starts_with("failed:")
253        || trimmed.starts_with("failure:")
254        || trimmed.starts_with("fatal:")
255        || trimmed.starts_with("panic:")
256    {
257        return true;
258    }
259
260    // Common structured error patterns
261    if trimmed.starts_with("{\"error\"") || trimmed.starts_with("{\"err\"") {
262        return true;
263    }
264
265    // Non-zero exit codes from shell tools.
266    // Use word-boundary-aware matching to avoid "exit code 0" matching inside
267    // "exit code 0137" (which would incorrectly classify as success).
268    if trimmed.contains("exit code") || trimmed.contains("exit status") {
269        // Exact "exit code 0" / "exit status 0" followed by non-digit → success.
270        // We check that the 0 isn't followed by another digit.
271        let is_zero_exit = |s: &str, prefix: &str| -> bool {
272            if let Some(idx) = s.find(prefix) {
273                let after = &s[idx + prefix.len()..];
274                // next char must be non-digit or end-of-string to be "exit code 0"
275                after.is_empty() || !after.starts_with(|c: char| c.is_ascii_digit())
276            } else {
277                false
278            }
279        };
280        if is_zero_exit(trimmed, "exit code 0") || is_zero_exit(trimmed, "exit status 0") {
281            return false;
282        }
283        return true;
284    }
285
286    false
287}
288
289fn ingest_relationship_memory(
290    db: &roboticus_db::Database,
291    session_id: &str,
292    user_msg: &str,
293    assistant_summary: &str,
294    turn_type: TurnType,
295) {
296    let Some(session) = roboticus_db::sessions::get_session(db, session_id)
297        .inspect_err(
298            |e| warn!(error = %e, session_id, "failed to load session for relationship ingest"),
299        )
300        .ok()
301        .flatten()
302    else {
303        return;
304    };
305
306    let Some((channel, peer_id)) = session.scope_key.as_deref().and_then(parse_peer_scope_key)
307    else {
308        return;
309    };
310
311    let entity_id = format!("peer:{channel}:{peer_id}");
312    let entity_name = peer_id;
313    let trust_score = match turn_type {
314        TurnType::Social => 0.8,
315        TurnType::Financial => 0.75,
316        TurnType::ToolUse | TurnType::Reasoning | TurnType::Creative => 0.65,
317    };
318    let interaction_summary = summarize_relationship_interaction(user_msg, assistant_summary);
319    if let Err(e) = roboticus_db::memory::store_relationship_interaction(
320        db,
321        &entity_id,
322        entity_name,
323        trust_score,
324        interaction_summary.as_deref(),
325    ) {
326        warn!(error = %e, entity_id, "failed to store relationship memory");
327    }
328}
329
330fn parse_peer_scope_key(scope_key: &str) -> Option<(&str, &str)> {
331    let rest = scope_key.strip_prefix("peer:")?;
332    let (channel, peer_id) = rest.split_once(':')?;
333    if channel.is_empty() || peer_id.is_empty() {
334        return None;
335    }
336    Some((channel, peer_id))
337}
338
339fn summarize_relationship_interaction(user_msg: &str, assistant_summary: &str) -> Option<String> {
340    let user_summary = user_msg.trim();
341    let assistant_summary = assistant_summary.trim();
342    if user_summary.is_empty() && assistant_summary.is_empty() {
343        return None;
344    }
345
346    let user_summary = if user_summary.len() > 120 {
347        &user_summary[..user_summary.floor_char_boundary(120)]
348    } else {
349        user_summary
350    };
351    let assistant_summary = if assistant_summary.len() > 120 {
352        &assistant_summary[..assistant_summary.floor_char_boundary(120)]
353    } else {
354        assistant_summary
355    };
356
357    Some(format!(
358        "User: {user_summary}; Assistant: {assistant_summary}"
359    ))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn default_config() -> MemoryConfig {
367        MemoryConfig {
368            working_budget_pct: 30.0,
369            episodic_budget_pct: 25.0,
370            semantic_budget_pct: 20.0,
371            procedural_budget_pct: 15.0,
372            relationship_budget_pct: 10.0,
373            embedding_provider: None,
374            embedding_model: None,
375            hybrid_weight: 0.5,
376            ann_index: false,
377            similarity_threshold: 0.0,
378            decay_half_life_days: 7.0,
379            ann_activation_threshold: 1000,
380        }
381    }
382
383    #[test]
384    fn budget_allocation_matches_percentages() {
385        let mgr = MemoryBudgetManager::new(default_config());
386        let budgets = mgr.allocate_budgets(10_000);
387
388        assert_eq!(budgets.working, 3_000);
389        assert_eq!(budgets.episodic, 2_500);
390        assert_eq!(budgets.semantic, 2_000);
391        assert_eq!(budgets.procedural, 1_500);
392        assert_eq!(budgets.relationship, 1_000);
393
394        let sum = budgets.working
395            + budgets.episodic
396            + budgets.semantic
397            + budgets.procedural
398            + budgets.relationship;
399        assert_eq!(sum, 10_000);
400    }
401
402    #[test]
403    fn rollover_goes_to_working() {
404        let mgr = MemoryBudgetManager::new(default_config());
405        let budgets = mgr.allocate_budgets(99);
406
407        let sum = budgets.working
408            + budgets.episodic
409            + budgets.semantic
410            + budgets.procedural
411            + budgets.relationship;
412        assert_eq!(sum, 99, "all tokens must be distributed");
413        assert!(budgets.working >= pct(99, 30.0));
414    }
415
416    #[test]
417    fn zero_total_tokens() {
418        let mgr = MemoryBudgetManager::new(default_config());
419        let budgets = mgr.allocate_budgets(0);
420
421        assert_eq!(
422            budgets,
423            MemoryBudgets {
424                working: 0,
425                episodic: 0,
426                semantic: 0,
427                procedural: 0,
428                relationship: 0,
429            }
430        );
431    }
432
433    #[test]
434    fn classify_turn_tool_use() {
435        let results = vec![("echo".into(), "hello".into())];
436        assert_eq!(
437            classify_turn("test", "response", &results),
438            TurnType::ToolUse
439        );
440    }
441
442    #[test]
443    fn classify_turn_financial() {
444        assert_eq!(
445            classify_turn("check my wallet balance", "Your balance is 42 USDC", &[]),
446            TurnType::Financial
447        );
448    }
449
450    #[test]
451    fn classify_turn_social() {
452        assert_eq!(
453            classify_turn("hello how are you", "I'm great!", &[]),
454            TurnType::Social
455        );
456    }
457
458    #[test]
459    fn classify_turn_creative() {
460        assert_eq!(
461            classify_turn("write a poem about rust", "Here's a poem...", &[]),
462            TurnType::Creative
463        );
464    }
465
466    #[test]
467    fn classify_turn_reasoning() {
468        assert_eq!(
469            classify_turn("explain monads", "A monad is a design pattern...", &[]),
470            TurnType::Reasoning
471        );
472    }
473
474    #[test]
475    fn ingest_turn_stores_memories() {
476        let db = roboticus_db::Database::new(":memory:").unwrap();
477        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
478        ingest_turn(
479            &db,
480            &session_id,
481            "What is Rust?",
482            "Rust is a systems programming language focused on safety and performance.",
483            &[],
484        );
485        let working = roboticus_db::memory::retrieve_working(&db, &session_id).unwrap();
486        assert!(
487            !working.is_empty(),
488            "should store turn summary in working memory"
489        );
490    }
491
492    #[test]
493    fn ingest_turn_with_tools_stores_episodic() {
494        let db = roboticus_db::Database::new(":memory:").unwrap();
495        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
496        roboticus_db::memory::store_procedural(&db, "echo", "echo tool").ok();
497        ingest_turn(
498            &db,
499            &session_id,
500            "echo hello",
501            "Tool says: hello",
502            &[("echo".into(), "hello".into())],
503        );
504        let episodic = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
505        assert!(
506            !episodic.is_empty(),
507            "should store tool use in episodic memory"
508        );
509    }
510
511    #[test]
512    fn ingest_turn_financial_stores_episodic() {
513        let db = roboticus_db::Database::new(":memory:").unwrap();
514        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
515        ingest_turn(
516            &db,
517            &session_id,
518            "check my wallet balance",
519            "Your balance is 42 USDC",
520            &[],
521        );
522        let episodic = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
523        assert!(
524            !episodic.is_empty(),
525            "financial turn should store episodic memory"
526        );
527        assert!(
528            episodic
529                .iter()
530                .any(|e| e.content.contains("Financial interaction")),
531            "should prefix with 'Financial interaction'"
532        );
533    }
534
535    #[test]
536    fn ingest_turn_long_reasoning_stores_semantic() {
537        let db = roboticus_db::Database::new(":memory:").unwrap();
538        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
539        // assistant_msg > 100 chars + Reasoning turn type -> stores semantic
540        let long_response = "A ".repeat(60); // 120 chars
541        ingest_turn(&db, &session_id, "explain monads", &long_response, &[]);
542        let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
543        assert!(
544            !semantic.is_empty(),
545            "long reasoning turn should store semantic memory"
546        );
547        assert!(
548            semantic[0]
549                .key
550                .starts_with(&format!("session:{session_id}:"))
551        );
552        assert_eq!(semantic[0].memory_state, "active");
553    }
554
555    #[test]
556    fn ingest_turn_long_creative_stores_semantic() {
557        let db = roboticus_db::Database::new(":memory:").unwrap();
558        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
559        let long_response = "B ".repeat(60); // 120 chars
560        ingest_turn(
561            &db,
562            &session_id,
563            "write a poem about Rust",
564            &long_response,
565            &[],
566        );
567        let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
568        assert!(
569            !semantic.is_empty(),
570            "long creative turn should store semantic memory"
571        );
572    }
573
574    #[test]
575    fn ingest_turn_short_reasoning_skips_semantic() {
576        let db = roboticus_db::Database::new(":memory:").unwrap();
577        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
578        // assistant_msg <= 100 chars => no semantic storage
579        ingest_turn(&db, &session_id, "explain monads", "short answer", &[]);
580        let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
581        assert!(
582            semantic.is_empty(),
583            "short reasoning turn should not store semantic memory"
584        );
585    }
586
587    #[test]
588    fn ingest_turn_truncates_long_summary() {
589        let db = roboticus_db::Database::new(":memory:").unwrap();
590        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
591        // assistant_msg > 200 chars -> summary truncated to first 200
592        let long_response = "X".repeat(300);
593        ingest_turn(&db, &session_id, "explain something", &long_response, &[]);
594        let working = roboticus_db::memory::retrieve_working(&db, &session_id).unwrap();
595        assert!(!working.is_empty());
596        // The stored summary should be at most 200 chars
597        for entry in &working {
598            assert!(
599                entry.content.len() <= 200,
600                "working memory summary should be truncated to 200 chars, got {}",
601                entry.content.len()
602            );
603        }
604    }
605
606    #[test]
607    fn ingest_turn_records_procedural_success() {
608        let db = roboticus_db::Database::new(":memory:").unwrap();
609        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
610        roboticus_db::memory::store_procedural(&db, "custom_tool", "a tool").ok();
611        ingest_turn(
612            &db,
613            &session_id,
614            "use custom_tool",
615            "done",
616            &[("custom_tool".into(), "success".into())],
617        );
618        // This exercises the procedural success recording path
619        // The test passes if no panic occurs
620    }
621
622    #[test]
623    fn truncation_emoji_at_boundary() {
624        // 🦀 is 4 bytes; 198 ASCII + 🦀 = 202 bytes, slice at 200 would split the emoji
625        let msg = format!("{}{}", "A".repeat(198), "🦀");
626        assert!(msg.len() == 202);
627        let summary = if msg.len() > 200 {
628            &msg[..msg.floor_char_boundary(200)]
629        } else {
630            &msg
631        };
632        assert!(summary.len() <= 200);
633        assert!(summary.is_char_boundary(summary.len()));
634    }
635
636    #[test]
637    fn truncation_cjk_near_boundary() {
638        // CJK characters are 3 bytes each; 199 ASCII + 中 = 202 bytes
639        let msg = format!("{}{}", "B".repeat(199), "中");
640        assert!(msg.len() == 202);
641        let summary = if msg.len() > 200 {
642            &msg[..msg.floor_char_boundary(200)]
643        } else {
644            &msg
645        };
646        assert!(summary.len() <= 200);
647        assert!(summary.is_char_boundary(summary.len()));
648    }
649
650    #[test]
651    fn truncation_ascii_over_200() {
652        let msg = "C".repeat(300);
653        let summary = if msg.len() > 200 {
654            &msg[..msg.floor_char_boundary(200)]
655        } else {
656            &msg
657        };
658        assert_eq!(summary.len(), 200);
659    }
660
661    #[test]
662    fn classify_turn_financial_payment() {
663        // BUG-08: need >= 2 financial keywords to classify as Financial
664        assert_eq!(
665            classify_turn(
666                "make a payment of $50 from wallet",
667                "Processing payment",
668                &[]
669            ),
670            TurnType::Financial
671        );
672    }
673
674    #[test]
675    fn classify_turn_financial_transfer() {
676        assert_eq!(
677            classify_turn("transfer 10 USDC", "Transferring...", &[]),
678            TurnType::Financial
679        );
680    }
681
682    #[test]
683    fn classify_turn_creative_compose() {
684        assert_eq!(
685            classify_turn("compose a sonnet", "Here is your sonnet...", &[]),
686            TurnType::Creative
687        );
688    }
689
690    #[test]
691    fn classify_turn_creative_design() {
692        assert_eq!(
693            classify_turn("design a logo concept", "Here's the concept...", &[]),
694            TurnType::Creative
695        );
696    }
697
698    #[test]
699    fn classify_turn_creative_generate() {
700        assert_eq!(
701            classify_turn("generate a story", "Once upon a time...", &[]),
702            TurnType::Creative
703        );
704    }
705
706    #[test]
707    fn classify_turn_social_thanks() {
708        assert_eq!(
709            classify_turn("thanks for your help", "You're welcome!", &[]),
710            TurnType::Social
711        );
712    }
713
714    #[test]
715    fn classify_turn_tool_use_takes_precedence() {
716        // Even if content matches financial keywords, tool_results non-empty -> ToolUse
717        assert_eq!(
718            classify_turn(
719                "check my wallet balance",
720                "Done",
721                &[("wallet".into(), "42".into())]
722            ),
723            TurnType::ToolUse
724        );
725    }
726
727    // ── is_tool_failure tests ──────────────────────────────────────
728
729    #[test]
730    fn tool_failure_error_prefix() {
731        assert!(is_tool_failure("Error: file not found"));
732        assert!(is_tool_failure("error: connection refused"));
733        assert!(is_tool_failure("  Error: indented"));
734    }
735
736    #[test]
737    fn tool_failure_failed_prefix() {
738        assert!(is_tool_failure("Failed: command returned non-zero"));
739        assert!(is_tool_failure("failure: assertion failed"));
740        assert!(is_tool_failure("fatal: not a git repository"));
741        assert!(is_tool_failure("panic: index out of bounds"));
742    }
743
744    #[test]
745    fn tool_failure_json_error() {
746        assert!(is_tool_failure(r#"{"error": "not found"}"#));
747        assert!(is_tool_failure(r#"{"err": "timeout"}"#));
748    }
749
750    #[test]
751    fn tool_failure_exit_code() {
752        assert!(is_tool_failure("process exited with exit code 1"));
753        assert!(is_tool_failure("exit status 127"));
754        assert!(!is_tool_failure("exit code 0 — success"));
755        assert!(!is_tool_failure("exit status 0"));
756    }
757
758    #[test]
759    fn tool_success_normal_output() {
760        assert!(!is_tool_failure("hello world"));
761        assert!(!is_tool_failure("42"));
762        assert!(!is_tool_failure("file created successfully"));
763        assert!(!is_tool_failure(""));
764    }
765
766    #[test]
767    fn ingest_turn_records_procedural_failure() {
768        let db = roboticus_db::Database::new(":memory:").unwrap();
769        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
770        roboticus_db::memory::store_procedural(&db, "bad_tool", "a tool").ok();
771        ingest_turn(
772            &db,
773            &session_id,
774            "use bad_tool",
775            "error occurred",
776            &[("bad_tool".into(), "Error: something broke".into())],
777        );
778        // If the procedural entry exists, failure_count should have incremented.
779        // The test passes if no panic occurs (silent degradation).
780    }
781
782    #[test]
783    fn ingest_turn_peer_scope_stores_relationship_memory() {
784        let db = roboticus_db::Database::new(":memory:").unwrap();
785        let scope = roboticus_db::sessions::SessionScope::Peer {
786            peer_id: "alice".into(),
787            channel: "telegram".into(),
788        };
789        let session_id =
790            roboticus_db::sessions::find_or_create(&db, "test-agent", Some(&scope)).unwrap();
791
792        ingest_turn(
793            &db,
794            &session_id,
795            "Can you remind me what we decided?",
796            "We agreed to prioritize the Telegram stability work first.",
797            &[],
798        );
799
800        let entry = roboticus_db::memory::retrieve_relationship(&db, "peer:telegram:alice")
801            .unwrap()
802            .expect("peer-scoped turns should create relationship memory");
803        assert_eq!(entry.entity_name.as_deref(), Some("alice"));
804        assert_eq!(entry.interaction_count, 1);
805        assert!(
806            entry
807                .interaction_summary
808                .as_deref()
809                .unwrap_or("")
810                .contains("prioritize the Telegram stability work"),
811            "relationship interaction summary should capture the turn context"
812        );
813    }
814
815    #[test]
816    fn parse_peer_scope_key_parses_identity() {
817        assert_eq!(
818            parse_peer_scope_key("peer:telegram:user-42"),
819            Some(("telegram", "user-42"))
820        );
821        assert_eq!(parse_peer_scope_key("agent"), None);
822        assert_eq!(parse_peer_scope_key("peer::user-42"), None);
823    }
824
825    #[test]
826    fn ingest_turn_marks_older_semantic_summaries_stale_per_session() {
827        let db = roboticus_db::Database::new(":memory:").unwrap();
828        let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
829        let first = "Alpha incident resolved after rollback with careful verification and communication to every stakeholder involved. ".repeat(2);
830        let second = "Beta migration is active with the new phased plan, improved monitoring, and rollback checkpoints in place. ".repeat(2);
831
832        ingest_turn(&db, &session_id, "summarize alpha", &first, &[]);
833        ingest_turn(&db, &session_id, "summarize beta", &second, &[]);
834
835        let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
836        assert_eq!(semantic.len(), 2);
837        let active = semantic
838            .iter()
839            .filter(|entry| entry.memory_state == "active")
840            .collect::<Vec<_>>();
841        let stale = semantic
842            .iter()
843            .filter(|entry| entry.memory_state == "stale")
844            .collect::<Vec<_>>();
845        assert_eq!(active.len(), 1);
846        assert_eq!(stale.len(), 1);
847        assert!(active[0].value.contains("Beta migration is active"));
848        assert!(stale[0].value.contains("Alpha incident resolved"));
849        assert_eq!(
850            stale[0].state_reason.as_deref(),
851            Some("superseded_by_newer_session_summary")
852        );
853    }
854}