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(
289 name = "memory.reasoning.retrieve_by_embedding",
290 skip(self, embedding),
291 fields(top_k)
292 )]
293 pub async fn retrieve_by_embedding(
294 &self,
295 embedding: &[f32],
296 top_k: u64,
297 ) -> Result<Vec<ReasoningStrategy>, MemoryError> {
298 let Some(ref vs) = self.vector_store else {
299 return Ok(Vec::new());
300 };
301
302 static CLAMP_WARNED: std::sync::atomic::AtomicBool =
303 std::sync::atomic::AtomicBool::new(false);
304 if let Ok(requested) = usize::try_from(top_k) {
305 crate::warn_if_search_limit_clamped(
306 "ReasoningMemory::retrieve_by_embedding",
307 requested,
308 &CLAMP_WARNED,
309 );
310 }
311 let top_k = top_k.clamp(1, crate::MAX_SEARCH_LIMIT as u64);
312 let scored = vs
313 .search(REASONING_COLLECTION, embedding.to_vec(), top_k, None)
314 .await?;
315
316 if scored.is_empty() {
317 return Ok(Vec::new());
318 }
319
320 let ids: Vec<String> = scored.into_iter().map(|p| p.id).collect();
321 self.fetch_by_ids(&ids).await
322 }
323
324 #[tracing::instrument(name = "memory.reasoning.mark_used", skip(self), fields(n = ids.len()))]
334 pub async fn mark_used(&self, ids: &[String]) -> Result<(), MemoryError> {
335 if ids.is_empty() {
336 return Ok(());
337 }
338
339 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
340 for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
341 let ph = placeholder_list(1, chunk.len());
342 let sql = format!(
345 "UPDATE reasoning_strategies \
346 SET use_count = use_count + 1, last_used_at = {epoch_now} \
347 WHERE id IN ({ph})"
348 );
349 let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
350 for id in chunk {
351 q = q.bind(id.as_str());
352 }
353 q.execute(&self.pool).await?;
354 }
355
356 Ok(())
357 }
358
359 #[tracing::instrument(name = "memory.reasoning.evict_lru", skip(self), fields(store_limit))]
375 pub async fn evict_lru(&self, store_limit: usize) -> Result<usize, MemoryError> {
376 let count = self.count().await?;
377 if count <= store_limit {
378 return Ok(0);
379 }
380
381 let over_by = count - store_limit;
382 let deleted_cold = self.delete_oldest_cold(over_by).await?;
383 if deleted_cold > 0 {
384 tracing::debug!(
386 deleted = deleted_cold,
387 count,
388 "reasoning: evicted cold strategies"
389 );
390 return Ok(deleted_cold);
391 }
392
393 let hard_ceiling = store_limit.saturating_mul(2);
395 if count <= hard_ceiling {
396 tracing::debug!(
397 count,
398 store_limit,
399 "reasoning: hot saturation — growth allowed under 2x ceiling"
400 );
401 return Ok(0);
402 }
403
404 let forced = count - store_limit;
406 let deleted_forced = self.delete_oldest_unconditional(forced).await?;
407 tracing::warn!(
408 deleted = deleted_forced,
409 count,
410 hard_ceiling,
411 "reasoning: hard-ceiling eviction — evicted hot strategies; consider raising store_limit"
412 );
413
414 Ok(deleted_forced)
415 }
416
417 pub async fn count(&self) -> Result<usize, MemoryError> {
423 let row: (i64,) = zeph_db::query_as("SELECT COUNT(*) FROM reasoning_strategies")
424 .fetch_one(&self.pool)
425 .await?;
426 Ok(usize::try_from(row.0.max(0)).unwrap_or(0))
427 }
428
429 pub(crate) async fn fetch_by_ids(
433 &self,
434 ids: &[String],
435 ) -> Result<Vec<ReasoningStrategy>, MemoryError> {
436 if ids.is_empty() {
437 return Ok(Vec::new());
438 }
439
440 let mut strategies = Vec::with_capacity(ids.len());
441 for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
442 let ph = placeholder_list(1, chunk.len());
443 let sql = format!(
445 "SELECT id, summary, outcome, task_hint, created_at, last_used_at, use_count, embedded_at \
446 FROM reasoning_strategies WHERE id IN ({ph})"
447 );
448 let mut q = zeph_db::query_as::<
449 _,
450 (String, String, String, String, i64, i64, i64, Option<i64>),
451 >(sqlx::AssertSqlSafe(sql));
452 for id in chunk {
453 q = q.bind(id.as_str());
454 }
455 let rows = q.fetch_all(&self.pool).await?;
456 for (
457 id,
458 summary,
459 outcome_str,
460 task_hint,
461 created_at,
462 last_used_at,
463 use_count,
464 embedded_at,
465 ) in rows
466 {
467 let outcome = Outcome::from_str(&outcome_str).unwrap_or(Outcome::Failure);
468 strategies.push(ReasoningStrategy {
469 id,
470 summary,
471 outcome,
472 task_hint,
473 created_at,
474 last_used_at,
475 use_count,
476 embedded_at,
477 });
478 }
479 }
480
481 Ok(strategies)
482 }
483
484 async fn delete_oldest_cold(&self, n: usize) -> Result<usize, MemoryError> {
488 let limit = i64::try_from(n).unwrap_or(i64::MAX);
489 let raw = format!(
491 "DELETE FROM reasoning_strategies \
492 WHERE id IN ( \
493 SELECT id FROM reasoning_strategies \
494 WHERE use_count <= {HOT_STRATEGY_USE_COUNT} \
495 ORDER BY last_used_at ASC LIMIT ? \
496 )"
497 );
498 let sql = zeph_db::rewrite_placeholders(&raw);
499 let result = zeph_db::query(sqlx::AssertSqlSafe(sql))
500 .bind(limit)
501 .execute(&self.pool)
502 .await?;
503 Ok(usize::try_from(result.rows_affected()).unwrap_or(0))
504 }
505
506 async fn delete_oldest_unconditional(&self, n: usize) -> Result<usize, MemoryError> {
510 let limit = i64::try_from(n).unwrap_or(i64::MAX);
511 let raw = "DELETE FROM reasoning_strategies \
512 WHERE id IN ( \
513 SELECT id FROM reasoning_strategies \
514 ORDER BY last_used_at ASC LIMIT ? \
515 )";
516 let sql = zeph_db::rewrite_placeholders(raw);
517 let result = zeph_db::query(sqlx::AssertSqlSafe(sql))
518 .bind(limit)
519 .execute(&self.pool)
520 .await?;
521 Ok(usize::try_from(result.rows_affected()).unwrap_or(0))
522 }
523}
524
525#[tracing::instrument(name = "memory.reasoning.self_judge", skip(provider, messages), fields(n = messages.len()))]
550pub async fn run_self_judge(
551 provider: &AnyProvider,
552 messages: &[Message],
553 extraction_timeout: Duration,
554) -> Option<SelfJudgeOutcome> {
555 if messages.is_empty() {
556 return None;
557 }
558
559 let user_prompt = build_transcript_prompt(messages);
560
561 let llm_messages = [
562 Message::from_legacy(Role::System, SELF_JUDGE_SYSTEM),
563 Message::from_legacy(Role::User, user_prompt),
564 ];
565
566 let response = match timeout(extraction_timeout, provider.chat(&llm_messages)).await {
567 Ok(Ok(text)) => text,
568 Ok(Err(e)) => {
569 tracing::warn!(error = %e, "reasoning: self-judge LLM call failed");
570 return None;
571 }
572 Err(_) => {
573 tracing::warn!("reasoning: self-judge timed out");
574 return None;
575 }
576 };
577
578 parse_self_judge_response(&response)
579}
580
581#[tracing::instrument(name = "memory.reasoning.distill", skip(provider, reasoning_chain))]
601pub async fn distill_strategy(
602 provider: &AnyProvider,
603 outcome: Outcome,
604 reasoning_chain: &str,
605 distill_timeout: Duration,
606) -> Option<String> {
607 if reasoning_chain.is_empty() {
608 return None;
609 }
610
611 let user_prompt = format!(
612 "Outcome: {}\n\nReasoning chain:\n{reasoning_chain}",
613 outcome.as_str()
614 );
615
616 let llm_messages = [
617 Message::from_legacy(Role::System, DISTILL_SYSTEM),
618 Message::from_legacy(Role::User, user_prompt),
619 ];
620
621 let response = match timeout(distill_timeout, provider.chat(&llm_messages)).await {
622 Ok(Ok(text)) => text,
623 Ok(Err(e)) => {
624 tracing::warn!(error = %e, "reasoning: distillation LLM call failed");
625 return None;
626 }
627 Err(_) => {
628 tracing::warn!("reasoning: distillation timed out");
629 return None;
630 }
631 };
632
633 let trimmed = trim_to_three_sentences(&response);
634 if trimmed.is_empty() {
635 None
636 } else {
637 Some(trimmed)
638 }
639}
640
641#[derive(Debug, Clone, Copy)]
645pub struct ProcessTurnConfig {
646 pub store_limit: usize,
648 pub extraction_timeout: Duration,
650 pub distill_timeout: Duration,
652 pub embed_timeout: Duration,
654 pub self_judge_window: usize,
658 pub min_assistant_chars: usize,
661}
662
663#[tracing::instrument(name = "memory.reasoning.process_turn", skip_all)]
674pub async fn process_turn(
675 memory: &ReasoningMemory,
676 extract_provider: &AnyProvider,
677 distill_provider: &AnyProvider,
678 embed_provider: &AnyProvider,
679 messages: &[Message],
680 cfg: ProcessTurnConfig,
681) -> Result<(), MemoryError> {
682 let ProcessTurnConfig {
683 store_limit,
684 extraction_timeout,
685 distill_timeout,
686 embed_timeout,
687 self_judge_window,
688 min_assistant_chars,
689 } = cfg;
690
691 let judge_messages = if messages.len() > self_judge_window {
694 &messages[messages.len() - self_judge_window..]
695 } else {
696 messages
697 };
698
699 let last_assistant_chars = judge_messages
701 .iter()
702 .rev()
703 .find(|m| m.role == Role::Assistant)
704 .map_or(0, |m| m.content.len());
705 if last_assistant_chars < min_assistant_chars {
706 return Ok(());
707 }
708
709 let Some(outcome) = run_self_judge(extract_provider, judge_messages, extraction_timeout).await
710 else {
711 return Ok(());
712 };
713
714 let outcome_enum = if outcome.success {
715 Outcome::Success
716 } else {
717 Outcome::Failure
718 };
719
720 let Some(summary) = distill_strategy(
721 distill_provider,
722 outcome_enum,
723 &outcome.reasoning_chain,
724 distill_timeout,
725 )
726 .await
727 else {
728 return Ok(());
729 };
730
731 let embed_input = format!("{}\n{}", outcome.task_hint, summary);
733 let embedding =
734 match tokio::time::timeout(embed_timeout, embed_provider.embed(&embed_input)).await {
735 Ok(Ok(v)) => v,
736 Ok(Err(e)) => {
737 tracing::warn!(error = %e, "reasoning: embedding failed — strategy not stored");
738 return Ok(());
739 }
740 Err(_) => {
741 tracing::warn!("reasoning: embed timed out — strategy not stored");
742 return Ok(());
743 }
744 };
745
746 let id = uuid::Uuid::new_v4().to_string();
747 let strategy = ReasoningStrategy {
748 id,
749 summary,
750 outcome: outcome_enum,
751 task_hint: outcome.task_hint,
752 created_at: 0, last_used_at: 0,
754 use_count: 0,
755 embedded_at: None,
756 };
757
758 let count_before = memory.count().await.unwrap_or(0);
763
764 if let Err(e) = memory.insert(&strategy, embedding).await {
765 tracing::warn!(error = %e, "reasoning: insert failed");
766 return Ok(());
767 }
768
769 if count_before >= store_limit
770 && let Err(e) = memory.evict_lru(store_limit).await
771 {
772 tracing::warn!(error = %e, "reasoning: evict_lru failed");
773 }
774
775 Ok(())
776}
777
778const MAX_TRANSCRIPT_MESSAGE_CHARS: usize = 2000;
785
786fn build_transcript_prompt(messages: &[Message]) -> String {
792 let mut prompt = String::from("Agent turn messages:\n");
793 for (i, msg) in messages.iter().enumerate() {
794 use std::fmt::Write as _;
795 let role = format!("{:?}", msg.role);
796 let content: std::borrow::Cow<str> =
798 if msg.content.chars().count() > MAX_TRANSCRIPT_MESSAGE_CHARS {
799 msg.content
800 .char_indices()
801 .nth(MAX_TRANSCRIPT_MESSAGE_CHARS)
802 .map_or(msg.content.as_str().into(), |(byte_idx, _)| {
803 msg.content[..byte_idx].into()
804 })
805 } else {
806 msg.content.as_str().into()
807 };
808 let _ = writeln!(prompt, "[{}] {}: {}", i + 1, role, content);
809 }
810 prompt.push_str("\nEvaluate this turn and return JSON.");
811 prompt
812}
813
814fn parse_self_judge_response(response: &str) -> Option<SelfJudgeOutcome> {
819 let stripped = response
821 .trim()
822 .trim_start_matches("```json")
823 .trim_start_matches("```")
824 .trim_end_matches("```")
825 .trim();
826
827 if let Ok(v) = serde_json::from_str::<SelfJudgeOutcome>(stripped) {
828 return Some(v);
829 }
830
831 if let (Some(start), Some(end)) = (stripped.find('{'), stripped.rfind('}'))
833 && end > start
834 && let Ok(v) = serde_json::from_str::<SelfJudgeOutcome>(&stripped[start..=end])
835 {
836 return Some(v);
837 }
838
839 tracing::warn!(
840 "reasoning: failed to parse self-judge response (len={}): {:.200}",
841 response.len(),
842 response
843 );
844 None
845}
846
847fn trim_to_three_sentences(text: &str) -> String {
852 const MAX_CHARS: usize = 512;
853 const MAX_SENTENCES: usize = 3;
854
855 let text = text.trim();
856 let mut sentence_ends: Vec<usize> = Vec::new();
857 let chars: Vec<char> = text.chars().collect();
858 let len = chars.len();
859
860 for (i, &ch) in chars.iter().enumerate() {
861 if matches!(ch, '.' | '!' | '?') {
862 let next_is_boundary = i + 1 >= len || chars[i + 1].is_whitespace();
863 if next_is_boundary {
864 sentence_ends.push(i + 1); if sentence_ends.len() >= MAX_SENTENCES {
866 break;
867 }
868 }
869 }
870 }
871
872 let char_limit = if let Some(&end) = sentence_ends.last() {
873 end.min(MAX_CHARS)
874 } else {
875 text.chars().count().min(MAX_CHARS)
876 };
877
878 let result: String = text.chars().take(char_limit).collect();
879 match result.char_indices().nth(MAX_CHARS) {
881 Some((byte_idx, _)) => result[..byte_idx].to_owned(),
882 None => result,
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 #[test]
893 fn outcome_as_str_round_trip() {
894 assert_eq!(Outcome::Success.as_str(), "success");
895 assert_eq!(Outcome::Failure.as_str(), "failure");
896 }
897
898 #[test]
899 fn outcome_from_str_success() {
900 assert_eq!(Outcome::from_str("success").unwrap(), Outcome::Success);
901 }
902
903 #[test]
904 fn outcome_from_str_failure() {
905 assert_eq!(Outcome::from_str("failure").unwrap(), Outcome::Failure);
906 }
907
908 #[test]
909 fn outcome_from_str_unknown_defaults_to_failure() {
910 assert_eq!(Outcome::from_str("partial").unwrap(), Outcome::Failure);
912 }
913
914 #[test]
917 fn parse_direct_json() {
918 let json = r#"{"success":true,"reasoning_chain":"tried X","task_hint":"do Y"}"#;
919 let outcome = parse_self_judge_response(json).unwrap();
920 assert!(outcome.success);
921 assert_eq!(outcome.reasoning_chain, "tried X");
922 assert_eq!(outcome.task_hint, "do Y");
923 }
924
925 #[test]
926 fn parse_json_with_markdown_fences() {
927 let response =
928 "```json\n{\"success\":false,\"reasoning_chain\":\"r\",\"task_hint\":\"t\"}\n```";
929 let outcome = parse_self_judge_response(response).unwrap();
930 assert!(!outcome.success);
931 }
932
933 #[test]
934 fn parse_json_embedded_in_prose() {
935 let response = r#"Here is the evaluation: {"success":true,"reasoning_chain":"chain","task_hint":"hint"} — done."#;
936 let outcome = parse_self_judge_response(response).unwrap();
937 assert!(outcome.success);
938 }
939
940 #[test]
941 fn parse_invalid_returns_none() {
942 let outcome = parse_self_judge_response("not json at all");
943 assert!(outcome.is_none());
944 }
945
946 #[test]
949 fn trim_three_sentences_short_text() {
950 let text = "One. Two. Three.";
951 assert_eq!(trim_to_three_sentences(text), "One. Two. Three.");
952 }
953
954 #[test]
955 fn trim_three_sentences_truncates_at_third() {
956 let text = "One. Two. Three. Four. Five.";
957 let result = trim_to_three_sentences(text);
958 assert!(result.ends_with("Three."), "got: {result}");
959 assert!(!result.contains("Four"));
960 }
961
962 #[test]
963 fn trim_three_sentences_hard_cap() {
964 let long: String = "x".repeat(600);
966 let result = trim_to_three_sentences(&long);
967 assert!(result.chars().count() <= 512);
968 }
969
970 #[test]
971 fn trim_three_sentences_empty() {
972 assert_eq!(trim_to_three_sentences(" "), "");
973 }
974
975 #[cfg(feature = "sqlite")]
980 async fn make_test_pool() -> DbPool {
981 let pool = sqlx::SqlitePool::connect(":memory:").await.unwrap();
982 sqlx::query(
983 "CREATE TABLE reasoning_strategies (
984 id TEXT PRIMARY KEY NOT NULL,
985 summary TEXT NOT NULL,
986 outcome TEXT NOT NULL,
987 task_hint TEXT NOT NULL,
988 created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
989 last_used_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
990 use_count INTEGER NOT NULL DEFAULT 0,
991 embedded_at INTEGER
992 )",
993 )
994 .execute(&pool)
995 .await
996 .unwrap();
997 pool
998 }
999
1000 #[cfg(feature = "sqlite")]
1001 fn make_strategy(id: &str) -> ReasoningStrategy {
1002 ReasoningStrategy {
1003 id: id.to_owned(),
1004 summary: format!("Summary for {id}"),
1005 outcome: Outcome::Success,
1006 task_hint: format!("Task hint for {id}"),
1007 created_at: 0,
1008 last_used_at: 0,
1009 use_count: 0,
1010 embedded_at: None,
1011 }
1012 }
1013
1014 #[cfg(feature = "sqlite")]
1015 #[tokio::test]
1016 async fn insert_and_fetch_by_ids() {
1017 let pool = make_test_pool().await;
1018 let mem = ReasoningMemory::new(pool, None);
1019
1020 let s = make_strategy("abc-123");
1021 mem.insert(&s, vec![]).await.unwrap();
1022
1023 let rows = mem.fetch_by_ids(&["abc-123".to_owned()]).await.unwrap();
1024 assert_eq!(rows.len(), 1);
1025 assert_eq!(rows[0].id, "abc-123");
1026 assert_eq!(rows[0].outcome, Outcome::Success);
1027 }
1028
1029 #[cfg(feature = "sqlite")]
1034 #[tokio::test]
1035 #[tracing_test::traced_test]
1036 async fn retrieve_by_embedding_clamps_oversized_top_k() {
1037 let pool = make_test_pool().await;
1038 let vector_store = std::sync::Arc::new(crate::in_memory_store::InMemoryVectorStore::new());
1039 vector_store
1040 .ensure_collection(REASONING_COLLECTION, 4)
1041 .await
1042 .unwrap();
1043 let mem = ReasoningMemory::new(pool, Some(vector_store));
1044
1045 for i in 0..(crate::MAX_SEARCH_LIMIT + 10) {
1046 let id = format!("strat-{i}");
1047 mem.insert(&make_strategy(&id), vec![1.0, 0.0, 0.0, 0.0])
1048 .await
1049 .unwrap();
1050 }
1051
1052 let results = mem
1053 .retrieve_by_embedding(&[1.0, 0.0, 0.0, 0.0], u64::MAX)
1054 .await
1055 .unwrap();
1056 assert_eq!(results.len(), crate::MAX_SEARCH_LIMIT);
1057 assert!(
1058 logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
1059 "expected a one-shot warn when the clamp actually reduces the requested limit"
1060 );
1061 }
1062
1063 #[cfg(feature = "sqlite")]
1064 #[tokio::test]
1065 async fn mark_used_increments_count() {
1066 let pool = make_test_pool().await;
1067 let mem = ReasoningMemory::new(pool, None);
1068
1069 let s = make_strategy("mark-1");
1070 mem.insert(&s, vec![]).await.unwrap();
1071 mem.mark_used(&["mark-1".to_owned()]).await.unwrap();
1072 mem.mark_used(&["mark-1".to_owned()]).await.unwrap();
1073
1074 let rows = mem.fetch_by_ids(&["mark-1".to_owned()]).await.unwrap();
1075 assert_eq!(rows[0].use_count, 2);
1076 }
1077
1078 #[cfg(feature = "sqlite")]
1079 #[tokio::test]
1080 async fn mark_used_empty_is_noop() {
1081 let pool = make_test_pool().await;
1082 let mem = ReasoningMemory::new(pool, None);
1083 mem.mark_used(&[]).await.unwrap();
1085 }
1086
1087 #[cfg(feature = "sqlite")]
1088 #[tokio::test]
1089 async fn count_returns_correct_total() {
1090 let pool = make_test_pool().await;
1091 let mem = ReasoningMemory::new(pool, None);
1092
1093 for i in 0..5 {
1094 mem.insert(&make_strategy(&format!("s{i}")), vec![])
1095 .await
1096 .unwrap();
1097 }
1098
1099 assert_eq!(mem.count().await.unwrap(), 5);
1100 }
1101
1102 #[cfg(feature = "sqlite")]
1103 #[tokio::test]
1104 async fn evict_lru_cold_rows() {
1105 let pool = make_test_pool().await;
1106 let mem = ReasoningMemory::new(pool, None);
1107
1108 for i in 0..5 {
1110 mem.insert(&make_strategy(&format!("cold-{i}")), vec![])
1111 .await
1112 .unwrap();
1113 }
1114
1115 let deleted = mem.evict_lru(3).await.unwrap();
1117 assert_eq!(deleted, 2);
1118 assert_eq!(mem.count().await.unwrap(), 3);
1119 }
1120
1121 #[cfg(feature = "sqlite")]
1122 #[tokio::test]
1123 async fn evict_lru_respects_hot_rows_under_ceiling() {
1124 let pool = make_test_pool().await;
1125 let mem = ReasoningMemory::new(pool.clone(), None);
1126
1127 for i in 0..5 {
1129 let id = format!("hot-{i}");
1130 mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1131 let ids: Vec<String> = (0..11).map(|_| id.clone()).collect();
1133 for chunk_ids in ids.chunks(1) {
1134 mem.mark_used(chunk_ids).await.unwrap();
1135 }
1136 }
1137
1138 let deleted = mem.evict_lru(3).await.unwrap();
1140 assert_eq!(deleted, 0);
1141 assert_eq!(mem.count().await.unwrap(), 5);
1142 }
1143
1144 #[cfg(feature = "sqlite")]
1145 #[tokio::test]
1146 async fn evict_lru_hard_ceiling_forces_deletion() {
1147 let pool = make_test_pool().await;
1148 let mem = ReasoningMemory::new(pool.clone(), None);
1149
1150 for i in 0..7 {
1152 let id = format!("hot2-{i}");
1153 mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1154 for _ in 0..=HOT_STRATEGY_USE_COUNT {
1156 mem.mark_used(std::slice::from_ref(&id)).await.unwrap();
1157 }
1158 }
1159
1160 let deleted = mem.evict_lru(3).await.unwrap();
1161 assert!(deleted > 0, "expected forced deletion");
1162 let remaining = mem.count().await.unwrap();
1163 assert_eq!(remaining, 3, "should be trimmed to store_limit");
1164 }
1165
1166 #[cfg(feature = "sqlite")]
1167 #[tokio::test]
1168 async fn evict_lru_no_op_when_under_limit() {
1169 let pool = make_test_pool().await;
1170 let mem = ReasoningMemory::new(pool, None);
1171
1172 for i in 0..3 {
1173 mem.insert(&make_strategy(&format!("s{i}")), vec![])
1174 .await
1175 .unwrap();
1176 }
1177
1178 let deleted = mem.evict_lru(10).await.unwrap();
1180 assert_eq!(deleted, 0);
1181 }
1182
1183 #[cfg(feature = "sqlite")]
1186 #[tokio::test]
1187 async fn mark_used_chunked_over_490_ids() {
1188 let pool = make_test_pool().await;
1189 let mem = ReasoningMemory::new(pool, None);
1190
1191 for i in 0..500usize {
1193 mem.insert(&make_strategy(&format!("chunked-{i}")), vec![])
1194 .await
1195 .unwrap();
1196 }
1197
1198 let ids: Vec<String> = (0..500usize).map(|i| format!("chunked-{i}")).collect();
1199 mem.mark_used(&ids).await.unwrap();
1200
1201 let first = mem.fetch_by_ids(&[ids[0].clone()]).await.unwrap();
1203 let over_chunk = mem.fetch_by_ids(&[ids[490].clone()]).await.unwrap();
1204 assert_eq!(first[0].use_count, 1, "first id should have use_count = 1");
1205 assert_eq!(
1206 over_chunk[0].use_count, 1,
1207 "id past the chunk boundary should have use_count = 1"
1208 );
1209 }
1210
1211 #[tokio::test]
1214 async fn run_self_judge_malformed_json_returns_none() {
1215 use zeph_llm::any::AnyProvider;
1216 use zeph_llm::mock::MockProvider;
1217
1218 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
1220 "This is not JSON at all.".to_string(),
1221 ]));
1222 let msgs = vec![Message::from_legacy(Role::User, "hello")];
1223 let result = run_self_judge(&provider, &msgs, std::time::Duration::from_secs(5)).await;
1224 assert!(result.is_none(), "malformed LLM response must return None");
1225 }
1226
1227 #[tokio::test]
1230 async fn distill_strategy_truncates_to_three_sentences() {
1231 use zeph_llm::any::AnyProvider;
1232 use zeph_llm::mock::MockProvider;
1233
1234 let long_response = "One. Two. Three. Four. Five.";
1235 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
1236 long_response.to_string(),
1237 ]));
1238 let result = distill_strategy(
1239 &provider,
1240 Outcome::Success,
1241 "chain here",
1242 std::time::Duration::from_secs(5),
1243 )
1244 .await
1245 .unwrap();
1246 assert!(result.ends_with("Three."), "got: {result}");
1247 assert!(
1248 !result.contains("Four"),
1249 "should not contain 4th sentence: {result}"
1250 );
1251 }
1252
1253 #[cfg(feature = "sqlite")]
1256 #[tokio::test]
1257 async fn process_turn_with_empty_messages_is_noop() {
1258 use zeph_llm::any::AnyProvider;
1259 use zeph_llm::mock::MockProvider;
1260
1261 let pool = make_test_pool().await;
1262 let mem = ReasoningMemory::new(pool, None);
1263 let provider = AnyProvider::Mock(MockProvider::default());
1266 let cfg = ProcessTurnConfig {
1267 store_limit: 100,
1268 extraction_timeout: std::time::Duration::from_secs(1),
1269 distill_timeout: std::time::Duration::from_secs(1),
1270 embed_timeout: std::time::Duration::from_secs(5),
1271 self_judge_window: 2,
1272 min_assistant_chars: 0,
1273 };
1274 let result = process_turn(&mem, &provider, &provider, &provider, &[], cfg).await;
1275 assert!(
1276 result.is_ok(),
1277 "process_turn with empty messages must succeed"
1278 );
1279 assert_eq!(
1280 mem.count().await.unwrap(),
1281 0,
1282 "no strategies should be stored"
1283 );
1284 }
1285}