Skip to main content

zeph_core/agent/persistence/
history.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Conversation history loading.
5//!
6//! [`Agent::load_history`] delegates the bulk of the work to
7//! [`PersistenceService::load_history`] and applies the post-load mutations that touch
8//! agent-internal singletons (session counts, semantic fact count, token recompute).
9
10use super::super::Agent;
11use crate::channel::Channel;
12use zeph_agent_persistence::{LoadHistoryParams, MemoryPersistenceView, PersistenceService};
13
14impl<C: Channel> Agent<C> {
15    /// Load conversation history from memory and inject into messages.
16    ///
17    /// Delegates to [`PersistenceService::load_history`]. Post-load operations that touch
18    /// agent-internal singletons (session count increment, semantic fact count recompute,
19    /// token recompute) remain in this shim because they access fields outside the
20    /// borrow-lens view.
21    ///
22    /// # Errors
23    ///
24    /// Returns an error if loading history from `SQLite` fails.
25    ///
26    /// # Panics
27    ///
28    /// Does not panic. The internal `unwrap_or(0)` conversions are on fallible `i64 → usize`
29    /// casts that saturate to zero on overflow; they cannot panic.
30    #[tracing::instrument(name = "core.persist.load_history", skip_all, level = "debug", err)]
31    pub async fn load_history(&mut self) -> Result<(), super::super::error::AgentError> {
32        // Idempotency guard (spec-068, #5343): a session already hydrated from the durable JSONL
33        // event log via `AgentBuilder::with_preloaded_messages` (see `spawn_acp_agent`,
34        // `src/acp.rs`) must not also load from `SQLite` — `PersistenceService::load_history`
35        // appends rather than replaces, so calling both would duplicate every message. Gated on
36        // the explicit `history_preloaded` flag, not `messages.is_empty()`: `Agent::new` always
37        // seeds `messages` with the system-prompt message, so emptiness never distinguishes
38        // "already hydrated" from "not yet loaded."
39        if self.msg.history_preloaded {
40            return Ok(());
41        }
42
43        let (Some(memory), Some(cid)) = (
44            self.services.memory.persistence.memory.as_ref(),
45            self.services.memory.persistence.conversation_id,
46        ) else {
47            return Ok(());
48        };
49
50        // Clone so we can call methods after the borrow-lens view is dropped.
51        let memory = memory.clone();
52
53        let mut unsummarized = self.services.memory.persistence.unsummarized_count;
54        // `memory_view` is not `mut` — the `&mut unsummarized` inside is established at
55        // construction and passed as `&memory_view` to load_history (shared borrow).
56        let memory_view = MemoryPersistenceView {
57            memory: Some(&memory),
58            conversation_id: self.services.memory.persistence.conversation_id,
59            autosave_assistant: self.services.memory.persistence.autosave_assistant,
60            autosave_min_length: self.services.memory.persistence.autosave_min_length,
61            unsummarized_count: &mut unsummarized,
62            goal_text: self.services.memory.extraction.goal_text.clone(),
63        };
64
65        let svc = PersistenceService::new();
66        let outcome = svc
67            .load_history(LoadHistoryParams {
68                messages: &mut self.msg.messages,
69                last_persisted_message_id: &mut self.msg.last_persisted_message_id,
70                deferred_hide_ids: &mut self.msg.deferred_db_hide_ids,
71                memory_view: &memory_view,
72            })
73            .await
74            .map_err(|e| {
75                super::super::error::AgentError::Memory(zeph_memory::MemoryError::Other(
76                    e.to_string(),
77                ))
78            })?;
79
80        // Write back lens-borrowed local to the field.
81        self.services.memory.persistence.unsummarized_count = unsummarized;
82
83        if outcome.messages_loaded > 0 {
84            // Increment session counts so tier promotion can track cross-session access.
85            let _ = memory
86                .sqlite()
87                .increment_session_counts_for_conversation(cid)
88                .await
89                .inspect_err(|e| {
90                    tracing::warn!(error = %e, "failed to increment tier session counts");
91                });
92        }
93
94        // Set absolute SQLite message count and semantic fact count (not deltas).
95        self.update_metrics(|m| {
96            m.sqlite_message_count = outcome.sqlite_total_messages;
97        });
98        if let Ok(count) = memory.sqlite().count_semantic_facts().await {
99            let count_u64 = u64::try_from(count).unwrap_or(0);
100            self.update_metrics(|m| {
101                m.semantic_fact_count = count_u64;
102            });
103        }
104        if let Ok(count) = memory.unsummarized_message_count(cid).await {
105            self.services.memory.persistence.unsummarized_count =
106                usize::try_from(count).unwrap_or(0);
107        }
108
109        self.recompute_prompt_tokens();
110        Ok(())
111    }
112}