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            // Resume banner for the `[session] enabled = false` `SQLite`-fallback path
94            // (spec-068 §13.4, S1): the durable-log hydration path (`with_preloaded_messages`)
95            // already short-circuits this whole function via the `history_preloaded` guard
96            // above, so this can only run when that path was skipped — either the event-log
97            // feature is disabled, or a legacy pre-#5343 conversation had no session row yet.
98            // Spec §13.4 explicitly names this `PersistenceService::load_history` fallback as
99            // an `is_resume` input; without this, resuming with `[session] enabled = false`
100            // silently showed no banner despite genuinely resuming prior history.
101            if self.runtime.config.resume_config.show_banner
102                && !self.channel.requires_input_sanitization()
103            {
104                let resume_info = crate::session_resume::SessionResumeInfo::from_messages(
105                    &self.msg.messages,
106                    None,
107                );
108                if let Some(banner) = resume_info.banner_text() {
109                    let _ = self.channel.send_resume_banner(&banner).await;
110                }
111            }
112        }
113
114        // Set absolute SQLite message count and semantic fact count (not deltas).
115        self.update_metrics(|m| {
116            m.sqlite_message_count = outcome.sqlite_total_messages;
117        });
118        if let Ok(count) = memory.sqlite().count_semantic_facts().await {
119            let count_u64 = u64::try_from(count).unwrap_or(0);
120            self.update_metrics(|m| {
121                m.semantic_fact_count = count_u64;
122            });
123        }
124        if let Ok(count) = memory.unsummarized_message_count(cid).await {
125            self.services.memory.persistence.unsummarized_count =
126                usize::try_from(count).unwrap_or(0);
127        }
128
129        // `PersistenceService::load_history` mutates `messages` through a borrowed
130        // `&mut Vec<Message>` (part of `LoadHistoryParams`), so the non-system counter
131        // (#6427) can't be updated inline at the mutation site — recompute it here,
132        // mirroring the `recompute_prompt_tokens` call this already needed.
133        self.msg.recompute_non_system_count();
134        self.recompute_prompt_tokens();
135        Ok(())
136    }
137}