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}