feat(lightkernel): N2c —— 轻量内核 profile(窄接口 GraphMemory + nil 即禁用整理面)

按用户指出的关键点(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 干净。
This commit is contained in:
JianFeeeee
2026-09-13 10:02:02 +08:00
parent 93aa942e1e
commit 48cfa8fb6c
6 changed files with 288 additions and 17 deletions

View File

@ -545,11 +545,11 @@ type GraphMemory interface {
| **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18既有全部测试通过这是纯收敛不含新能力 |
| **N1a** | **通道登记层**inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21S23 |
| **N1b** | 输出通道授权过滤 + 目标解析outputch → 目标 agent 的 inputch | S1S3 |
| **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 可写 / 主库只读 / 并集查询 + 应用层合并) | S13S15 ✅ |
| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) |
| — | 其余记忆面doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
| **N2c** | **Agent 级 profile(轻量内核接线)**:见下方施工方案 | S13S15 |
| **N2d** | **晋升与丢弃**`Promote(space, records)`(回收时父把选中的 temp 记录写进 main+ `DropSpace(space)`(回收/销毁时丢弃 temp | S11 数据面 |
| **N2c** | ~~Agent 级 profile~~ **已完成**`GraphMemory` 窄接口Recall/Commit+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13S15 |
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 |
| **N3** | **驻留子生命周期**:创建 / 销毁 / 登记表 / 父退出清理 | S12、S16 |
| **N4** | **跨 agent 投递**:子→父 L3、父→子 L4+ `isKernelLevelSource` 分层) | S4、S6、S17 |
| **N5** | **inputch 处理表**:主动写入工具 + 自动写兜底 + 生命周期 | S8、S9 |

View File

@ -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 是**轻量内核**的图记忆装配(驻留子用;读 tempmain只写 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。

View File

@ -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)

View File

@ -0,0 +1,169 @@
package core
// N2c 验收:**轻量内核 profile**。
//
// 设计 docs/zh/resident-subagent-design.md §16.0(窄接口 + nil 即禁用)与 §5.5。
//
// 轻量内核(驻留子)的记忆装配:
// - graph = *memory.LightMemory读 tempmain只写 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)
}
}

View File

@ -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读 tempmain只写 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)
}

View File

@ -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)