feat: 更新长期记忆工具提示,增强用户使用指导
This commit is contained in:
parent
71a8033d15
commit
0331774466
@ -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 {
|
||||
|
||||
@ -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<SessionStore>,
|
||||
channel_name: String,
|
||||
sender_id: String,
|
||||
chat_id: String,
|
||||
history: Vec<ChatMessage>,
|
||||
) -> Result<Option<String>, 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<Vec<String>, 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::<Vec<_>>()
|
||||
.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<String> {
|
||||
if let Ok(keywords) = serde_json::from_str::<Vec<String>>(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<String>) -> Vec<String> {
|
||||
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<SessionStore>,
|
||||
channel_name: &str,
|
||||
sender_id: &str,
|
||||
chat_id: &str,
|
||||
keywords: &[String],
|
||||
) -> Result<Vec<MemoryRecord>, 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 和自动化工具"));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user