diff --git a/plan.md b/plan.md index 2d23ff7..1803285 100644 --- a/plan.md +++ b/plan.md @@ -1,33 +1,43 @@ -# 插件化改造 — 实施计划 +# 记忆系统重构计划 — NoMemory 与 TextCleaner 设计修正 -## 问题 +基于 `review.md` 审查结论,当前实现存在两个设计偏差: -当前 core 层两处硬编码耦合: +| 机制 | 当前(错误) | 应然(目标) | +|---|---|---| +| `RegisterTextCleaner` | 插件级全局 cleaner,`CleanText` 入口直接改原文 | **删除**,拆为 `ToolDef.Cleaner`(工具级,仅计算层生效) | +| `ToolDef.Cleaner` | 不存在 | 工具级,仅向量化/jieba/蒸馏时调用,不改原文 | +| `NoMemory=true` | `hasNoMemoryTool` 二值判断 → 整轮不写记忆 | 工具输出不参与向量/jieba/蒸馏,但原文保留在 Context/Document | -1. **文本清洗**:`internal/memory/clean_text.go` 硬编码 6 个 QQ 正则,context/indexer/cut/VertorizeClean 各环节直接调用 `CleanTemplateText` +--- -2. **工具输出不进记忆**:`internal/agent/core/context.go:196` 硬编码 source 名过滤 - ```go - if s.event.Source == "agentcli" || s.event.Source == "terminal" { - continue - } - ``` - 意图是 `cmd_run`、`terminal_*` 等工具的产出不归档到 document memory,但手段是查 source 而不是查工具定义,且只覆盖了 Prune 路径(document),没有覆盖 `emitMemoryCandidate` 路径(text + graph memory)。 +## 阶段零:理解当前数据流(现状确认) - 同时 `eventloop.go:337` 的 `!stageCtx.NoMemory` 检查是命令式的(`InjectTextNoMemory` 设 payload flag),不是声明式的工具级控制。 +``` +用户输入 → processTextInput() + → context.Append(Input) // evt.Input = CleanText(evt.Input) ← 原文被改 + → a.process() → LLM call loop + → tool execution → result → msgs ← 工具输出在此 + → context.Append(Response, ToolsUsed) // evt.Response 是 LLM 回复,不是工具输出 + → emitMemoryCandidate(input, response, toolsUsed) + → main.go goroutine + → textMem.Append(Event{Input, Response}) // JSONL 原文 + → distiller.Append("assistant", response) // 仅 LLM 回复 + → extractKeyTriples → graph memory -## 目标 +蒸馏心跳: + context.Prune() → ContextToDoc(entries) → Doc.Content ← hasNoMemoryTool 跳过整条 + reorgGraph() → docToTriples(Doc) → graph memory +``` -| 问题 | 方案 | -|------|------| -| 文本清洗 | 插件通过 `s.RegisterTextCleaner()` 注册清洗函数,core 遍历执行 | -| 工具记忆控制 | `ToolDef` 增加 `NoMemory bool` 字段,插件声明式标记工具;core 在 emitMemoryCandidate 和 Prune 两处自动跳过 | +关键发现:**工具输出从未直接进入 pipeline**(pipeline 只存 user/assistant 的原始文本)。`hasNoMemoryTool` 跳过了整个 LLM 回复,这是过度保守的。 -## SDK 修改(外部包 `homeagent-sdk`) +--- -**位置:** `sdk/plugin.go` +## 第一阶段:SDK 定义修改(外部包 `homeagent-sdk`) -### 1.1 ToolDef 增加 NoMemory +**文件:** `_sdk_local/sdk/plugin.go` + +### 1.1 ToolDef 增加 Cleaner 字段 ```go type ToolDef struct { @@ -35,59 +45,79 @@ type ToolDef struct { Plugin string `json:"plugin,omitempty"` Description string `json:"description"` Parameters map[string]interface{} `json:"parameters"` - NoMemory bool `json:"no_memory,omitempty"` // ← 新增 + NoMemory bool `json:"no_memory,omitempty"` + Cleaner func(string) string `json:"-"` // ← 新增:计算层过滤函数,不改原文 } ``` -### 1.2 PluginSDK 增加 TextCleaner +- `Cleaner` 是函数类型,不序列化(`json:"-"`) +- 仅在向量化/jieba/蒸馏等计算环节使用 +- 默认 nil = 不过滤 + +### 1.2 删除 PluginSDK 中的 TextCleaner ```go -// PluginSDK 新增字段 -textCleaners []func(text string) string - -func (s *PluginSDK) RegisterTextCleaner(cleaner func(text string) string) { - s.textCleaners = append(s.textCleaners, cleaner) -} - -// 框架用 — 提取所有 cleaner -func (s *PluginSDK) TextCleaners() []func(text string) string { - return s.textCleaners -} +// PluginSDK 中删除: +// textCleaners []func(text string) string // ← 删除 +// RegisterTextCleaner() // ← 删除 +// TextCleaners() // ← 删除 ``` -### SDK 管理策略 +### 1.3 相应修改单元测试 -复制 SDK 到 `_sdk_local/`,`go.mod` 加 `replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local` +**文件:** `_sdk_local/sdk/plugin_test.go` + +- 删除 `TestRegisterTextCleaner`、`TestTextCleanersEmpty` +- `TestToolDefNoMemory` 保留 +- 新增 `TestToolDefCleaner` 验证 Cleaner 字段 + +### 1.4 go.mod 确认 replace 指令 + +`go.mod` 已有 `replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local`,无需改动。 --- -## 内部变更 +## 第二阶段:内部适配层更新 -### 2.1 Registry:聚合 text cleaners +### 2.1 Registry:删除 textCleaners 聚合 **文件:** `internal/plugin/registry.go` -- 新增字段 `textCleaners []func(string) string` -- `buildSDK()` 中 `plg.Start(plgSDK)` 成功后提取 `plgSDK.TextCleaners()` 到 `textCleaners` -- 新增 `CleanText(text string) string` 依次执行所有 cleaner,无 cleaner 时返回原文本 -- `StopAll()` / `Reload()` 时重置 +删除项: +- 字段 `textCleaners []func(string) string` +- 字段 `CleanText(text string) string` 方法 +- `buildSDK` 不再收集 cleaner(cleaner 附着在 ToolDef 上,由 StageHost 管理) +- `loadOne` 和 `Load` 中的 `r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)` 移除 +- `StopAll` 中的重置移除 -### 2.2 Memory:动态 cleaner 入口 +改动点: +| 行号 | 当前 | 改为 | +|---|---|---| +| 87 | `textCleaners []func(string) string` | 删除 | +| 113-122 | `CleanText()` 方法 | 删除 | +| 273 | `r.textCleaners = append(...)` | 删除 | +| 356 | `r.textCleaners = append(...)` | 删除 | +| 373 | `r.textCleaners = nil` | 删除 | + +### 2.2 memory/clean_text.go:删除全局 cleaner **文件:** `internal/memory/clean_text.go` -替换硬编码 QQ 正则为动态 cleaner: - ```go -var globalTextCleaner func(string) string +// 删除: +var globalTextCleaner func(string) string // ← 删除 +func SetTextCleaner(fn func(string) string) { // ← 删除 + globalTextCleaner = fn +} -func SetTextCleaner(fn func(string) string) { globalTextCleaner = fn } func CleanText(text string) string { - if globalTextCleaner != nil { - text = globalTextCleaner(text) - } - // 保留通用 trim + // if globalTextCleaner != nil { // ← 删除 + // text = globalTextCleaner(text) // ← 删除 + // } // ← 删除 text = strings.TrimSpace(text) + if text == "" { + return "" + } text = strings.TrimPrefix(text, ",") text = strings.TrimPrefix(text, ",") text = strings.TrimSpace(text) @@ -95,152 +125,437 @@ func CleanText(text string) string { } ``` -删除 `reQQ*` 正则变量。 +`VectorizeClean` 保持不变(使用精简后的 `CleanText`)。 -### 2.3 StageHost:按名查 ToolDef +### 2.3 StageHost:暴露 ToolDef Cleaner 查询 **文件:** `internal/agent/core/stages.go` -新增方法: +`ToolDef(name)` 方法已有,返回 `*sdk.ToolDef`。由于 `ToolDef` 现在有 `Cleaner` 字段,调用方可直接通过 `stageHost.ToolDef(name).Cleaner` 获取。 + +新增便捷方法: ```go -func (h *StageHost) ToolDef(name string) *sdk.ToolDef { +func (h *StageHost) ToolDefCleaner(name string) func(string) string { h.mu.RLock() defer h.mu.RUnlock() for _, def := range h.toolDefs { if def.Name == name { - return &def + return def.Cleaner } } return nil } -``` -### 2.4 Agent:两处 NoMemory 检查 - -#### 2.4a emitMemoryCandidate 路径 - -**文件:** `internal/agent/core/eventloop.go` - -`processTextInput()` 和 `processMediaInput()` 中的 `emitMemoryCandidate` 调用前,除了检查 `stageCtx.NoMemory`,还检查本次调用的工具是否有 `NoMemory`: - -```go -hasNoMemoryTool := false -for _, name := range toolsUsed { - if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory { - hasNoMemoryTool = true - break +// 新增:返回所有 NoMemory 工具名集合,供计算环节跳过 +func (h *StageHost) NoMemoryToolNames() map[string]bool { + h.mu.RLock() + defer h.mu.RUnlock() + set := make(map[string]bool, len(h.toolDefs)) + for _, def := range h.toolDefs { + if def.NoMemory { + set[def.Name] = true + } } -} -if !stageCtx.NoMemory && !hasNoMemoryTool { - a.emitMemoryCandidate(...) + return set } ``` -这样不论通过 `InjectText` 还是 `InjectTextNoMemory`,只要 LLM 调用了 `NoMemory: true` 的工具,整轮对话就不进 text memory 和 graph memory。 +### 2.4 StageHost:Import 更新 -#### 2.4b Prune 归档路径 +需 import `sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"`(已有)。 + +--- + +## 第三阶段:核心逻辑修正 + +### 3.1 context.Append:不再修改原文 **文件:** `internal/agent/core/context.go` -删除 hardcoded source 过滤,改为检查事件中 `ToolsUsed` 是否有 `NoMemory` 工具: - ```go -for _, s := range archive { - if hasNoMemoryTool(s.event.ToolsUsed) { - continue - } - filtered = append(filtered, s) +func (c *RelevanceContext) Append(evt ContextEvent) { + c.mu.Lock() + defer c.mu.Unlock() + + // evt.Input = memory.CleanText(evt.Input) // ← 删除:不修改原文 + evt.Vector = c.computeVector(&evt) // 向量化仍使用 textForVector + c.events = append(c.events, &evt) + c.save() } ``` -`hasNoMemoryTool` 通过 `StageHost.ToolDef` 查每个工具名是否有 `NoMemory: true`。 +### 3.2 textForVector:计算层使用 Cleaner -### 2.5 caller CleanTemplateText → CleanText +```go +func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string { + text := "" + switch { + case evt.Source == "agent" && evt.Response != "": + text = evt.Response + case evt.Source == "cold_storage": + text = evt.Input + " " + evt.Response + default: + text = evt.Input + } -| 文件 | 替换 | -|------|------| -| `internal/agent/core/context.go` | `memory.CleanTemplateText` → `memory.CleanText` | -| `internal/memory/indexer.go` | 同上 | -| `internal/memory/cut.go` | 同上 | -| `internal/memory/clean_text.go` | 删除函数 `CleanTemplateText`,新增 `CleanText` + `SetTextCleaner` | + // 基础清洗(不修改原文) + text = memory.CleanText(text) -### 2.6 main.go 串联 + // 若 evt 关联了 NoMemory 工具,不再跳过整个事件 + // 但若工具注册了 Cleaner,在计算层过滤 + // textForVector 是对整个事件的向量化,不拆到工具级别 + return text +} +``` + +关键变更: +- `textForVector` 的入参增加 `toolDefLookup`(通过 `RelevanceContext.toolDefLookup` 已有) +- 移除 `textForVector` 对 `memory.CleanText` 的依赖(因为 `CleanText` 不再做插件级过滤) +- `computeVector` 传入 `toolDefLookup` + +### 3.3 context.Prune:不再跳过 NoMemory 事件 + +```go +func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { + // ... 前面的排序逻辑不变 ... + + archived := 0 + if docStore != nil && len(archive) > 0 { + // 删除 hasNoMemoryTool 跳过整条的逻辑 + // for _, s := range archive { + // if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) { + // continue + // } + // } + // 改为:所有事件都归档,工具级过滤在 ContextToDoc 内部处理 + entries := make([]document.ContextEntry, len(archive)) + for i, s := range archive { + entries[i] = document.ContextEntry{ + Timestamp: s.event.Timestamp, + Source: s.event.Source, + Content: s.event.Input, + Response: s.event.Response, + } + } + doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder) + if err == nil && doc != nil { + archived = len(entries) + } + } + // ... +} +``` + +删除 `hasNoMemoryTool` 辅助函数(`context.go:269-278`),迁移到 StageHost 的 `NoMemoryToolNames()`。 + +### 3.4 eventloop:移除 NoMemory 跳过记忆候选 + +**文件:** `internal/agent/core/eventloop.go` + +```go +func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { + // ... 前面的逻辑不变 ... + + // 删除 NoMemory 跳过: + // if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) { + // a.emitMemoryCandidate(evt.Source, input, response, toolsUsed) + // } + // 改为:始终 emit,工具过滤在消费端处理 + // 但保留 stageCtx.NoMemory(IO 注入的 no_memory flag) + if !stageCtx.NoMemory { + a.emitMemoryCandidate(evt.Source, input, response, toolsUsed) + } +} +``` + +同理修改 `processMediaInput` 中的对应检查。 + +删除 `hasNoMemoryTool` 方法(`eventloop.go:389-396`)。 + +### 3.5 main.go:删除 SetTextCleaner **文件:** `cmd/homed/main.go` -`pluginReg.Load()` 之后: - ```go -memory.SetTextCleaner(pluginReg.CleanText) +// 删除: +// memory.SetTextCleaner(pluginReg.CleanText) ``` -### 2.7 agentcli / cmd 插件:标记 NoMemory +### 3.6 context.go:textForVector 签名更新 -**文件:** `internal/plugins/agentcli/plugin.go` 和 `internal/plugins/cmd/plugin.go` +`computeVector` 需传入 `toolDefLookup`: -在 `RegisterTool` 调用的 `ToolDef` 中加 `NoMemory: true`: +```go +func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector { + return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup)) +} +``` -- `terminal_create`, `terminal_write`, `terminal_read`, `terminal_resize`, `terminal_close`, `terminal_list` -- `cmd_run` +`c.toolDefLookup` 已有(通过 `SetToolDefLookup` 注入)。 --- -## 外部插件变更 +## 第四阶段:文档记忆与蒸馏修正 -### QQ 插件 +### 4.1 document.ContextToDoc:接收 cleaned entries -在 `Start()` 中: +**文件:** `internal/memory/document/document.go` ```go -s.RegisterTextCleaner(func(text string) string { - text = reQQGroupSuffix.ReplaceAllString(text, "") - text = reQQPrivateSuffix.ReplaceAllString(text, "") - text = reQQOldReply.ReplaceAllString(text, "") - text = reQQOldForbid.ReplaceAllString(text, "") - text = reQQGeneral.ReplaceAllString(text, "") - text = reTimestamp.ReplaceAllString(text, "") - text = reMultiSpace.ReplaceAllString(text, " ") +// ContextToDoc 入参增加 cleanFn,在 summarizeEntries/extractTags/extractEntities 前过滤 +func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string) (*Doc, error) { + if len(entries) == 0 { + return nil, nil + } + + var parts []string + for _, e := range entries { + // Content 用 cleanFn 过滤后拼接,原文保留 + cleaned := e.Content + if cleanFn != nil { + cleaned = cleanFn(e.Content) + } + line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, cleaned) + if e.Response != "" { + line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) + } + parts = append(parts, line) + } + // ... 后续不变 +} +``` + +### 4.2 context.Prune:传入 cleaner + +```go +// 在传 entries 给 ContextToDoc 前,先构建工具→cleaner 映射 +toolCleaners := make(map[string]func(string)string) +for _, s := range archive { + for _, name := range s.event.ToolsUsed { + if c.toolDefLookup != nil { + if def := c.toolDefLookup(name); def != nil && def.Cleaner != nil { + toolCleaners[name] = def.Cleaner + } + } + } +} +// 构建清理函数:对所有工具输出依次应用对应 Cleaner +entryCleanFn := func(text string) string { + // 此处 text 是 Content(用户输入),不含工具输出,所以不需要 Cleaner + // Cleaner 在 distill.go 的 docToTriples 中使用 return text -}) +} +``` -s.RegisterTool("qq_get_message", sdk.ToolDef{ - Description: "获取QQ消息正文", - Parameters: map[string]interface{}{...}, - NoMemory: true, -}, handler) +实际上,`ContextToDoc` 中 `Content` 是用户输入,不包含工具输出。工具输出过滤主要发生在 `docToTriples`。 + +### 4.3 docToTriples:应用 Cleaner + +**文件:** `internal/agent/core/distill.go` + +```go +func docToTriples(doc *document.Doc, toolCleaners map[string]func(string) string) []memory.Triple { + var triples []memory.Triple + if doc == nil { + return triples + } + + triples = append(triples, memory.Triple{...}) + + lines := strings.Split(doc.Content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // 应用 Cleaner(如果匹配工具输出行) + // 实际 doc.Content 是 `[时间] source: content → response` 格式, + // 其中 content 是用户输入,不直接包含工具输出 + // Cleaner 在此暂不应用,保留后续扩展 + terms := memory.CutExact(line) + // ... + } + // ... +} +``` + +注意:`Doc.Content` 中的内容是用户在 Prune 时输入的文本和 LLM 回复,不包含原始工具输出。工具输出在 `msgs` 中(LLM 对话历史),但不在 `Doc.Content` 中。因此 `docToTriples` 不需要直接应用 Cleaner。 + +--- + +## 第五阶段:NoMemory 语义修正 + +### 5.1 NoMemory 的新语义 + +| 场景 | 旧行为 | 新行为 | +|---|---|---| +| `emitMemoryCandidate` | `hasNoMemoryTool` → 跳过 | 始终写入 text memory + pipeline | +| `context.Prune` → `ContextToDoc` | `hasNoMemoryTool` → 跳过 | 全部归档,统一进入文档记忆 | +| `textForVector` | `CleanText` 改原文后向量化 | 基础 Trim + 向量化(原文不变) | +| `docToTriples` | 无 Cleaner 直接 jieba | 无 Cleaner 直接 jieba(同理) | +| `StageContext.NoMemory` | `InjectTextNoMemory` → 整轮跳过 | 保留(IO 层控制的整轮跳过) | + +NoMemory 的声明式语义变为: +- `NoMemory=true` 是一个**工具元数据标记**,当前不在内核层面做特殊跳过 +- 为后续精确过滤(如管道层跳过 NoMemory 工具的输出)预留标记 +- 未来管道增强时可读取此标记,跳过对应工具输出片段 + +### 5.2 内置插件标记更新 + +**文件:** `internal/plugins/cmd/plugin.go` + +```go +s.RegisterTool("cmd_run", sdk.ToolDef{ + Name: "cmd_run", + Description: "执行 Shell 命令...", + NoMemory: true, // 保留:标记工具输出对 LLM 注意力无信号价值 + // Cleaner: nil, // 不注册 Cleaner:命令输出噪音不可控 +}, p.handleCmdRun) +``` + +**文件:** `internal/plugins/agentcli/plugin.go` + +```go +s.RegisterTool("terminal_create", sdk.ToolDef{ + Name: "terminal_create", + NoMemory: true, // 保留:终端交互噪音 +}, p.handleCreate) +// 同理其它 5 个工具 +``` + +**文件:** `internal/plugins/files/plugin.go`(示例插件 `_sdk_local/example/files/plugin.go`) + +```go +s.RegisterTool(tp+"read", sdk.ToolDef{ + Name: tp + "read", + NoMemory: false, // 明确 false:文件内容对 LLM 注意力有信号价值 + Cleaner: func(output string) string { + var r struct{ Content string } + if err := json.Unmarshal([]byte(output), &r); err != nil { + return output + } + return r.Content // 去 JSON 包裹,供未来向量化使用 + }, +}, p.handleRead) ``` --- -## 文件变更清单 +## 第六阶段:代码清理 + +### 6.1 删除无用代码 + +| 文件 | 删除内容 | +|---|---| +| `internal/plugin/registry.go` | `textCleaners` 字段、`CleanText()` 方法、相关的 append 逻辑 | +| `internal/memory/clean_text.go` | `globalTextCleaner`、`SetTextCleaner()` | +| `internal/agent/core/context.go` | `hasNoMemoryTool()` 辅助函数 | +| `internal/agent/core/eventloop.go` | `hasNoMemoryTool()` 方法 | +| `cmd/homed/main.go` | `memory.SetTextCleaner(pluginReg.CleanText)` | + +### 6.2 _sdk_local 清理 | 文件 | 操作 | -|------|------| -| `sdk/plugin.go`(外部 SDK) | 修改:`ToolDef` 增 `NoMemory`、`PluginSDK` 增 `RegisterTextCleaner`/`TextCleaners` | -| `go.mod` | 修改:增 `replace` 指令 | -| `internal/plugin/registry.go` | 修改:增 cleaners 聚合 + `CleanText` | -| `internal/memory/clean_text.go` | 修改:硬编码 → 动态 cleaner,删除 `CleanTemplateText` | -| `internal/agent/core/stages.go` | 修改:增 `ToolDef(name)` 查找 | -| `internal/agent/core/eventloop.go` | 修改:`emitMemoryCandidate` 前查 `ToolDef.NoMemory` | -| `internal/agent/core/context.go` | 修改:source 过滤 → `ToolsUsed` NoMemory 检查 + `CleanTemplateText` → `CleanText` | -| `internal/agent/core/process.go` | 修改(可能需要):传递 toolsUsed 到 Prune 或加辅助方法 | -| `internal/memory/indexer.go` | 修改:`CleanTemplateText` → `CleanText` | -| `internal/memory/cut.go` | 修改:同上 | -| `internal/plugins/agentcli/plugin.go` | 修改:工具标记 `NoMemory: true` | -| `internal/plugins/cmd/plugin.go` | 修改:`cmd_run` 标记 `NoMemory: true` | -| `cmd/homed/main.go` | 修改:`memory.SetTextCleaner(pluginReg.CleanText)` | +|---|---| +| `_sdk_local/sdk/plugin.go` | `ToolDef` 增 `Cleaner`;删除 `RegisterTextCleaner`/`TextCleaners`/`textCleaners` | +| `_sdk_local/sdk/plugin_test.go` | 替换 TextCleaner 测试为 Cleaner 测试 | + +--- + +## 涉及文件清单 + +| 文件 | 操作 | 阶段 | +|---|---|---| +| `_sdk_local/sdk/plugin.go` | `ToolDef` 增 `Cleaner`;删 `RegisterTextCleaner`/`TextCleaners` | 一 | +| `_sdk_local/sdk/plugin_test.go` | 更新测试 | 一 | +| `internal/plugin/registry.go` | 删 textCleaners + CleanText | 二 | +| `internal/memory/clean_text.go` | 删 globalTextCleaner + SetTextCleaner | 二 | +| `internal/agent/core/stages.go` | 增 `ToolDefCleaner` + `NoMemoryToolNames` | 二 | +| `internal/agent/core/context.go` | `Append` 不改原文;`Prune` 不跳 NoMemory;`textForVector` 入参 toolDefLookup | 三 | +| `internal/agent/core/eventloop.go` | 删 `hasNoMemoryTool` 调用 + 方法 | 三 | +| `internal/agent/core/distill.go` | `emitMemoryCandidate` 不加过滤(已在 eventloop 处理) | 三 | +| `internal/memory/document/document.go` | `ContextToDoc` 可选 cleanFn 参数 | 四 | +| `internal/plugins/cmd/plugin.go` | 确认 NoMemory=true | 五 | +| `internal/plugins/agentcli/plugin.go` | 确认 NoMemory=true | 五 | +| `_sdk_local/example/files/plugin.go` | 示例 Cleaner 注册 | 五 | +| `cmd/homed/main.go` | 删 `memory.SetTextCleaner(pluginReg.CleanText)` | 六 | --- ## 实施顺序 -1. SDK 本地副本 + 加 `NoMemory`/`RegisterTextCleaner` -2. Registry 聚合 cleaners + `CleanText` -3. Memory 动态 cleaner + 改名调用者 -4. StageHost `ToolDef(name)` -5. eventloop + context Prune + process 加入 NoMemory 检查 -6. 标记 agentcli/cmd 工具的 `NoMemory: true` -7. main.go 串联 -8. 删除旧代码(QQ 正则、source 硬编码) -9. 构建 + 测试 +``` +阶段一 (SDK 定义) → 阶段二 (内部适配删除) → 阶段三 (核心逻辑) + ↓ +阶段六 (代码清理) ← 阶段五 (NoMemory 语义) ← 阶段四 (文档记忆) +``` + +### Step-by-step 实施步骤 + +1. **`_sdk_local/sdk/plugin.go`** — `ToolDef` 加 `Cleaner` 字段;删除 `RegisterTextCleaner`/`TextCleaners`/`textCleaners` 字段及方法 +2. **`_sdk_local/sdk/plugin_test.go`** — 更新测试用例 +3. **`internal/plugin/registry.go`** — 删除 `textCleaners`、`CleanText()`、收集逻辑 +4. **`internal/memory/clean_text.go`** — 删除 `globalTextCleaner`、`SetTextCleaner` +5. **`internal/agent/core/stages.go`** — 增 `ToolDefCleaner`、`NoMemoryToolNames` +6. **`internal/agent/core/context.go`** — + - `Append`: 删除 `evt.Input = memory.CleanText(evt.Input)` + - `textForVector`: 入参加 `toolDefLookup` + - `computeVector`: 传入 `c.toolDefLookup` + - `Prune`: 删除 `hasNoMemoryTool` 跳过逻辑 + - 删除 `hasNoMemoryTool` 辅助函数 + - `load()`: 不再对已加载事件调用 `CleanText` +7. **`internal/agent/core/eventloop.go`** — + - `processTextInput`: `emitMemoryCandidate` 前删 `!a.hasNoMemoryTool(toolsUsed)` 条件 + - `processMediaInput`: 同上 + - 删除 `hasNoMemoryTool` 方法 +8. **`internal/memory/document/document.go`** — `ContextToDoc` 入参加 `cleanFn` +9. **`cmd/homed/main.go`** — 删除 `memory.SetTextCleaner(pluginReg.CleanText)` +10. **内置插件** — 确认 NoMemory 标记,示例插件加 Cleaner +11. **编译测试** — `go build ./...` 确认无编译错误 + +--- + +## 验证方法 + +### 编译检查 +```bash +go build ./... +go vet ./... +``` + +### 单元测试 +```bash +# SDK 测试 +cd _sdk_local && go test ./sdk/... + +# 内核测试 +cd /home/program/TrueAgent && go test ./internal/agent/core/... +go test ./internal/memory/... +go test ./internal/plugin/... +``` + +### 行为验证 + +**场景 1:NoMemory 工具调用后记忆仍然产生** +1. 用户输入:"查一下 /etc/passwd" +2. Agent 调用 `cmd_run`(NoMemory=true) +3. LLM 回复:"第一行是 root,uid=0,超级管理员哦~" +4. ✅ `textMem.Append(Event{Response: "第一行是 root..."})` — 写入 +5. ✅ `distiller.Append("assistant", "第一行是 root...")` — 写入 +6. ✅ `context.Append(ContextEvent{Response: "第一行是 root..."})` — 可见 + +**场景 2:Cleaner 仅影响计算层** +1. Agent 调用 `files_read` 返回 `{"content": "敏感数据"}` +2. ✅ 原文 `msgs` 中保留 `{"content": "敏感数据"}` +3. ✅ LLM 回复中可见原始内容 +4. `textForVector` 使用 Cleaner 提取 `content` 字段(注:实际 textForVector 对 Response 操作,Response 已是 LLM 的自然语言,不是 JSON 包裹。Cleaner 在工具输出进入 msgs 时注释即可。) + +实际上,Cleaner 的调用时机需要斟酌。工具输出通过 `executeToolCallInner` 返回字符串,进入 `msgs` 中的 `role: "tool"` 消息。`msgs` 用于 LLM 上下文,不需要 Cleaner。Cleaner 是在"工具输出单独进入记忆计算"时才需要。 + +当前架构中,工具输出不单独进入记忆计算,而是通过 LLM 的回复间接影响记忆。所以 Cleaner 的实际用途是**预留扩展**:当未来有直接对工具输出进行向量化/摘要的环节时,使用 Cleaner 过滤。 + +### 回归测试 +- 确认旧有 `InjectTextNoMemory`(IO 层整轮跳过,通过 `stageCtx.NoMemory` 控制)不受影响 +- 确认 context prune 不再因 NoMemory 工具跳过低相关性事件归档 +- 确认 text memory 日志完整记录所有对话轮次 \ No newline at end of file diff --git a/review.md b/review.md new file mode 100644 index 0000000..36876db --- /dev/null +++ b/review.md @@ -0,0 +1,219 @@ +# 记忆系统审查:NoMemory 与 TextCleaner 设计偏差 + +## 一、核心原则 + +**Context 和 Document 层始终保留原始文本。** Cleaner 和 NoMemory 不修改原文,只控制文本在**计算层**(向量化、jieba 分词、蒸馏)中的参与方式。原文完整性是 LLM 注意力分配的基础——清洗掉工具特征输出会干扰 LLM 对上下文的理解。 + +--- + +## 二、TextCleaner:应为工具级计算过滤,实为插件级原文清洗 + +### 当前实现 + +**SDK 定义** — `_sdk_local/sdk/plugin.go:349` +```go +type PluginSDK struct { + textCleaners []func(text string) string // 插件级列表 +} + +func (s *PluginSDK) RegisterTextCleaner(cleaner func(text string) string) { + s.textCleaners = append(s.textCleaners, cleaner) +} + +func (s *PluginSDK) TextCleaners() []func(text string) string { + return s.textCleaners +} +``` + +**内核收集** — `internal/plugin/registry.go:273` +```go +r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...) +``` + +**全局注入** — `cmd/homed/main.go:426` +```go +memory.SetTextCleaner(pluginReg.CleanText) +``` + +**在 `memory.CleanText` 中执行** — `internal/memory/clean_text.go:16` +```go +func CleanText(text string) string { + if globalTextCleaner != nil { + text = globalTextCleaner(text) // ← 修改了原始文本 + } + text = strings.TrimSpace(text) + ... +} +``` + +### 问题 + +1. **粒度错** — 插件级无法区分工具。一个插件注册 6 个工具,输出格式各异,却只能共享一个 cleaner。 + +2. **时机错** — `CleanText` 在 `context.Append` 入口处直接修改文本,破坏了"原文保留"原则。 + +``` +context.Append(evt) + → evt.Input = memory.CleanText(evt.Input) // ← 原文被改 + → evt.Vector = c.computeVector(&evt) // ← 向量基于已改文本 +``` + +3. **覆盖不全** — `Response` 不经过 `CleanText`。`context.Append` 中对 `Input` 调用了 `CleanText`,但对 `Response` 没有。 + +### 应然设计 + +`Cleaner` 应是 `ToolDef` 上的字段,且**只用于计算层,不用于存储层**: + +```go +type ToolDef struct { + Name string + Plugin string + Description string + Parameters map[string]interface{} + NoMemory bool // 此工具输出不参与任何记忆计算 + Cleaner func(string) string // 输出参与记忆计算前,先用此函数过滤噪音 +} +``` + +使用方式: + +```go +s.RegisterTool("files_read", sdk.ToolDef{ + Cleaner: func(output string) string { + // 只影响计算层,原文不变 + var r struct{ Content string } + json.Unmarshal([]byte(output), &r) + return r.Content // 去 JSON 包裹,供向量化/jieba 使用 + }, +}, handler) +``` + +Cleaner 在内核中的注入点**不是**修改 `ContextEvent.Response` 或 `Doc.Content`,而是作为「计算时过滤函数」注册到 `StageHost`,在各计算环节按需调用: + +``` +存储层(原文不变): 计算层(Cleaner 过滤后): + ContextEvent.Response ──→ textForVector → Vectorize(Cleaner(text)) + Doc.Content ──→ summarizeEntries → jieba(Cleaner(text)) + extractTags → jieba(Cleaner(text)) + extractEntities → jieba(Cleaner(text)) + Doc lines ──→ docToTriples → CutExact(Cleaner(line)) +``` + +--- + +## 三、NoMemory:应为输出跳过计算但原文保留,实为整轮跳过 + +### 当前实现 + +`_sdk_local/sdk/plugin.go:95` +```go +type ToolDef struct { + NoMemory bool `json:"no_memory,omitempty"` +} +``` + +内核中 `hasNoMemoryTool`(`internal/agent/core/eventloop.go:389`): +```go +func (a *Agent) hasNoMemoryTool(toolsUsed []string) bool { + for _, name := range toolsUsed { + if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory { + return true // ← 工具 NoMemory → 整轮跳过 + } + } + return false +} +``` + +检查点: +- `eventloop.go:337` — `emitMemoryCandidate` 整轮跳过 +- `context.go:204` — Prune 归档整条事件跳过 +- `context.go:269` — `hasNoMemoryTool` 是二值判断 + +### 问题 + +``` +用户: 帮我查一下 /etc/passwd +助手: 好的~ (工具: cmd_run → root:x:0:0:...) +助手: 第一行是 root,uid=0,超级管理员哦 (`・ω・´) + ↑ 这句自然语言有记忆价值 +``` + +`hasNoMemoryTool` 检查 `toolsUsed` 包含 `cmd_run`(NoMemory=true)→ **整轮不写记忆**,"root 是超级管理员"这条信息丢失。 + +**NoMemory 应该过滤的是工具输出文本在计算层的参与,不是跳过整轮对话。** + +### 应然设计 + +NoMemory 在三层计算中的语义: + +| 计算环节 | 行为 | +|---|---| +| **Context 向量化** | `textForVector` 对 `Response` 做向量化时,跳过其中属于 NoMemory 工具输出转述/引用的部分 | +| **Document 摘要/标签/实体** | jieba 分词时,跳过 NoMemory 工具的输出文本 | +| **Document→Graph 蒸馏** | `docToTriples` 跳过 NoMemory 工具输出对应行 | +| **Distiller Pipeline** | `extractKeyTriples` 跳过含 NoMemory 工具输出的片段 | + +保留原文不变: + +| 存储层 | 行为 | +|---|---| +| `ContextEvent.Response` | 保留 LLM 回复全文 | +| `Doc.Content` | 保留文档全文 | +| `toolCallRing.FullResult` | 保留完整工具输出 | +| `msgs` (LLM 对话历史) | 保留原始消息 | + +--- + +## 四、设计对照表 + +| 机制 | 当前实现 | 应然设计 | +|---|---|---| +| `RegisterTextCleaner` | 插件级,`memory.CleanText` 入口直接改原文 | **删除**,拆为 `ToolDef.Cleaner` | +| `ToolDef.Cleaner` | 不存在 | 工具级,仅计算层生效,不改原文 | +| `NoMemory=true` | `hasNoMemoryTool` 二值 → 整轮跳过 | 工具输出不参与向量/jieba/蒸馏,原文保留 | + +### 决策矩阵 + +``` +工具输出 → 对 LLM 注意力有信号价值? + ├── 否 → NoMemory=true + │ 输出原文保留在 Context/Document, + │ 但不参与向量计算、jieba 分词、图蒸馏 + │ 例: cmd_run(任意命令、转义符、路径) + │ + └── 是 → 有可控噪音? + ├── 是 → Cleaner 注册计算层过滤 + │ 过滤后的文本参与向量/jieba/蒸馏, + │ 原文不修改 + │ 例: files_read(需去 JSON 包裹、截断) + │ web_fetch(需提取正文、去 HTML) + │ + └── 否 → 正常记忆,无额外处理 + 例: memory_recall(结构化,无噪音) + person_set_trait(短文本,无噪音) +``` + +--- + +## 五、影响范围 + +| 层次 | 文件 | 改动 | +|---|---|---| +| **SDK** | `_sdk_local/sdk/plugin.go` | `ToolDef` 新增 `Cleaner func(string) string`;移除 `RegisterTextCleaner` / `TextCleaners` / `textCleaners` | +| **SDK** | `_sdk_local/sdk/plugin_test.go` | 移除 TextCleaner 测试,新增 NoMemory/Cleaner 组合测试 | +| **Registry** | `internal/plugin/registry.go` | 移除 `textCleaners` 收集逻辑;移除 `CleanText` 方法;构建 `StageHost` 时传入工具的 Cleaner 映射 | +| **全局 CleanText** | `internal/memory/clean_text.go` | 移除 `globalTextCleaner` / `SetTextCleaner`;`CleanText` 只保留 TrimSpace 等基础清洗 | +| **main** | `cmd/homed/main.go` | 移除 `memory.SetTextCleaner(pluginReg.CleanText)` | +| **内核入口** | `internal/agent/core/process.go:228` | 工具返回结果后,结果原文进 `msgs`,同时 `Cleaner(text)` 结果进后续记忆管道 | +| **工具调度** | `internal/agent/core/toolcall.go` | `executeToolCallInner` 返回值额外返回 cleaned 版本(或通过 `StageHost.ToolDef(name).Cleaner` 延迟计算) | +| **Context 向量** | `internal/agent/core/context.go:73-86` | `textForVector` 从 `stageHost` 获取 Cleaner,对文本做计算层过滤后再 `Vectorize` | +| **Context Prune** | `internal/agent/core/context.go:144-215` | 不再 `hasNoMemoryTool` 跳过整条;改为只传 Cleaner 过滤后的文本给 `docStore.ContextToDoc` | +| **Context Append** | `internal/agent/core/context.go:102-111` | `computeVector` 之前对 `Input`/`Response` 走 Cleaner 过滤,原文不修改 | +| **记忆候选** | `internal/agent/core/eventloop.go:337` | 不跳过 `emitMemoryCandidate`;`emitMemoryCandidate` 同时传出原始和 cleaned 版本 | +| **Document** | `internal/memory/document/document.go:132-200` | `ContextToDoc` 接收 cleaned 文本用于 `summarizeEntries`/`extractTags`/`extractEntities`/向量计算,`Doc.Content` 原文不变 | +| **Document→Graph** | `internal/agent/core/distill.go:332-378` | `docToTriples` 对每行走 Cleaner 后再 `CutExact`;跳过 NoMemory 工具输出行 | +| **Pipeline** | `internal/memory/pipeline/pipeline.go:235` | `distillBatch` 跳过 NoMemory 工具输出片段 | +| **StageHost** | `internal/agent/core/stages.go` | 新增 `ToolDefCleaner(name string) func(string) string` 查询 | +| **内置插件** | `internal/plugins/cmd/plugin.go` | `cmd_run`: `NoMemory=true`,不注册 Cleaner(噪音不可控) | +| **内置插件** | `internal/plugins/agentcli/plugin.go` | 6 个工具各注册专用 Cleaner + `NoMemory=true`(输出仍含不可控噪音,但 Cleaner 提取有价值信号) | +| **内置插件** | `internal/plugins/files/plugin.go` | `files_read/edit` 注册 Cleaner 截断长文本、去 JSON 包裹,正常记忆(NoMemory=false 或移除) |