feat(sdk): RecallPolicy —— 声明工具输出是否触发记忆召回

与 ContextPolicy **正交**,但默认值刻意相反:

| | 管什么 | 默认 |
|---|---|---|
| ContextPolicy | **裁剪**:把低相关 L0 事件归档 | 关(剪裁是破坏性的,须显式声明) |
| RecallPolicy | **召回**:把 L2/L3 相关记忆注入本轮 | 输入/注入 `auto`,**工具 `none`** |

工具默认 none 的理由:多数工具输出是噪声,据它召回会把无关记忆拉进来
(源码注释原文)。需要「取回真实内容后据它召回」的工具才显式声明 auto。

## qq 的落地(本改动想解决的具体问题)

qq 通道到达的是**中断通知(meta)**而不是用户正文。原先用这条 meta 文本
去触发召回 —— 那是无关词,召不回真正相关的东西。改为:

- 通道声明 `RecallPolicy: none`(meta 不该据它召回)
- `qq_get_message` 声明 `auto`:消息正文取回后**由正文**触发召回
- `qq_get_history` 同样 `auto` + `prune`:拉回的历史消息既用完即裁、
  又据正文召回(否则有「记忆里有、但拉历史时不注入」的盲区)

注:本实现已先随主仓 vendored 副本进入 main(两份经 diff 校验字节一致,
0 行差异),此处是把 SDK 仓自身补齐,使两仓 HEAD 对齐。
This commit is contained in:
JianFeeeee
2026-09-20 09:15:34 +08:00
parent db207fd9b7
commit 7717bf5ca5
2 changed files with 48 additions and 3 deletions

View File

@ -439,7 +439,9 @@ type 枚举: text文字/ voice语音转文字后发送/ image
}
return cleaned
}
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
// qq 通道到达的是**中断通知meta**,不是用户正文,不据它召回;
// 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone})
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
cleaner := func(output string) string {
@ -459,6 +461,9 @@ type 枚举: text文字/ voice语音转文字后发送/ image
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
// 长会话下持续挤占 token 预算§13.8)。
ContextPolicy: "prune",
// 正文才是真实内容:取回后用**正文**触发一次召回,
// 而不是用中断通知的 meta 文本去召回(那是无关词)。
RecallPolicy: "auto",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
@ -484,6 +489,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name可用 qq_download_file 工具下载。",
NoMemory: false,
Cleaner: cleaner,
// 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要,
// 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占
// L0又据正文召回取进来。不声明 recall 的话就是「记忆里有、但
// 拉回历史消息时不注入」的盲区。
ContextPolicy: "prune",
RecallPolicy: "auto",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
@ -1482,6 +1493,8 @@ func (p *Plugin) injectInterrupt(text, level string) {
p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{
NoMemory: true,
Priority: level,
// 中断文本是路由/取正文的指令,不是对话内容,不据它召回。
RecallPolicy: sdk.RecallPolicyNone,
})
}
@ -2802,7 +2815,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
// Priority同上QQ 侧一律低级别中断L1
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone})
}
} else {
errMsg = "下载失败,文件可能已过期"