Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)
ci / vet-and-test (pull_request) Successful in 41s

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
This commit is contained in:
2026-10-07 16:23:24 +00:00
parent 80a7468523
commit 766a96e817
8 changed files with 256 additions and 5 deletions
+82
View File
@@ -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
}
+94
View File
@@ -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)
}
}
+8 -3
View File
@@ -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) != "" {
+6
View File
@@ -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)
},
+40
View File
@@ -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)
}
}
+24
View File
@@ -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 "" }