diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 8399ffb..f3241eb 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -21,7 +21,7 @@ use std::time::Instant; const MAX_TOOL_RESULT_CHARS: usize = 16_000; /// Minimum characters to keep when truncating const TRUNCATION_SUFFIX_LEN: usize = 200; -const MEMORY_AUTOSAVE_SYSTEM_PROMPT: &str = "你可以在处理任务过程中使用长期记忆工具。读取记忆时,优先使用 memory_search:当你需要用户长期偏好、稳定事实、历史决策、持续任务上下文时,先 search;已知 namespace/key 时可用 get;需要浏览最近记忆时可用 list。写入或修改记忆时,再使用 memory_manage。仅在遇到高价值且未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务/项目上下文、明确决策。不要保存一次性工具结果、临时列表、敏感凭证或不确定推测。写入时优先使用规范 namespace:preferences、profile、tasks、decisions,并优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。检索时尽量同时提供中文关键词、英文别名,以及可能的 snake_case memory_key 词,例如 email / 邮件 / email_folder_preference。"; +const MEMORY_TOOL_USAGE_SYSTEM_PROMPT: &str = "你可以在处理任务过程中使用长期记忆工具。读取记忆时,优先使用 memory_search:当你需要用户长期偏好、稳定事实、历史决策、持续任务上下文时,先 search;已知 namespace/key 时可用 get;需要浏览最近记忆时可用 list。写入或修改记忆时,再使用 memory_manage。仅在遇到高价值且未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务/项目上下文、明确决策。不要保存一次性工具结果、临时列表、敏感凭证或不确定推测。写入时优先使用规范 namespace:preferences、profile、tasks、decisions,并优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。检索时尽量同时提供中文关键词、英文别名,以及可能的 snake_case memory_key 词,例如 email / 邮件 / email_folder_preference。"; const PENDING_USER_ACTION_MARKER: &str = "__PICOBOT_PENDING_USER_ACTION__"; const DEFAULT_PENDING_ASSISTANT_MESSAGE: &str = "工具已经启动并进入等待用户操作的状态。请先完成外部操作,完成后直接告诉我继续。"; @@ -368,7 +368,7 @@ impl AgentLoop { if let Some(skill_prompt) = self.skills.system_index_prompt() { messages_for_llm.push(Message::system(skill_prompt)); } - messages_for_llm.push(Message::system(MEMORY_AUTOSAVE_SYSTEM_PROMPT)); + messages_for_llm.push(Message::system(MEMORY_TOOL_USAGE_SYSTEM_PROMPT)); messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message)); // Build request @@ -520,7 +520,6 @@ impl AgentLoop { if let Some(skill_prompt) = self.skills.system_index_prompt() { messages_for_llm.push(Message::system(skill_prompt)); } - messages_for_llm.push(Message::system(MEMORY_AUTOSAVE_SYSTEM_PROMPT)); messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message)); let request = ChatCompletionRequest { diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 79a4c4d..44c6102 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fs; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -8,10 +8,9 @@ use uuid::Uuid; use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; use crate::config::LLMProviderConfig; use crate::agent::{AgentLoop, AgentError, ContextCompressor, EmittedMessageHandler}; -use crate::providers::{create_provider, ChatCompletionRequest, Message}; use crate::protocol::WsOutbound; use crate::skills::SkillRuntime; -use crate::storage::{MemoryRecord, SessionRecord, SessionStore, persistent_session_id}; +use crate::storage::{SessionRecord, SessionStore, persistent_session_id}; use crate::tools::{ BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool, MemoryManageTool, MemorySearchTool, SkillListTool, SkillManageTool, ToolContext, ToolRegistry, @@ -19,10 +18,6 @@ use crate::tools::{ }; const DEFAULT_AGENT_PROMPT: &str = "# PicoBot 代理配置\n\n## 身份\n- 你是 PicoBot,一名务实、可靠的通用助理。\n- 你的目标是理解用户当下的真实需求,并给出清晰、可执行的帮助。\n\n## 工作方式\n- 优先理解意图,再给出回应或行动。\n- 保持简洁、准确、自然,不故作热情,也不空泛铺陈。\n- 能直接验证的内容尽量先验证,避免凭空猜测。\n- 当现有工具是完成任务的最直接方式时,优先使用工具。\n- 除非用户明确要求改变方向,否则保持用户原本目标不变。\n\n## 助理原则\n- 优先解决问题,而不是展示过程。\n- 输出要方便用户立即使用,结论尽量明确。\n- 对不确定的地方要直说,不把猜测包装成事实。\n- 复杂任务先收敛重点,简单任务直接给结果。\n- 避免不必要的重复、客套和冗长说明。\n\n## 回复规则\n- 除非用户另有要求,否则使用中文回复。\n- 默认短而清楚,按信息密度组织内容。\n- 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。\n- 如果存在限制、风险或前提条件,要直接说明。\n\n## 补充要求\n- 你是 PicoBot。\n- 回答应以帮助用户完成当前目标为中心。\n- 在信息不足时先补关键前提,在信息充分时直接执行。\n"; -const MEMORY_KEYWORD_SYSTEM_PROMPT: &str = "你负责为长期记忆检索生成关键词。根据给定的最近会话,仅输出 JSON 数组字符串。关键词必须同时覆盖中文和英文:优先为同一主题同时给出中文关键词和对应英文关键词。关键词必须是短词语,优先使用最容易命中记忆的核心检索词,不要输出完整句子、解释或长描述。必要时优先保留实体名、产品名、偏好名、snake_case key 风格短词。数组元素总数控制在 2 到 6 个简短关键词或短语。不要输出解释,不要输出 Markdown。如果最新的会话跟前面有明显主题变化,且新主题可能与旧主题的关键词不同,优先输出新主题的关键词;如果最新会话是旧主题的延续,优先输出旧主题的关键词;如果最新会话同时包含新旧主题,优先输出同时覆盖新旧主题的关键词。"; -const RELATED_MEMORY_SYSTEM_PROMPT_PREFIX: &str = "找到相关的记忆。你必须优先参考这些记忆,并在后续推理中把它们当作当前会话的补充上下文;若与用户本轮明确要求冲突,以用户本轮要求为准。"; -const MEMORY_KEYWORD_REASONING_EFFORT: &str = "none"; -const MEMORY_KEYWORD_MAX_CHARS: usize = 32; /// Session 按 channel 隔离,每个 channel 一个 Session /// History 按 chat_id 隔离,由 Session 统一管理 @@ -627,43 +622,7 @@ impl SessionManager { let user_message_id = user_message.id.clone(); session_guard.append_persisted_message(chat_id, user_message)?; - // 获取完整历史 - let mut history = session_guard.get_or_create_history(chat_id).clone(); - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - history_len = history.len(), - "Starting synchronous related memory search" - ); - if let Some(memory_prompt) = build_related_memory_prompt( - session_guard.provider_config().clone(), - self.store.clone(), - channel_name.to_string(), - sender_id.to_string(), - chat_id.to_string(), - history.clone(), - ) - .await? - { - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - prompt_len = memory_prompt.len(), - "Injecting related memory system prompt before agent processing" - ); - let memory_message = ChatMessage::system(memory_prompt); - session_guard.append_persisted_message(chat_id, memory_message.clone())?; - history.push(memory_message); - } else { - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - "No related memory prompt generated before agent processing" - ); - } + let history = session_guard.get_or_create_history(chat_id).clone(); // 压缩历史(如果需要) let history = session_guard.compressor @@ -719,233 +678,6 @@ impl SessionManager { } } -async fn build_related_memory_prompt( - provider_config: LLMProviderConfig, - store: Arc, - channel_name: String, - sender_id: String, - chat_id: String, - history: Vec, -) -> Result, AgentError> { - let keywords = generate_memory_search_keywords(provider_config, &history).await?; - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - keyword_count = keywords.len(), - keywords = ?keywords, - "Generated memory search keywords" - ); - if keywords.is_empty() { - return Ok(None); - } - - let memories = search_related_memories( - store, - &channel_name, - &sender_id, - &chat_id, - &keywords, - ) - .await?; - - if memories.is_empty() { - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - keyword_count = keywords.len(), - "Related memory search returned no matches" - ); - return Ok(None); - } - - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - keyword_count = keywords.len(), - memory_count = memories.len(), - "Related memory search produced matches" - ); - - Ok(Some(format_related_memory_system_prompt(&keywords, &memories))) -} - -async fn generate_memory_search_keywords( - mut provider_config: LLMProviderConfig, - history: &[ChatMessage], -) -> Result, AgentError> { - if provider_config.provider_type == "openai" { - provider_config.model_extra.insert( - "reasoning_effort".to_string(), - serde_json::Value::String(MEMORY_KEYWORD_REASONING_EFFORT.to_string()), - ); - } - - let provider = create_provider(provider_config) - .map_err(|err| AgentError::ProviderCreation(err.to_string()))?; - - let request = ChatCompletionRequest { - messages: vec![ - Message::system(MEMORY_KEYWORD_SYSTEM_PROMPT), - Message::user(build_memory_keyword_context(history)), - ], - temperature: Some(0.0), - max_tokens: Some(128), - tools: None, - }; - - let response = provider - .chat(request) - .await - .map_err(|err| AgentError::LlmError(err.to_string()))?; - - Ok(parse_memory_keywords(&response.content)) -} - -fn build_memory_keyword_context(history: &[ChatMessage]) -> String { - let recent_messages: Vec<&ChatMessage> = history - .iter() - .rev() - .filter(|message| message.role != "system") - .take(8) - .collect::>() - .into_iter() - .rev() - .collect(); - - let mut lines = vec!["请基于以下最近会话生成长期记忆搜索关键词:".to_string()]; - for message in recent_messages { - let content = message.content.trim(); - if content.is_empty() { - continue; - } - - let compact = if content.chars().count() > 240 { - let prefix: String = content.chars().take(240).collect(); - format!("{}...", prefix) - } else { - content.to_string() - }; - lines.push(format!("{}: {}", message.role, compact)); - } - - lines.join("\n") -} - -fn parse_memory_keywords(raw: &str) -> Vec { - if let Ok(keywords) = serde_json::from_str::>(raw) { - return normalize_keywords(keywords); - } - - normalize_keywords( - raw.split(|ch| matches!(ch, '\n' | ',' | ',' | ';' | ';')) - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(ToOwned::to_owned) - .collect(), - ) -} - -fn normalize_keywords(keywords: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut normalized = Vec::new(); - - for keyword in keywords { - let candidate = keyword - .trim() - .trim_matches('"') - .trim_matches('[') - .trim_matches(']') - .trim() - .to_string(); - let candidate = compact_memory_keyword(&candidate); - if candidate.is_empty() { - continue; - } - - let key = candidate.to_lowercase(); - if seen.insert(key) { - normalized.push(candidate); - } - - if normalized.len() >= 6 { - break; - } - } - - normalized -} - -fn compact_memory_keyword(candidate: &str) -> String { - let compact = candidate - .split_whitespace() - .next() - .unwrap_or(candidate) - .trim_matches(|ch: char| matches!(ch, '"' | '\'' | '[' | ']' | '。' | ',' | ',' | ';' | ';' | ':' | ':')) - .trim(); - - if compact.is_empty() { - return String::new(); - } - - compact.chars().take(MEMORY_KEYWORD_MAX_CHARS).collect() -} - -async fn search_related_memories( - store: Arc, - channel_name: &str, - sender_id: &str, - chat_id: &str, - keywords: &[String], -) -> Result, AgentError> { - tracing::debug!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - keyword_count = keywords.len(), - keywords = ?keywords, - "Searching related memories with a single batched FTS query" - ); - - let scope_key = format!("{}:{}", channel_name, sender_id); - let merged = store - .search_memories_any("user", &scope_key, keywords, None, 10) - .map_err(|err| AgentError::Other(format!("batched memory search error: {}", err)))?; - - tracing::info!( - channel = %channel_name, - chat_id = %chat_id, - sender_id = %sender_id, - keyword_count = keywords.len(), - deduped_memory_count = merged.len(), - "Finished related memory search aggregation" - ); - - Ok(merged) -} - -fn format_related_memory_system_prompt(keywords: &[String], memories: &[MemoryRecord]) -> String { - let mut lines = vec![ - RELATED_MEMORY_SYSTEM_PROMPT_PREFIX.to_string(), - format!("检索关键词: {}", keywords.join(" / ")), - "相关记忆: ".to_string(), - ]; - - for (index, memory) in memories.iter().take(8).enumerate() { - lines.push(format!( - "{}. [{} / {}] {}", - index + 1, - memory.namespace, - memory.memory_key, - memory.content.replace('\n', " ").trim() - )); - } - - lines.join("\n") -} - #[cfg(test)] mod tests { use super::*; @@ -1162,42 +894,4 @@ mod tests { assert_eq!(history[0].role, "system"); } - #[test] - fn test_parse_memory_keywords_handles_json_and_dedup() { - let keywords = parse_memory_keywords("[\"Rust\", \"偏好\", \"rust\", \"自动化\"]"); - assert_eq!(keywords, vec!["Rust", "偏好", "自动化"]); - - let fallback = parse_memory_keywords("Rust, 偏好\n自动化"); - assert_eq!(fallback, vec!["Rust", "偏好", "自动化"]); - - let compacted = parse_memory_keywords("[\"用户 身份 信息 长描述\", \"email_folder_preference details\"]"); - assert_eq!(compacted, vec!["用户", "email_folder_preference"]); - } - - #[test] - fn test_format_related_memory_system_prompt_includes_prefix_keywords_and_memory_lines() { - let prompt = format_related_memory_system_prompt( - &["Rust 偏好".to_string(), "审批项目".to_string()], - &[MemoryRecord { - id: "memory-1".to_string(), - scope_kind: "user".to_string(), - scope_key: "feishu:user-1".to_string(), - namespace: "profile".to_string(), - memory_key: "language".to_string(), - content: "用户偏好 Rust 和自动化工具".to_string(), - source_type: "message".to_string(), - source_session_id: Some("feishu:chat-1".to_string()), - source_message_id: Some("msg-1".to_string()), - source_message_seq: Some(1), - source_channel_name: Some("feishu".to_string()), - source_chat_id: Some("chat-1".to_string()), - created_at: 1, - updated_at: 1, - }], - ); - - assert!(prompt.contains("找到相关的记忆")); - assert!(prompt.contains("Rust 偏好 / 审批项目")); - assert!(prompt.contains("[profile / language] 用户偏好 Rust 和自动化工具")); - } }