fix(memory): 把插件的 NoMemory 声明落到事件上

声明链此前断在最后一步:
  InjectOptions.NoMemory / ChannelDef.NoMemory
    → payload["no_memory"] → stageCtx.NoMemory   ✅ 已合成
    → ContextEvent.NoMemory                      ❌ 从未赋值

后果:QQ 那句取件样板("来自「X」的私聊消息(message_id=…) 先用
qq_get_message 取正文…")每轮都进计算层(向量化/关键词/蒸馏),
而它是指令不是内容 —— 真实对话内容在记忆里被它挤薄。

按字段**既有契约**处置(context.go 的注释写明):只影响计算层,
归档照常。理由是同一条注释里的 2026-10-22 教训 —— coldcontext_query
的 Consume 已从 L2 删除原文,回填事件必须能落回 L2,否则三层全无。

判据:nomemory_flag_test.go
(NoMemory 事件 textForVector 为空但 Input/Response 保留;普通事件不受影响)
This commit is contained in:
JianFeeeee
2026-10-27 15:40:43 +08:00
parent 14daf5b584
commit 20cf138d3b
3 changed files with 83 additions and 0 deletions

View File

@ -410,6 +410,15 @@ func (c *RelevanceContext) Save() error {
// (另一处 InsertByTimestamp 同样处理)。所以明文拦截放在这里, // (另一处 InsertByTimestamp 同样处理)。所以明文拦截放在这里,
// 而不是在每个调用点 —— 逐个补必漏(实测踩到:只拦了 f.Msgs, // 而不是在每个调用点 —— 逐个补必漏(实测踩到:只拦了 f.Msgs,
// 而 f.CleanInput / f.ToolResults / f.Response 都是独立副本)。 // 而 f.CleanInput / f.ToolResults / f.Response 都是独立副本)。
// snapshotEvents 返回当前事件的浅拷贝(判据/诊断用;调用方不得修改元素)。
func (c *RelevanceContext) snapshotEvents() []*ContextEvent {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]*ContextEvent, len(c.events))
copy(out, c.events)
return out
}
func (c *RelevanceContext) Append(evt ContextEvent) { func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()

View File

@ -0,0 +1,61 @@
package core
// 插件的 `NoMemory` 声明必须落到**事件**上(2026-10-27)。
//
// 声明链:InjectOptions.NoMemory / ChannelDef.NoMemory
// → payload["no_memory"] → stageCtx.NoMemory → ContextEvent.NoMemory
//
// 此前最后一步从来没发生:`ContextEvent.NoMemory` 只有在 coldcontext_query
// 回填时才被置 true,于是 QQ 那句取件样板
//("来自「X」的私聊消息(message_id=…) 先用 qq_get_message 取正文…")
// 每轮都进计算层(向量化 / 关键词 / 蒸馏)。
//
// 判据(按字段既有契约,不是新增语义):
// ① NoMemory 事件的 textForVector 为空(不进计算层);
// ② 但事件**照常归档**(Input/Response 原样保留,能从 L1 落回 L2);
// ③ 非 NoMemory 事件不受影响。
import (
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func newNoMemFlagContext() *RelevanceContext {
return NewRelevanceContext("", memory.NewStaticEmbedder(""))
}
func TestNoMemoryEventSkipsComputationButKeepsArchiveFields(t *testing.T) {
rc := newNoMemFlagContext()
ev := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
NoMemory: true,
Input: "来自「—/」的私聊消息(message_id=1)。先用qq_get_message取正文",
Response: "收到。",
}
rc.Append(ev)
got := rc.snapshotEvents()
if len(got) != 1 {
t.Fatalf("应写入一条事件,实际 %d", len(got))
}
// ① 计算层:不进向量
if txt := textForVector(got[0], nil, nil); txt != "" {
t.Fatalf("NoMemory 事件不得进入计算层,实际 textForVector=%q", txt)
}
// ② 归档字段保留(否则 L2 回放闭环断掉)
if got[0].Input == "" || got[0].Response == "" {
t.Fatalf("归档字段必须保留,实际 Input=%q Response=%q", got[0].Input, got[0].Response)
}
}
func TestNormalEventStillEntersComputation(t *testing.T) {
rc := newNoMemFlagContext()
rc.Append(ContextEvent{Timestamp: time.Now(), Source: "webui", Input: "帮我看下日志"})
got := rc.snapshotEvents()
if txt := textForVector(got[0], nil, nil); txt == "" {
t.Fatal("普通事件必须进入计算层")
}
}

View File

@ -558,9 +558,22 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调 // 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。 // multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。
// ★ 把插件声明落到事件上(2026-10-27):`NoMemory` 只影响**计算层**
//(不向量化 / 不提关键词 / 不蒸馏)与**记忆视图**,而**归档照常** ——
// 这是 ContextEvent.NoMemory 的既有契约(见 context.go 的字段注释:
// coldcontext_query 的 Consume 已从 L2 删掉原文,回填事件必须能落回 L2,
// 否则三层都不在,内容被永久丢弃)。
//
// 此前这个位从来没被赋值:插件声明 InjectOptions.NoMemory=true(QQ 那句
// "来自「X」的私聊消息(message_id=…) 先用 qq_get_message 取正文…"),
// 经 payload.no_memory 合进 stageCtx.NoMemory 后,却只有
// emitMemoryCandidate 一处消费它 —— 于是这句样板每轮都进计算层。
//
// 声明来源:payload["no_memory"](插件注入)或 ChannelDef.NoMemory(通道)。
turnEvt := ContextEvent{ turnEvt := ContextEvent{
Timestamp: time.Now(), Timestamp: time.Now(),
Source: "agent", Source: "agent",
NoMemory: f.StageCtx.NoMemory,
Input: f.CleanInput, Input: f.CleanInput,
Response: f.Response, Response: f.Response,
ToolsUsed: f.ToolsUsed, ToolsUsed: f.ToolsUsed,