1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Conversation history loading.
//!
//! [`Agent::load_history`] delegates the bulk of the work to
//! [`PersistenceService::load_history`] and applies the post-load mutations that touch
//! agent-internal singletons (session counts, semantic fact count, token recompute).
use super::super::Agent;
use crate::channel::Channel;
use zeph_agent_persistence::{LoadHistoryParams, MemoryPersistenceView, PersistenceService};
impl<C: Channel> Agent<C> {
/// Load conversation history from memory and inject into messages.
///
/// Delegates to [`PersistenceService::load_history`]. Post-load operations that touch
/// agent-internal singletons (session count increment, semantic fact count recompute,
/// token recompute) remain in this shim because they access fields outside the
/// borrow-lens view.
///
/// # Errors
///
/// Returns an error if loading history from `SQLite` fails.
///
/// # Panics
///
/// Does not panic. The internal `unwrap_or(0)` conversions are on fallible `i64 → usize`
/// casts that saturate to zero on overflow; they cannot panic.
#[tracing::instrument(name = "core.persist.load_history", skip_all, level = "debug", err)]
pub async fn load_history(&mut self) -> Result<(), super::super::error::AgentError> {
// Idempotency guard (spec-068, #5343): a session already hydrated from the durable JSONL
// event log via `AgentBuilder::with_preloaded_messages` (see `spawn_acp_agent`,
// `src/acp.rs`) must not also load from `SQLite` — `PersistenceService::load_history`
// appends rather than replaces, so calling both would duplicate every message. Gated on
// the explicit `history_preloaded` flag, not `messages.is_empty()`: `Agent::new` always
// seeds `messages` with the system-prompt message, so emptiness never distinguishes
// "already hydrated" from "not yet loaded."
if self.msg.history_preloaded {
return Ok(());
}
let (Some(memory), Some(cid)) = (
self.services.memory.persistence.memory.as_ref(),
self.services.memory.persistence.conversation_id,
) else {
return Ok(());
};
// Clone so we can call methods after the borrow-lens view is dropped.
let memory = memory.clone();
let mut unsummarized = self.services.memory.persistence.unsummarized_count;
// `memory_view` is not `mut` — the `&mut unsummarized` inside is established at
// construction and passed as `&memory_view` to load_history (shared borrow).
let memory_view = MemoryPersistenceView {
memory: Some(&memory),
conversation_id: self.services.memory.persistence.conversation_id,
autosave_assistant: self.services.memory.persistence.autosave_assistant,
autosave_min_length: self.services.memory.persistence.autosave_min_length,
unsummarized_count: &mut unsummarized,
goal_text: self.services.memory.extraction.goal_text.clone(),
};
let svc = PersistenceService::new();
let outcome = svc
.load_history(LoadHistoryParams {
messages: &mut self.msg.messages,
last_persisted_message_id: &mut self.msg.last_persisted_message_id,
deferred_hide_ids: &mut self.msg.deferred_db_hide_ids,
memory_view: &memory_view,
})
.await
.map_err(|e| {
super::super::error::AgentError::Memory(zeph_memory::MemoryError::Other(
e.to_string(),
))
})?;
// Write back lens-borrowed local to the field.
self.services.memory.persistence.unsummarized_count = unsummarized;
if outcome.messages_loaded > 0 {
// Increment session counts so tier promotion can track cross-session access.
let _ = memory
.sqlite()
.increment_session_counts_for_conversation(cid)
.await
.inspect_err(|e| {
tracing::warn!(error = %e, "failed to increment tier session counts");
});
// Resume banner for the `[session] enabled = false` `SQLite`-fallback path
// (spec-068 §13.4, S1): the durable-log hydration path (`with_preloaded_messages`)
// already short-circuits this whole function via the `history_preloaded` guard
// above, so this can only run when that path was skipped — either the event-log
// feature is disabled, or a legacy pre-#5343 conversation had no session row yet.
// Spec §13.4 explicitly names this `PersistenceService::load_history` fallback as
// an `is_resume` input; without this, resuming with `[session] enabled = false`
// silently showed no banner despite genuinely resuming prior history.
if self.runtime.config.resume_config.show_banner
&& !self.channel.requires_input_sanitization()
{
let resume_info = crate::session_resume::SessionResumeInfo::from_messages(
&self.msg.messages,
None,
);
if let Some(banner) = resume_info.banner_text() {
let _ = self.channel.send_resume_banner(&banner).await;
}
}
}
// Set absolute SQLite message count and semantic fact count (not deltas).
self.update_metrics(|m| {
m.sqlite_message_count = outcome.sqlite_total_messages;
});
if let Ok(count) = memory.sqlite().count_semantic_facts().await {
let count_u64 = u64::try_from(count).unwrap_or(0);
self.update_metrics(|m| {
m.semantic_fact_count = count_u64;
});
}
if let Ok(count) = memory.unsummarized_message_count(cid).await {
self.services.memory.persistence.unsummarized_count =
usize::try_from(count).unwrap_or(0);
}
// `PersistenceService::load_history` mutates `messages` through a borrowed
// `&mut Vec<Message>` (part of `LoadHistoryParams`), so the non-system counter
// (#6427) can't be updated inline at the mutation site — recompute it here,
// mirroring the `recompute_prompt_tokens` call this already needed.
self.msg.recompute_non_system_count();
self.recompute_prompt_tokens();
Ok(())
}
}