From 3a7440a2fea392f3f9242e94a7593301d4ed3522 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 27 Oct 2026 15:02:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E4=B8=AD=E6=96=AD=E7=9A=84?= =?UTF-8?q?=E5=8F=96=E4=BB=B6=E6=A0=B7=E6=9D=BF=E4=B8=8D=E5=86=8D=E5=86=99?= =?UTF-8?q?=E5=85=A5=20L1=EF=BC=88=E9=A2=97=E7=B2=92=E5=BA=A6=3D=E5=8D=95?= =?UTF-8?q?=E6=9D=A1=E6=B6=88=E6=81=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户口径(反复强调):进记忆的禁忌是**中断消息本身**,不是整轮。 此前 finishInputTask 无条件把 CleanInput 写进事件,而中断轮的 CleanInput 是 "来自「X」的私聊消息(message_id=…) 先用 qq_get_message 取正文…" 这类样板: 每轮一字不差堆进 L1,真实对话内容只能靠工具回执碎片体现 —— 实测用户回一句 "233719",模型反问"那串数字什么意思"(指代消解失效)。 改法:只清样板,不清内容。 - isInjectionBoilerplate 识别插件取件指令("来自「…」…message_id=" 或 "先用…取正文"), 命中则 Input 置空; - 本轮的工具回执(qq_get_message 正文,NoMemory=false)、agent 回复照常入库; - 内核中断正文不清空:测试 D1=B 要求「中断任务的上下文加载回被抢占任务之上」。 判据:internal/agent/core/interrupt_input_memory_test.go(样板被清、内核中断保留、 普通输入不受影响、回执与回复保留) --- internal/agent/core/context.go | 9 +++ .../agent/core/interrupt_input_memory_test.go | 74 +++++++++++++++++++ internal/agent/core/task.go | 35 ++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 internal/agent/core/interrupt_input_memory_test.go diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 5be10da2..dfd803b8 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -410,6 +410,15 @@ func (c *RelevanceContext) Save() error { // (另一处 InsertByTimestamp 同样处理)。所以明文拦截放在这里, // 而不是在每个调用点 —— 逐个补必漏(实测踩到:只拦了 f.Msgs, // 而 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) { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/agent/core/interrupt_input_memory_test.go b/internal/agent/core/interrupt_input_memory_test.go new file mode 100644 index 00000000..ef9c1f34 --- /dev/null +++ b/internal/agent/core/interrupt_input_memory_test.go @@ -0,0 +1,74 @@ +package core + +// 中断**消息本身**不得进入记忆(用户口径,2026-10-27,颗粒度=单条消息)。 +// +// 中断的 CleanInput 是触发文本("来自「X」的私聊消息(message_id=…)先用 +// qq_get_message 取正文…"),它是指令不是用户的话。存进 L1 的后果实测: +// 时间线里每轮都是同一句样板,真正的对话内容只剩工具回执碎片 —— +// 用户回一句 "233719",模型却问"那串数字什么意思"(指代消解失效)。 +// +// 但**本轮的工具回执与回复必须照常入库**:qq_get_message 的正文各自声明 +// NoMemory=false,那才是要记的内容。判据就是这两条一起成立。 + +import ( + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +// newInterruptTestContext 建一个不落盘的 L1(判据只看内存态)。 +func newInterruptTestContext() *RelevanceContext { + return NewRelevanceContext("", memory.NewStaticEmbedder("")) +} + +func TestInterruptTriggerTextNotStoredButToolResultsAre(t *testing.T) { + rc := newInterruptTestContext() + turn := ContextEvent{ + Timestamp: time.Now(), + Source: "agent", + Input: "", // finishInputTask 对中断轮置空 + Response: "已经回你了。", + ToolResults: []ToolResultItem{{Name: "qq_get_message", Output: `{"content":"233719"}`}}, + } + rc.Append(turn) + + got := rc.snapshotEvents() + if len(got) != 1 { + t.Fatalf("应写入一条事件,实际 %d", len(got)) + } + if got[0].Input != "" { + t.Fatalf("中断触发文本不得入库,实际 Input=%q", got[0].Input) + } + if len(got[0].ToolResults) != 1 { + t.Fatalf("工具回执必须保留(内容侧),实际 %+v", got[0].ToolResults) + } + if got[0].Response == "" { + t.Fatal("agent 回复应保留") + } +} + +// 只清"取件指令样板",内核中断的正文必须保留(测试 D1=B:中断上下文要 +// 加载回被抢占任务之上)。 +func TestInjectionBoilerplateOnly(t *testing.T) { + boiler := "来自「—/」的私聊消息(message_id=814968683, user_id=2198972886)。先用qq_get_message(message_id=814968683)取正文;若取不到(消息已过期),改用qq_get_history(user_id=2198972886)按会话拉取上下文。用output_send__qq回复对方" + if !isInjectionBoilerplate(boiler) { + t.Fatal("插件注入的取件样板应被识别") + } + if isInjectionBoilerplate("紧急打断:立刻停下手上的活") { + t.Fatal("内核中断的正文不是样板,不得清空") + } + if isInjectionBoilerplate("帮我看下日志") { + t.Fatal("普通输入不得被判成样板") + } +} + +// 非中断轮必须原样保留用户输入(不能被同一条规则误伤)。 +func TestNormalTurnKeepsInput(t *testing.T) { + rc := newInterruptTestContext() + rc.Append(ContextEvent{Timestamp: time.Now(), Source: "webui", Input: "帮我看下日志"}) + got := rc.snapshotEvents() + if len(got) != 1 || got[0].Input != "帮我看下日志" { + t.Fatalf("普通轮输入不能被清空,实际 %+v", got) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index adf6b392..9728210a 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -528,6 +528,26 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi return f, terminalNone } +// isInjectionBoilerplate 判断一段中断文本是否只是**取件指令样板**。 +// +// 为什么不是"所有中断都清空":内核中断(如 overflow 抢占、用户 L4 打断)的 +// 正文本身是有意义的内容,测试 D1=B 明确要求「中断任务的上下文要加载回被抢占 +// 任务之上」。真正污染记忆的是插件注入的那句样板: +// +// 来自「X」的私聊消息(message_id=…) 先用 qq_get_message 取正文;若取不到… +// +// 它每轮一字不差,存进 L1 只会把真实内容挤掉(实测导致指代消解失效)。 +func isInjectionBoilerplate(text string) bool { + t := strings.TrimSpace(text) + if t == "" { + return false + } + if strings.HasPrefix(t, "来自「") && strings.Contains(t, "message_id=") { + return true + } + return strings.Contains(t, "取正文") && strings.Contains(t, "先用") +} + // finishInputTask 执行 processInput 的后半段(日志、上下文提交、回执、记忆候选)。 // // 只在任务真正结束时调用一次——这正是不变量 I5(每任务恰一次终态)的落点。 @@ -558,10 +578,23 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { // 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调 // multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。 + // ★ 中断**消息本身**不进记忆(2026-10-27 用户口径,颗粒度=单条消息,不是单轮): + // 中断的 CleanInput 是"来自「X」的私聊消息(message_id=…)先用 qq_get_message 取正文…" + // 这类触发文本 —— 它是给模型的指令,不是用户说的话。存进 L1 的后果: + // a) 记忆里堆满同一句样板(每轮一字不差),时间线里全是它; + // b) 真正的对话内容反而只能靠工具回执片段体现 → 指代消解失效 + // (实测:用户回"233719",模型问"那串数字什么意思")。 + // + // 所以只清掉**这条消息**:Input 置空,而本轮的工具回执(qq_get_message 正文等, + // 它们各自声明 NoMemory=false)与 agent 的回复照常入库 —— 内容该记的照记。 + input := f.CleanInput + if f.IsInterrupt && isInjectionBoilerplate(input) { + input = "" + } turnEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", - Input: f.CleanInput, + Input: input, Response: f.Response, ToolsUsed: f.ToolsUsed, ToolResults: f.ToolResults,