1use std::str::FromStr;
34use std::time::Duration;
35
36use serde::Deserialize;
37use tokio::time::timeout;
38use zeph_db::{ActiveDialect, DbPool, placeholder_list};
39use zeph_llm::any::AnyProvider;
40use zeph_llm::provider::{LlmProvider as _, Message, Role};
41
42use crate::error::MemoryError;
43use crate::vector_store::VectorStore;
44
45const HOT_STRATEGY_USE_COUNT: i64 = 10;
50
51const MAX_IDS_PER_QUERY: usize = 490;
53
54const SELF_JUDGE_SYSTEM: &str = "\
59You are a task outcome evaluator. Given an agent turn transcript, analyze the conversation and determine:
601. Did the agent successfully complete the user's request? (true/false)
612. Extract the key reasoning steps the agent took (reasoning chain).
623. Summarize the task in one sentence (task hint).
63
64Respond ONLY with valid JSON, no markdown fences, no prose:
65{\"success\": bool, \"reasoning_chain\": \"string\", \"task_hint\": \"string\"}";
66
67const DISTILL_SYSTEM: &str = "\
71You are a strategy distiller. Given a reasoning chain from an agent turn, distill it into \
72a short generalizable strategy (at most 3 sentences) that could help an agent facing a similar \
73task. Focus on the transferable principle, not the specific instance. \
74Respond with the strategy text only — no headers, no lists, no markdown.";
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum Outcome {
82 Success,
84 Failure,
86}
87
88impl Outcome {
89 #[must_use]
91 pub fn as_str(self) -> &'static str {
92 match self {
93 Outcome::Success => "success",
94 Outcome::Failure => "failure",
95 }
96 }
97}
98
99#[derive(Debug, thiserror::Error)]
101#[error("unknown outcome: {0}")]
102pub struct OutcomeParseError(String);
103
104impl FromStr for Outcome {
105 type Err = OutcomeParseError;
106
107 fn from_str(s: &str) -> Result<Self, Self::Err> {
108 match s {
109 "success" => Ok(Outcome::Success),
110 "failure" => Ok(Outcome::Failure),
111 other => {
112 tracing::warn!(
113 value = other,
114 "reasoning: unknown outcome, defaulting to Failure"
115 );
116 Ok(Outcome::Failure)
117 }
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
127pub struct ReasoningStrategy {
128 pub id: String,
130 pub summary: String,
132 pub outcome: Outcome,
134 pub task_hint: String,
136 pub created_at: i64,
138 pub last_used_at: i64,
140 pub use_count: i64,
142 pub embedded_at: Option<i64>,
146}
147
148#[derive(Debug, Deserialize)]
154pub struct SelfJudgeOutcome {
155 pub success: bool,
157 pub reasoning_chain: String,
159 pub task_hint: String,
161}
162
163pub struct ReasoningMemory {
169 pool: DbPool,
170 vector_store: Option<std::sync::Arc<dyn VectorStore>>,
174}
175
176pub const REASONING_COLLECTION: &str = "reasoning_strategies";
178
179impl ReasoningMemory {
180 #[must_use]
195 pub fn new(pool: DbPool, vector_store: Option<std::sync::Arc<dyn VectorStore>>) -> Self {
196 Self { pool, vector_store }
197 }
198
199 #[tracing::instrument(name = "memory.reasoning.insert", skip(self, embedding), fields(id = %strategy.id))]
209 pub async fn insert(
210 &self,
211 strategy: &ReasoningStrategy,
212 embedding: Vec<f32>,
213 ) -> Result<(), MemoryError> {
214 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
215 let raw = format!(
216 "INSERT INTO reasoning_strategies \
217 (id, summary, outcome, task_hint, created_at, last_used_at, use_count, embedded_at) \
218 VALUES (?, ?, ?, ?, {epoch_now}, {epoch_now}, 0, NULL) \
219 ON CONFLICT (id) DO UPDATE SET \
220 summary = EXCLUDED.summary, \
221 outcome = EXCLUDED.outcome, \
222 task_hint = EXCLUDED.task_hint, \
223 last_used_at = EXCLUDED.last_used_at, \
224 embedded_at = EXCLUDED.embedded_at"
225 );
226 let sql = zeph_db::rewrite_placeholders(&raw);
227 zeph_db::query(sqlx::AssertSqlSafe(sql))
228 .bind(&strategy.id)
229 .bind(&strategy.summary)
230 .bind(strategy.outcome.as_str())
231 .bind(&strategy.task_hint)
232 .execute(&self.pool)
233 .await?;
234
235 if let Some(ref vs) = self.vector_store {
237 let point = crate::vector_store::VectorPoint {
238 id: strategy.id.clone(),
239 vector: embedding,
240 payload: std::collections::HashMap::from([
241 (
242 "outcome".to_owned(),
243 serde_json::Value::String(strategy.outcome.as_str().to_owned()),
244 ),
245 (
246 "task_hint".to_owned(),
247 serde_json::Value::String(strategy.task_hint.clone()),
248 ),
249 ]),
250 };
251 if let Err(e) = vs.upsert(REASONING_COLLECTION, vec![point]).await {
252 tracing::warn!(error = %e, id = %strategy.id, "reasoning: Qdrant upsert failed — SQLite-only mode");
253 } else {
254 let update_sql = zeph_db::rewrite_placeholders(&format!(
256 "UPDATE reasoning_strategies SET embedded_at = {epoch_now} WHERE id = ?"
257 ));
258 if let Err(e) = zeph_db::query(sqlx::AssertSqlSafe(update_sql))
259 .bind(&strategy.id)
260 .execute(&self.pool)
261 .await
262 {
263 tracing::warn!(error = %e, "reasoning: failed to set embedded_at");
264 }
265 }
266 }
267
268 tracing::debug!(id = %strategy.id, outcome = strategy.outcome.as_str(), "reasoning: strategy inserted");
269 Ok(())
270 }
271
272 #[tracing::instrument(
284 name = "memory.reasoning.retrieve_by_embedding",
285 skip(self, embedding),
286 fields(top_k)
287 )]
288 pub async fn retrieve_by_embedding(
289 &self,
290 embedding: &[f32],
291 top_k: u64,
292 ) -> Result<Vec<ReasoningStrategy>, MemoryError> {
293 let Some(ref vs) = self.vector_store else {
294 return Ok(Vec::new());
295 };
296
297 let scored = vs
298 .search(REASONING_COLLECTION, embedding.to_vec(), top_k, None)
299 .await?;
300
301 if scored.is_empty() {
302 return Ok(Vec::new());
303 }
304
305 let ids: Vec<String> = scored.into_iter().map(|p| p.id).collect();
306 self.fetch_by_ids(&ids).await
307 }
308
309 #[tracing::instrument(name = "memory.reasoning.mark_used", skip(self), fields(n = ids.len()))]
319 pub async fn mark_used(&self, ids: &[String]) -> Result<(), MemoryError> {
320 if ids.is_empty() {
321 return Ok(());
322 }
323
324 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
325 for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
326 let ph = placeholder_list(1, chunk.len());
327 let sql = format!(
330 "UPDATE reasoning_strategies \
331 SET use_count = use_count + 1, last_used_at = {epoch_now} \
332 WHERE id IN ({ph})"
333 );
334 let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
335 for id in chunk {
336 q = q.bind(id.as_str());
337 }
338 q.execute(&self.pool).await?;
339 }
340
341 Ok(())
342 }
343
344 #[tracing::instrument(name = "memory.reasoning.evict_lru", skip(self), fields(store_limit))]
360 pub async fn evict_lru(&self, store_limit: usize) -> Result<usize, MemoryError> {
361 let count = self.count().await?;
362 if count <= store_limit {
363 return Ok(0);
364 }
365
366 let over_by = count - store_limit;
367 let deleted_cold = self.delete_oldest_cold(over_by).await?;
368 if deleted_cold > 0 {
369 tracing::debug!(
371 deleted = deleted_cold,
372 count,
373 "reasoning: evicted cold strategies"
374 );
375 return Ok(deleted_cold);
376 }
377
378 let hard_ceiling = store_limit.saturating_mul(2);
380 if count <= hard_ceiling {
381 tracing::debug!(
382 count,
383 store_limit,
384 "reasoning: hot saturation — growth allowed under 2x ceiling"
385 );
386 return Ok(0);
387 }
388
389 let forced = count - store_limit;
391 let deleted_forced = self.delete_oldest_unconditional(forced).await?;
392 tracing::warn!(
393 deleted = deleted_forced,
394 count,
395 hard_ceiling,
396 "reasoning: hard-ceiling eviction — evicted hot strategies; consider raising store_limit"
397 );
398
399 Ok(deleted_forced)
400 }
401
402 pub async fn count(&self) -> Result<usize, MemoryError> {
408 let row: (i64,) = zeph_db::query_as("SELECT COUNT(*) FROM reasoning_strategies")
409 .fetch_one(&self.pool)
410 .await?;
411 Ok(usize::try_from(row.0.max(0)).unwrap_or(0))
412 }
413
414 pub(crate) async fn fetch_by_ids(
418 &self,
419 ids: &[String],
420 ) -> Result<Vec<ReasoningStrategy>, MemoryError> {
421 if ids.is_empty() {
422 return Ok(Vec::new());
423 }
424
425 let mut strategies = Vec::with_capacity(ids.len());
426 for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
427 let ph = placeholder_list(1, chunk.len());
428 let sql = format!(
430 "SELECT id, summary, outcome, task_hint, created_at, last_used_at, use_count, embedded_at \
431 FROM reasoning_strategies WHERE id IN ({ph})"
432 );
433 let mut q = zeph_db::query_as::<
434 _,
435 (String, String, String, String, i64, i64, i64, Option<i64>),
436 >(sqlx::AssertSqlSafe(sql));
437 for id in chunk {
438 q = q.bind(id.as_str());
439 }
440 let rows = q.fetch_all(&self.pool).await?;
441 for (
442 id,
443 summary,
444 outcome_str,
445 task_hint,
446 created_at,
447 last_used_at,
448 use_count,
449 embedded_at,
450 ) in rows
451 {
452 let outcome = Outcome::from_str(&outcome_str).unwrap_or(Outcome::Failure);
453 strategies.push(ReasoningStrategy {
454 id,
455 summary,
456 outcome,
457 task_hint,
458 created_at,
459 last_used_at,
460 use_count,
461 embedded_at,
462 });
463 }
464 }
465
466 Ok(strategies)
467 }
468
469 async fn delete_oldest_cold(&self, n: usize) -> Result<usize, MemoryError> {
473 let limit = i64::try_from(n).unwrap_or(i64::MAX);
474 let raw = format!(
476 "DELETE FROM reasoning_strategies \
477 WHERE id IN ( \
478 SELECT id FROM reasoning_strategies \
479 WHERE use_count <= {HOT_STRATEGY_USE_COUNT} \
480 ORDER BY last_used_at ASC LIMIT ? \
481 )"
482 );
483 let sql = zeph_db::rewrite_placeholders(&raw);
484 let result = zeph_db::query(sqlx::AssertSqlSafe(sql))
485 .bind(limit)
486 .execute(&self.pool)
487 .await?;
488 Ok(usize::try_from(result.rows_affected()).unwrap_or(0))
489 }
490
491 async fn delete_oldest_unconditional(&self, n: usize) -> Result<usize, MemoryError> {
495 let limit = i64::try_from(n).unwrap_or(i64::MAX);
496 let raw = "DELETE FROM reasoning_strategies \
497 WHERE id IN ( \
498 SELECT id FROM reasoning_strategies \
499 ORDER BY last_used_at ASC LIMIT ? \
500 )";
501 let sql = zeph_db::rewrite_placeholders(raw);
502 let result = zeph_db::query(sqlx::AssertSqlSafe(sql))
503 .bind(limit)
504 .execute(&self.pool)
505 .await?;
506 Ok(usize::try_from(result.rows_affected()).unwrap_or(0))
507 }
508}
509
510#[tracing::instrument(name = "memory.reasoning.self_judge", skip(provider, messages), fields(n = messages.len()))]
535pub async fn run_self_judge(
536 provider: &AnyProvider,
537 messages: &[Message],
538 extraction_timeout: Duration,
539) -> Option<SelfJudgeOutcome> {
540 if messages.is_empty() {
541 return None;
542 }
543
544 let user_prompt = build_transcript_prompt(messages);
545
546 let llm_messages = [
547 Message::from_legacy(Role::System, SELF_JUDGE_SYSTEM),
548 Message::from_legacy(Role::User, user_prompt),
549 ];
550
551 let response = match timeout(extraction_timeout, provider.chat(&llm_messages)).await {
552 Ok(Ok(text)) => text,
553 Ok(Err(e)) => {
554 tracing::warn!(error = %e, "reasoning: self-judge LLM call failed");
555 return None;
556 }
557 Err(_) => {
558 tracing::warn!("reasoning: self-judge timed out");
559 return None;
560 }
561 };
562
563 parse_self_judge_response(&response)
564}
565
566#[tracing::instrument(name = "memory.reasoning.distill", skip(provider, reasoning_chain))]
586pub async fn distill_strategy(
587 provider: &AnyProvider,
588 outcome: Outcome,
589 reasoning_chain: &str,
590 distill_timeout: Duration,
591) -> Option<String> {
592 if reasoning_chain.is_empty() {
593 return None;
594 }
595
596 let user_prompt = format!(
597 "Outcome: {}\n\nReasoning chain:\n{reasoning_chain}",
598 outcome.as_str()
599 );
600
601 let llm_messages = [
602 Message::from_legacy(Role::System, DISTILL_SYSTEM),
603 Message::from_legacy(Role::User, user_prompt),
604 ];
605
606 let response = match timeout(distill_timeout, provider.chat(&llm_messages)).await {
607 Ok(Ok(text)) => text,
608 Ok(Err(e)) => {
609 tracing::warn!(error = %e, "reasoning: distillation LLM call failed");
610 return None;
611 }
612 Err(_) => {
613 tracing::warn!("reasoning: distillation timed out");
614 return None;
615 }
616 };
617
618 let trimmed = trim_to_three_sentences(&response);
619 if trimmed.is_empty() {
620 None
621 } else {
622 Some(trimmed)
623 }
624}
625
626#[derive(Debug, Clone, Copy)]
630pub struct ProcessTurnConfig {
631 pub store_limit: usize,
633 pub extraction_timeout: Duration,
635 pub distill_timeout: Duration,
637 pub embed_timeout: Duration,
639 pub self_judge_window: usize,
643 pub min_assistant_chars: usize,
646}
647
648#[tracing::instrument(name = "memory.reasoning.process_turn", skip_all)]
659pub async fn process_turn(
660 memory: &ReasoningMemory,
661 extract_provider: &AnyProvider,
662 distill_provider: &AnyProvider,
663 embed_provider: &AnyProvider,
664 messages: &[Message],
665 cfg: ProcessTurnConfig,
666) -> Result<(), MemoryError> {
667 let ProcessTurnConfig {
668 store_limit,
669 extraction_timeout,
670 distill_timeout,
671 embed_timeout,
672 self_judge_window,
673 min_assistant_chars,
674 } = cfg;
675
676 let judge_messages = if messages.len() > self_judge_window {
679 &messages[messages.len() - self_judge_window..]
680 } else {
681 messages
682 };
683
684 let last_assistant_chars = judge_messages
686 .iter()
687 .rev()
688 .find(|m| m.role == Role::Assistant)
689 .map_or(0, |m| m.content.len());
690 if last_assistant_chars < min_assistant_chars {
691 return Ok(());
692 }
693
694 let Some(outcome) = run_self_judge(extract_provider, judge_messages, extraction_timeout).await
695 else {
696 return Ok(());
697 };
698
699 let outcome_enum = if outcome.success {
700 Outcome::Success
701 } else {
702 Outcome::Failure
703 };
704
705 let Some(summary) = distill_strategy(
706 distill_provider,
707 outcome_enum,
708 &outcome.reasoning_chain,
709 distill_timeout,
710 )
711 .await
712 else {
713 return Ok(());
714 };
715
716 let embed_input = format!("{}\n{}", outcome.task_hint, summary);
718 let embedding =
719 match tokio::time::timeout(embed_timeout, embed_provider.embed(&embed_input)).await {
720 Ok(Ok(v)) => v,
721 Ok(Err(e)) => {
722 tracing::warn!(error = %e, "reasoning: embedding failed — strategy not stored");
723 return Ok(());
724 }
725 Err(_) => {
726 tracing::warn!("reasoning: embed timed out — strategy not stored");
727 return Ok(());
728 }
729 };
730
731 let id = uuid::Uuid::new_v4().to_string();
732 let strategy = ReasoningStrategy {
733 id,
734 summary,
735 outcome: outcome_enum,
736 task_hint: outcome.task_hint,
737 created_at: 0, last_used_at: 0,
739 use_count: 0,
740 embedded_at: None,
741 };
742
743 let count_before = memory.count().await.unwrap_or(0);
748
749 if let Err(e) = memory.insert(&strategy, embedding).await {
750 tracing::warn!(error = %e, "reasoning: insert failed");
751 return Ok(());
752 }
753
754 if count_before >= store_limit
755 && let Err(e) = memory.evict_lru(store_limit).await
756 {
757 tracing::warn!(error = %e, "reasoning: evict_lru failed");
758 }
759
760 Ok(())
761}
762
763const MAX_TRANSCRIPT_MESSAGE_CHARS: usize = 2000;
770
771fn build_transcript_prompt(messages: &[Message]) -> String {
777 let mut prompt = String::from("Agent turn messages:\n");
778 for (i, msg) in messages.iter().enumerate() {
779 use std::fmt::Write as _;
780 let role = format!("{:?}", msg.role);
781 let content: std::borrow::Cow<str> =
783 if msg.content.chars().count() > MAX_TRANSCRIPT_MESSAGE_CHARS {
784 msg.content
785 .char_indices()
786 .nth(MAX_TRANSCRIPT_MESSAGE_CHARS)
787 .map_or(msg.content.as_str().into(), |(byte_idx, _)| {
788 msg.content[..byte_idx].into()
789 })
790 } else {
791 msg.content.as_str().into()
792 };
793 let _ = writeln!(prompt, "[{}] {}: {}", i + 1, role, content);
794 }
795 prompt.push_str("\nEvaluate this turn and return JSON.");
796 prompt
797}
798
799fn parse_self_judge_response(response: &str) -> Option<SelfJudgeOutcome> {
804 let stripped = response
806 .trim()
807 .trim_start_matches("```json")
808 .trim_start_matches("```")
809 .trim_end_matches("```")
810 .trim();
811
812 if let Ok(v) = serde_json::from_str::<SelfJudgeOutcome>(stripped) {
813 return Some(v);
814 }
815
816 if let (Some(start), Some(end)) = (stripped.find('{'), stripped.rfind('}'))
818 && end > start
819 && let Ok(v) = serde_json::from_str::<SelfJudgeOutcome>(&stripped[start..=end])
820 {
821 return Some(v);
822 }
823
824 tracing::warn!(
825 "reasoning: failed to parse self-judge response (len={}): {:.200}",
826 response.len(),
827 response
828 );
829 None
830}
831
832fn trim_to_three_sentences(text: &str) -> String {
837 const MAX_CHARS: usize = 512;
838 const MAX_SENTENCES: usize = 3;
839
840 let text = text.trim();
841 let mut sentence_ends: Vec<usize> = Vec::new();
842 let chars: Vec<char> = text.chars().collect();
843 let len = chars.len();
844
845 for (i, &ch) in chars.iter().enumerate() {
846 if matches!(ch, '.' | '!' | '?') {
847 let next_is_boundary = i + 1 >= len || chars[i + 1].is_whitespace();
848 if next_is_boundary {
849 sentence_ends.push(i + 1); if sentence_ends.len() >= MAX_SENTENCES {
851 break;
852 }
853 }
854 }
855 }
856
857 let char_limit = if let Some(&end) = sentence_ends.last() {
858 end.min(MAX_CHARS)
859 } else {
860 text.chars().count().min(MAX_CHARS)
861 };
862
863 let result: String = text.chars().take(char_limit).collect();
864 match result.char_indices().nth(MAX_CHARS) {
866 Some((byte_idx, _)) => result[..byte_idx].to_owned(),
867 None => result,
868 }
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
878 fn outcome_as_str_round_trip() {
879 assert_eq!(Outcome::Success.as_str(), "success");
880 assert_eq!(Outcome::Failure.as_str(), "failure");
881 }
882
883 #[test]
884 fn outcome_from_str_success() {
885 assert_eq!(Outcome::from_str("success").unwrap(), Outcome::Success);
886 }
887
888 #[test]
889 fn outcome_from_str_failure() {
890 assert_eq!(Outcome::from_str("failure").unwrap(), Outcome::Failure);
891 }
892
893 #[test]
894 fn outcome_from_str_unknown_defaults_to_failure() {
895 assert_eq!(Outcome::from_str("partial").unwrap(), Outcome::Failure);
897 }
898
899 #[test]
902 fn parse_direct_json() {
903 let json = r#"{"success":true,"reasoning_chain":"tried X","task_hint":"do Y"}"#;
904 let outcome = parse_self_judge_response(json).unwrap();
905 assert!(outcome.success);
906 assert_eq!(outcome.reasoning_chain, "tried X");
907 assert_eq!(outcome.task_hint, "do Y");
908 }
909
910 #[test]
911 fn parse_json_with_markdown_fences() {
912 let response =
913 "```json\n{\"success\":false,\"reasoning_chain\":\"r\",\"task_hint\":\"t\"}\n```";
914 let outcome = parse_self_judge_response(response).unwrap();
915 assert!(!outcome.success);
916 }
917
918 #[test]
919 fn parse_json_embedded_in_prose() {
920 let response = r#"Here is the evaluation: {"success":true,"reasoning_chain":"chain","task_hint":"hint"} — done."#;
921 let outcome = parse_self_judge_response(response).unwrap();
922 assert!(outcome.success);
923 }
924
925 #[test]
926 fn parse_invalid_returns_none() {
927 let outcome = parse_self_judge_response("not json at all");
928 assert!(outcome.is_none());
929 }
930
931 #[test]
934 fn trim_three_sentences_short_text() {
935 let text = "One. Two. Three.";
936 assert_eq!(trim_to_three_sentences(text), "One. Two. Three.");
937 }
938
939 #[test]
940 fn trim_three_sentences_truncates_at_third() {
941 let text = "One. Two. Three. Four. Five.";
942 let result = trim_to_three_sentences(text);
943 assert!(result.ends_with("Three."), "got: {result}");
944 assert!(!result.contains("Four"));
945 }
946
947 #[test]
948 fn trim_three_sentences_hard_cap() {
949 let long: String = "x".repeat(600);
951 let result = trim_to_three_sentences(&long);
952 assert!(result.chars().count() <= 512);
953 }
954
955 #[test]
956 fn trim_three_sentences_empty() {
957 assert_eq!(trim_to_three_sentences(" "), "");
958 }
959
960 #[cfg(feature = "sqlite")]
965 async fn make_test_pool() -> DbPool {
966 let pool = sqlx::SqlitePool::connect(":memory:").await.unwrap();
967 sqlx::query(
968 "CREATE TABLE reasoning_strategies (
969 id TEXT PRIMARY KEY NOT NULL,
970 summary TEXT NOT NULL,
971 outcome TEXT NOT NULL,
972 task_hint TEXT NOT NULL,
973 created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
974 last_used_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
975 use_count INTEGER NOT NULL DEFAULT 0,
976 embedded_at INTEGER
977 )",
978 )
979 .execute(&pool)
980 .await
981 .unwrap();
982 pool
983 }
984
985 #[cfg(feature = "sqlite")]
986 fn make_strategy(id: &str) -> ReasoningStrategy {
987 ReasoningStrategy {
988 id: id.to_owned(),
989 summary: format!("Summary for {id}"),
990 outcome: Outcome::Success,
991 task_hint: format!("Task hint for {id}"),
992 created_at: 0,
993 last_used_at: 0,
994 use_count: 0,
995 embedded_at: None,
996 }
997 }
998
999 #[cfg(feature = "sqlite")]
1000 #[tokio::test]
1001 async fn insert_and_fetch_by_ids() {
1002 let pool = make_test_pool().await;
1003 let mem = ReasoningMemory::new(pool, None);
1004
1005 let s = make_strategy("abc-123");
1006 mem.insert(&s, vec![]).await.unwrap();
1007
1008 let rows = mem.fetch_by_ids(&["abc-123".to_owned()]).await.unwrap();
1009 assert_eq!(rows.len(), 1);
1010 assert_eq!(rows[0].id, "abc-123");
1011 assert_eq!(rows[0].outcome, Outcome::Success);
1012 }
1013
1014 #[cfg(feature = "sqlite")]
1015 #[tokio::test]
1016 async fn mark_used_increments_count() {
1017 let pool = make_test_pool().await;
1018 let mem = ReasoningMemory::new(pool, None);
1019
1020 let s = make_strategy("mark-1");
1021 mem.insert(&s, vec![]).await.unwrap();
1022 mem.mark_used(&["mark-1".to_owned()]).await.unwrap();
1023 mem.mark_used(&["mark-1".to_owned()]).await.unwrap();
1024
1025 let rows = mem.fetch_by_ids(&["mark-1".to_owned()]).await.unwrap();
1026 assert_eq!(rows[0].use_count, 2);
1027 }
1028
1029 #[cfg(feature = "sqlite")]
1030 #[tokio::test]
1031 async fn mark_used_empty_is_noop() {
1032 let pool = make_test_pool().await;
1033 let mem = ReasoningMemory::new(pool, None);
1034 mem.mark_used(&[]).await.unwrap();
1036 }
1037
1038 #[cfg(feature = "sqlite")]
1039 #[tokio::test]
1040 async fn count_returns_correct_total() {
1041 let pool = make_test_pool().await;
1042 let mem = ReasoningMemory::new(pool, None);
1043
1044 for i in 0..5 {
1045 mem.insert(&make_strategy(&format!("s{i}")), vec![])
1046 .await
1047 .unwrap();
1048 }
1049
1050 assert_eq!(mem.count().await.unwrap(), 5);
1051 }
1052
1053 #[cfg(feature = "sqlite")]
1054 #[tokio::test]
1055 async fn evict_lru_cold_rows() {
1056 let pool = make_test_pool().await;
1057 let mem = ReasoningMemory::new(pool, None);
1058
1059 for i in 0..5 {
1061 mem.insert(&make_strategy(&format!("cold-{i}")), vec![])
1062 .await
1063 .unwrap();
1064 }
1065
1066 let deleted = mem.evict_lru(3).await.unwrap();
1068 assert_eq!(deleted, 2);
1069 assert_eq!(mem.count().await.unwrap(), 3);
1070 }
1071
1072 #[cfg(feature = "sqlite")]
1073 #[tokio::test]
1074 async fn evict_lru_respects_hot_rows_under_ceiling() {
1075 let pool = make_test_pool().await;
1076 let mem = ReasoningMemory::new(pool.clone(), None);
1077
1078 for i in 0..5 {
1080 let id = format!("hot-{i}");
1081 mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1082 let ids: Vec<String> = (0..11).map(|_| id.clone()).collect();
1084 for chunk_ids in ids.chunks(1) {
1085 mem.mark_used(chunk_ids).await.unwrap();
1086 }
1087 }
1088
1089 let deleted = mem.evict_lru(3).await.unwrap();
1091 assert_eq!(deleted, 0);
1092 assert_eq!(mem.count().await.unwrap(), 5);
1093 }
1094
1095 #[cfg(feature = "sqlite")]
1096 #[tokio::test]
1097 async fn evict_lru_hard_ceiling_forces_deletion() {
1098 let pool = make_test_pool().await;
1099 let mem = ReasoningMemory::new(pool.clone(), None);
1100
1101 for i in 0..7 {
1103 let id = format!("hot2-{i}");
1104 mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1105 for _ in 0..=HOT_STRATEGY_USE_COUNT {
1107 mem.mark_used(std::slice::from_ref(&id)).await.unwrap();
1108 }
1109 }
1110
1111 let deleted = mem.evict_lru(3).await.unwrap();
1112 assert!(deleted > 0, "expected forced deletion");
1113 let remaining = mem.count().await.unwrap();
1114 assert_eq!(remaining, 3, "should be trimmed to store_limit");
1115 }
1116
1117 #[cfg(feature = "sqlite")]
1118 #[tokio::test]
1119 async fn evict_lru_no_op_when_under_limit() {
1120 let pool = make_test_pool().await;
1121 let mem = ReasoningMemory::new(pool, None);
1122
1123 for i in 0..3 {
1124 mem.insert(&make_strategy(&format!("s{i}")), vec![])
1125 .await
1126 .unwrap();
1127 }
1128
1129 let deleted = mem.evict_lru(10).await.unwrap();
1131 assert_eq!(deleted, 0);
1132 }
1133
1134 #[cfg(feature = "sqlite")]
1137 #[tokio::test]
1138 async fn mark_used_chunked_over_490_ids() {
1139 let pool = make_test_pool().await;
1140 let mem = ReasoningMemory::new(pool, None);
1141
1142 for i in 0..500usize {
1144 mem.insert(&make_strategy(&format!("chunked-{i}")), vec![])
1145 .await
1146 .unwrap();
1147 }
1148
1149 let ids: Vec<String> = (0..500usize).map(|i| format!("chunked-{i}")).collect();
1150 mem.mark_used(&ids).await.unwrap();
1151
1152 let first = mem.fetch_by_ids(&[ids[0].clone()]).await.unwrap();
1154 let over_chunk = mem.fetch_by_ids(&[ids[490].clone()]).await.unwrap();
1155 assert_eq!(first[0].use_count, 1, "first id should have use_count = 1");
1156 assert_eq!(
1157 over_chunk[0].use_count, 1,
1158 "id past the chunk boundary should have use_count = 1"
1159 );
1160 }
1161
1162 #[tokio::test]
1165 async fn run_self_judge_malformed_json_returns_none() {
1166 use zeph_llm::any::AnyProvider;
1167 use zeph_llm::mock::MockProvider;
1168
1169 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
1171 "This is not JSON at all.".to_string(),
1172 ]));
1173 let msgs = vec![Message::from_legacy(Role::User, "hello")];
1174 let result = run_self_judge(&provider, &msgs, std::time::Duration::from_secs(5)).await;
1175 assert!(result.is_none(), "malformed LLM response must return None");
1176 }
1177
1178 #[tokio::test]
1181 async fn distill_strategy_truncates_to_three_sentences() {
1182 use zeph_llm::any::AnyProvider;
1183 use zeph_llm::mock::MockProvider;
1184
1185 let long_response = "One. Two. Three. Four. Five.";
1186 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
1187 long_response.to_string(),
1188 ]));
1189 let result = distill_strategy(
1190 &provider,
1191 Outcome::Success,
1192 "chain here",
1193 std::time::Duration::from_secs(5),
1194 )
1195 .await
1196 .unwrap();
1197 assert!(result.ends_with("Three."), "got: {result}");
1198 assert!(
1199 !result.contains("Four"),
1200 "should not contain 4th sentence: {result}"
1201 );
1202 }
1203
1204 #[cfg(feature = "sqlite")]
1207 #[tokio::test]
1208 async fn process_turn_with_empty_messages_is_noop() {
1209 use zeph_llm::any::AnyProvider;
1210 use zeph_llm::mock::MockProvider;
1211
1212 let pool = make_test_pool().await;
1213 let mem = ReasoningMemory::new(pool, None);
1214 let provider = AnyProvider::Mock(MockProvider::default());
1217 let cfg = ProcessTurnConfig {
1218 store_limit: 100,
1219 extraction_timeout: std::time::Duration::from_secs(1),
1220 distill_timeout: std::time::Duration::from_secs(1),
1221 embed_timeout: std::time::Duration::from_secs(5),
1222 self_judge_window: 2,
1223 min_assistant_chars: 0,
1224 };
1225 let result = process_turn(&mem, &provider, &provider, &provider, &[], cfg).await;
1226 assert!(
1227 result.is_ok(),
1228 "process_turn with empty messages must succeed"
1229 );
1230 assert_eq!(
1231 mem.count().await.unwrap(),
1232 0,
1233 "no strategies should be stored"
1234 );
1235 }
1236}