fix(memory): 回填事件必须照常归档 —— 纠正 NoMemory 语义被错误扩大到存储层

用户指出:doc 召回部分需要回填记忆,因为 doc 召回后原文在 doc 层已经删除了。

## 我上一轮的错误(PR #22)

把「工具输出不进记忆」与「回填事件不归档」塞进了同一个 NoMemory 标记:

    doc_query → Consume(L2 副本删除)→ 回填 L1(标记 NoMemory)
              → L1 裁掉它 → Prune 跳过归档(错误实现)
              → 三层都不在 ⇒ 内容永久丢失

Consume 已经把 L2 副本删掉,**L1 是唯一副本**。裁掉时不归档 = 丢数据。
这不是自激防护,这是把数据丢掉当成防护。

## 正确区分(两件事不能共用一个标记)

| 对象 | 进计算层 | 进归档 | 理由 |
|---|---|---|---|
| doc_query 的**工具返回值**(正文摘录) | ❌ | ❌ | 它是事件的摘录副本,再向量化 = 重复注入 ⇒ 自激 |
| **回填的事件本身**(原文) | ❌ | ✅ **必须** | L2 副本已删,L1 是唯一副本;不归档即丢失 |

## 修法

ContextEvent.NoMemory 语义定为「**不参与计算,照常归档**」,
与 ToolDef.NoMemory 一致:只影响计算层,不影响存储层。

自激的入口在"重新计算向量"那一侧(textForVector 返回空),
不在"归档"这一侧 —— 删掉 Prune 里的跳过逻辑。

## 判据(改写了那条断言错误行为的判据)

TestSelfExcite_RestoredEventsNotReArchived 现在断言三件事:
1. 回填事件带 NoMemory 标记且未被向量化(不参与计算)
2. 前提成立:L2 里确实已无该事件(Consume 删掉了)
3. ★ 被裁掉后必须能落回 L2(否则内容永久丢失)

变异验证:
- Prune 重新跳过 NoMemory ⇒ 红,报「三层都不在,内容永久丢失」
- restoreDocEvents 不置 NoMemory ⇒ 红(会重复加权)

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
This commit is contained in:
JianFeeeee
2026-10-22 20:37:05 +08:00
parent 41921c0b6d
commit 6bea130639
4 changed files with 108 additions and 51 deletions

View File

@ -327,9 +327,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
// 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。
// 文案必须让模型知道该做什么,而不是含糊的"执行失败"——
// 后者会让模型反复重试同一个不存在的名字。
return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+
"先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+
"确认名称无误后再调用", tc.Name)}
// ★ 提示必须**先指向 skill**(2026-10-22)。
//
// 按需插件(auto_start=false)的工具不在初始清单里,而它的
// skill 正是"这个插件是什么、要怎么用、需要先启动什么"的唯一
// 说明(SDK autostart.go 的 OnDemandNeedsSkill 就是这条契约)。
//
// 此前这里只提 get_plugin_tools / output_list_channels,
// 一个字没提 skill —— 模型调用了、失败了,提示却不告诉它去
// 看说明书,于是只能盲目试工具名。
return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册(它可能属于未加载的插件)。按这个顺序处理:\n"+
"1. 先看【可用技能】里有没有相关的:有就先 skill_info(\"技能名\") 读全文 —— "+
"skill 里写着该插件的正确用法、已知坑,以及需要先启动哪个插件;\n"+
"2. 没有相关 skill 就调 get_plugin_tools(\"\") 查当前可用的插件与工具;\n"+
"3. 通道相关的不确定时用 output_list_channels 看有哪些通道。\n"+
"确认之后再调用,不要反复重试同一个名字。", tc.Name)}
}
return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)}
}
@ -1305,13 +1317,15 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
Source: ev.Source,
Input: input,
Response: response,
// ★ 回填事件**不参与记忆计算、不再归档回 L2**。
// ★ 回填事件**不参与记忆计算**,但**照常参与归档**。
//
// 它们本来就在记忆体系里(来自 L2,可能已蒸馏进 L3)。
// 不标记的后果是自激震荡:
// doc_query 从 L2 取出 → Prune 因相关而保留它们、
// 把真正的新事件挤掉 → 下一轮 Prune 又归档回 L2 →
// 再被 doc_query 召回 ⇒ 块在 L1↔L2 之间反复搬迁。
// 为什么要标记:它们的内容刚在 L2 里被向量化过一次,
// 再算一次会让同一内容在向量空间里被重复加权(自激)。
//
// ★★ 为什么仍要归档(不能顺手关掉):
// Consume 已经把 L2 副本删了,L1 是**唯一副本**。
// 裁掉时不归档 = 三层都不在 = 内容永久丢失。
// (这个错犯过:曾用同一个 NoMemory 同时关掉计算与归档。)
NoMemory: true,
ToolsUsed: append([]string(nil), ev.ToolsUsed...),
ToolResults: func() []ToolResultItem {