From 48cfa8fb6cac570b5cf25435ae16967f1893e909 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 10:02:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(lightkernel):=20N2c=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E8=BD=BB=E9=87=8F=E5=86=85=E6=A0=B8=20profile=EF=BC=88?= =?UTF-8?q?=E7=AA=84=E6=8E=A5=E5=8F=A3=20GraphMemory=20+=20nil=20=E5=8D=B3?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E6=95=B4=E7=90=86=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户指出的关键点(a.memory 多数使用点属"主 agent 整理记忆"与"记忆整理流水线", 子不该有那些路径)实现,方案见设计 §16.0。 ## 窄接口:只有 Recall + Commit 新增 `GraphMemory` 接口(memoryface.go)—— 按调用方实测分类后,真正"根与子都要"的只有这两个: - `Recall`:上下文检索 / memory_recall - `Commit`:自动写入路径的图部分 / memory_commit 新增 `Agent.graph`(共同面)与 `Agent.graphMem()` 访问器: - `graph` 显式为 nil 时**回落**到 `memory` ⇒ 既有"只用 Agent 字面量设 memory"的测试无需改动 (原本会出现"必须同时设两个字段"的脚坑,实测踩到后去掉了) - 轻量内核:`graph = *memory.LightMemory`,`memory = nil` ## nil 即禁用:整理面自动消失,不需要受限包装 子的 `a.memory == nil` ⇒ 既有的 22 处 `if a.memory != nil` 关卡自动禁掉全部整理面: - 记忆整理流水线(distill.go 的 archive/review/merge 循环) - 记忆块 + 媒体桥(graphmedia.go / medialoop.go) - 记忆整理工具(memory_merge / memory_delete_entity / memory_block_merge / memory_purge / memory_edit / memory_introspect)—— 它们本就在 `if a.memory != nil` 块内 唯一拆开的一处是自动写入 `commitTriplesWithMedia`: 图部分走 `graphMem().Commit`(父落 main、子落 temp),块/媒体部分仍由 `a.memory != nil` 守卫。 `executeMemoryTool` 的读路径改走 `graphMem().Recall`;整理类 case 加 `requireFull()` 闸门, 被直调时明确报"本 agent 是轻量内核:记忆整理不可用",不静默降级。 ## 验收(3 项新测试) - `TestLightProfile_MemoryFaceWiring`:整理面为 nil、写入只落 temp(主库无子痕迹)、 读是并集(主库实体 + temp 实体都看得到) - `TestLightProfile_OrganizeToolsAbsentAndRefused`:整理类工具**不进工具表**; 即便被直调也明确报"轻量内核不支持" - `TestFullProfile_KeepsOrganizeFace`:对照,根 agent 仍保留整理面与整理工具 全仓 go test ./... 37 包 ok / 0 FAIL;-race(agent/memory)干净;gofmt 干净。 --- docs/zh/resident-subagent-design.md | 8 +- internal/agent/core/agent.go | 55 ++++++-- internal/agent/core/graphmedia.go | 9 +- internal/agent/core/lightprofile_test.go | 169 +++++++++++++++++++++++ internal/agent/core/memoryface.go | 33 +++++ internal/agent/core/toolcall.go | 31 ++++- 6 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 internal/agent/core/lightprofile_test.go create mode 100644 internal/agent/core/memoryface.go diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index c719b67..02ae1cc 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -545,11 +545,11 @@ type GraphMemory interface { | **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18;既有全部测试通过(这是纯收敛,不含新能力) | | **N1a** | **通道登记层**:inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21–S23 | | **N1b** | 输出通道授权过滤 + 目标解析(outputch → 目标 agent 的 inputch) | S1–S3 | -| **N2a** | **作用域对象 + 图记忆 space 维度**:`memory.Scope{Write string; Read []string}`;`entities/sentences/relations` 加 `space` 列(默认 `main`,老数据迁移视为 main);写落 `Write`、查询过滤 `space IN Read` | 同 space 可见、跨 space 不可见 | -| **N2b** | **图记忆的向量检索**接入同一 space 过滤(子能按 space 语义检索) | 检索不跨 space 越界 | +| **N2a** | ~~作用域对象 + 图记忆 space 维度~~ **已完成(改为独立存储实例)**:`OpenGraphDBReadOnly`(query_only 受限句柄)+ `LightMemory`(temp 可写 / 主库只读 / 并集查询 + 应用层合并) | S13–S15 ✅ | +| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) | | — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 | -| **N2c** | **Agent 级 profile(轻量内核接线)**:见下方施工方案 | S13–S15 | -| **N2d** | **晋升与丢弃**:`Promote(space, records)`(回收时父把选中的 temp 记录写进 main)+ `DropSpace(space)`(回收/销毁时丢弃 temp) | S11 的数据面 | +| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ | +| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ | | **N3** | **驻留子生命周期**:创建 / 销毁 / 登记表 / 父退出清理 | S12、S16 | | **N4** | **跨 agent 投递**:子→父 L3、父→子 L4(+ `isKernelLevelSource` 分层) | S4、S6、S17 | | **N5** | **inputch 处理表**:主动写入工具 + 自动写兜底 + 生命周期 | S8、S9 | diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index d38ff06..173a6b0 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -37,12 +37,15 @@ type Agent struct { providerManager *agentAPI.ProviderManager io *agentIO.IOManager memory *memory.GraphDB - indexer *memory.Indexer - tracker *tracker.Tracker - context *RelevanceContext - systemPrompt string - ctx context.Context - cancel context.CancelFunc + // graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。 + // 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。 + graph GraphMemory + indexer *memory.Indexer + tracker *tracker.Tracker + context *RelevanceContext + systemPrompt string + ctx context.Context + cancel context.CancelFunc // 文档记忆(第二层) docStore *document.Store @@ -192,8 +195,13 @@ type AgentConfig struct { ProviderManager *agentAPI.ProviderManager IO *agentIO.IOManager Memory *memory.GraphDB - Indexer *memory.Indexer - Tracker *tracker.Tracker + // LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 temp∪main,只写 temp)。 + // + // 给了它就意味着这是轻量内核:`Memory` 必须为 nil, + // 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go)。 + LightMemory *memory.LightMemory + Indexer *memory.Indexer + Tracker *tracker.Tracker DocStore *document.Store Knowledge *knowledge.Store @@ -291,6 +299,7 @@ func New(cfg AgentConfig) *Agent { providerManager: cfg.ProviderManager, io: cfg.IO, memory: cfg.Memory, + graph: graphMemoryOf(cfg), indexer: cfg.Indexer, tracker: cfg.Tracker, context: rc, @@ -351,6 +360,36 @@ func (a *Agent) Stop() { a.cancel() } +// graphMemoryOf 决定本 agent 的图记忆共同面实现。 +// +// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**; +// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory)。 +func graphMemoryOf(cfg AgentConfig) GraphMemory { + if cfg.LightMemory != nil { + return cfg.LightMemory + } + if cfg.Memory == nil { + return nil + } + return cfg.Memory +} + +// graphMem 返回本 agent 的图记忆**共同面**。 +// +// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造" +// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。 +// 轻量内核则显式设 graph=LightMemory 且 memory=nil:共同面走 LightMemory, +// 整理面因 memory==nil 而全部不可达。 +func (a *Agent) graphMem() GraphMemory { + if a.graph != nil { + return a.graph + } + if a.memory == nil { + return nil + } + return a.memory +} + func (a *Agent) ID() types.AgentID { return a.id } // IsOutputAllowed 报告某个输出通道是否被授权给本 agent。 diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index af02d7a..e3a40c9 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -107,11 +107,14 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) // seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份; // 普通对话路径传 nil。blocks 是本次写入 L3 的块数。 func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) { - if a.memory == nil { + g := a.graphMem() + if g == nil { return 0, 0, 0, fmt.Errorf("graph memory 未启用") } - if a.mediaStore == nil { - ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID) + // 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。 + // 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。 + if a.memory == nil || a.mediaStore == nil { + ec, rc, cErr := g.Commit(triples, sessionID, turnID) return ec, rc, 0, cErr } sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID) diff --git a/internal/agent/core/lightprofile_test.go b/internal/agent/core/lightprofile_test.go new file mode 100644 index 0000000..d6d387c --- /dev/null +++ b/internal/agent/core/lightprofile_test.go @@ -0,0 +1,169 @@ +package core + +// N2c 验收:**轻量内核 profile**。 +// +// 设计 docs/zh/resident-subagent-design.md §16.0(窄接口 + nil 即禁用)与 §5.5。 +// +// 轻量内核(驻留子)的记忆装配: +// - graph = *memory.LightMemory(读 temp∪main,只写 temp) +// - memory = nil ⇒ 既有的 `if a.memory != nil` 关卡自动禁掉**全部**整理面: +// 记忆整理流水线(distill.go 的 archive/review/merge 循环)、记忆块与媒体桥 +// (graphmedia.go / medialoop.go)、记忆整理工具(merge/delete/purge/edit/block_merge) +// +// 因此这里要钉住四件事: +// ① 子的写入只落 temp,主库不受影响; +// ② 子的读是并集(看得到主库 + 自己的 temp); +// ③ 整理类工具**不进子的工具表**; +// ④ 即便被直调,整理类操作也明确报"轻量内核不支持"(纵深防御,不静默降级)。 + +import ( + "path/filepath" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// newLightAgent 构造一个轻量内核 agent(驻留子形态):有 LightMemory,没有整理面。 +func newLightAgent(t *testing.T, main *memory.GraphDB, tempPath string) *Agent { + t.Helper() + light, err := memory.NewLightMemory(main, tempPath, true) + if err != nil { + t.Fatal(err) + } + a := New(AgentConfig{ + ID: "sub-1", + Provider: &scriptProvider{}, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + LightMemory: light, // 轻量内核:只给图记忆共同面 + }) + t.Cleanup(func() { light.Close() }) + return a +} + +func TestLightProfile_MemoryFaceWiring(t *testing.T) { + dir := t.TempDir() + main, err := memory.NewGraphDB(filepath.Join(dir, "main.db")) + if err != nil { + t.Fatal(err) + } + defer main.Close() + if _, _, err := main.Commit([]memory.Triple{ + {Subject: "主记忆实体", Relation: "属于", Object: "主库"}, + }, "sess", 1); err != nil { + t.Fatal(err) + } + + a := newLightAgent(t, main, filepath.Join(dir, "sub.db")) + + // ① 整理面必须为 nil —— 这正是"nil 即禁用"的开关。 + if a.memory != nil { + t.Fatal("轻量内核不该有整理面(a.memory 必须为 nil)") + } + if a.graphMem() == nil { + t.Fatal("轻量内核必须有图记忆共同面") + } + + // ② 写入只落 temp:主库不得出现子才知道的实体。 + if _, _, _, err := a.commitTriplesWithMedia([]memory.Triple{ + {Subject: "子独有实体", Relation: "来自", Object: "子的temp"}, + }, "sess", 1, nil); err != nil { + t.Fatalf("子的写入应成功(落 temp): %v", err) + } + mainRes, err := main.Recall([]string{"子独有实体"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + for _, e := range mainRes.Entities { + if e.Name == "子独有实体" { + t.Fatalf("子的写入不该进主库:%v", e.Name) + } + } + + // ③ 读是并集:主库的实体与 temp 的实体都要看得到。 + got := a.executeMemoryTool(agentAPI.ToolCall{ + ID: "c1", Name: "memory_recall", + Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"}, + }) + if !strings.Contains(got, "主记忆实体") { + t.Fatalf("子应看得到主记忆:%s", got) + } + if !strings.Contains(got, "子独有实体") { + t.Fatalf("子应看得到自己的 temp:%s", got) + } +} + +func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) { + dir := t.TempDir() + main, err := memory.NewGraphDB(filepath.Join(dir, "main.db")) + if err != nil { + t.Fatal(err) + } + defer main.Close() + + a := newLightAgent(t, main, filepath.Join(dir, "sub.db")) + + // ① 整理类工具不进子的工具表(不是进去再报不可用)。 + names := toolNames(a) + for _, banned := range []string{ + "memory_merge", "memory_delete_entity", "memory_block_merge", + "memory_purge", "memory_edit", + } { + if hasTool(names, banned) { + t.Fatalf("轻量内核不该声明整理类工具 %s:%v", banned, names) + } + } + // 对照:共同面/无关能力照常在。 + if !hasTool(names, "input_channels") { + t.Fatalf("轻量内核仍应有共同面工具:%v", names) + } + + // ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。 + for _, tool := range []string{ + "memory_merge", "memory_delete_entity", "memory_block_merge", + "memory_purge", "memory_edit", "memory_introspect", + } { + got := a.executeMemoryTool(agentAPI.ToolCall{ + ID: "x", Name: tool, + Arguments: map[string]interface{}{ + "name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{}, + }, + }) + if !strings.Contains(got, "轻量内核") { + t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got) + } + } +} + +// 对照:完整内核(根 agent)仍有整理面与整理工具。 +func TestFullProfile_KeepsOrganizeFace(t *testing.T) { + dir := t.TempDir() + main, err := memory.NewGraphDB(filepath.Join(dir, "main.db")) + if err != nil { + t.Fatal(err) + } + defer main.Close() + + a := New(AgentConfig{ + ID: "root", + Provider: &scriptProvider{}, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + Memory: main, + }) + if a.memory == nil { + t.Fatal("根 agent 必须有整理面") + } + if a.graphMem() == nil { + t.Fatal("根 agent 必须有图记忆共同面") + } + names := toolNames(a) + if !hasTool(names, "memory_merge") { + t.Fatalf("根 agent 应保留整理类工具:%v", names) + } +} diff --git a/internal/agent/core/memoryface.go b/internal/agent/core/memoryface.go new file mode 100644 index 0000000..6094a3f --- /dev/null +++ b/internal/agent/core/memoryface.go @@ -0,0 +1,33 @@ +package core + +// 图记忆的**共同面**(设计 docs/zh/resident-subagent-design.md §16.0)。 +// +// 只有两个方法 —— 因为按调用方实测分类后,`Agent.memory` 的 42 处使用里, +// 真正"根与子都要"的只有: +// +// Recall 读(上下文检索 / memory_recall) +// Commit 写(自动写入路径的图部分 / memory_commit) +// +// 其余 40 处全是**主 agent 整理记忆**与**记忆整理流水线**: +// - 记忆整理流水线:distill.go 的 archive/review/merge 循环 +// - 记忆块 + 媒体桥:graphmedia.go / medialoop.go +// - 记忆整理工具:memory_merge / memory_delete_entity / memory_block_merge / +// memory_purge / memory_edit / memory_introspect +// +// 所以驻留子的轻量内核**不该有那些代码路径**:它用 `*memory.LightMemory` 接上 `graph`, +// 而 `a.memory`(整理面)保持 nil —— 既有的 22 处 `if a.memory != nil` 关卡 +// 会自动把整理面全部禁掉,不需要写"每个方法都返回错误"的受限包装。 + +import "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + +// GraphMemory 是任意 agent 都能用的图记忆面。 +// +// 实现者: +// - 根 agent:直接就是 *memory.GraphDB +// - 驻留子:*memory.LightMemory(读 temp∪main,只写 temp) +type GraphMemory interface { + // Recall 按关键词/种子实体召回(子的实现是两空间并集)。 + Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error) + // Commit 写入三元组(子的实现只落自己的 temp 空间)。 + Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error) +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index cca2ff9..83c26bf 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -106,12 +106,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin } func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { - if a.memory == nil { + g := a.graphMem() + if g == nil { if tc.Name == "memory_document_query" { return a.executeDocTool(tc) } return "图记忆系统不可用" } + // 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。 + // 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。 + requireFull := func() string { + if a.memory == nil { + return "本 agent 是轻量内核:只能读写图记忆,记忆整理(合并/删除/清理/编辑/统计)不可用" + } + return "" + } switch tc.Name { case "memory_recall": query, _ := tc.Arguments["query_intent"].(string) @@ -127,7 +136,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(keywords) == 1 { keywords = memory.ExtractKeywords(query) } - result, err := a.memory.Recall(keywords, nil, int(depth), "") + result, err := g.Recall(keywords, nil, int(depth), "") if err != nil { return fmt.Sprintf("记忆检索失败: %v", err) } @@ -165,6 +174,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return strings.Join(parts, "\n") case "memory_block_merge": + if msg := requireFull(); msg != "" { + return msg + } entityA, _ := tc.Arguments["entity_a"].(string) entityB, _ := tc.Arguments["entity_b"].(string) rounds, _ := tc.Arguments["rounds"].(float64) @@ -225,6 +237,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc) case "memory_introspect": + if msg := requireFull(); msg != "" { + return msg + } stats, err := a.memory.Introspect() if err != nil { return fmt.Sprintf("查询失败: %v", err) @@ -235,6 +250,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return a.executeDocTool(tc) case "memory_merge": + if msg := requireFull(); msg != "" { + return msg + } source, _ := tc.Arguments["source"].(string) target, _ := tc.Arguments["target"].(string) if source == "" || target == "" { @@ -247,6 +265,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count) case "memory_delete_entity": + if msg := requireFull(); msg != "" { + return msg + } name, _ := tc.Arguments["name"].(string) if name == "" { return "name 不能为空" @@ -257,6 +278,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name) case "memory_purge": + if msg := requireFull(); msg != "" { + return msg + } criteria := make(map[string]string) if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" { criteria["subject_contains"] = v @@ -291,6 +315,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { return strings.Join(parts, ",") case "memory_edit": + if msg := requireFull(); msg != "" { + return msg + } oldSubject, _ := tc.Arguments["old_subject"].(string) oldRelation, _ := tc.Arguments["old_relation"].(string) oldObject, _ := tc.Arguments["old_object"].(string)