From 766a96e817d34a4dbfab9b7fa468ba28b6a4b1fe Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 16:23:24 +0000 Subject: [PATCH] Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: memory recall lived as a product-side interface assertion with no library port, AgentSpec.MemoryScope had no consumer, and the model had no write-back path. The port (Agent-Native#34) now exists; the product adapts it over memory_entries. Evidence: go test -count=1 ./... green; TestStoreMemoryRecallScopeAndLimit (scope filters and limit), TestStoreMemoryAppendRoundtrip (project context inheritance), TestRunCtxStoreMemory (runCtx outlet), TestDirectRunInjectsMemoryBlock (the [记忆] block reaches the request with the pre-migration text shape and deterministic order). Impact: assembly-side injection removed (the loop injects per request — same text, no duplication); memory_write available on the kernel path; recall defaults to all scopes (pre-migration semantics), with AgentSpec.MemoryScope narrowing where hosts pass it. Refs: Agent-Native#34, Agent-Squad#38, N3 plan WP5 --- deps/agent-native | 2 +- deps/agent-squad | 2 +- internal/runmgr/memory.go | 82 ++++++++++++++++++++++ internal/runmgr/memory_test.go | 94 ++++++++++++++++++++++++++ internal/runtime/llm.go | 11 ++- internal/runtime/llm_direct.go | 6 ++ internal/runtime/llm_direct_n3_test.go | 40 +++++++++++ internal/runtime/llm_test.go | 24 +++++++ 8 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 internal/runmgr/memory.go create mode 100644 internal/runmgr/memory_test.go diff --git a/deps/agent-native b/deps/agent-native index f04c87a..0e03cc2 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit f04c87ac317efc7281b614827a6a2ec879fa5b69 +Subproject commit 0e03cc2144471661a4c6423fb1dd8779f6454c2a diff --git a/deps/agent-squad b/deps/agent-squad index ff36b23..bf7fe84 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit ff36b231916531f16b166d3a9142d54715f0c7e9 +Subproject commit bf7fe8429bdfa7ae1cbf021de11111b9e581fa2f diff --git a/internal/runmgr/memory.go b/internal/runmgr/memory.go new file mode 100644 index 0000000..d8ffc39 --- /dev/null +++ b/internal/runmgr/memory.go @@ -0,0 +1,82 @@ +package runmgr + +import ( + "context" + "time" + + "agent-native/runtime/memory" + core "agent-system-core" + "agent-system-backend/internal/store" +) + +// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。 +// owner/项目态由适配器持有(端口本身不传租户态)。 +type storeMemory struct { + st *store.Store + owner string + projectID string +} + +// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。 +func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store { + if owner == "" { + owner = store.OwnerDefault + } + return &storeMemory{st: st, owner: owner, projectID: projectID} +} + +// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义), +// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。 +func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) { + projectID := "" + if q.Scope == "project" { + projectID = m.projectID + } + list, err := m.st.ListMemory(m.owner, q.Scope, projectID) + if err != nil { + return nil, err + } + limit := q.Limit + if limit <= 0 { + limit = memory.MaxRecallEntries + } + out := make([]memory.Entry, 0, len(list)) + for _, e := range list { + out = append(out, memory.Entry{ + ID: e.ID, Scope: e.Scope, ProjectID: string(e.ProjectID), Type: e.Type, + Content: e.Content, Source: e.Source, Confidence: e.Confidence, + CreatedAt: e.CreatedAt, ExpiresAt: e.ExpiresAt, + }) + if len(out) >= limit { + break + } + } + return out, nil +} + +// StoreMemory 是 runCtx 的端口出口(llm_direct 经接口断言取用)。 +func (c *runCtx) StoreMemory() memory.Store { + if c.mgr == nil || c.mgr.st == nil { + return nil + } + return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID) +} + +// Append 写回一条(project scope 用适配器的项目上下文)。 +func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error { + projectID := e.ProjectID + if e.Scope == "project" && projectID == "" { + projectID = m.projectID + } + now := time.Now().UTC() + created := e.CreatedAt + if created.IsZero() { + created = now + } + _, err := m.st.PutMemory(m.owner, core.MemoryEntry{ + ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type, + Content: e.Content, Source: e.Source, Confidence: e.Confidence, + CreatedAt: created, ExpiresAt: e.ExpiresAt, + }) + return err +} diff --git a/internal/runmgr/memory_test.go b/internal/runmgr/memory_test.go new file mode 100644 index 0000000..e13b221 --- /dev/null +++ b/internal/runmgr/memory_test.go @@ -0,0 +1,94 @@ +package runmgr + +import ( + "context" + "path/filepath" + "testing" + "time" + + "agent-native/runtime/memory" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// ---- N3 WP5:storeMemory 适配器 ---- + +func openMemStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestStoreMemoryRecallScopeAndLimit(t *testing.T) { + st := openMemStore(t) + put := func(scope, project, typ, content string) { + t.Helper() + if _, err := st.PutMemory("u1", core.MemoryEntry{Scope: scope, ProjectID: core.ID(project), Type: typ, Content: content, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + } + put("user", "", "fact", "用户事实") + put("project", "p1", "decision", "项目决定") + put("project", "p2", "decision", "别的项目") + m := NewStoreMemory(st, "u1", "p1") + // 空 scope = 全量(迁移前 Memories() 语义)。 + all, err := m.Recall(context.Background(), memory.RecallQuery{}) + if err != nil || len(all) != 3 { + t.Fatalf("全量召回:%d %v", len(all), err) + } + // user scope。 + users, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "user"}) + if len(users) != 1 || users[0].Content != "用户事实" { + t.Fatalf("user scope:%+v", users) + } + // project scope 用适配器的项目上下文(p1,不含 p2)。 + projs, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "project"}) + if len(projs) != 1 || projs[0].Content != "项目决定" { + t.Fatalf("project scope:%+v", projs) + } + // Limit。 + limited, _ := m.Recall(context.Background(), memory.RecallQuery{Limit: 2}) + if len(limited) != 2 { + t.Fatalf("Limit:%d", len(limited)) + } +} + +func TestStoreMemoryAppendRoundtrip(t *testing.T) { + st := openMemStore(t) + m := NewStoreMemory(st, "u1", "p1") + future := time.Now().Add(48 * time.Hour) + if err := m.Append(context.Background(), memory.Entry{ + Scope: "project", Type: "procedure", Content: "先跑 lint", Source: "agent", ExpiresAt: &future, + }); err != nil { + t.Fatal(err) + } + list, err := st.ListMemory("u1", "project", "p1") + if err != nil || len(list) != 1 { + t.Fatalf("回读:%d %v", len(list), err) + } + got := list[0] + if got.Type != "procedure" || got.Content != "先跑 lint" || got.Source != "agent" || + got.ProjectID != "p1" || got.ExpiresAt == nil { + t.Fatalf("写入形态:%+v", got) + } +} + +// runCtx 出口:同一 store 上的 runCtx 暴露同一端口语义。 +func TestRunCtxStoreMemory(t *testing.T) { + c, st := newSpawnEnv(t, &gateRunner{}) + if _, err := st.PutMemory(c.run.Owner, core.MemoryEntry{Scope: "user", Type: "fact", Content: "跨会话事实", CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + port := c.StoreMemory() + if port == nil { + t.Fatal("StoreMemory 必须非空") + } + entries, err := port.Recall(context.Background(), memory.RecallQuery{}) + if err != nil || len(entries) != 1 || entries[0].Content != "跨会话事实" { + t.Fatalf("runCtx 召回:%+v %v", entries, err) + } +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index a92aad5..fed67fd 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -16,6 +16,7 @@ import ( "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" "agent-system-backend/internal/quality" + agentmemory "agent-native/runtime/memory" core "agent-system-core" ) @@ -228,9 +229,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } // 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。 resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) } - if ms, ok := rc.(interface{ Memories() []core.MemoryEntry }); ok { - sys = ctxasm.InjectMemory(sys, ms.Memories()) - } + // 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入, + // 组装侧不再注入(防双份;注入文案同一形态)。 + _ = ctxasm.InjectMemory msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact) tail := ctxasm.Tail{ Instructions: eff.GetString("context.userInstructions", ""), @@ -399,6 +400,10 @@ func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key strin return 0 } +// memoryStore 是记忆端口的最小面(agent-native runtime/memory.Store 的本地别名; +// 组合根经 runCtx 注入,runmgr 的 storeMemory 实现之)。 +type memoryStore = agentmemory.Store + // agentNameOf 是 OTel gen_ai.agent.name 的取值(画像 ID,空回退 agent-system)。 func agentNameOf(prof core.AgentProfile) string { if strings.TrimSpace(prof.ID) != "" { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 4d9f5b8..145554b 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -29,6 +29,7 @@ type directRun struct { rc RunCtx eff core.EffectiveSettings cardflow bool + memoryStore memoryStore finalParts []core.Part explicitCard bool deliverables []*core.ArtifactRef @@ -92,6 +93,10 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int) error { st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow} + // 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。 + if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok { + st.memoryStore = ms.StoreMemory() + } st.loadChecklist() if msg, ok := lastAddressed(rc.History()); ok && msg.Role == core.RoleSystem { var text string @@ -205,6 +210,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin PermissionAsk: PermissionAskOf(rc), Trace: l.Trace, AgentName: agentNameOf(rc.Profile()), + Memory: st.memoryStore, UnknownTool: func(call agentllm.ToolCall) agenttools.Result { return st.runTool(call.Name, call.Arguments) }, diff --git a/internal/runtime/llm_direct_n3_test.go b/internal/runtime/llm_direct_n3_test.go index d1f9bf0..1d69607 100644 --- a/internal/runtime/llm_direct_n3_test.go +++ b/internal/runtime/llm_direct_n3_test.go @@ -124,3 +124,43 @@ func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) { } } } + +// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)---- + +// 端口注入的 [记忆] 块到达请求(迁移前经组装侧 InjectMemory 同文案形态)。 +func TestDirectRunInjectsMemoryBlock(t *testing.T) { + var gotReq []agentllm.Message + base := scriptStream(textTurns("收到"), nil) + rc := &fakeRunCtx{ + ctx: context.Background(), + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, + memEntries: []memEntry{ + {typ: "fact", content: "项目用 Go 1.27"}, + {typ: "preference", content: "喜欢简洁回复"}, + }, + } + r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + gotReq = append([]agentllm.Message{}, msgs...) + return base(ctx, model, msgs, tools, opts) + }} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + var block string + for _, m := range gotReq { + if strings.Contains(m.Content, "[记忆]") { + block = m.Content + } + } + if block == "" { + t.Fatalf("记忆块缺失:%#v", gotReq) + } + // 形态对照(与 ctxasm.InjectMemory 同文):[记忆]\n- (typ) content,确定性排序。 + if !strings.Contains(block, "- (fact) 项目用 Go 1.27") || + !strings.Contains(block, "- (preference) 喜欢简洁回复") { + t.Fatalf("记忆块形态不对:%q", block) + } + if strings.Index(block, "(fact)") > strings.Index(block, "(preference)") { + t.Fatalf("排序不确定:%q", block) + } +} diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 62c89b9..a29305a 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -2,6 +2,8 @@ package runtime import ( "context" + + agentmemory "agent-native/runtime/memory" "errors" "fmt" "strings" @@ -40,6 +42,7 @@ type fakeRunCtx struct { bgID string bgErr error sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试) + memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试) bgCalls int } @@ -102,6 +105,27 @@ func (f *fakeRunCtx) ConversationSandbox() string { return "" } +// memEntry 是测试用记忆条目(避免 fake 引入库类型)。 +type memEntry struct{ typ, content string } + +type fakeMemStore struct{ entries []memEntry } + +func (s fakeMemStore) Recall(_ context.Context, q agentmemory.RecallQuery) ([]agentmemory.Entry, error) { + out := make([]agentmemory.Entry, 0, len(s.entries)) + for _, e := range s.entries { + out = append(out, agentmemory.Entry{Type: e.typ, Content: e.content}) + } + return out, nil +} +func (s fakeMemStore) Append(_ context.Context, e agentmemory.Entry) error { return nil } + +func (f *fakeRunCtx) StoreMemory() agentmemory.Store { + if f.memEntries == nil { + return nil + } + return fakeMemStore{entries: f.memEntries} +} + func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil } func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil } func (f *fakeRunCtx) Subtask() string { return "" }