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    /// # Errors
281    ///
282    /// Returns an error if the Qdrant search or `SQLite` fetch fails.
283    #[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    /// Increment `use_count` and update `last_used_at` for each id in the list.
310    ///
311    /// Safe to call with an empty slice — no SQL is issued.
312    /// The list is chunked into batches of [`MAX_IDS_PER_QUERY`] to respect `SQLite`'s
313    /// variable limit.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the database update fails.
318    #[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            // Note: placeholder_list already generates ?1,?2,... (SQLite) or $1,$2,... (postgres).
328            // Do NOT call rewrite_placeholders here — that would corrupt ?1 into $11.
329            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    /// Evict strategies when the table exceeds `store_limit`.
345    ///
346    /// **Normal path**: delete rows with `use_count <= HOT_STRATEGY_USE_COUNT`, oldest
347    /// first, until the table returns to `store_limit`.
348    ///
349    /// **Saturation path**: when the normal path deletes nothing AND the table exceeds
350    /// `2 × store_limit`, bypass hot-row protection and delete oldest rows regardless of
351    /// `use_count`. Emits a `warn!` with the eviction count so operators can tune
352    /// `store_limit` upward or lower the hot threshold.
353    ///
354    /// Returns the number of rows deleted.
355    ///
356    /// # Errors
357    ///
358    /// Returns an error if any database operation fails.
359    #[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            // Also delete from Qdrant best-effort (ids not tracked here — full resync on recovery).
370            tracing::debug!(
371                deleted = deleted_cold,
372                count,
373                "reasoning: evicted cold strategies"
374            );
375            return Ok(deleted_cold);
376        }
377
378        // All rows over limit are hot. Check hard ceiling.
379        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        // Hard ceiling breached: force-evict oldest rows unconditionally.
390        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    /// Return the total number of rows in `reasoning_strategies`.
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if the database query fails.
407    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    // ── private helpers ───────────────────────────────────────────────────────
415
416    /// Fetch strategy rows by their ids in a single `WHERE id IN (...)` query.
417    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            // Note: placeholder_list generates DB-specific ?N/$N syntax — do NOT rewite.
429            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    /// Delete up to `n` cold rows (`use_count <= HOT_STRATEGY_USE_COUNT`), oldest first.
470    ///
471    /// Returns the number of deleted rows.
472    async fn delete_oldest_cold(&self, n: usize) -> Result<usize, MemoryError> {
473        let limit = i64::try_from(n).unwrap_or(i64::MAX);
474        // Use plain `?` + rewrite_placeholders so postgres gets `$1`.
475        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    /// Delete up to `n` rows unconditionally (oldest by `last_used_at`).
492    ///
493    /// Used only for the hard-ceiling saturation path.
494    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// ── Free functions ────────────────────────────────────────────────────────────
511
512/// Run the self-judge step against a turn's message tail.
513///
514/// Sends the last `messages` slice to the LLM with the self-judge system prompt and
515/// attempts to parse the JSON response into a [`SelfJudgeOutcome`].
516///
517/// Returns `None` on parse failure, timeout, or LLM error — never propagates errors.
518/// Callers should log the `None` case at most at `debug` level.
519///
520/// # Examples
521///
522/// ```no_run
523/// use std::time::Duration;
524/// use zeph_llm::any::AnyProvider;
525/// use zeph_memory::reasoning::run_self_judge;
526///
527/// async fn demo(provider: AnyProvider, messages: &[zeph_llm::provider::Message]) {
528///     let outcome = run_self_judge(&provider, messages, Duration::from_secs(10)).await;
529///     if let Some(o) = outcome {
530///         println!("success={}, hint={}", o.success, o.task_hint);
531///     }
532/// }
533/// ```
534#[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/// Run the distillation step.
567///
568/// Sends the reasoning chain and outcome label to the LLM and trims the response to
569/// at most 3 sentences and 512 characters.
570///
571/// Returns `None` on LLM error, timeout, or empty response.
572///
573/// # Examples
574///
575/// ```no_run
576/// use std::time::Duration;
577/// use zeph_llm::any::AnyProvider;
578/// use zeph_memory::reasoning::{Outcome, distill_strategy};
579///
580/// async fn demo(provider: AnyProvider) {
581///     let summary = distill_strategy(&provider, Outcome::Success, "tried X, worked", Duration::from_secs(10)).await;
582///     println!("{:?}", summary);
583/// }
584/// ```
585#[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/// Configuration for the [`process_turn`] extraction pipeline.
627///
628/// Groups timeout and limit parameters that rarely change between turns.
629#[derive(Debug, Clone, Copy)]
630pub struct ProcessTurnConfig {
631    /// Maximum rows to retain in the `reasoning_strategies` table.
632    pub store_limit: usize,
633    /// Timeout for the self-judge LLM call.
634    pub extraction_timeout: Duration,
635    /// Timeout for the distillation LLM call.
636    pub distill_timeout: Duration,
637    /// Timeout for each `embed()` invocation in the pipeline. Default: 5 s.
638    pub embed_timeout: Duration,
639    /// Maximum number of recent messages sliced from the turn history before passing
640    /// to the self-judge evaluator. Narrowing the window prevents digest/recap messages
641    /// from prior sessions from confusing the classifier. Default: `2`.
642    pub self_judge_window: usize,
643    /// Minimum character count in the last assistant message to trigger self-judge.
644    /// Short or trivial responses (greetings, one-word answers) are skipped. Default: `50`.
645    pub min_assistant_chars: usize,
646}
647
648/// Run the full extraction pipeline for a single turn.
649///
650/// Calls [`run_self_judge`], then [`distill_strategy`], then inserts the result.
651/// `evict_lru` is called when the table exceeds `store_limit`. All errors are
652/// logged at `warn` level and the function returns `Ok(())` so callers never
653/// propagate pipeline failures.
654///
655/// # Errors
656///
657/// Returns an error if the embedding call fails, but not if self-judge or distillation fails.
658#[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    // Narrow the message window to reduce noise from session digests and welcome-back
677    // messages that span prior sessions, which can confuse the self-judge classifier.
678    let judge_messages = if messages.len() > self_judge_window {
679        &messages[messages.len() - self_judge_window..]
680    } else {
681        messages
682    };
683
684    // Skip self-judge when the last assistant response is too short to be meaningful.
685    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    // Embed task_hint + summary for Qdrant retrieval (S2 from architect plan).
717    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, // filled by SQL EPOCH_NOW
738        last_used_at: 0,
739        use_count: 0,
740        embedded_at: None,
741    };
742
743    // P2-2: check count before insert to skip the evict_lru SELECT+DELETE when not needed.
744    // If count is already at or above store_limit, evict after insert. Approximate: two
745    // concurrent inserts can both read the same count and both decide to evict — the
746    // evict_lru implementation is idempotent so over-eviction by ≤1 row is acceptable.
747    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
763// ── private helpers ───────────────────────────────────────────────────────────
764
765/// Maximum characters taken from a single message's content in the transcript prompt.
766///
767/// Prevents unbounded prompt growth when long tool outputs or code blocks are present
768/// in the turn history (S-Med2 fix).
769const MAX_TRANSCRIPT_MESSAGE_CHARS: usize = 2000;
770
771/// Build a turn transcript prompt from the message slice.
772///
773/// Each message's content is truncated to [`MAX_TRANSCRIPT_MESSAGE_CHARS`] to bound
774/// the prompt length regardless of tool-output size. Mirrors the
775/// `build_extraction_prompt` format in `trajectory.rs` for consistency.
776fn 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        // Truncate at a char boundary to avoid invalid UTF-8 slices.
782        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
799/// Parse the LLM response from the self-judge step into a [`SelfJudgeOutcome`].
800///
801/// Strips markdown code fences, then tries direct parse; on failure, locates the
802/// outermost `{…}` brackets and tries again. Returns `None` on persistent parse failure.
803fn parse_self_judge_response(response: &str) -> Option<SelfJudgeOutcome> {
804    // Strip markdown fences (```json … ```)
805    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    // Try to extract the first `{…}` span.
817    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
832/// Trim text to at most 3 sentences and 512 characters.
833///
834/// Sentence boundaries are detected by `.`, `!`, `?` followed by whitespace or end-of-string.
835/// The hard 512-char cap truncates at the nearest char boundary below the limit.
836fn 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); // exclusive byte position (chars)
850                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    // Hard cap on byte length (chars already limited, but enforce once more).
865    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    // ── Outcome ────────────────────────────────────────────────────────────────
876
877    #[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        // Unknown values silently map to Failure (forward-compatible).
896        assert_eq!(Outcome::from_str("partial").unwrap(), Outcome::Failure);
897    }
898
899    // ── parse_self_judge_response ─────────────────────────────────────────────
900
901    #[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    // ── trim_to_three_sentences ───────────────────────────────────────────────
932
933    #[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        // 600 chars, no sentence boundaries → should be capped at 512 chars
950        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    // ── ReasoningMemory (in-memory SQLite) ────────────────────────────────────
961    // These tests hardcode `sqlx::SqlitePool` and are not portable to PostgreSQL;
962    // skipped entirely when `postgres` is the active backend (see issue #5364).
963
964    #[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        // Should not panic or error on empty slice.
1035        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        // Insert 5 cold rows (use_count = 0 by default).
1060        for i in 0..5 {
1061            mem.insert(&make_strategy(&format!("cold-{i}")), vec![])
1062                .await
1063                .unwrap();
1064        }
1065
1066        // Store limit is 3 → should delete 2 oldest.
1067        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        // Insert 5 hot rows by manually setting use_count > HOT_STRATEGY_USE_COUNT.
1079        for i in 0..5 {
1080            let id = format!("hot-{i}");
1081            mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1082            // Mark used 11 times to make them hot.
1083            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        // store_limit=3, count=5, all hot, 5 < 2*3=6 → under ceiling → no deletion.
1090        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        // Insert 7 hot rows. store_limit=3, ceiling=6. 7 > 6 → forced eviction.
1102        for i in 0..7 {
1103            let id = format!("hot2-{i}");
1104            mem.insert(&make_strategy(&id), vec![]).await.unwrap();
1105            // Make hot.
1106            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        // store_limit=10 → count(3) ≤ 10 → no deletion.
1130        let deleted = mem.evict_lru(10).await.unwrap();
1131        assert_eq!(deleted, 0);
1132    }
1133
1134    // ── mark_used chunked path ────────────────────────────────────────────────
1135
1136    #[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        // Insert 500 strategies — exceeds MAX_IDS_PER_QUERY (490) forcing two SQL batches.
1143        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        // Spot-check: first and 491st should both have use_count == 1.
1153        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    // ── run_self_judge malformed response ─────────────────────────────────────
1163
1164    #[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        // with_responses populates the one-shot queue; chat() returns this prose string.
1170        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    // ── distill_strategy truncation ───────────────────────────────────────────
1179
1180    #[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    // ── process_turn smoke test ───────────────────────────────────────────────
1205
1206    #[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        // MockProvider returns "{}" which parse_self_judge_response will return None for
1215        // (missing required fields) → Ok(()) with zero inserts.
1216        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}