Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)
ci / vet-and-test (pull_request) Successful in 41s
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:
Vendored
+1
-1
Submodule deps/agent-native updated: f04c87ac31...0e03cc2144
Vendored
+1
-1
Submodule deps/agent-squad updated: ff36b23191...bf7fe8429b
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "" }
|
||||
|
||||
Reference in New Issue
Block a user