PicoBot/src/gateway/session_history.rs
oudecheng 4baa8e7a6b fix: 内存历史按 topic_id 键化,新增 replace_topic_history 修复 DB 压缩覆盖
- SessionHistory.chat_histories 改为 topic_histories 按 topic_id 键化,消除多话题并发时内存历史互相覆盖的根因

- is_current_turn 改用 current_topic == original_topic_id 直接比较,替代失效的内存最新消息匹配判断

- 新增 ConversationRepository::replace_topic_history 按 topic 删除/插入消息,解决 replace_active_history 删除整个 session 消息的 pre-existing 问题

- compaction 路径改用 replace_topic_history,避免压缩时覆盖其他 topic 的 DB 消息

- switch_topic 不再 remove_history,不同 topic 历史独立存储互斥

- append_persisted_message 仅当 topic_id == current_topic 时更新内存,防止延迟消息污染新话题历史
2026-07-28 15:32:42 +08:00

302 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::agent::AgentError;
use crate::bus::ChatMessage;
use crate::storage::{
ConversationRepository, SessionRecord, SkillEventRepository, persistent_session_id,
};
fn preview_text(content: &str, max_chars: usize) -> String {
let mut preview = content.chars().take(max_chars).collect::<String>();
if content.chars().count() > max_chars {
preview.push_str("...");
}
preview.replace('\n', "\\n")
}
pub(crate) struct SessionHistory {
channel_name: String,
/// 按 topic_id 键化的内存历史缓存。
/// 不同 topic 的历史独立存储,互不干扰,支持多话题并发执行。
topic_histories: HashMap<String, Vec<ChatMessage>>,
/// UI 状态:每个 chat 当前活跃的 topic按 chat_id 键)。
chat_topic_ids: HashMap<String, String>,
/// 正在压缩中的 topic_id 集合
compression_in_flight: HashSet<String>,
/// 按 topic_id 的串行化锁。
/// 同一 topic 的消息处理agent loop + 压缩)必须串行执行,
/// 防止并发 loop 操作同一历史的不同快照产生交错序列。
/// 不同 topic 之间互不阻塞,支持多话题并发执行。
topic_serial_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
conversations: Arc<dyn ConversationRepository>,
skill_events: Arc<dyn SkillEventRepository>,
}
impl SessionHistory {
pub(crate) fn new(
channel_name: impl Into<String>,
conversations: Arc<dyn ConversationRepository>,
skill_events: Arc<dyn SkillEventRepository>,
) -> Self {
Self {
channel_name: channel_name.into(),
topic_histories: HashMap::new(),
chat_topic_ids: HashMap::new(),
compression_in_flight: HashSet::new(),
topic_serial_locks: HashMap::new(),
conversations,
skill_events,
}
}
/// 获取或创建该 topic 的串行化锁。
/// 同一 topic 的所有消息处理共享同一个锁,保证串行执行;
/// 不同 topic 之间互不阻塞,支持多话题并发执行。
pub(crate) fn topic_serial_lock(&mut self, topic_id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.topic_serial_locks
.entry(topic_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub(crate) fn persistent_session_id(&self, chat_id: &str) -> String {
persistent_session_id(&self.channel_name, chat_id)
}
pub(crate) fn ensure_persistent_session(
&self,
chat_id: &str,
) -> Result<SessionRecord, AgentError> {
self.conversations
.ensure_channel_session(&self.channel_name, chat_id)
.map_err(|err| AgentError::Other(format!("session persistence error: {}", err)))
}
/// 确保指定 topic 的历史已加载到内存。
/// 按 topic_id 键化查找,如果已存在则直接返回,否则从 DB 加载。
pub(crate) fn ensure_chat_loaded(
&mut self,
chat_id: &str,
topic_id: Option<&str>,
) -> Result<(), AgentError> {
let Some(tid) = topic_id else {
return Ok(());
};
if self.topic_histories.contains_key(tid) {
return Ok(());
}
let sid = self.persistent_session_id(chat_id);
let mut history = self
.conversations
.load_messages_for_topic(tid, Some(&sid))
.map_err(|err| AgentError::Other(format!("session history load error: {}", err)))?;
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut history);
if removed > 0 {
tracing::warn!(
topic_id = %tid,
removed_count = removed,
"Sanitized incomplete tool_call sequences on history load"
);
}
self.topic_histories.insert(tid.to_string(), history);
Ok(())
}
pub(crate) fn ensure_agent_prompt_before_user_message(
&mut self,
_chat_id: &str,
) -> Result<(), AgentError> {
// 提示词现在由 AgentPromptProvider 统一处理,不需要在此处注入
Ok(())
}
pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec<ChatMessage> {
self.topic_histories.entry(topic_id.to_string()).or_default()
}
pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec<ChatMessage>> {
self.topic_histories.get(topic_id)
}
pub(crate) fn set_history(&mut self, topic_id: &str, history: Vec<ChatMessage>) {
self.topic_histories.insert(topic_id.to_string(), history);
}
/// 设置指定 chat 的当前 topicUI 状态)
pub(crate) fn set_chat_topic(&mut self, chat_id: &str, topic_id: String) {
self.chat_topic_ids.insert(chat_id.to_string(), topic_id);
}
/// 获取指定 chat 的当前 topicUI 状态)
pub(crate) fn chat_topic(&self, chat_id: &str) -> Option<&str> {
self.chat_topic_ids.get(chat_id).map(|s| s.as_str())
}
/// 清除指定 chat 的 topicUI 状态)
pub(crate) fn clear_chat_topic(&mut self, chat_id: &str) {
self.chat_topic_ids.remove(chat_id);
}
pub(crate) fn add_message(&mut self, topic_id: &str, message: ChatMessage) {
self.get_or_create_history(topic_id).push(message);
}
pub(crate) fn remove_history(&mut self, topic_id: &str) {
self.topic_histories.remove(topic_id);
self.compression_in_flight.remove(topic_id);
}
/// 清空指定 chat/topic 的内存历史和 DB 消息。
/// 内存按 topic_id 清DB 按 session_id 清(保留原行为以兼容无 topic 场景)。
pub(crate) fn clear_chat_history(
&mut self,
chat_id: &str,
topic_id: Option<&str>,
) -> Result<(), AgentError> {
if let Some(tid) = topic_id {
if let Some(history) = self.topic_histories.get_mut(tid) {
let len = history.len();
history.clear();
#[cfg(debug_assertions)]
tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared");
}
}
self.conversations
.clear_messages(&self.persistent_session_id(chat_id))
.map_err(|err| AgentError::Other(format!("clear history persistence error: {}", err)))
}
pub(crate) fn append_persisted_messages<I>(
&mut self,
topic_id: &str,
messages: I,
) -> Result<(), AgentError>
where
I: IntoIterator<Item = ChatMessage>,
{
let messages: Vec<ChatMessage> = messages.into_iter().collect();
if messages.is_empty() {
return Ok(());
}
if let Some(history) = self.topic_histories.get_mut(topic_id) {
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(history);
if removed > 0 {
tracing::warn!(
topic_id = %topic_id,
removed_count = removed,
"Sanitized in-memory history before appending persisted messages"
);
}
}
for message in messages {
self.add_message(topic_id, message);
}
Ok(())
}
/// 将消息保存到指定话题
/// 每条消息已通过 PersistingEmittedMessageHandler 逐条持久化,此处仅保留接口兼容
pub(crate) fn append_to_topic(
&self,
_chat_id: &str,
_topic_id: &str,
messages: &[ChatMessage],
) -> Result<(), AgentError> {
if messages.is_empty() {
return Ok(());
}
Ok(())
}
pub(crate) fn latest_user_message(&self, topic_id: &str) -> Option<&ChatMessage> {
self.get_history(topic_id)
.and_then(|history| history.iter().rev().find(|message| message.role == "user"))
}
pub(crate) fn matches_current_user_turn(&self, topic_id: &str, message: &ChatMessage) -> bool {
self.latest_user_message(topic_id)
.map(|current| {
current.id == message.id
|| (current.content == message.content
&& current.timestamp == message.timestamp
&& current.media_refs == message.media_refs)
})
.unwrap_or(false)
}
pub(crate) fn stale_result_diagnostics(
&self,
topic_id: &str,
) -> (Option<&str>, Option<String>, bool, usize) {
let latest_user = self.latest_user_message(topic_id);
let latest_user_id = latest_user.map(|message| message.id.as_str());
let latest_user_preview = latest_user.map(|message| preview_text(&message.content, 80));
let compression_in_flight = self.compression_in_flight.contains(topic_id);
let history_len = self
.get_history(topic_id)
.map(|history| history.len())
.unwrap_or(0);
(
latest_user_id,
latest_user_preview,
compression_in_flight,
history_len,
)
}
/// 清空所有内存历史(主要用于测试全局重置)。
/// 不遍历清 DB生产环境如需清 DB 应由调用方显式调用。
pub(crate) fn clear_all_history(&mut self) -> Result<(), AgentError> {
let total: usize = self.topic_histories.values().map(|h| h.len()).sum();
self.topic_histories.clear();
self.compression_in_flight.clear();
#[cfg(debug_assertions)]
tracing::debug!(previous_total = total, "All topic histories cleared");
Ok(())
}
/// 按 topic_id 从 DB 重新加载历史到内存
pub(crate) fn reload_topic_history(
&mut self,
chat_id: &str,
topic_id: &str,
) -> Result<(), AgentError> {
let sid = self.persistent_session_id(chat_id);
let history = self
.conversations
.load_messages_for_topic(topic_id, Some(&sid))
.map_err(|err| AgentError::Other(format!("session history reload error: {}", err)))?;
self.topic_histories.insert(topic_id.to_string(), history);
Ok(())
}
pub(crate) fn conversations(&self) -> Arc<dyn ConversationRepository> {
self.conversations.clone()
}
pub(crate) fn append_skill_event(
&self,
chat_id: &str,
event_type: &str,
skill_name: Option<&str>,
payload: &serde_json::Value,
) -> Result<(), AgentError> {
self.skill_events
.append_skill_event(
Some(&self.persistent_session_id(chat_id)),
event_type,
skill_name,
payload,
)
.map_err(|err| AgentError::Other(format!("append skill event error: {}", err)))
}
}