Skip to main content

zeph_memory/
reasoning.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `ReasoningBank`: distilled reasoning strategy memory (#3342).
5//!
6//! After each completed agent turn a three-stage async pipeline runs off the hot path:
7//!
8//! 1. **Self-judge** ([`run_self_judge`]) — a fast LLM evaluates success/failure and
9//!    extracts the key reasoning steps.
10//! 2. **Distillation** ([`distill_strategy`]) — a strategy summary (≤ 3 sentences) is
11//!    generated from the reasoning chain, capturing the transferable principle.
12//! 3. **Storage** ([`ReasoningMemory::insert`]) — the summary is written to `SQLite`
13//!    and, when Qdrant is available, embedded and indexed for vector retrieval.
14//!
15//! At context-build time [`ReasoningMemory::retrieve_by_embedding`] fetches top-k
16//! strategies by embedding similarity. The caller (in `zeph-context`) calls
17//! [`ReasoningMemory::mark_used`] only for strategies actually injected into the prompt,
18//! after budget truncation (C4 split from architect plan).
19//!
20//! # LRU eviction
21//!
22//! [`ReasoningMemory::evict_lru`] protects rows with `use_count > HOT_STRATEGY_USE_COUNT`
23//! (default 10) from normal eviction. When all rows are hot and the table exceeds
24//! `2 × store_limit`, a forced eviction pass deletes the oldest rows unconditionally
25//! and emits a `warn!` so operators can tune `store_limit` upward.
26//!
27//! # LRU eviction race note
28//!
29//! Two concurrent turns may race on the count check in `evict_lru`. Either both evict
30//! (over-eviction by at most `top_k` rows) or neither. This is acceptable for MVP —
31//! the table remains bounded.
32
33use 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
45/// Minimum retrieval count to protect a strategy from normal LRU eviction.
46///
47/// Strategies with `use_count > HOT_STRATEGY_USE_COUNT` are skipped during normal
48/// cold-eviction and only removed when the table exceeds `2 × store_limit`.
49const HOT_STRATEGY_USE_COUNT: i64 = 10;
50
51/// Maximum ids per `SQLite` `WHERE id IN (...)` bind list (`SQLite` variable limit is 999).
52const MAX_IDS_PER_QUERY: usize = 490;
53
54/// System prompt for the self-judge LLM step.
55///
56/// Instructs the LLM to evaluate success/failure and extract the reasoning chain
57/// as structured JSON matching [`SelfJudgeOutcome`].
58const 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
67/// System prompt for the distillation LLM step.
68///
69/// Instructs the LLM to compress a reasoning chain into a short, generalizable strategy.
70const 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/// Outcome of a reasoning strategy: whether the agent succeeded or failed.
77///
78/// Stored as a `TEXT NOT NULL` column (`"success"` or `"failure"`).
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum Outcome {
82    /// The agent successfully completed the task.
83    Success,
84    /// The agent failed to complete the task.
85    Failure,
86}
87
88impl Outcome {
89    /// Returns the canonical string representation stored in the database.
90    #[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/// Error returned when parsing an [`Outcome`] from a string fails.
100#[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/// A distilled reasoning strategy row from the `reasoning_strategies` table.
123///
124/// Constructed after a successful self-judge + distillation pipeline run.
125/// Persisted in `SQLite` and (when Qdrant is available) indexed as a vector embedding.
126#[derive(Debug, Clone)]
127pub struct ReasoningStrategy {
128    /// UUID v4 primary key.
129    pub id: String,
130    /// Distilled strategy summary (≤ 3 sentences, ≤ 512 chars).
131    pub summary: String,
132    /// Whether the agent succeeded or failed on the source turn.
133    pub outcome: Outcome,
134    /// One-sentence description of the task that produced this strategy.
135    pub task_hint: String,
136    /// Unix timestamp (seconds) when this strategy was created.
137    pub created_at: i64,
138    /// Unix timestamp (seconds) of the last retrieval.
139    pub last_used_at: i64,
140    /// Number of times this strategy has been injected into context.
141    pub use_count: i64,
142    /// Unix timestamp (seconds) when the Qdrant embedding was created.
143    ///
144    /// `None` means this row has not been embedded yet (Qdrant was unavailable at insert time).
145    pub embedded_at: Option<i64>,
146}
147
148/// Parsed response from the self-judge LLM call.
149///
150/// Deserialized from the LLM JSON response in [`run_self_judge`].
151/// The `success` field drives [`Outcome`] selection; `reasoning_chain` and `task_hint`
152/// are forwarded to the distillation step.
153#[derive(Debug, Deserialize)]
154pub struct SelfJudgeOutcome {
155    /// Whether the agent successfully completed the task.
156    pub success: bool,
157    /// Key reasoning steps the agent took, as free-form text.
158    pub reasoning_chain: String,
159    /// One-sentence summary of the task.
160    pub task_hint: String,
161}
162
163/// SQLite-backed store for distilled reasoning strategies.
164///
165/// Attach to [`crate::semantic::SemanticMemory`] via `with_reasoning`.
166/// All write operations are best-effort: `SQLite` errors are propagated as
167/// [`MemoryError`], Qdrant failures are logged and silently ignored.
168pub struct ReasoningMemory {
169    pool: DbPool,
170    /// Optional vector store for embedding-similarity retrieval.
171    ///
172    /// `None` when Qdrant is unavailable; falls back to returning empty results.
173    vector_store: Option<std::sync::Arc<dyn VectorStore>>,
174}
175
176/// Qdrant collection name used for reasoning-strategy embeddings.
177pub const REASONING_COLLECTION: &str = "reasoning_strategies";
178
179impl ReasoningMemory {
180    /// Create a new `ReasoningMemory` backed by the given `SQLite` pool.
181    ///
182    /// Pass `vector_store = Some(arc)` to enable embedding-similarity retrieval via Qdrant.
183    /// When `None`, [`Self::retrieve_by_embedding`] always returns an empty vec.
184    ///
185    /// # Examples
186    ///
187    /// ```no_run
188    /// use zeph_memory::reasoning::ReasoningMemory;
189    ///
190    /// async fn demo(pool: zeph_db::DbPool) {
191    ///     let memory = ReasoningMemory::new(pool, None);
192    /// }
193    /// ```
194    #[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    /// Insert a new strategy into `SQLite`.
200    ///
201    /// When a `vector_store` is configured, the strategy is also upserted into
202    /// the Qdrant `reasoning_strategies` collection using the provided `embedding`.
203    /// Qdrant failures are logged at `warn` level and do not fail the insert.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the `SQLite` insert fails.
208    #[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        // Qdrant upsert — best effort: SQLite row already written.
236        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                // Mark embedded_at on success.
255                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    /// Retrieve up to `top_k` strategies by embedding similarity.
273    ///
274    /// This method is **pure** — it does not update `use_count` or `last_used_at`.
275    /// Call [`Self::mark_used`] with the ids of strategies actually injected into the
276    /// prompt (after budget truncation) to maintain accurate retrieval bookkeeping.
277    ///
278    /// Returns an empty vec when no vector store is configured.
279    ///
280    /// `top_k` is clamped to `[1, `[`MAX_SEARCH_LIMIT`](crate::MAX_SEARCH_LIMIT)`]` before
281    /// being forwarded to Qdrant (issue #6553) — the bound is enforced here rather than
282    /// relying on every caller to clamp before calling. A one-shot `tracing::warn!` fires
283    /// the first time this actually reduces the requested value.
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if the Qdrant search or `SQLite` fetch fails.
288    #[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    /// Increment `use_count` and update `last_used_at` for each id in the list.
325    ///
326    /// Safe to call with an empty slice — no SQL is issued.
327    /// The list is chunked into batches of `MAX_IDS_PER_QUERY` to respect `SQLite`'s
328    /// variable limit.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if the database update fails.
333    #[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            // Note: placeholder_list already generates ?1,?2,... (SQLite) or $1,$2,... (postgres).
343            // Do NOT call rewrite_placeholders here — that would corrupt ?1 into $11.
344            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    /// Evict strategies when the table exceeds `store_limit`.
360    ///
361    /// **Normal path**: delete rows with `use_count <= HOT_STRATEGY_USE_COUNT`, oldest
362    /// first, until the table returns to `store_limit`.
363    ///
364    /// **Saturation path**: when the normal path deletes nothing AND the table exceeds
365    /// `2 × store_limit`, bypass hot-row protection and delete oldest rows regardless of
366    /// `use_count`. Emits a `warn!` with the eviction count so operators can tune
367    /// `store_limit` upward or lower the hot threshold.
368    ///
369    /// Returns the number of rows deleted.
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if any database operation fails.
374    #[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            // Also delete from Qdrant best-effort (ids not tracked here — full resync on recovery).
385            tracing::debug!(
386                deleted = deleted_cold,
387                count,
388                "reasoning: evicted cold strategies"
389            );
390            return Ok(deleted_cold);
391        }
392
393        // All rows over limit are hot. Check hard ceiling.
394        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        // Hard ceiling breached: force-evict oldest rows unconditionally.
405        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    /// Return the total number of rows in `reasoning_strategies`.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if the database query fails.
422    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    // ── private helpers ───────────────────────────────────────────────────────
430
431    /// Fetch strategy rows by their ids in a single `WHERE id IN (...)` query.
432    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            // Note: placeholder_list generates DB-specific ?N/$N syntax — do NOT rewite.
444            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    /// Delete up to `n` cold rows (`use_count <= HOT_STRATEGY_USE_COUNT`), oldest first.
485    ///
486    /// Returns the number of deleted rows.
487    async fn delete_oldest_cold(&self, n: usize) -> Result<usize, MemoryError> {
488        let limit = i64::try_from(n).unwrap_or(i64::MAX);
489        // Use plain `?` + rewrite_placeholders so postgres gets `$1`.
490        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    /// Delete up to `n` rows unconditionally (oldest by `last_used_at`).
507    ///
508    /// Used only for the hard-ceiling saturation path.
509    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// ── Free functions ────────────────────────────────────────────────────────────
526
527/// Run the self-judge step against a turn's message tail.
528///
529/// Sends the last `messages` slice to the LLM with the self-judge system prompt and
530/// attempts to parse the JSON response into a [`SelfJudgeOutcome`].
531///
532/// Returns `None` on parse failure, timeout, or LLM error — never propagates errors.
533/// Callers should log the `None` case at most at `debug` level.
534///
535/// # Examples
536///
537/// ```no_run
538/// use std::time::Duration;
539/// use zeph_llm::any::AnyProvider;
540/// use zeph_memory::reasoning::run_self_judge;
541///
542/// async fn demo(provider: AnyProvider, messages: &[zeph_llm::provider::Message]) {
543///     let outcome = run_self_judge(&provider, messages, Duration::from_secs(10)).await;
544///     if let Some(o) = outcome {
545///         println!("success={}, hint={}", o.success, o.task_hint);
546///     }
547/// }
548/// ```
549#[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/// Run the distillation step.
582///
583/// Sends the reasoning chain and outcome label to the LLM and trims the response to
584/// at most 3 sentences and 512 characters.
585///
586/// Returns `None` on LLM error, timeout, or empty response.
587///
588/// # Examples
589///
590/// ```no_run
591/// use std::time::Duration;
592/// use zeph_llm::any::AnyProvider;
593/// use zeph_memory::reasoning::{Outcome, distill_strategy};
594///
595/// async fn demo(provider: AnyProvider) {
596///     let summary = distill_strategy(&provider, Outcome::Success, "tried X, worked", Duration::from_secs(10)).await;
597///     println!("{:?}", summary);
598/// }
599/// ```
600#[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/// Configuration for the [`process_turn`] extraction pipeline.
642///
643/// Groups timeout and limit parameters that rarely change between turns.
644#[derive(Debug, Clone, Copy)]
645pub struct ProcessTurnConfig {
646    /// Maximum rows to retain in the `reasoning_strategies` table.
647    pub store_limit: usize,
648    /// Timeout for the self-judge LLM call.
649    pub extraction_timeout: Duration,
650    /// Timeout for the distillation LLM call.
651    pub distill_timeout: Duration,
652    /// Timeout for each `embed()` invocation in the pipeline. Default: 5 s.
653    pub embed_timeout: Duration,
654    /// Maximum number of recent messages sliced from the turn history before passing
655    /// to the self-judge evaluator. Narrowing the window prevents digest/recap messages
656    /// from prior sessions from confusing the classifier. Default: `2`.
657    pub self_judge_window: usize,
658    /// Minimum character count in the last assistant message to trigger self-judge.
659    /// Short or trivial responses (greetings, one-word answers) are skipped. Default: `50`.
660    pub min_assistant_chars: usize,
661}
662
663/// Run the full extraction pipeline for a single turn.
664///
665/// Calls [`run_self_judge`], then [`distill_strategy`], then inserts the result.
666/// `evict_lru` is called when the table exceeds `store_limit`. All errors are
667/// logged at `warn` level and the function returns `Ok(())` so callers never
668/// propagate pipeline failures.
669///
670/// # Errors
671///
672/// Returns an error if the embedding call fails, but not if self-judge or distillation fails.
673#[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    // Narrow the message window to reduce noise from session digests and welcome-back
692    // messages that span prior sessions, which can confuse the self-judge classifier.
693    let judge_messages = if messages.len() > self_judge_window {
694        &messages[messages.len() - self_judge_window..]
695    } else {
696        messages
697    };
698
699    // Skip self-judge when the last assistant response is too short to be meaningful.
700    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    // Embed task_hint + summary for Qdrant retrieval (S2 from architect plan).
732    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, // filled by SQL EPOCH_NOW
753        last_used_at: 0,
754        use_count: 0,
755        embedded_at: None,
756    };
757
758    // P2-2: check count before insert to skip the evict_lru SELECT+DELETE when not needed.
759    // If count is already at or above store_limit, evict after insert. Approximate: two
760    // concurrent inserts can both read the same count and both decide to evict — the
761    // evict_lru implementation is idempotent so over-eviction by ≤1 row is acceptable.
762    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
778// ── private helpers ───────────────────────────────────────────────────────────
779
780/// Maximum characters taken from a single message's content in the transcript prompt.
781///
782/// Prevents unbounded prompt growth when long tool outputs or code blocks are present
783/// in the turn history (S-Med2 fix).
784const MAX_TRANSCRIPT_MESSAGE_CHARS: usize = 2000;
785
786/// Build a turn transcript prompt from the message slice.
787///
788/// Each message's content is truncated to [`MAX_TRANSCRIPT_MESSAGE_CHARS`] to bound
789/// the prompt length regardless of tool-output size. Mirrors the
790/// `build_extraction_prompt` format in `trajectory.rs` for consistency.
791fn 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        // Truncate at a char boundary to avoid invalid UTF-8 slices.
797        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
814/// Parse the LLM response from the self-judge step into a [`SelfJudgeOutcome`].
815///
816/// Strips markdown code fences, then tries direct parse; on failure, locates the
817/// outermost `{…}` brackets and tries again. Returns `None` on persistent parse failure.
818fn parse_self_judge_response(response: &str) -> Option<SelfJudgeOutcome> {
819    // Strip markdown fences (```json … ```)
820    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    // Try to extract the first `{…}` span.
832    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
847/// Trim text to at most 3 sentences and 512 characters.
848///
849/// Sentence boundaries are detected by `.`, `!`, `?` followed by whitespace or end-of-string.
850/// The hard 512-char cap truncates at the nearest char boundary below the limit.
851fn 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); // exclusive byte position (chars)
865                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    // Hard cap on byte length (chars already limited, but enforce once more).
880    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    // ── Outcome ────────────────────────────────────────────────────────────────
891
892    #[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        // Unknown values silently map to Failure (forward-compatible).
911        assert_eq!(Outcome::from_str("partial").unwrap(), Outcome::Failure);
912    }
913
914    // ── parse_self_judge_response ─────────────────────────────────────────────
915
916    #[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    // ── trim_to_three_sentences ───────────────────────────────────────────────
947
948    #[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        // 600 chars, no sentence boundaries → should be capped at 512 chars
965        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    // ── ReasoningMemory (in-memory SQLite) ────────────────────────────────────
976    // These tests hardcode `sqlx::SqlitePool` and are not portable to PostgreSQL;
977    // skipped entirely when `postgres` is the active backend (see issue #5364).
978
979    #[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    /// Issue #6553: `retrieve_by_embedding` must clamp an oversized `top_k` to
1030    /// `MAX_SEARCH_LIMIT` internally, rather than relying on every caller to clamp, and must
1031    /// log a one-shot warning so a config-driven candidate pool silently shrunk by the clamp
1032    /// is observable (critic finding S1).
1033    #[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        // Should not panic or error on empty slice.
1084        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        // Insert 5 cold rows (use_count = 0 by default).
1109        for i in 0..5 {
1110            mem.insert(&make_strategy(&format!("cold-{i}")), vec![])
1111                .await
1112                .unwrap();
1113        }
1114
1115        // Store limit is 3 → should delete 2 oldest.
1116        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        // Insert 5 hot rows by manually setting use_count > HOT_STRATEGY_USE_COUNT.
1128        for i in 0..5 {
1129            let id = format!("hot-{i}");
1130            mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1131            // Mark used 11 times to make them hot.
1132            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        // store_limit=3, count=5, all hot, 5 < 2*3=6 → under ceiling → no deletion.
1139        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        // Insert 7 hot rows. store_limit=3, ceiling=6. 7 > 6 → forced eviction.
1151        for i in 0..7 {
1152            let id = format!("hot2-{i}");
1153            mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1154            // Make hot.
1155            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        // store_limit=10 → count(3) ≤ 10 → no deletion.
1179        let deleted = mem.evict_lru(10).await.unwrap();
1180        assert_eq!(deleted, 0);
1181    }
1182
1183    // ── mark_used chunked path ────────────────────────────────────────────────
1184
1185    #[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        // Insert 500 strategies — exceeds MAX_IDS_PER_QUERY (490) forcing two SQL batches.
1192        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        // Spot-check: first and 491st should both have use_count == 1.
1202        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    // ── run_self_judge malformed response ─────────────────────────────────────
1212
1213    #[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        // with_responses populates the one-shot queue; chat() returns this prose string.
1219        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    // ── distill_strategy truncation ───────────────────────────────────────────
1228
1229    #[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    // ── process_turn smoke test ───────────────────────────────────────────────
1254
1255    #[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        // MockProvider returns "{}" which parse_self_judge_response will return None for
1264        // (missing required fields) → Ok(()) with zero inserts.
1265        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}