PicoBot/src/session/session.rs
2026-07-14 14:31:16 +08:00

2556 lines
100 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;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc, oneshot};
use super::persistence::append_persisted_messages;
use crate::bus::{ChatMessage, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind};
use crate::mcp::get_mcp_status;
use crate::storage::{Storage, StorageError};
use std::sync::Arc as StdArc;
pub(super) type MessagePersistSnapshot = (
StdArc<Storage>,
String,
crate::storage::message::MessageMeta,
crate::storage::session::SessionMeta,
);
const SESSION_QUEUE_CAPACITY: usize = 32;
fn outbound_session_metadata(session_id: &str) -> HashMap<String, String> {
HashMap::from([("_session_id".to_string(), session_id.to_string())])
}
tokio::task_local! {
pub(super) static CURRENT_SOURCE_SESSION: Option<String>;
}
/// 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),
/// Agent processing spawned in background; response will be sent via bus
AgentProcessing,
}
use crate::agent::context_compressor::ContextCompressionConfig;
use crate::agent::system_prompt::{build_runtime_context, build_system_prompt};
use crate::agent::{AgentError, AgentLoop, ContextCompressor};
use crate::channels::slash_command::parse_slash_command;
use crate::config::BrowserConfig;
use crate::config::LLMProviderConfig;
/// 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::bus::MessageBus;
use crate::providers::{LLMProvider, create_provider};
use crate::session::events::DialogInfo;
use crate::session::session_id::UnifiedSessionId;
use crate::skills::SkillsLoader;
use crate::tools::OutboundMessenger;
use crate::tools::SendMessageTool;
use crate::tools::{ToolRegistry, create_default_tools};
/// 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<ChatMessage>,
seq_counter: i64,
provider_config: LLMProviderConfig,
provider: Arc<dyn LLMProvider>,
tools: Arc<ToolRegistry>,
compressor: ContextCompressor,
storage: Option<StdArc<Storage>>,
routing_info: String,
archived_at: Option<i64>,
/// Timestamp (Unix ms) of the last consolidation.
/// Messages before this time have been compressed into memory.
pub last_consolidated_at: Option<i64>,
pub last_compressed_message_at: Option<i64>,
memory_manager: Arc<crate::memory::MemoryManager>,
/// Task queue for per-session serial agent processing
agent_tx: Option<mpsc::Sender<AgentTask>>,
/// Cancel signal for the currently executing agent task
current_cancel: Option<oneshot::Sender<()>>,
/// Monotonic counter to detect stale workers
worker_generation: u64,
/// Monotonic counter for in-memory session mutations.
///
/// Slow work such as memory recall, compression, and title generation runs
/// outside the session lock. Workers capture this version before starting
/// that work and verify it before committing results, so stale snapshots do
/// not overwrite a session that was changed by a command such as /clear or
/// /delete while the slow work was in flight.
state_version: u64,
/// Serializes durable mutations while allowing the session state mutex to
/// be released during SQLite I/O.
pub(super) persistence_lock: Arc<Mutex<()>>,
}
/// A task to be processed by the per-session agent worker
struct AgentTask {
channel: String,
chat_id: String,
content: String,
media: Vec<MediaItem>,
}
#[derive(Clone)]
struct AgentWorkerDeps {
bus: Arc<MessageBus>,
memory_manager: Arc<crate::memory::MemoryManager>,
skills_loader: Arc<SkillsLoader>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
}
impl Session {
pub async fn new(
id: UnifiedSessionId,
provider_config: LLMProviderConfig,
tools: Arc<ToolRegistry>,
storage: Option<StdArc<Storage>>,
routing_info: String,
title: String,
memory_manager: Arc<crate::memory::MemoryManager>,
) -> Result<Self, AgentError> {
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<dyn LLMProvider> = 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,
archived_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
memory_manager,
agent_tx: None,
current_cancel: None,
worker_generation: 0,
state_version: 0,
persistence_lock: Arc::new(Mutex::new(())),
})
}
/// 从 Storage 恢复 Session
pub async fn from_storage(
id: UnifiedSessionId,
provider_config: LLMProviderConfig,
tools: Arc<ToolRegistry>,
storage: StdArc<Storage>,
memory_manager: Arc<crate::memory::MemoryManager>,
) -> Result<Self, AgentError> {
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<dyn LLMProvider> = 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<ChatMessage> = Vec::new();
let mut restored_compressed_at = session_meta.last_compressed_message_at;
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
.map_err(|e| {
AgentError::Other(format!("failed to load session timelines: {}", e))
})?;
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
.map_err(|e| {
AgentError::Other(format!("failed to load messages after timestamp: {}", e))
})?;
let mut tail_msgs: Vec<ChatMessage> = tail
.into_iter()
.map(|m| ChatMessage {
id: m.id,
role: m.role,
content: m.content,
reasoning_content: m.reasoning_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::<Vec<crate::providers::ToolCall>>(&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
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,
reasoning_content: m.reasoning_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::<Vec<crate::providers::ToolCall>>(&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);
}
// Compress loaded history if it exceeds budget
if !chat_messages.is_empty() {
let result = compressor
.compress_if_needed(chat_messages)
.await
.map_err(|e| AgentError::Other(format!("compression during restore: {}", e)))?;
if result.created_timelines {
restored_compressed_at = Some(chrono::Utc::now().timestamp_millis());
}
chat_messages = result.history;
}
// seq_counter from actual DB max
let max_seq = storage
.get_max_message_seq(&id.to_string())
.await
.map_err(|e| AgentError::Other(format!("failed to load message sequence: {}", e)))?;
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(),
archived_at: session_meta.archived_at,
last_consolidated_at: session_meta.last_consolidated_at,
last_compressed_message_at: restored_compressed_at,
memory_manager,
agent_tx: None,
current_cancel: None,
worker_generation: 0,
state_version: 0,
persistence_lock: Arc::new(Mutex::new(())),
})
}
/// 获取 session ID
pub fn session_id(&self) -> String {
self.id.to_string()
}
pub(super) fn add_message_in_memory(
&mut self,
message: ChatMessage,
persist: bool,
) -> Option<MessagePersistSnapshot> {
let is_user = message.role == "user";
let now = chrono::Utc::now().timestamp_millis();
// Assign seq
let seq = self.seq_counter;
self.seq_counter += 1;
let persist_snapshot = if persist {
self.storage.clone().map(|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(),
reasoning_content: message.reasoning_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, self.id.to_string(), msg_meta)
})
} else {
None
};
// 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;
self.state_version = self.state_version.wrapping_add(1);
persist_snapshot.map(|(storage, session_id, msg_meta)| {
let session_meta = crate::storage::session::SessionMeta {
id: session_id.clone(),
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())
},
archived_at: self.archived_at,
deleted_at: None,
last_consolidated_at: self.last_consolidated_at,
last_compressed_message_at: self.last_compressed_message_at,
};
(storage, session_id, msg_meta, session_meta)
})
}
/// Roll back messages that were appended in memory but whose atomic
/// persistence failed. This is only called while holding the session lock,
/// so the suffix check also protects against removing unrelated messages.
pub(super) fn rollback_message_suffix(&mut self, message_ids: &[String]) {
if message_ids.is_empty() || self.messages.len() < message_ids.len() {
return;
}
let start = self.messages.len() - message_ids.len();
if self.messages[start..]
.iter()
.zip(message_ids)
.any(|(message, id)| &message.id != id)
{
tracing::error!(session_id = %self.id, "Refusing to roll back a non-matching message suffix");
return;
}
let removed_user_messages = self.messages[start..]
.iter()
.filter(|message| message.role == "user")
.count() as i64;
self.messages.truncate(start);
self.seq_counter -= message_ids.len() as i64;
self.total_message_count -= message_ids.len() as i64;
self.message_count -= removed_user_messages;
self.state_version = self.state_version.wrapping_add(1);
}
/// 获取消息历史
pub fn get_history(&self) -> &[ChatMessage] {
&self.messages
}
pub fn create_user_message(&self, content: &str, media_refs: Vec<MediaRef>) -> ChatMessage {
if media_refs.is_empty() {
ChatMessage::user(content)
} else {
ChatMessage::user_with_media(content, media_refs)
}
}
fn append_runtime_context_to_user_message(message: &mut ChatMessage, runtime_context: &str) {
if runtime_context.trim().is_empty() {
return;
}
if message.content.trim().is_empty() {
message.content = runtime_context.to_string();
} else {
message.content = format!("{}\n\n{}", message.content, runtime_context);
}
}
pub fn create_user_message_with_source(
&self,
content: &str,
media_refs: Vec<MediaRef>,
source: MessageSource,
) -> ChatMessage {
let mut message = ChatMessage::user_with_source(content, source);
message.media_refs = media_refs;
message
}
fn session_meta_snapshot(
&self,
) -> Option<(StdArc<Storage>, crate::storage::session::SessionMeta)> {
let storage = self.storage.clone()?;
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())
},
archived_at: self.archived_at,
deleted_at: None,
last_consolidated_at: self.last_consolidated_at,
last_compressed_message_at: self.last_compressed_message_at,
};
Some((storage, meta))
}
/// 检查是否需要自动生成 title5 条用户消息后)
pub fn should_generate_title(&self) -> bool {
self.title == "新对话" && self.message_count >= 5
}
fn title_prompt_snapshot(&self) -> Option<String> {
if !self.should_generate_title() {
return None;
}
Some(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::<Vec<_>>()
.join("\n")
))
}
fn apply_generated_title(&mut self, title: String) -> bool {
if title.is_empty() || !self.should_generate_title() {
return false;
}
self.title = title;
self.state_version = self.state_version.wrapping_add(1);
true
}
fn fresh_context_compressor(&self) -> ContextCompressor {
let compressor_config = ContextCompressionConfig {
protect_first_n: 2,
..Default::default()
};
let mut compressor = ContextCompressor::with_config(
self.provider.clone(),
self.provider_config.token_limit,
compressor_config,
self.memory_manager.clone(),
);
compressor.set_session_id(Some(self.id.to_string()));
compressor
}
fn replace_history_in_memory(&mut self, messages: Vec<ChatMessage>) {
self.messages = messages;
self.seq_counter = self.messages.len() as i64 + 1;
self.total_message_count = self.messages.len() as i64;
self.message_count = self.messages.iter().filter(|m| m.role == "user").count() as i64;
self.last_active_at = chrono::Utc::now().timestamp_millis();
self.state_version = self.state_version.wrapping_add(1);
}
/// 获取 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<AgentLoop, AgentError> {
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(),
self.provider_config.input_types.clone(),
)
.with_context_window(self.provider_config.token_limit))
}
/// 创建一个附通知通道的 AgentLoop 实例
pub fn create_agent_with_notify(
&self,
notify_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<AgentLoop, AgentError> {
Ok(self.create_agent()?.with_notify(notify_tx))
}
/// 构建系统提示词(包含 AgentLoop 的基础提示词 + skills + memory
pub fn build_system_prompt(&self, skills_prompt: &str) -> String {
let base_prompt = build_system_prompt(
&self.provider_config.workspace_dir,
&self.provider_config.model_id,
&self.tools,
);
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<String> {
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 [ChatMessage]) {
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::<Vec<_>>()
.join(", ")
);
messages[i].tool_calls = None;
}
i += 1;
}
}
/// SessionManager 管理所有 Session按 channel_name 路由
#[derive(Clone)]
pub struct SessionManager {
inner: Arc<Mutex<SessionManagerInner>>,
provider_config: LLMProviderConfig,
tools: Arc<ToolRegistry>,
skills_loader: Arc<SkillsLoader>,
storage: Arc<Storage>,
pub(super) bus: Arc<MessageBus>,
memory_manager: Arc<crate::memory::MemoryManager>,
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
}
struct SessionManagerInner {
/// Sessions keyed by UnifiedSessionId.to_string()
sessions: HashMap<String, Arc<Mutex<Session>>>,
/// Current active session per channel:chat_id
current_sessions: HashMap<String, String>,
}
/// 斜杠命令定义
#[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"],
},
SlashCommand {
name: "mcp",
description: "显示 MCP 服务状态和工具列表",
aliases: &["/mcp"],
},
SlashCommand {
name: "stop",
description: "停止当前正在执行的任务并清空消息队列",
aliases: &["/stop"],
},
];
impl SessionManager {
fn worker_deps(&self) -> AgentWorkerDeps {
AgentWorkerDeps {
bus: self.bus.clone(),
memory_manager: self.memory_manager.clone(),
skills_loader: self.skills_loader.clone(),
task_supervisor: self.task_supervisor.clone(),
}
}
pub fn new(
provider_config: LLMProviderConfig,
storage: Arc<Storage>,
bus: Arc<MessageBus>,
memory_manager: Arc<crate::memory::MemoryManager>,
browser_config: Option<BrowserConfig>,
max_concurrent_background_tasks: usize,
task_supervisor: crate::task_supervisor::TaskSupervisor,
) -> Result<Self, AgentError> {
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(),
None, // SubAgentManager created below
browser_config.as_ref(),
));
// Create SubAgentManager and register DelegateTool
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
let sub_agent_manager = Arc::new(crate::agent::SubAgentManager::new(
provider_config.clone(),
tools.clone(),
Some(storage.clone()),
notify_tx,
max_concurrent_background_tasks,
Some(skills_loader.clone()),
task_supervisor.clone(),
));
tools.register(crate::tools::DelegateTool::new(sub_agent_manager.clone()));
// Start background task notification consumer
let sm_bus = bus.clone();
task_supervisor.spawn("background-task-notifications", async move {
while let Some(notif) = notify_rx.recv().await {
let content =
format_task_notification(&notif.task_id, &notif.status, &notif.result_summary);
let outbound = OutboundMessage {
channel: notif.channel,
chat_id: notif.chat_id,
content,
reply_to: None,
media: vec![],
metadata: std::collections::HashMap::new(),
delivery: None,
};
let _ = sm_bus.publish_outbound(outbound).await;
}
});
// Start periodic background task cleanup (every hour, TTL 24h)
let cleanup_storage = storage.clone();
task_supervisor.spawn("background-task-cleanup", async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
match cleanup_storage.cleanup_old_tasks(86_400_000).await {
Ok(count) if count > 0 => {
tracing::info!(count, "Cleaned up old background tasks");
}
Err(e) => {
tracing::warn!(error = %e, "Failed to clean up old background tasks");
}
_ => {}
}
}
});
Ok(Self {
inner: Arc::new(Mutex::new(SessionManagerInner {
sessions: HashMap::new(),
current_sessions: HashMap::new(),
})),
provider_config,
tools,
skills_loader,
storage,
bus,
memory_manager,
sub_agent_manager,
task_supervisor,
})
}
/// Register the send_message tool (requires self in Arc)
pub fn register_outbound_tool(self: &Arc<Self>, available_channels: Vec<String>) {
let messenger: Arc<dyn OutboundMessenger> = self.clone();
self.tools
.register(SendMessageTool::new(messenger, available_channels));
}
pub fn tools(&self) -> Arc<ToolRegistry> {
self.tools.clone()
}
/// 为定时任务创建一个无 session 绑定的 AgentLoop
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
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(),
self.provider_config.input_types.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<UnifiedSessionId>, 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" => {
if let Some(sid) = current_session_id {
self.delete_dialog(sid).await?;
}
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 (original_count, history, mut compressor, base_version) = {
let session_guard = session.lock().await;
(
session_guard.get_history().len(),
session_guard.get_history().to_vec(),
session_guard.fresh_context_compressor(),
session_guard.state_version,
)
};
let result = compressor.compress_if_needed(history).await?;
let compressed_count = result.history.len();
let meta_snapshot = {
let mut session_guard = session.lock().await;
if session_guard.state_version != base_version {
return Ok((
None,
"Context changed while compacting; please run /compact again."
.to_string(),
));
}
if result.created_timelines {
session_guard.last_compressed_message_at =
Some(chrono::Utc::now().timestamp_millis());
}
session_guard.replace_history_in_memory(result.history);
session_guard.session_meta_snapshot()
};
if let Some((storage, meta)) = meta_snapshot
&& let Err(e) = storage.upsert_session(&meta).await
{
tracing::warn!(error = %e, "Failed to persist compression marker after /compact");
}
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 history = session_guard.get_history();
let message_count = 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();
let token_info = session_guard.compressor.token_info(history);
let cache_info = if token_info.cache_active {
format!(
"API精确: {} tokens",
token_info.last_api_tokens.unwrap_or(0)
)
} else {
"无API精确缓存".to_string()
};
let threshold_pct = if token_info.context_window > 0 {
(token_info.threshold as f64 / token_info.context_window as f64 * 100.0)
as usize
} else {
0
};
let usage_pct = if token_info.context_window > 0 {
(token_info.estimated_tokens as f64 / token_info.context_window as f64
* 100.0)
.min(100.0) as usize
} else {
0
};
let usage_bar = if token_info.context_window > 0 {
format!(
"{}/{} tokens ({}%)",
token_info.estimated_tokens, token_info.context_window, usage_pct
)
} else {
"未设置".to_string()
};
let compression_status = if token_info.estimated_tokens > token_info.threshold {
"[即将压缩]"
} else {
"[正常]"
};
let ctx_info = format!(
"[窗口] {} [阈值] {}/{} ({}) [状态] {} {}",
usage_bar,
token_info.threshold,
token_info.context_window,
threshold_pct,
compression_status,
cache_info,
);
Ok((
None,
format!(
"对话标题: {}\nSession ID: {}\n模型: {}\n用户消息: {} / 总消息: {}\n创建时间: {}\n最后活跃: {}\n\n上下文: {}",
title,
session_id_str,
model_name,
session_guard.message_count,
message_count,
created_at,
last_active_at,
ctx_info,
),
))
} 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);
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<String> = 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 <dialog_id>".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<String> = SLASH_COMMANDS
.iter()
.map(|c| format!(" {} - {}", c.aliases.join(", "), c.description))
.collect();
Ok((None, format!("可用命令:\n{}", lines.join("\n"))))
}
"mcp" => {
let servers = get_mcp_status();
if servers.is_empty() {
return Ok((None, "未配置 MCP 服务。".to_string()));
}
let lines: Vec<String> = servers
.iter()
.map(|s| {
let status = if s.connected {
format!("✅ 已连接 ({})", s.transport)
} else {
format!("❌ 连接失败: {}", s.error.as_deref().unwrap_or("未知错误"))
};
let tool_lines: Vec<String> = s
.tools
.iter()
.map(|t| {
let desc = if t.description.is_empty() {
"无描述".to_string()
} else {
t.description.chars().take(60).collect::<String>()
};
format!(" - {}: {}", t.name, desc)
})
.collect();
let tools_section = if tool_lines.is_empty() {
String::new()
} else {
format!("\n{}", tool_lines.join("\n"))
};
format!("{} {}{}", s.name, status, tools_section)
})
.collect();
Ok((None, format!("MCP 服务:\n\n{}", lines.join("\n\n"))))
}
"stop" => {
let sid = current_session_id
.ok_or_else(|| AgentError::Other("no active session".to_string()))?;
let session = self.get_or_create_session(sid).await?;
let msgs = {
let mut guard = session.lock().await;
let mut msgs: Vec<String> = Vec::new();
if guard.current_cancel.take().is_some() {
msgs.push("当前任务已发送停止信号。".to_string());
}
if guard.agent_tx.take().is_some() {
msgs.push("消息队列已清空。".to_string());
}
guard.worker_generation = guard.worker_generation.wrapping_add(1);
guard.state_version = guard.state_version.wrapping_add(1);
msgs
};
// Cancel all running background sub-agent tasks for this session
// after releasing the session lock.
self.sub_agent_manager
.cancel_by_session(&sid.to_string())
.await;
let resp = if msgs.is_empty() {
"没有正在执行的任务或队列。".to_string()
} else {
msgs.join(" ")
};
Ok((None, resp))
}
_ => 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())
},
archived_at: None,
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<Arc<Mutex<Session>>, AgentError> {
let session_id_str = unified_id.to_string();
if let Some(session) = self
.inner
.lock()
.await
.sessions
.get(&session_id_str)
.cloned()
{
return Ok(session);
}
// Perform storage/provider I/O without holding the global registry lock.
let session = 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");
Session::from_storage(
unified_id.clone(),
self.provider_config.clone(),
self.tools.clone(),
self.storage.clone(),
self.memory_manager.clone(),
)
.await?
}
Err(StorageError::NotFound(_)) => {
let now = chrono::Utc::now().timestamp_millis();
let meta = crate::storage::session::SessionMeta {
id: session_id_str.clone(),
channel: unified_id.channel.clone(),
chat_id: unified_id.chat_id.clone(),
dialog_id: unified_id.dialog_id.clone(),
title: "新对话".to_string(),
created_at: now,
last_active_at: now,
message_count: 0,
routing_info: None,
archived_at: None,
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))
})?;
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?
}
Err(e) => {
return Err(AgentError::Other(format!(
"failed to look up session in storage: {}",
e
)));
}
};
let arc = Arc::new(Mutex::new(session));
// Another caller may have completed the same load while I/O was in
// progress. Keep the already-published instance as the single source
// of truth.
let inner = &mut *self.inner.lock().await;
if let Some(existing) = inner.sessions.get(&session_id_str) {
return Ok(existing.clone());
}
inner.sessions.insert(session_id_str.clone(), arc.clone());
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<Option<UnifiedSessionId>, AgentError> {
let chat_scope = format!("{}:{}", channel, chat_id);
let current = {
self.inner
.lock()
.await
.current_sessions
.get(&chat_scope)
.cloned()
};
let Some(current) = current else {
return Ok(None);
};
match self.storage.get_session(&current).await {
Ok(_) => Ok(UnifiedSessionId::parse(&current)),
Err(StorageError::NotFound(_)) => Ok(None),
Err(e) => Err(AgentError::Other(format!("storage error: {}", e))),
}
}
pub async fn switch_dialog(
&self,
channel: &str,
chat_id: &str,
dialog_id: &str,
) -> Result<UnifiedSessionId, AgentError> {
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 get_dialog_history(
&self,
session_id: &UnifiedSessionId,
limit: u32,
) -> Result<Vec<crate::storage::message::MessageMeta>, AgentError> {
let session_id = session_id.to_string();
self.storage
.get_session(&session_id)
.await
.map_err(|e| AgentError::Other(format!("failed to load dialog: {e}")))?;
self.storage
.load_recent_session_messages(&session_id, limit)
.await
.map_err(|e| AgentError::Other(format!("failed to load dialog history: {e}")))
}
pub async fn list_dialogs(
&self,
channel: &str,
chat_id: &str,
include_archived: bool,
) -> Result<(Vec<DialogInfo>, Option<String>), AgentError> {
let metas = self
.storage
.list_sessions(channel, chat_id, 100, include_archived)
.await
.map_err(|e| AgentError::Other(format!("failed to list dialogs: {}", e)))?;
let current_dialog_id = self
.get_current_dialog(channel, chat_id)
.await?
.map(|sid| sid.dialog_id);
let dialogs: Vec<DialogInfo> = 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: meta.archived_at,
})
.collect();
Ok((dialogs, current_dialog_id))
}
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 persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let meta_snapshot = {
let mut session_guard = session.lock().await;
session_guard.title = title.to_string();
session_guard.state_version = session_guard.state_version.wrapping_add(1);
session_guard.session_meta_snapshot()
};
if let Some((storage, meta)) = meta_snapshot {
storage
.upsert_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();
let session = self.get_or_create_session(session_id).await?;
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
// 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 async fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
let session_id_str = session_id.to_string();
let session = self.get_or_create_session(session_id).await?;
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
self.storage
.archive_session(&session_id_str)
.await
.map_err(|e| AgentError::Other(format!("failed to archive dialog: {}", e)))?;
let mut inner = self.inner.lock().await;
inner.sessions.remove(&session_id_str);
let chat_scope = format!("{}:{}", session_id.channel, session_id.chat_id);
if inner
.current_sessions
.get(&chat_scope)
.is_some_and(|id| id == &session_id_str)
{
inner.current_sessions.remove(&chat_scope);
}
Ok(())
}
pub async fn clear_dialog_history(
&self,
session_id: &UnifiedSessionId,
) -> Result<(), AgentError> {
self.clear_session_history(session_id).await
}
/// 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<Arc<Mutex<Session>>, 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))),
}
}
pub(super) async fn resolve_dialog_id(
&self,
channel: &str,
chat_id: &str,
) -> Result<UnifiedSessionId, AgentError> {
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 {
match self.storage.get_session(current_id).await {
Ok(_) => {
if let Some(parsed) = UnifiedSessionId::parse(current_id) {
return Ok(parsed);
}
}
Err(StorageError::NotFound(_)) => {}
Err(e) => {
return Err(AgentError::Other(format!(
"failed to resolve current session: {}",
e
)));
}
}
}
match self
.storage
.find_most_recent_session(channel, chat_id)
.await
{
Ok(Some(meta)) => Ok(UnifiedSessionId::new(channel, chat_id, &meta.dialog_id)),
Ok(None) => {
let (new_id, _) = self
.create_session(channel, chat_id, None, String::new())
.await?;
Ok(new_id)
}
Err(e) => Err(AgentError::Other(format!(
"failed to find recent session: {}",
e
))),
}
}
pub(super) async fn restore_origin_dialog(&self, origin_id: &str, target: &UnifiedSessionId) {
let Some(origin) = UnifiedSessionId::parse(origin_id) else {
tracing::warn!(origin_id, "Ignoring malformed source session id");
return;
};
if origin.channel == target.channel
&& origin.chat_id == target.chat_id
&& origin.dialog_id != target.dialog_id
{
self.inner
.lock()
.await
.current_sessions
.insert(target.chat_scope(), origin_id.to_string());
}
}
/// 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 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);
append_persisted_messages(&session, vec![msg])
.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: outbound_session_metadata(&unified_id.to_string()),
delivery: None,
};
self.bus
.deliver_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<MediaItem>,
) -> Result<HandleResult, AgentError> {
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: enqueue to per-session worker for serial processing.
let task = AgentTask {
channel: channel.to_string(),
chat_id: chat_id.to_string(),
content: content.to_string(),
media,
};
let session_clone = session.clone();
let unified_str = unified_id.to_string();
{
let mut guard = session_clone.lock().await;
let needs_spawn = guard.agent_tx.is_none()
|| guard.agent_tx.as_ref().is_some_and(|tx| tx.is_closed());
if needs_spawn {
guard.agent_tx = None;
guard.current_cancel = None;
guard.worker_generation = guard.worker_generation.wrapping_add(1);
let generation = guard.worker_generation;
let (tx, rx) = mpsc::channel(SESSION_QUEUE_CAPACITY);
guard.agent_tx = Some(tx);
spawn_agent_worker(
rx,
session_clone.clone(),
self.worker_deps(),
generation,
unified_str.clone(),
);
}
let Some(agent_tx) = guard.agent_tx.as_ref() else {
return Err(AgentError::Other(
"agent worker queue was not initialized".to_string(),
));
};
if let Err(e) = agent_tx.try_send(task) {
if matches!(e, mpsc::error::TrySendError::Full(_)) {
tracing::warn!(session_id = %unified_str, capacity = SESSION_QUEUE_CAPACITY, "Session queue is full");
return Ok(HandleResult::CommandOutput(
"当前对话消息队列已满,请稍后重试。".to_string(),
));
}
// Worker died after we just spawned it — respawn with the recovered task
let task = e.into_inner();
guard.agent_tx = None;
guard.current_cancel = None;
guard.worker_generation = guard.worker_generation.wrapping_add(1);
let generation = guard.worker_generation;
let (tx, rx) = mpsc::channel(SESSION_QUEUE_CAPACITY);
guard.agent_tx = Some(tx);
spawn_agent_worker(
rx,
session_clone.clone(),
self.worker_deps(),
generation,
unified_str.clone(),
);
guard
.agent_tx
.as_ref()
.ok_or_else(|| {
AgentError::Other(
"agent worker queue was not initialized".to_string(),
)
})?
.try_send(task)
.map_err(|_| {
AgentError::Other(
"agent worker spawn+send failed irrecoverably".to_string(),
)
})?;
}
}
Ok(HandleResult::AgentProcessing)
})
.await
}
}
async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Result<(), AgentError> {
use crate::providers::{ChatCompletionRequest, ChatCompletionResponse, Message};
let (provider, prompt) = {
let guard = session.lock().await;
let Some(prompt) = guard.title_prompt_snapshot() else {
return Ok(());
};
(guard.provider.clone(), prompt)
};
let request = ChatCompletionRequest {
messages: vec![Message::user(prompt)],
temperature: Some(0.3),
max_tokens: Some(20),
tools: None,
};
let response: ChatCompletionResponse = provider
.chat(request)
.await
.map_err(|e| AgentError::Other(format!("LLM call failed: {}", e)))?;
let title = response.content.trim().to_string();
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let meta_snapshot = {
let mut guard = session.lock().await;
if guard.apply_generated_title(title) {
guard.session_meta_snapshot()
} else {
None
}
};
if let Some((storage, meta)) = meta_snapshot {
storage
.upsert_session(&meta)
.await
.map_err(|e| AgentError::Other(format!("failed to persist title: {}", e)))?;
}
Ok(())
}
fn spawn_agent_worker(
mut task_rx: mpsc::Receiver<AgentTask>,
session: Arc<Mutex<Session>>,
deps: AgentWorkerDeps,
worker_gen: u64,
unified_str: String,
) {
let AgentWorkerDeps {
bus,
memory_manager,
skills_loader,
task_supervisor,
} = deps;
let worker_supervisor = task_supervisor.clone();
task_supervisor.spawn(format!("session-worker:{unified_str}"), async move {
let unified_for_source = unified_str.clone();
let _scope = CURRENT_SOURCE_SESSION.scope(Some(unified_for_source), async {
'tasks: while let Some(task) = task_rx.recv().await {
let task_chan = task.channel.clone();
let task_cid = task.chat_id.clone();
let notification_session_id = unified_str.clone();
let (notify_tx, mut notify_rx) = mpsc::unbounded_channel();
// Spawn notification publisher
{
let bus = bus.clone();
let ch = task_chan.clone();
let cid = task_cid.clone();
worker_supervisor.spawn(
format!("session-notifications:{ch}:{cid}"),
async move {
while let Some(notif) = notify_rx.recv().await {
let mut metadata = HashMap::new();
metadata.insert("_type".to_string(), "notification".to_string());
metadata.insert(
"_session_id".to_string(),
notification_session_id.clone(),
);
let outbound = OutboundMessage {
channel: ch.clone(),
chat_id: cid.clone(),
content: notif,
reply_to: None,
media: vec![],
metadata,
delivery: None,
};
let _ = bus.publish_outbound(outbound).await;
}
},
);
}
// Phase 1: capture a stable session snapshot under lock.
// Memory recall and compression happen outside this block so
// /stop and other commands are not blocked behind slow I/O or
// LLM-backed compaction.
let skills_prompt = skills_loader.build_skills_prompt();
let user_message = {
let guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
let media_refs: Vec<MediaRef> =
task.media.iter().map(MediaItem::to_media_ref).collect();
guard.create_user_message(&task.content, media_refs)
};
if let Err(e) = append_persisted_messages(&session, vec![user_message]).await {
tracing::error!(error = %e, "Failed to persist user message");
let err_outbound = OutboundMessage {
channel: task_chan.clone(),
chat_id: task_cid.clone(),
content: "Failed to save your message, please try again.".to_string(),
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(&unified_str),
delivery: None,
};
let _ = bus.publish_outbound(err_outbound).await;
continue 'tasks;
}
let (agent, history_raw, mut compressor, base_version, cancel_rx) = {
let mut guard = session.lock().await;
if guard.worker_generation != worker_gen {
return; // stale worker
}
let history_raw = guard.get_history().to_vec();
let agent = match guard.create_agent_with_notify(notify_tx) {
Ok(a) => a,
Err(e) => {
tracing::error!(error = %e, "Failed to create agent");
let err_outbound = OutboundMessage {
channel: task_chan.clone(),
chat_id: task_cid.clone(),
content: "Agent creation failed, please try again."
.to_string(),
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(&unified_str),
delivery: None,
};
let _ = bus.publish_outbound(err_outbound).await;
continue 'tasks;
}
};
let (cancel_tx, cancel_rx) = oneshot::channel();
if guard.worker_generation != worker_gen {
return; // /stop replaced us
}
guard.current_cancel = Some(cancel_tx);
(
agent,
history_raw,
guard.fresh_context_compressor(),
guard.state_version,
cancel_rx,
)
}; // lock released
let memory_context = match memory_manager
.recall(
&task.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::<Vec<_>>()
.join("\n"),
),
Err(e) => {
tracing::warn!(error = %e, "Failed to fetch memory context");
None
}
_ => None,
};
let runtime_context =
build_runtime_context(Some(unified_str.as_str()), memory_context.as_deref());
let system_prompt_out = {
let guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
guard.build_system_prompt(&skills_prompt)
};
let compression_result = compressor.compress_if_needed(history_raw).await;
let mut history_out = match compression_result {
Ok(result) => {
let meta_snapshot = {
let mut guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
if guard.state_version != base_version {
tracing::warn!(
session_id = %guard.id,
"Session changed while preparing agent history; dropping stale task"
);
guard.current_cancel = None;
continue 'tasks;
}
if result.created_timelines {
guard.last_compressed_message_at =
Some(chrono::Utc::now().timestamp_millis());
}
guard.last_consolidated_at =
Some(chrono::Utc::now().timestamp_millis());
guard.session_meta_snapshot()
};
if let Some((storage, meta)) = meta_snapshot
&& let Err(e) = storage.upsert_session(&meta).await
{
tracing::warn!(error = %e, "Failed to persist session meta after compression");
}
result.history
}
Err(e) => {
tracing::warn!(error = %e, "Context compression failed in worker");
let guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
guard.get_history().to_vec()
}
};
history_out.insert(0, ChatMessage::system(system_prompt_out.clone()));
if let Some(last_msg) = history_out.iter_mut().rev().find(|m| m.role == "user") {
Session::append_runtime_context_to_user_message(last_msg, &runtime_context);
}
// Phase 2 + 3: LLM call with cancellation
let session2 = session.clone();
let bus2 = bus.clone();
let chan2 = task_chan.clone();
let cid2 = task_cid.clone();
let unified_str2 = unified_str.clone();
let process_future = async move {
let response_session_id = unified_str2.clone();
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
crate::agent::DelegateContext {
session_id: unified_str2,
channel: chan2.clone(),
chat_id: cid2.clone(),
},
agent.process(history_out.clone()),
).await;
let result = match process_result {
Ok(r) => r,
Err(AgentError::LlmError(ref msg))
if is_context_overflow_error(msg) =>
{
let (raw, mut retry_compressor, retry_base_version, new_window) = {
let guard = session2.lock().await;
let new_window =
crate::agent::ContextCompressor::parse_context_limit_from_error(msg)
.unwrap_or(guard.compressor_threshold());
tracing::warn!(
new_window,
error = %msg,
"Context overflow in worker — retrying"
);
(
guard.get_history().to_vec(),
guard.fresh_context_compressor(),
guard.state_version,
new_window,
)
};
retry_compressor.set_context_window(new_window);
let retry_result =
match retry_compressor.compress_if_needed(raw).await {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, "Retry compression failed");
let err_outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: "Context overflow handling failed."
.to_string(),
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(
&response_session_id,
),
delivery: None,
};
let _ = bus2.publish_outbound(err_outbound).await;
return;
}
};
let meta_snapshot = {
let mut guard = session2.lock().await;
if guard.state_version != retry_base_version {
tracing::warn!(
session_id = %guard.id,
"Session changed while retry-compressing after context overflow"
);
return;
}
guard.compressor.set_context_window(new_window);
if retry_result.created_timelines {
guard.last_compressed_message_at =
Some(chrono::Utc::now().timestamp_millis());
}
guard.session_meta_snapshot()
};
if let Some((storage, meta)) = meta_snapshot
&& let Err(e) = storage.upsert_session(&meta).await
{
tracing::warn!(error = %e, "Failed to persist session meta after retry compression");
}
let retry_history = {
let mut retry = retry_result.history;
retry.insert(
0,
ChatMessage::system(system_prompt_out.clone()),
);
if let Some(last_msg) = retry.iter_mut().rev().find(|m| m.role == "user")
{
Session::append_runtime_context_to_user_message(
last_msg,
&runtime_context,
);
}
retry
};
match agent.process(retry_history).await {
Ok(r) => r,
Err(e) => {
tracing::error!(
error = %e,
"Agent retry after overflow failed"
);
let err_outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: format!("Processing error: {}", e),
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(&response_session_id),
delivery: None,
};
let _ = bus2.publish_outbound(err_outbound).await;
return;
}
}
}
Err(e) => {
tracing::error!(error = %e, "Agent processing error");
let err_outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: format!("Processing error: {}", e),
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(&response_session_id),
delivery: None,
};
let _ = bus2.publish_outbound(err_outbound).await;
return;
}
};
let response_content = result.final_response.content;
let total_tokens = result.total_tokens;
let response =
if let Err(e) = append_persisted_messages(&session2, result.emitted_messages).await {
tracing::error!(error = %e, "Failed to atomically persist agent turn");
None
} else {
let mut guard = session2.lock().await;
let sent_count = guard.messages.len();
guard.compressor.set_last_api_info(sent_count, total_tokens);
Some(response_content)
};
let Some(response) = response else {
let err_outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: "Failed to save the agent response, please try again."
.to_string(),
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(&response_session_id),
delivery: None,
};
let _ = bus2.publish_outbound(err_outbound).await;
return;
};
if let Err(e) = maybe_generate_title_outside_lock(session2.clone()).await {
tracing::warn!("failed to generate title: {}", e);
}
let outbound = OutboundMessage {
channel: chan2,
chat_id: cid2,
content: response,
reply_to: None,
media: vec![],
metadata: outbound_session_metadata(&response_session_id),
delivery: None,
};
let _ = bus2.publish_outbound(outbound).await;
};
tokio::select! {
() = process_future => {}
_ = cancel_rx => {
// cancelled — current_cancel already taken by /stop
}
}
// Clean up
let mut guard = session.lock().await;
if guard.worker_generation == worker_gen {
guard.current_cancel = None;
}
}
}).await;
});
}
impl SessionManager {
///
/// 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<HandleResult, AgentError> {
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,
);
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 persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let (storage, session_id, meta_snapshot) = {
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;
session_guard.last_consolidated_at = None;
session_guard.last_compressed_message_at = None;
session_guard.state_version = session_guard.state_version.wrapping_add(1);
(
session_guard.storage.clone(),
session_guard.id.to_string(),
session_guard.session_meta_snapshot(),
)
};
// Clear Storage outside the session lock.
if let Some(storage) = storage {
storage
.clear_messages(&session_id)
.await
.map_err(|e| AgentError::Other(format!("failed to clear messages: {}", e)))?;
}
if let Some((storage, meta)) = meta_snapshot {
storage.upsert_session(&meta).await.map_err(|e| {
AgentError::Other(format!("failed to persist cleared session: {}", e))
})?;
}
Ok(())
}
}
fn format_task_notification(
task_id: &str,
status: &crate::agent::TaskStatus,
summary: &str,
) -> String {
match status {
crate::agent::TaskStatus::Completed => format!(
"📋 后台任务完成\n\n任务 ID: {}\n\n结果:\n{}",
task_id, summary
),
crate::agent::TaskStatus::Failed(err) => {
format!("📋 后台任务失败\n\n任务 ID: {}\n错误: {}", task_id, err)
}
crate::agent::TaskStatus::Cancelled => format!("📋 后台任务已取消\n\n任务 ID: {}", task_id),
crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id),
}
}