Files
HomeAgent/plan.md

20 KiB
Raw Blame History

记忆系统重构计划 — NoMemory 与 TextCleaner 设计修正

基于 review.md 审查结论,当前实现存在两个设计偏差:

机制 当前(错误) 应然(目标)
RegisterTextCleaner 插件级全局 cleanerCleanText 入口直接改原文 删除,拆为 ToolDef.Cleaner(工具级,仅计算层生效)
ToolDef.Cleaner 不存在 工具级,仅向量化/jieba/蒸馏时调用,不改原文
NoMemory=true hasNoMemoryTool 二值判断 → 整轮不写记忆 工具输出不参与向量/jieba/蒸馏,但原文保留在 Context/Document

阶段零:理解当前数据流(现状确认)

用户输入 → 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

关键发现:工具输出从未直接进入 pipelinepipeline 只存 user/assistant 的原始文本)。hasNoMemoryTool 跳过了整个 LLM 回复,这是过度保守的。


第一阶段SDK 定义修改(外部包 homeagent-sdk

文件: _sdk_local/sdk/plugin.go

1.1 ToolDef 增加 Cleaner 字段

type ToolDef struct {
    Name        string                 `json:"name"`
    Plugin      string                 `json:"plugin,omitempty"`
    Description string                 `json:"description"`
    Parameters  map[string]interface{} `json:"parameters"`
    NoMemory    bool                   `json:"no_memory,omitempty"`
    Cleaner     func(string) string    `json:"-"` // ← 新增:计算层过滤函数,不改原文
}
  • Cleaner 是函数类型,不序列化(json:"-"
  • 仅在向量化/jieba/蒸馏等计算环节使用
  • 默认 nil = 不过滤

1.2 删除 PluginSDK 中的 TextCleaner

// PluginSDK 中删除:
//   textCleaners []func(text string) string  // ← 删除
//   RegisterTextCleaner()                    // ← 删除
//   TextCleaners()                           // ← 删除

1.3 相应修改单元测试

文件: _sdk_local/sdk/plugin_test.go

  • 删除 TestRegisterTextCleanerTestTextCleanersEmpty
  • TestToolDefNoMemory 保留
  • 新增 TestToolDefCleaner 验证 Cleaner 字段

1.4 go.mod 确认 replace 指令

go.mod 已有 replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local,无需改动。


第二阶段:内部适配层更新

2.1 Registry删除 textCleaners 聚合

文件: internal/plugin/registry.go

删除项:

  • 字段 textCleaners []func(string) string
  • 字段 CleanText(text string) string 方法
  • buildSDK 不再收集 cleanercleaner 附着在 ToolDef 上,由 StageHost 管理)
  • loadOneLoad 中的 r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...) 移除
  • StopAll 中的重置移除

改动点:

行号 当前 改为
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

// 删除:
var globalTextCleaner func(string) string   // ← 删除
func SetTextCleaner(fn func(string) string) { // ← 删除
    globalTextCleaner = fn
}

func CleanText(text string) string {
    // 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)
    return text
}

VectorizeClean 保持不变(使用精简后的 CleanText)。

2.3 StageHost暴露 ToolDef Cleaner 查询

文件: internal/agent/core/stages.go

ToolDef(name) 方法已有,返回 *sdk.ToolDef。由于 ToolDef 现在有 Cleaner 字段,调用方可直接通过 stageHost.ToolDef(name).Cleaner 获取。

新增便捷方法:

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.Cleaner
        }
    }
    return nil
}

// 新增:返回所有 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
        }
    }
    return set
}

2.4 StageHostImport 更新

需 import sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"(已有)。


第三阶段:核心逻辑修正

3.1 context.Append不再修改原文

文件: internal/agent/core/context.go

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()
}

3.2 textForVector计算层使用 Cleaner

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
    }

    // 基础清洗(不修改原文)
    text = memory.CleanText(text)

    // 若 evt 关联了 NoMemory 工具,不再跳过整个事件
    // 但若工具注册了 Cleaner在计算层过滤
    // textForVector 是对整个事件的向量化,不拆到工具级别
    return text
}

关键变更:

  • textForVector 的入参增加 toolDefLookup(通过 RelevanceContext.toolDefLookup 已有)
  • 移除 textForVectormemory.CleanText 的依赖(因为 CleanText 不再做插件级过滤)
  • computeVector 传入 toolDefLookup

3.3 context.Prune不再跳过 NoMemory 事件

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

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.NoMemoryIO 注入的 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

// 删除:
// memory.SetTextCleaner(pluginReg.CleanText)

3.6 context.gotextForVector 签名更新

computeVector 需传入 toolDefLookup

func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
    return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
}

c.toolDefLookup 已有(通过 SetToolDefLookup 注入)。


第四阶段:文档记忆与蒸馏修正

4.1 document.ContextToDoc接收 cleaned entries

文件: internal/memory/document/document.go

// 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

// 在传 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
}

实际上,ContextToDocContent 是用户输入,不包含工具输出。工具输出过滤主要发生在 docToTriples

4.3 docToTriples应用 Cleaner

文件: internal/agent/core/distill.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 回复,不包含原始工具输出。工具输出在 msgsLLM 对话历史),但不在 Doc.Content 中。因此 docToTriples 不需要直接应用 Cleaner。


第五阶段NoMemory 语义修正

5.1 NoMemory 的新语义

场景 旧行为 新行为
emitMemoryCandidate hasNoMemoryTool → 跳过 始终写入 text memory + pipeline
context.PruneContextToDoc hasNoMemoryTool → 跳过 全部归档,统一进入文档记忆
textForVector CleanText 改原文后向量化 基础 Trim + 向量化(原文不变)
docToTriples 无 Cleaner 直接 jieba 无 Cleaner 直接 jieba同理
StageContext.NoMemory InjectTextNoMemory → 整轮跳过 保留IO 层控制的整轮跳过)

NoMemory 的声明式语义变为:

  • NoMemory=true 是一个工具元数据标记,当前不在内核层面做特殊跳过
  • 为后续精确过滤(如管道层跳过 NoMemory 工具的输出)预留标记
  • 未来管道增强时可读取此标记,跳过对应工具输出片段

5.2 内置插件标记更新

文件: internal/plugins/cmd/plugin.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

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

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 globalTextCleanerSetTextCleaner()
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_local/sdk/plugin.go ToolDefCleaner;删除 RegisterTextCleaner/TextCleaners/textCleaners
_sdk_local/sdk/plugin_test.go 替换 TextCleaner 测试为 Cleaner 测试

涉及文件清单

文件 操作 阶段
_sdk_local/sdk/plugin.go ToolDefCleaner;删 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 不跳 NoMemorytextForVector 入参 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)

实施顺序

阶段一 (SDK 定义)     →  阶段二 (内部适配删除)  →  阶段三 (核心逻辑)
                                                      ↓
阶段六 (代码清理)  ←  阶段五 (NoMemory 语义)  ←  阶段四 (文档记忆)

Step-by-step 实施步骤

  1. _sdk_local/sdk/plugin.goToolDefCleaner 字段;删除 RegisterTextCleaner/TextCleaners/textCleaners 字段及方法
  2. _sdk_local/sdk/plugin_test.go — 更新测试用例
  3. internal/plugin/registry.go — 删除 textCleanersCleanText()、收集逻辑
  4. internal/memory/clean_text.go — 删除 globalTextCleanerSetTextCleaner
  5. internal/agent/core/stages.go — 增 ToolDefCleanerNoMemoryToolNames
  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.goContextToDoc 入参加 cleanFn
  9. cmd/homed/main.go — 删除 memory.SetTextCleaner(pluginReg.CleanText)
  10. 内置插件 — 确认 NoMemory 标记,示例插件加 Cleaner
  11. 编译测试go build ./... 确认无编译错误

验证方法

编译检查

go build ./...
go vet ./...

单元测试

# SDK 测试
cd _sdk_local && go test ./sdk/...

# 内核测试
cd /home/program/TrueAgent && go test ./internal/agent/core/...
go test ./internal/memory/...
go test ./internal/plugin/...

行为验证

场景 1NoMemory 工具调用后记忆仍然产生

  1. 用户输入:"查一下 /etc/passwd"
  2. Agent 调用 cmd_runNoMemory=true
  3. LLM 回复:"第一行是 rootuid=0超级管理员哦"
  4. textMem.Append(Event{Response: "第一行是 root..."}) — 写入
  5. distiller.Append("assistant", "第一行是 root...") — 写入
  6. context.Append(ContextEvent{Response: "第一行是 root..."}) — 可见

场景 2Cleaner 仅影响计算层

  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 过滤。

回归测试

  • 确认旧有 InjectTextNoMemoryIO 层整轮跳过,通过 stageCtx.NoMemory 控制)不受影响
  • 确认 context prune 不再因 NoMemory 工具跳过低相关性事件归档
  • 确认 text memory 日志完整记录所有对话轮次