use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use crate::bus::{ChatMessage, MediaItem, MessageSource, OutboundMessage, SourceKind}; use crate::storage::{Storage, StorageError}; use std::sync::Arc as StdArc; tokio::task_local! { static CURRENT_SOURCE_SESSION: Option; } /// Result of handling a message - either an AI response or a command output pub enum HandleResult { /// AI response to be sent as AssistantResponse AgentResponse(String), /// Command output to be sent as CommandExecuted CommandOutput(String), } use crate::channels::slash_command::parse_slash_command; use crate::config::LLMProviderConfig; use crate::agent::{AgentLoop, AgentError, ContextCompressor}; use crate::agent::system_prompt::build_system_prompt; use crate::agent::context_compressor::ContextCompressionConfig; /// Check if an LLM error message indicates a context window overflow. fn is_context_overflow_error(msg: &str) -> bool { let lower = msg.to_lowercase(); lower.contains("context length") || lower.contains("context window") || lower.contains("maximum context") || lower.contains("too many tokens") || lower.contains("token limit exceeded") || lower.contains("prompt is too long") || lower.contains("input is too long") } use crate::providers::{create_provider, LLMProvider}; use crate::session::session_id::UnifiedSessionId; use crate::session::events::DialogInfo; use crate::skills::SkillsLoader; use crate::tools::{ToolRegistry, create_default_tools}; use crate::bus::MessageBus; use crate::tools::OutboundMessenger; use crate::tools::SendMessageTool; /// Session = 一个 dialog /// 每个 Session 对应一个 UnifiedSessionId,有独立的 messages history pub struct Session { pub id: UnifiedSessionId, pub title: String, pub created_at: i64, pub last_active_at: i64, pub message_count: i64, pub total_message_count: i64, messages: Vec, seq_counter: i64, provider_config: LLMProviderConfig, provider: Arc, tools: Arc, compressor: ContextCompressor, storage: Option>, routing_info: String, /// Timestamp (Unix ms) of the last consolidation. /// Messages before this time have been compressed into memory. pub last_consolidated_at: Option, pub last_compressed_message_at: Option, #[allow(dead_code)] memory_manager: Arc, } impl Session { pub async fn new( id: UnifiedSessionId, provider_config: LLMProviderConfig, tools: Arc, storage: Option>, routing_info: String, title: String, memory_manager: Arc, ) -> Result { let mut provider_box = create_provider(provider_config.clone()) .map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?; if let Some(ref s) = storage { provider_box.set_storage(s.clone()); } let provider: Arc = Arc::from(provider_box); let compressor_config = ContextCompressionConfig { protect_first_n: 2, ..Default::default() }; let mut compressor = ContextCompressor::with_config(provider.clone(), provider_config.token_limit, compressor_config, memory_manager.clone()); compressor.set_session_id(Some(id.to_string())); let now = chrono::Utc::now().timestamp_millis(); Ok(Self { id: id.clone(), title, created_at: now, last_active_at: now, message_count: 0, total_message_count: 0, messages: Vec::new(), seq_counter: 1, provider_config: provider_config.clone(), provider: provider.clone(), tools, compressor, storage, routing_info, last_consolidated_at: None, last_compressed_message_at: None, memory_manager, }) } /// 从 Storage 恢复 Session pub async fn from_storage( id: UnifiedSessionId, provider_config: LLMProviderConfig, tools: Arc, storage: StdArc, memory_manager: Arc, ) -> Result { let session_meta = storage.get_session(&id.to_string()).await .map_err(|e| AgentError::Other(format!("failed to load session from storage: {}", e)))?; let mut provider_box = create_provider(provider_config.clone()) .map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?; provider_box.set_storage(storage.clone()); let provider: Arc = Arc::from(provider_box); let compressor_config = ContextCompressionConfig { protect_first_n: 2, ..Default::default() }; let mut compressor = ContextCompressor::with_config(provider.clone(), provider_config.token_limit, compressor_config, memory_manager.clone()); compressor.set_session_id(Some(id.to_string())); let mut chat_messages: Vec = Vec::new(); if let Some(after_ts) = session_meta.last_compressed_message_at { // Load last 4 timelines to detect if there are more than 3 let timelines = storage .load_session_timelines(&id.to_string(), 4) .await .unwrap_or_else(|e| { tracing::warn!(error = %e, "Failed to load session timelines"); Vec::new() }); let has_more_timelines = timelines.len() > 3; if has_more_timelines { chat_messages.push(ChatMessage::user( "[Earlier conversation summaries exist. \ Use `timeline_recall` to search if needed.]" )); } // Insert latest 3 timelines as context (reversed: oldest first) for tl in timelines.iter().take(3).rev() { chat_messages.push(ChatMessage::user(format!( "[Previous Context]\n{}", tl.content ))); } // Load raw messages after compressed timestamp let tail = storage .load_messages_after_timestamp(&id.to_string(), after_ts) .await .unwrap_or_else(|e| { tracing::warn!(error = %e, "Failed to load messages after timestamp"); Vec::new() }); let mut tail_msgs: Vec = tail.into_iter().map(|m| { ChatMessage { id: m.id, role: m.role, content: m.content, media_refs: m.media_refs.map(|refs| serde_json::from_str(&refs).unwrap_or_default()).unwrap_or_default(), timestamp: m.created_at, tool_call_id: m.tool_call_id, tool_name: m.tool_name, tool_calls: m.tool_calls .and_then(|tc| serde_json::from_str::>(&tc).ok()) .filter(|v| !v.is_empty()), source: m.source.and_then(|s| serde_json::from_str(&s).ok()), } }).collect(); repair_tool_call_chains(&mut tail_msgs); chat_messages.extend(tail_msgs); } else { // No prior compression — load all messages (existing behavior) let messages = storage.load_messages(&id.to_string(), 0).await .map_err(|e| AgentError::Other(format!("failed to load messages from storage: {}", e)))?; chat_messages = messages.into_iter().map(|m| { ChatMessage { id: m.id, role: m.role, content: m.content, media_refs: m.media_refs.map(|refs| serde_json::from_str(&refs).unwrap_or_default()).unwrap_or_default(), timestamp: m.created_at, tool_call_id: m.tool_call_id, tool_name: m.tool_name, tool_calls: m.tool_calls .and_then(|tc| serde_json::from_str::>(&tc).ok()) .filter(|v| !v.is_empty()), source: m.source.and_then(|s| serde_json::from_str(&s).ok()), } }).collect(); repair_tool_call_chains(&mut chat_messages); } // seq_counter from actual DB max let max_seq = storage .get_max_message_seq(&id.to_string()) .await .unwrap_or(0); let seq_counter = max_seq + 1; let total_message_count = session_meta.message_count; Ok(Self { id: id.clone(), title: session_meta.title, created_at: session_meta.created_at, last_active_at: session_meta.last_active_at, message_count: session_meta.message_count, total_message_count, messages: chat_messages, seq_counter, provider_config: provider_config.clone(), provider: provider.clone(), tools, compressor, storage: Some(storage), routing_info: session_meta.routing_info.unwrap_or_default(), last_consolidated_at: session_meta.last_consolidated_at, last_compressed_message_at: session_meta.last_compressed_message_at, memory_manager, }) } /// 获取 session ID pub fn session_id(&self) -> String { self.id.to_string() } /// 添加消息到历史并持久化到 Storage /// 如果 `persist` 为 false,只更新内存(用于 compaction 场景) pub async fn add_message(&mut self, message: ChatMessage, persist: bool) -> Result<(), StorageError> { let is_user = message.role == "user"; let now = chrono::Utc::now().timestamp_millis(); // Assign seq let seq = self.seq_counter; self.seq_counter += 1; // Persist to Storage if persist && let Some(ref storage) = self.storage { let msg_meta = crate::storage::message::MessageMeta { id: message.id.clone(), session_id: self.id.to_string(), seq, role: message.role.clone(), content: message.content.clone(), media_refs: if message.media_refs.is_empty() { None } else { Some(serde_json::to_string(&message.media_refs).unwrap_or_default()) }, tool_call_id: message.tool_call_id.clone(), tool_name: message.tool_name.clone(), tool_calls: message.tool_calls.as_ref().and_then(|tc| serde_json::to_string(tc).ok()), source: message.source.as_ref().map(|s| serde_json::to_string(s).unwrap_or_default()), created_at: now, }; storage.append_message_with_retry(&self.id.to_string(), &msg_meta).await?; } // Update in-memory state self.messages.push(message); self.total_message_count += 1; if is_user { self.message_count += 1; } self.last_active_at = now; // Sync message_count to Storage if persist { tracing::debug!(session_id = %self.id, last_active_at = %now, message_count = %self.message_count, "Persisting session meta after add_message"); if let Err(e) = self.persist_session_meta().await { tracing::warn!("failed to persist session meta: {}", e); } } Ok(()) } /// 获取消息历史 pub fn get_history(&self) -> &[ChatMessage] { &self.messages } /// 清除历史消息 pub fn clear_history(&mut self) { let len = self.messages.len(); self.messages.clear(); self.seq_counter = 1; self.total_message_count = 0; self.message_count = 0; #[cfg(debug_assertions)] tracing::debug!(session_id = %self.id, previous_len = len, "Chat history cleared"); } /// 重置对话上下文 pub fn reset_context(&mut self) { let len = self.messages.len(); self.messages.clear(); self.seq_counter = 1; self.total_message_count = 0; self.message_count = 0; #[cfg(debug_assertions)] tracing::debug!(session_id = %self.id, previous_len = len, "Chat context reset in memory"); } pub fn create_user_message(&self, content: &str, media_refs: Vec) -> ChatMessage { if media_refs.is_empty() { ChatMessage::user(content) } else { ChatMessage::user_with_media(content, media_refs) } } pub fn create_user_message_with_source( &self, content: &str, media_refs: Vec, source: MessageSource, ) -> ChatMessage { if media_refs.is_empty() { ChatMessage::user_with_source(content, source) } else { ChatMessage::user_with_source(content, source) } } /// 将 session 元数据写回 Storage pub async fn persist_session_meta(&self) -> Result<(), StorageError> { if let Some(ref storage) = self.storage { let meta = crate::storage::session::SessionMeta { id: self.id.to_string(), channel: self.id.channel.clone(), chat_id: self.id.chat_id.clone(), dialog_id: self.id.dialog_id.clone(), title: self.title.clone(), created_at: self.created_at, last_active_at: self.last_active_at, message_count: self.message_count, routing_info: if self.routing_info.is_empty() { None } else { Some(self.routing_info.clone()) }, deleted_at: None, last_consolidated_at: self.last_consolidated_at, last_compressed_message_at: self.last_compressed_message_at, }; storage.upsert_session(&meta).await?; } Ok(()) } /// 检查是否需要自动生成 title(5 条用户消息后) pub fn should_generate_title(&self) -> bool { self.title == "新对话" && self.message_count >= 5 } /// 生成标题(调用 LLM) pub async fn generate_title(&mut self) -> Result<(), AgentError> { if !self.should_generate_title() { return Ok(()); } let prompt = format!( r#"给定以下对话历史,生成一个简短的会话标题(5-15 个中文字符),概括这个对话的核心内容或用户的主要需求。只返回一个标题,不要解释。 历史: {}"#, self.messages.iter() .filter(|m| m.role == "user" || m.role == "assistant") .take(20) .map(|m| format!("[{}]: {}", m.role, m.content)) .collect::>() .join("\n") ); let title = self.call_llm_for_title(&prompt).await?; if !title.is_empty() { self.title = title.clone(); if let Err(e) = self.persist_session_meta().await { tracing::warn!("failed to persist title: {}", e); } } Ok(()) } /// 调用 LLM 生成标题 async fn call_llm_for_title(&self, prompt: &str) -> Result { use crate::providers::{ChatCompletionRequest, ChatCompletionResponse, Message}; let request = ChatCompletionRequest { messages: vec![ Message::user(prompt.to_string()) ], temperature: Some(0.3), max_tokens: Some(20), tools: None, }; let response: ChatCompletionResponse = self.provider.chat(request).await .map_err(|e| AgentError::Other(format!("LLM call failed: {}", e)))?; Ok(response.content.trim().to_string()) } /// 获取 provider_config 引用 pub fn provider_config(&self) -> &LLMProviderConfig { &self.provider_config } /// 获取 compressor 引用 pub fn compressor(&self) -> &ContextCompressor { &self.compressor } /// Get the compressor's current threshold for diagnostics/fallback. pub fn compressor_threshold(&self) -> usize { self.compressor.threshold() } /// 创建一个临时的 AgentLoop 实例来处理消息 pub fn create_agent(&self) -> Result { Ok(AgentLoop::with_provider_and_tools( self.provider.clone(), self.tools.clone(), self.provider_config.max_tool_iterations, self.provider_config.model_id.clone(), self.provider_config.workspace_dir.clone(), ).with_context_window(self.provider_config.token_limit)) } /// 创建一个附通知通道的 AgentLoop 实例 pub fn create_agent_with_notify( &self, notify_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result { Ok(self.create_agent()?.with_notify(notify_tx)) } /// 构建系统提示词(包含 AgentLoop 的基础提示词 + skills + memory) pub fn build_system_prompt(&self, skills_prompt: &str, memory_context: Option<&str>) -> String { let base_prompt = build_system_prompt( &self.provider_config.workspace_dir, &self.provider_config.model_id, &self.tools, Some(&self.id.to_string()), memory_context, self.last_compressed_message_at.is_some(), ); if skills_prompt.trim().is_empty() { base_prompt } else { format!("{}\n\n{}", base_prompt, skills_prompt) } } /// 将当前 session 导出为 markdown 文档并保存到文件 pub fn dump_to_file(&self, system_prompt: &str) -> std::io::Result { use chrono::Local; use std::fs; use std::io::Write; let md = self.dump_as_markdown_with_system_prompt(system_prompt); // Create dumps directory under workspace let dumps_dir = self.provider_config.workspace_dir.join("dumps"); fs::create_dir_all(&dumps_dir)?; // Generate filename based on session info let timestamp = Local::now().format("%Y%m%d_%H%M%S"); let filename = format!("{}_{}_{}.md", self.id.channel, self.id.chat_id, timestamp); let filepath = dumps_dir.join(&filename); // Write to file let mut file = fs::File::create(&filepath)?; file.write_all(md.as_bytes())?; Ok(filepath.to_string_lossy().to_string()) } /// 将当前 session 导出为 markdown 文档(纯内存版本) pub fn dump_as_markdown(&self) -> String { use chrono::{DateTime, Local}; let now = Local::now().format("%Y-%m-%d %H:%M:%S"); let mut md = String::new(); md.push_str("# Session Dump\n\n"); md.push_str(&format!("- **Session ID**: `{}`\n", self.id)); md.push_str(&format!("- **Channel**: `{}`\n", self.id.channel)); md.push_str(&format!("- **Chat ID**: `{}`\n", self.id.chat_id)); md.push_str(&format!("- **Dialog ID**: `{}`\n", self.id.dialog_id)); md.push_str(&format!("- **Message Count**: {}\n", self.messages.len())); md.push_str(&format!("- **Model**: `{}`\n", self.provider_config.model_id)); md.push_str(&format!("- **Exported At**: {}\n", now)); md.push_str("\n---\n\n"); md.push_str("## Conversation History\n\n"); for (i, msg) in self.messages.iter().enumerate() { let role = match msg.role.as_str() { "system" => "System", "user" => "User", "assistant" => "Assistant", "tool" => "Tool", r => r, }; let timestamp = if msg.timestamp > 0 { DateTime::from_timestamp_millis(msg.timestamp) .map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default() } else { String::new() }; md.push_str(&format!("### [{:03}] {} {}\n\n", i + 1, role, timestamp)); md.push_str("```\n"); if let Some(ref tool_calls) = msg.tool_calls { md.push_str("[Tool Calls]\n"); for tc in tool_calls { md.push_str(&format!("- {}: {:?}\n", tc.name, tc.arguments)); } } if let Some(ref tool_name) = msg.tool_name { md.push_str(&format!("[Tool: {}]\n", tool_name)); } if let Some(ref tool_call_id) = msg.tool_call_id { md.push_str(&format!("[Tool Call ID: {}]\n", tool_call_id)); } md.push_str(&msg.content); md.push_str("\n```\n\n"); if !msg.media_refs.is_empty() { md.push_str(&format!("**Media**: {:?}\n\n", msg.media_refs)); } } md } /// 将当前 session 导出为 markdown 文档(包含系统提示词) pub fn dump_as_markdown_with_system_prompt(&self, system_prompt: &str) -> String { use chrono::{DateTime, Local}; let now = Local::now().format("%Y-%m-%d %H:%M:%S"); let mut md = String::new(); md.push_str("# Session Dump\n\n"); md.push_str(&format!("- **Session ID**: `{}`\n", self.id)); md.push_str(&format!("- **Channel**: `{}`\n", self.id.channel)); md.push_str(&format!("- **Chat ID**: `{}`\n", self.id.chat_id)); md.push_str(&format!("- **Dialog ID**: `{}`\n", self.id.dialog_id)); md.push_str(&format!("- **Message Count**: {}\n", self.messages.len())); md.push_str(&format!("- **Model**: `{}`\n", self.provider_config.model_id)); md.push_str(&format!("- **Exported At**: {}\n", now)); md.push_str("\n---\n\n"); // System Prompt Section md.push_str("## System Prompt (Injected to Model)\n\n"); md.push_str("```\n"); md.push_str(system_prompt); md.push_str("\n```\n\n"); md.push_str("---\n\n"); md.push_str("## Conversation History\n\n"); for (i, msg) in self.messages.iter().enumerate() { let role = match msg.role.as_str() { "system" => "System", "user" => "User", "assistant" => "Assistant", "tool" => "Tool", r => r, }; let timestamp = if msg.timestamp > 0 { DateTime::from_timestamp_millis(msg.timestamp) .map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default() } else { String::new() }; md.push_str(&format!("### [{:03}] {} {}\n\n", i + 1, role, timestamp)); md.push_str("```\n"); if let Some(ref tool_calls) = msg.tool_calls { md.push_str("[Tool Calls]\n"); for tc in tool_calls { md.push_str(&format!("- {}: {:?}\n", tc.name, tc.arguments)); } } if let Some(ref tool_name) = msg.tool_name { md.push_str(&format!("[Tool: {}]\n", tool_name)); } if let Some(ref tool_call_id) = msg.tool_call_id { md.push_str(&format!("[Tool Call ID: {}]\n", tool_call_id)); } md.push_str(&msg.content); md.push_str("\n```\n\n"); if !msg.media_refs.is_empty() { md.push_str(&format!("**Media**: {:?}\n\n", msg.media_refs)); } } md } } /// Repair damaged tool call chains after restoring from storage. /// Handles cases where the gateway crashed mid-loop, leaving assistant /// tool_calls without corresponding tool result messages. fn repair_tool_call_chains(messages: &mut Vec) { let mut i = 0; while i < messages.len() { let calls = match &messages[i].tool_calls { Some(calls) if !calls.is_empty() => calls.clone(), _ => { i += 1; continue; } }; if messages[i].role != "assistant" { i += 1; continue; } // Collect expected tool call IDs let expected_ids: std::collections::HashSet<&str> = calls.iter().map(|c| c.id.as_str()).collect(); let expected_count = expected_ids.len(); // Check following messages for matching tool results (same tool_call_id) let mut found = 0; let mut j = i + 1; while j < messages.len() && found < expected_count { if messages[j].role == "tool" { if let Some(ref tc_id) = messages[j].tool_call_id && expected_ids.contains(tc_id.as_str()) { found += 1; } } else if messages[j].role == "user" || messages[j].role == "assistant" { // Next user/assistant message — stop scanning, chain is broken break; } j += 1; } if found < expected_count { // Incomplete chain: remove tool_calls and add interruption note tracing::warn!( found, expected = expected_count, "Repairing incomplete tool call chain — gateway restart likely interrupted execution" ); let old_content = std::mem::take(&mut messages[i].content); messages[i].content = format!( "{}\n\n[Tool calls ({}): {} — execution interrupted by gateway restart]", old_content, expected_count, calls.iter().map(|c| c.name.as_str()).collect::>().join(", ") ); messages[i].tool_calls = None; } i += 1; } } /// SessionManager 管理所有 Session,按 channel_name 路由 #[derive(Clone)] pub struct SessionManager { inner: Arc>, provider_config: LLMProviderConfig, tools: Arc, skills_loader: Arc, storage: Arc, bus: Arc, memory_manager: Arc, } struct SessionManagerInner { /// Sessions keyed by UnifiedSessionId.to_string() sessions: HashMap>>, /// Current active session per channel:chat_id current_sessions: HashMap, } /// 斜杠命令定义 #[derive(Debug, Clone)] pub struct SlashCommand { /// 命令名称 pub name: &'static str, /// 命令描述 pub description: &'static str, /// 命令别名(触发词) pub aliases: &'static [&'static str], } impl SlashCommand { /// 检查给定内容是否匹配此命令 pub fn matches(&self, content: &str) -> bool { let trimmed = content.trim(); self.aliases.iter().any(|&alias| trimmed == alias || trimmed.starts_with(&format!("{} ", alias))) } } /// Session 支持的斜杠命令列表 pub static SLASH_COMMANDS: &[SlashCommand] = &[ SlashCommand { name: "new", description: "创建新对话", aliases: &["/new"], }, SlashCommand { name: "sessions", description: "列出最近对话", aliases: &["/sessions"], }, SlashCommand { name: "switch", description: "切换到指定对话", aliases: &["/switch"], }, SlashCommand { name: "rename", description: "重命名当前对话", aliases: &["/rename"], }, SlashCommand { name: "delete", description: "删除当前对话", aliases: &["/delete"], }, SlashCommand { name: "compact", description: "手动触发上下文压缩", aliases: &["/compact"], }, SlashCommand { name: "info", description: "显示当前对话信息", aliases: &["/info"], }, SlashCommand { name: "dump", description: "保存当前对话为 markdown 文档", aliases: &["/dump"], }, SlashCommand { name: "?", description: "显示帮助", aliases: &["/?", "/help"], }, ]; impl SessionManager { pub fn new( provider_config: LLMProviderConfig, storage: Arc, bus: Arc, memory_manager: Arc, ) -> Result { let mut skills_loader = SkillsLoader::new(); skills_loader.load_skills(); skills_loader.set_workspace_skills_dir(provider_config.workspace_dir.clone()); let skills_loader = Arc::new(skills_loader); let tools = Arc::new(create_default_tools(skills_loader.clone(), memory_manager.clone())); Ok(Self { inner: Arc::new(Mutex::new(SessionManagerInner { sessions: HashMap::new(), current_sessions: HashMap::new(), })), provider_config, tools, skills_loader, storage, bus, memory_manager, }) } /// Register the send_message tool (requires self in Arc) pub fn register_outbound_tool(self: &Arc, available_channels: Vec) { let messenger: Arc = self.clone(); self.tools.register(SendMessageTool::new(messenger, available_channels)); } pub fn tools(&self) -> Arc { self.tools.clone() } /// 为定时任务创建一个无 session 绑定的 AgentLoop pub fn create_cron_agent(&self) -> Result { let provider = create_provider(self.provider_config.clone()) .map_err(|e| AgentError::Other(format!("failed to create cron provider: {}", e)))?; Ok(AgentLoop::with_provider_and_tools( Arc::from(provider), self.tools.clone(), self.provider_config.max_tool_iterations, self.provider_config.model_id.clone(), self.provider_config.workspace_dir.clone(), ).with_context_window(self.provider_config.token_limit)) } /// 获取所有可用的斜杠命令 pub fn get_slash_commands(&self) -> &[SlashCommand] { SLASH_COMMANDS } /// 执行斜杠命令 /// 返回 (新session_id, 响应消息) pub async fn execute_slash_command( &self, command: &str, args: Option<&str>, channel: &str, chat_id: &str, current_session_id: Option<&UnifiedSessionId>, ) -> Result<(Option, String), AgentError> { let cmd = SLASH_COMMANDS .iter() .find(|c| c.name == command) .ok_or_else(|| AgentError::Other(format!("Unknown command: {}", command)))?; tracing::info!(cmd = %cmd.name, args = ?args, "Executing slash command"); match cmd.name { "new" => { let title = args.map(|s| s.to_string()); let (new_id, title) = self.create_session(channel, chat_id, title.as_deref(), String::new()).await?; Ok((Some(new_id), format!("新对话 '{}' 已创建。", title))) } "delete" => { let (new_id, _title) = self.create_session(channel, chat_id, None, String::new()).await?; Ok((Some(new_id), "对话已删除。新对话已创建。".to_string())) } "compact" => { if let Some(sid) = current_session_id { let session = self.get_or_create_session(sid).await?; let mut session_guard = session.lock().await; let original_count = session_guard.get_history().len(); let history = session_guard.get_history().to_vec(); let result = session_guard.compressor .compress_if_needed(history) .await?; let compressed_count = result.history.len(); if result.created_timelines { session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis()); if let Err(e) = session_guard.persist_session_meta().await { tracing::warn!(error = %e, "Failed to persist compression marker after /compact"); } } session_guard.clear_history(); for msg in result.history { session_guard.add_message(msg, false).await .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; } Ok((None, format!( "Context compressed: {} → {} messages.", original_count, compressed_count ))) } else { Ok((None, "No active conversation to compress.".to_string())) } } "info" => { if let Some(sid) = current_session_id { let session = self.get_or_create_session(sid).await?; let session_guard = session.lock().await; let message_count = session_guard.get_history().len(); let session_id_str = session_guard.session_id(); let title = &session_guard.title; let model_name = &session_guard.provider_config.name; let created_at = chrono::DateTime::from_timestamp_millis(session_guard.created_at) .map(|dt| dt.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default(); let last_active_at = chrono::DateTime::from_timestamp_millis(session_guard.last_active_at) .map(|dt| dt.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default(); Ok((None, format!( "对话标题: {}\nSession ID: {}\n模型: {}\n用户消息: {} / 总消息: {}\n创建时间: {}\n最后活跃: {}", title, session_id_str, model_name, session_guard.message_count, message_count, created_at, last_active_at ))) } else { Ok((None, "No active session.".to_string())) } } "dump" => { if let Some(sid) = current_session_id { let session = self.get_or_create_session(sid).await?; let session_guard = session.lock().await; // Build the same system prompt that would be injected to the model let skills_prompt = self.skills_loader.build_skills_prompt(); let system_prompt = session_guard.build_system_prompt(&skills_prompt, None); let filepath = session_guard.dump_to_file(&system_prompt) .map_err(|e| AgentError::Other(format!("Failed to save dump: {}", e)))?; Ok((None, format!("Session dump saved to: {}", filepath))) } else { Ok((None, "No active session.".to_string())) } } "sessions" => { let (dialogs, _current) = self.list_dialogs(channel, chat_id, false).await?; if dialogs.is_empty() { Ok((None, "暂无对话记录。".to_string())) } else { let lines: Vec = dialogs.iter().map(|d| { let current = if current_session_id.map(|s| s.dialog_id == d.session_id.dialog_id).unwrap_or(false) { " [当前]" } else { "" }; format!("- {} ({}){} — {}", d.session_id.dialog_id, d.title, current, chrono::DateTime::from_timestamp_millis(d.last_active_at).map(|dt| dt.with_timezone(&chrono::Local).format("%m-%d %H:%M").to_string()).unwrap_or_default()) }).collect(); Ok((None, format!("最近对话:\n{}", lines.join("\n")))) } } "switch" => { let dialog_id = args.ok_or_else(|| AgentError::Other("Usage: /switch ".to_string()))?; let new_id = self.switch_dialog(channel, chat_id, dialog_id).await?; Ok((None, format!("已切换到对话:{}", new_id.dialog_id))) } "rename" => { let title = args.ok_or_else(|| AgentError::Other("Usage: /rename <新标题>".to_string()))?; if let Some(sid) = current_session_id { self.rename_dialog(sid, title).await?; Ok((None, format!("对话已重命名为:{}", title))) } else { Ok((None, "No active session.".to_string())) } } "?" | "help" => { let lines: Vec = SLASH_COMMANDS.iter().map(|c| { format!(" {} - {}", c.aliases.join(", "), c.description) }).collect(); Ok((None, format!("可用命令:\n{}", lines.join("\n")))) } _ => Err(AgentError::Other(format!("未知命令:/{}。输入 /? 获取帮助。", cmd.name))), } } pub async fn create_session( &self, channel: &str, chat_id: &str, title: Option<&str>, routing_info: String, ) -> Result<(UnifiedSessionId, String), AgentError> { let dialog_id = crate::util::short_id(); let unified_id = UnifiedSessionId::new(channel, chat_id, &dialog_id); let session_id_str = unified_id.to_string(); let title = title .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| "新对话".to_string()); // Write to Storage first let now = chrono::Utc::now().timestamp_millis(); let meta = crate::storage::session::SessionMeta { id: session_id_str.clone(), channel: channel.to_string(), chat_id: chat_id.to_string(), dialog_id: dialog_id.clone(), title: title.clone(), created_at: now, last_active_at: now, message_count: 0, routing_info: if routing_info.is_empty() { None } else { Some(routing_info.clone()) }, deleted_at: None, last_consolidated_at: None, last_compressed_message_at: None, }; self.storage.upsert_session(&meta).await .map_err(|e| AgentError::Other(format!("failed to create session in storage: {}", e)))?; let session = Session::new( unified_id.clone(), self.provider_config.clone(), self.tools.clone(), Some(self.storage.clone()), routing_info, title.clone(), self.memory_manager.clone(), ).await?; let arc = Arc::new(Mutex::new(session)); let inner = &mut *self.inner.lock().await; inner.sessions.insert(session_id_str.clone(), arc.clone()); // Set as current session for this channel:chat_id let chat_scope = format!("{}:{}", channel, chat_id); inner.current_sessions.insert(chat_scope, session_id_str); Ok((unified_id, title)) } pub async fn get_or_create_session(&self, unified_id: &UnifiedSessionId) -> Result>, AgentError> { let session_id_str = unified_id.to_string(); let inner = &mut *self.inner.lock().await; if let Some(session) = inner.sessions.get(&session_id_str) { return Ok(session.clone()); } // Try to restore from Storage match self.storage.get_session(&session_id_str).await { Ok(meta) => { tracing::debug!(session_id = %session_id_str, last_active_at = %meta.last_active_at, message_count = %meta.message_count, "Restoring session from Storage"); let session = Session::from_storage( unified_id.clone(), self.provider_config.clone(), self.tools.clone(), self.storage.clone(), self.memory_manager.clone(), ).await?; let arc = Arc::new(Mutex::new(session)); inner.sessions.insert(session_id_str.clone(), arc.clone()); // Set as current session let chat_scope = format!("{}:{}", unified_id.channel, unified_id.chat_id); inner.current_sessions.insert(chat_scope, session_id_str); return Ok(arc); } Err(_) => { // Session not in Storage, create new } } // Create new session let session = Session::new( unified_id.clone(), self.provider_config.clone(), self.tools.clone(), Some(self.storage.clone()), String::new(), "新对话".to_string(), self.memory_manager.clone(), ).await?; let arc = Arc::new(Mutex::new(session)); inner.sessions.insert(session_id_str.clone(), arc.clone()); // Set as current session let chat_scope = format!("{}:{}", unified_id.channel, unified_id.chat_id); inner.current_sessions.insert(chat_scope, session_id_str); Ok(arc) } pub async fn create_dialog( &self, channel: &str, chat_id: &str, title: Option<&str>, ) -> Result<(UnifiedSessionId, String), AgentError> { self.create_session(channel, chat_id, title, String::new()).await } pub async fn get_current_dialog( &self, _channel: &str, _chat_id: &str, ) -> Result, AgentError> { Ok(None) } pub async fn switch_dialog( &self, channel: &str, chat_id: &str, dialog_id: &str, ) -> Result { let unified_id = UnifiedSessionId::new(channel, chat_id, dialog_id); // Ensure session is loaded into memory self.get_or_create_session(&unified_id).await?; // Update current session tracking let mut inner = self.inner.lock().await; let chat_scope = format!("{}:{}", channel, chat_id); inner.current_sessions.insert(chat_scope, unified_id.to_string()); Ok(unified_id) } pub async fn list_dialogs( &self, channel: &str, chat_id: &str, _include_archived: bool, ) -> Result<(Vec, Option), AgentError> { let metas = self.storage.list_sessions(channel, chat_id, 10).await .map_err(|e| AgentError::Other(format!("failed to list dialogs: {}", e)))?; let dialogs: Vec = metas.into_iter().map(|meta| { DialogInfo { session_id: UnifiedSessionId::new(channel, chat_id, &meta.dialog_id), title: meta.title, created_at: meta.created_at, last_active_at: meta.last_active_at, message_count: meta.message_count, archived_at: None, } }).collect(); Ok((dialogs, None)) } pub async fn rename_dialog(&self, session_id: &UnifiedSessionId, title: &str) -> Result<(), AgentError> { // Update in-memory session let session = self.get_or_create_session(session_id).await?; let mut session_guard = session.lock().await; session_guard.title = title.to_string(); session_guard.persist_session_meta().await .map_err(|e| AgentError::Other(format!("failed to rename dialog: {}", e)))?; Ok(()) } pub async fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> { let session_id_str = session_id.to_string(); // Soft delete from Storage self.storage.soft_delete_session(&session_id_str).await .map_err(|e| AgentError::Other(format!("failed to delete dialog: {}", e)))?; // Remove from memory and current sessions let mut inner = self.inner.lock().await; inner.sessions.remove(&session_id_str); let chat_scope = format!("{}:{}", session_id.channel, session_id.chat_id); inner.current_sessions.remove(&chat_scope); Ok(()) } pub fn archive_dialog(&self, _session_id: &UnifiedSessionId) -> Result<(), AgentError> { // Archive concept removed - just return OK Ok(()) } pub fn clear_dialog_history(&self, _session_id: &UnifiedSessionId) -> Result<(), AgentError> { Err(AgentError::Other("clear_dialog_history not available".to_string())) } /// Get or activate a specific session by its full UnifiedSessionId. /// Returns an error if the session does not exist in storage. /// If the session was expired from memory but still in storage, /// it will be restored (reactivated). pub async fn get_or_activate_session( &self, unified_id: &UnifiedSessionId, ) -> Result>, AgentError> { let session_id_str = unified_id.to_string(); match self.storage.get_session(&session_id_str).await { Ok(_) => self.get_or_create_session(unified_id).await, Err(StorageError::NotFound(_)) => { Err(AgentError::Other(format!("session not found: {}", unified_id))) } Err(e) => Err(AgentError::Other(format!("storage error: {}", e))), } } async fn resolve_dialog_id( &self, channel: &str, chat_id: &str, ) -> Result { let chat_scope = format!("{}:{}", channel, chat_id); let current_id = { self.inner.lock().await.current_sessions.get(&chat_scope).cloned() }; if let Some(ref current_id) = current_id && let Ok(_) = self.storage.get_session(current_id).await { let parts: Vec<&str> = current_id.split(':').collect(); if parts.len() == 3 { return Ok(UnifiedSessionId::new(channel, chat_id, parts[2])); } } match self.storage.find_most_recent_session(channel, chat_id).await { Ok(Some(meta)) => Ok(UnifiedSessionId::new(channel, chat_id, &meta.dialog_id)), _ => { let (new_id, _) = self.create_session(channel, chat_id, None, String::new()).await?; Ok(new_id) } } } /// Send a system notification (no LLM triggered). /// /// Flow: /// 1. Resolve target session (resolve_dialog_id) /// 2. Write assistant message with source tag to history /// 3. Publish OutboundMessage via bus to target channel pub async fn send_notification( &self, channel: &str, chat_id: &str, content: &str, system_name: &str, task_id: Option<&str>, ) -> Result<(), AgentError> { let unified_id = self.resolve_dialog_id(channel, chat_id).await?; let session = self.get_or_create_session(&unified_id).await?; { let mut guard = session.lock().await; let source = MessageSource { kind: SourceKind::SystemNotification, from_channel: None, from_session: None, from_user_id: None, system_name: Some(system_name.to_string()), task_id: task_id.map(|s| s.to_string()), }; let msg = ChatMessage::assistant_with_source(content, source); guard.add_message(msg, true).await .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; } let outbound = OutboundMessage { channel: channel.to_string(), chat_id: chat_id.to_string(), content: content.to_string(), reply_to: None, media: vec![], metadata: HashMap::new(), }; self.bus.publish_outbound(outbound).await .map_err(|e| AgentError::Other(format!("bus publish error: {}", e)))?; Ok(()) } pub async fn handle_message( &self, channel: &str, _sender_id: &str, chat_id: &str, content: &str, media: Vec, ) -> Result { let unified_id = self.resolve_dialog_id(channel, chat_id).await?; tracing::debug!(unified_id = %unified_id, "handle_message resolved unified_id"); let session = self.get_or_create_session(&unified_id).await?; CURRENT_SOURCE_SESSION.scope(Some(unified_id.to_string()), async { // Check for slash command if let Some((cmd_name, cmd_args)) = parse_slash_command(content) { let result = self.execute_slash_command( cmd_name, if cmd_args.is_empty() { None } else { Some(cmd_args) }, channel, chat_id, Some(&unified_id), ).await; return match result { Ok((_new_session_id, response)) => Ok(HandleResult::CommandOutput(response)), Err(e) => Ok(HandleResult::CommandOutput(e.to_string())), }; } // Normal message handling through LLM let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel(); // Spawn notification publisher — sends immediately when tools are detected { let bus = self.bus.clone(); let ch = channel.to_string(); let cid = chat_id.to_string(); tokio::spawn(async move { while let Some(notif) = notify_rx.recv().await { let mut metadata = HashMap::new(); metadata.insert("_type".to_string(), "notification".to_string()); let outbound = OutboundMessage { channel: ch.clone(), chat_id: cid.clone(), content: notif, reply_to: None, media: vec![], metadata, }; let _ = bus.publish_outbound(outbound).await; } }); } // Phase 1: prepare data under session lock let (agent, history, system_prompt) = { let mut session_guard = session.lock().await; let media_refs: Vec = media.iter().map(|m| m.path.clone()).collect(); #[cfg(debug_assertions)] if !media_refs.is_empty() { tracing::debug!(media_count = %media.len(), media_refs = ?media_refs, "Adding user message with media"); } let user_message = session_guard.create_user_message(content, media_refs); session_guard.add_message(user_message, true).await .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; let history = session_guard.get_history().to_vec(); let skills_prompt = self.skills_loader.build_skills_prompt(); let memory_context = match self.memory_manager.recall(content, 5, Some(crate::memory::MemoryCategory::Knowledge), None).await { Ok(entries) if !entries.is_empty() => { Some(entries.iter() .map(|e| format!("- {}: {}", e.key, e.content)) .collect::>() .join("\n")) } Err(e) => { tracing::warn!(error = %e, "Failed to fetch memory context"); None } _ => None, }; let system_prompt = session_guard.build_system_prompt(&skills_prompt, memory_context.as_deref()); let result = session_guard.compressor .compress_if_needed(history) .await .inspect_err(|e| { tracing::warn!(error = %e, "Context compression failed in handle_message"); })?; if result.created_timelines { session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis()); } let mut history = result.history; history.insert(0, ChatMessage::system(system_prompt.clone())); let now = chrono::Utc::now().timestamp_millis(); session_guard.last_consolidated_at = Some(now); if let Err(e) = session_guard.persist_session_meta().await { tracing::warn!(error = %e, "Failed to persist consolidation timestamp"); } let agent = session_guard.create_agent_with_notify(notify_tx)?; (agent, history, system_prompt) }; // session lock released — send_message can now lock freely // Phase 2: LLM call (no session lock held) let result = match agent.process(history).await { Ok(r) => r, Err(AgentError::LlmError(ref msg)) if is_context_overflow_error(msg) => { let retry_history = { let mut session_guard = session.lock().await; let new_window = crate::agent::ContextCompressor::parse_context_limit_from_error(msg) .unwrap_or(session_guard.compressor_threshold()); tracing::warn!( new_window, error = %msg, "Context overflow in handle_message — retrying with tighter window" ); session_guard.compressor.set_context_window(new_window); let raw = session_guard.get_history().to_vec(); let retry_result = session_guard.compressor.compress_if_needed(raw).await?; if retry_result.created_timelines { session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis()); if let Err(e) = session_guard.persist_session_meta().await { tracing::warn!(error = %e, "Failed to persist compression marker on retry"); } } let mut retry = retry_result.history; retry.insert(0, ChatMessage::system(system_prompt.clone())); retry }; // lock released again for retry agent.process(retry_history).await .inspect_err(|e| { tracing::error!(error = %e, "Agent retry after context compression failed"); })? } Err(e) => { tracing::error!(error = %e, "Agent processing error — propagating to caller"); return Err(e); }, }; // Phase 3: persist results under session lock let response: String = { let mut session_guard = session.lock().await; for msg in result.emitted_messages { session_guard.add_message(msg, true).await .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; } if session_guard.should_generate_title() && let Err(e) = session_guard.generate_title().await { tracing::warn!("failed to generate title: {}", e); } result.final_response.content }; #[cfg(debug_assertions)] tracing::debug!( channel = %channel, chat_id = %chat_id, response_len = %response.len(), "Agent response received" ); Ok(HandleResult::AgentResponse(response)) }).await } /// Handle a message triggered by a scheduled cron job. /// /// Runs in a stateless manner: no session creation, no history persistence. /// The cron system prompt instructs the LLM to deliver results via the /// `send_message` tool, which handles both delivery and history writing /// on the target session. pub async fn handle_cron_message( &self, channel: &str, chat_id: &str, prompt: &str, job_id: &str, job_name: &str, ) -> Result { let skills_prompt = self.skills_loader.build_skills_prompt(); let base_prompt = build_system_prompt( &self.provider_config.workspace_dir, &self.provider_config.model_id, &self.tools, Some(&format!("cron:{}:{}", job_name, job_id)), None, false, ); let cron_context = format!( "## 定时任务执行\n\n\ 你正在执行定时任务「{job_name}」({job_id})。\n\ 目标渠道: {channel}:{chat_id}\n\n\ 规则:\n\ - 这不是聊天对话,没有用户会直接看到你的输出\n\ - 你必须使用 send_message 工具将最终结果发送到目标渠道\n\ - send_message 格式: target_chat_id=\"{channel}:{chat_id}\", content=\"消息内容\"\n\ - 可以调用其他工具收集信息、处理任务,但最终消息必须通过 send_message 发送\n\ - 只输出最终消息内容,不要输出中间思考过程或分析!" ); let full_system_prompt = format!("{}\n\n{}\n\n{}", base_prompt, skills_prompt, cron_context); let history = vec![ ChatMessage::system(full_system_prompt), ChatMessage::user(prompt), ]; let agent = self.create_cron_agent()?; let source_session = format!("cron:{}", job_name); let result = CURRENT_SOURCE_SESSION.scope(Some(source_session), async { agent.process(history).await }) .await .inspect_err(|e| { tracing::error!(error = %e, job_id = %job_id, "Cron agent processing error"); })?; Ok(HandleResult::AgentResponse(result.final_response.content)) } pub async fn clear_session_history(&self, unified_id: &UnifiedSessionId) -> Result<(), AgentError> { let session = self.get_or_create_session(unified_id).await?; let mut session_guard = session.lock().await; // Clear in-memory session_guard.messages.clear(); session_guard.seq_counter = 1; session_guard.total_message_count = 0; session_guard.message_count = 0; // Clear Storage if let Some(ref storage) = session_guard.storage { storage.clear_messages(&session_guard.id.to_string()).await .map_err(|e| AgentError::Other(format!("failed to clear messages: {}", e)))?; } Ok(()) } } #[async_trait::async_trait] impl OutboundMessenger for SessionManager { async fn send_message( &self, channel: &str, chat_id: &str, dialog_id: Option<&str>, content: &str, mut source: MessageSource, ) -> Result<(), String> { // Fill origin from current source session if not provided if source.from_session.is_none() { source.from_session = CURRENT_SOURCE_SESSION.try_with(|v| v.clone()).ok().flatten(); } let (target_sid, session) = if let Some(did) = dialog_id { let sid = UnifiedSessionId::new(channel, chat_id, did); let session = self.get_or_activate_session(&sid).await .map_err(|e| e.to_string())?; (sid, session) } else { let sid = self.resolve_dialog_id(channel, chat_id).await .map_err(|e| e.to_string())?; let session = self.get_or_create_session(&sid).await .map_err(|e| e.to_string())?; (sid, session) }; // Build message prefix: [message from ] let origin = source.from_session.as_deref().unwrap_or("unknown"); let origin_id = source.from_session.clone(); let prefix = format!("[message from {}] ", origin); let marked_content = format!("{}\n{}", prefix, content); // Write source-tagged assistant message to target session history { let mut guard = session.lock().await; let msg = ChatMessage::assistant_with_source(marked_content.clone(), source); guard.add_message(msg, true).await .map_err(|e| e.to_string())?; } // Restore active dialog if source and target share channel:chat_id but differ in dialog_id if let Some(ref origin_id) = origin_id { let parts: Vec<&str> = origin_id.split(':').collect(); if parts.len() == 3 && parts[0] == channel && parts[1] == chat_id && parts[2] != target_sid.dialog_id { let scope = format!("{}:{}", channel, chat_id); self.inner.lock().await.current_sessions.insert(scope, origin_id.clone()); } } // Publish OutboundMessage via bus to target channel let outbound = OutboundMessage { channel: channel.to_string(), chat_id: chat_id.to_string(), content: marked_content, reply_to: None, media: vec![], metadata: HashMap::new(), }; self.bus.publish_outbound(outbound).await .map_err(|e| e.to_string())?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[allow(dead_code)] fn test_provider_config() -> LLMProviderConfig { LLMProviderConfig { provider_type: "openai".to_string(), name: "test".to_string(), base_url: "http://localhost".to_string(), api_key: "test-key".to_string(), extra_headers: HashMap::new(), model_id: "test-model".to_string(), temperature: Some(0.0), max_tokens: Some(32), model_extra: HashMap::new(), max_tool_iterations: 1, token_limit: 4096, workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"), } } }