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}