From bdb45c3c2ca2072e998ea89e0a5882e2bb94abc9 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 16:05:36 +0000 Subject: [PATCH] Add the memory port with loop recall injection and the memory_write tool (N3 WP5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the completion doc assigns the Memory port to the library; today memory recall is a product-side interface assertion with no library surface, AgentSpec.MemoryScope has no consumer, and the model has no write-back path. Evidence: GOWORK=off go vet + go test -count=1 ./... green; TestLoopInjectsMemoryAndRegistersWrite (recall block lands after the system section, memory_write executes through the loop), TestEmptyRecallInjectsNothing (empty recall changes nothing), TestBlockRenderOrderingAndCaps (deterministic order, expiry/empty pruning), TestWriteToolValidation (fail-closed scope/type/content). Impact: new opt-in Config.Memory/MemoryScope; nil port keeps behavior byte-identical; injection is request-scoped (never persisted, never accumulates) and deterministic ([记忆] text shape preserved for the product migration). Refs: Agent-System-Documents#43 (N3 plan WP5) --- runtime/loop.go | 36 +++++++++++++ runtime/memory/memory.go | 83 ++++++++++++++++++++++++++++++ runtime/memory/memory_test.go | 91 +++++++++++++++++++++++++++++++++ runtime/memory/write.go | 93 +++++++++++++++++++++++++++++++++ runtime/memory_n3_test.go | 96 +++++++++++++++++++++++++++++++++++ 5 files changed, 399 insertions(+) create mode 100644 runtime/memory/memory.go create mode 100644 runtime/memory/memory_test.go create mode 100644 runtime/memory/write.go create mode 100644 runtime/memory_n3_test.go diff --git a/runtime/loop.go b/runtime/loop.go index 2d6a020..c4f5dbb 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -11,6 +11,7 @@ import ( "agent-native/runtime/hooks" "agent-native/runtime/llm" + "agent-native/runtime/memory" "agent-native/runtime/reminders" "agent-native/runtime/session" "agent-native/runtime/telemetry" @@ -124,6 +125,12 @@ type Config struct { // ProviderName 是 gen_ai.system 属性值(如 openai / anthropic)。 // 空 = "openai"。 ProviderName string + // Memory 是记忆端口(N3 WP5):nil = 不启用。启用时每次请求前 + // Recall 一次并以系统消息注入请求副本(不落盘、不累计)。 + Memory memory.Store + // MemoryScope 是 Recall 的 scope 过滤("" = 不限,含 user/project; + // AgentSpec.MemoryScope 的消费点)。 + MemoryScope string // traceCtx 是本次 Run 的 span 关联(Run 启动时建立;指针随 Config // 副本传播到各轮与各工具)。 traceCtx *traceIDs @@ -187,6 +194,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { cfg.ToolExecution = ExecSequential } serializeHostCallbacks(&cfg) + if cfg.Memory != nil && cfg.Tools != nil { + memory.RegisterWrite(cfg.Tools, cfg.Memory, cfg.MemoryScope) + } cfg.traceCtx = newTraceContext() endRun := startSpan(cfg, "invoke_agent "+agentName(cfg), map[string]string{ "gen_ai.system": providerName(cfg), @@ -750,9 +760,35 @@ func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usag func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message { request := clearOldResults(cfg, persistedProjection(cfg, messages)) + request = injectMemoryBlock(cfg, request) return reminders.Attach(request, turnReminderTexts(cfg, turn, repeats, lengthNudge)) } +// injectMemoryBlock 把记忆召回块插到请求副本的系统段之后(request-scoped: +// 不落盘、不累计;Recall 失败静默跳过——记忆是增强面,不阻断对话)。 +func injectMemoryBlock(cfg Config, request []llm.Message) []llm.Message { + if cfg.Memory == nil { + return request + } + entries, err := cfg.Memory.Recall(context.Background(), memory.RecallQuery{Scope: cfg.MemoryScope}) + if err != nil { + return request + } + block := memory.Block(entries, time.Now()) + if block == "" { + return request + } + // 系统段之后插入(保持历史与尾部不动)。 + i := 0 + for i < len(request) && request[i].Role == llm.RoleSystem { + i++ + } + out := make([]llm.Message, 0, len(request)+1) + out = append(out, request[:i]...) + out = append(out, llm.Message{Role: llm.RoleSystem, Content: block}) + return append(out, request[i:]...) +} + // persistedProjection 在有会话存储时只采用已经落盘的消息。 // steer 与 follow-up 必须先写入存储,下一轮请求才会看见它们。 func persistedProjection(cfg Config, messages []llm.Message) []llm.Message { diff --git a/runtime/memory/memory.go b/runtime/memory/memory.go new file mode 100644 index 0000000..4dcc0c1 --- /dev/null +++ b/runtime/memory/memory.go @@ -0,0 +1,83 @@ +// Package memory 定义记忆端口(N3 WP5)。宿主注入 Store 实现;循环在每次 +// 请求前把 Recall 结果以系统消息注入请求副本(不落盘、不累计——与 +// reminders 同款的 request-scoped 语义);nil = 不启用。 +// +// 注入文案与产品既有 [记忆] 形态对齐(迁移前后同文): +// +// [记忆] +// - (typ) content +// +// 排序确定性:按 (Type, Content) 字典序(同一 Recall 结果集在任何 +// 消费方下渲染一致)。 +package memory + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// Entry 是一条记忆。Scope 为 user 或 project。 +type Entry struct { + ID string + Scope string + ProjectID string + Type string + Content string + Source string + Confidence float64 + CreatedAt time.Time + ExpiresAt *time.Time +} + +// RecallQuery 是一次召回。Scope 空 = 不限(含 user/project);Limit ≤0 = 20。 +type RecallQuery struct { + Scope string + Limit int +} + +// Store 是记忆端口。实现方自担 owner/项目上下文(循环不传租户态)。 +type Store interface { + Recall(ctx context.Context, q RecallQuery) ([]Entry, error) + Append(ctx context.Context, e Entry) error +} + +// MaxRecallEntries 是注入的条目上限(与产品既有形态一致)。 +const MaxRecallEntries = 20 + +// Block 把一次 Recall 结果渲染成注入文本;空结果返回空串(调用方不注入)。 +// 过期与空内容条目在渲染时剔除(与产品既有 InjectMemory 同语义)。 +func Block(entries []Entry, now time.Time) string { + live := make([]Entry, 0, len(entries)) + for _, e := range entries { + if e.ExpiresAt != nil && !e.ExpiresAt.After(now) { + continue + } + if strings.TrimSpace(e.Content) == "" { + continue + } + live = append(live, e) + } + if len(live) == 0 { + return "" + } + sort.Slice(live, func(i, j int) bool { + if live[i].Type != live[j].Type { + return live[i].Type < live[j].Type + } + return live[i].Content < live[j].Content + }) + var b strings.Builder + b.WriteString("[记忆]\n") + n := 0 + for _, e := range live { + fmt.Fprintf(&b, "- (%s) %s\n", e.Type, strings.TrimSpace(e.Content)) + n++ + if n >= MaxRecallEntries { + break + } + } + return b.String() +} diff --git a/runtime/memory/memory_test.go b/runtime/memory/memory_test.go new file mode 100644 index 0000000..8e39533 --- /dev/null +++ b/runtime/memory/memory_test.go @@ -0,0 +1,91 @@ +package memory + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +// ---- N3 WP5:记忆端口 ---- + +type fakeStore struct { + entries []Entry + appended []Entry + recallQ RecallQuery + err error +} + +func (f *fakeStore) Recall(_ context.Context, q RecallQuery) ([]Entry, error) { + f.recallQ = q + return f.entries, f.err +} +func (f *fakeStore) Append(_ context.Context, e Entry) error { + f.appended = append(f.appended, e) + return nil +} + +func TestBlockRenderOrderingAndCaps(t *testing.T) { + future := time.Now().Add(24 * time.Hour) + past := time.Now().Add(-24 * time.Hour) + entries := []Entry{ + {Type: "preference", Content: "喜欢简洁回复"}, + {Type: "fact", Content: "项目用 Go 1.27"}, + {Type: "fact", Content: " "}, + {Type: "fact", Content: "过期了", ExpiresAt: &past}, + {Type: "fact", Content: "没过期", ExpiresAt: &future}, + } + out := Block(entries, time.Now()) + if !strings.Contains(out, "[记忆]\n") { + t.Fatalf("缺块头:%q", out) + } + // 确定性排序:Type 字典序(fact 先于 preference);过期与空内容剔除。 + firstFact := strings.Index(out, "fact") + firstPref := strings.Index(out, "preference") + if firstFact < 0 || firstPref < 0 || firstFact > firstPref { + t.Fatalf("排序不确定:%q", out) + } + if strings.Contains(out, "过期了") { + t.Fatalf("过期条目未剔除:%q", out) + } + if !strings.Contains(out, "没过期") { + t.Fatalf("未过期条目被误剔:%q", out) + } + if Block(nil, time.Now()) != "" { + t.Fatal("空结果应为空串") + } +} + +func TestWriteToolValidation(t *testing.T) { + store := &fakeStore{} + tool := &writeTool{store: store} + cases := []struct { + name string + args string + wantErr string + }{ + {"坏 scope", `{"scope":"global","type":"fact","content":"x"}`, "user 或 project"}, + {"坏 type", `{"type":"todo","content":"x"}`, "fact|preference"}, + {"空 content", `{"type":"fact","content":" "}`, "非空"}, + {"超长 content", `{"type":"fact","content":"` + strings.Repeat("长", 2001) + `"}`, "上限"}, + } + for _, tc := range cases { + res, err := tool.Execute(context.Background(), json.RawMessage(tc.args)) + if err != nil || !res.IsError || !strings.Contains(res.Content, tc.wantErr) { + t.Fatalf("%s:%+v %v", tc.name, res, err) + } + } + if len(store.appended) != 0 { + t.Fatalf("校验失败不得落库:%+v", store.appended) + } + // 合法路径:默认 scope=user + 过期换算。 + res, err := tool.Execute(context.Background(), json.RawMessage(`{"type":"preference","content":"喜欢简洁","expires_in_days":30}`)) + if err != nil || res.IsError { + t.Fatalf("合法写入被拒:%+v %v", res, err) + } + if len(store.appended) != 1 || store.appended[0].Scope != "user" || + store.appended[0].Type != "preference" || store.appended[0].ExpiresAt == nil { + t.Fatalf("写入形态:%+v", store.appended) + } +} diff --git a/runtime/memory/write.go b/runtime/memory/write.go new file mode 100644 index 0000000..6845bc3 --- /dev/null +++ b/runtime/memory/write.go @@ -0,0 +1,93 @@ +package memory + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "agent-native/runtime/tools" +) + +// memory_write 工具:模型自服务写回(fail-closed 校验 scope/type/content)。 +// 只在宿主配置 Store 时注册(RegisterWrite 由循环在 Run 启动时接线)。 +type writeTool struct { + store Store + scope string // 默认 scope(AgentSpec.MemoryScope 驱动;空 = user) +} + +var validScopes = map[string]bool{"user": true, "project": true} +var validTypes = map[string]bool{ + "fact": true, "preference": true, "decision": true, "episode": true, "procedure": true, +} + +// RegisterWrite 把 memory_write 注册进 reg(store nil 时跳过;重复注册按 +// 覆盖处理——宿主不得在同一注册表上给不同 run 挂不同实现)。 +func RegisterWrite(reg *tools.Registry, store Store, defaultScope string) { + if reg == nil || store == nil { + return + } + reg.Register(&writeTool{store: store, scope: defaultScope}) +} + +func (t *writeTool) Name() string { return "memory_write" } +func (t *writeTool) Description() string { + return "把一条值得跨会话记住的事实/偏好/决定写入长期记忆(scope: user 或 project;type: fact|preference|decision|episode|procedure)。" +} +func (t *writeTool) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","required":["type","content"],"properties":{ + "scope":{"type":"string"}, + "type":{"type":"string"}, + "content":{"type":"string"}, + "expires_in_days":{"type":"number"} + }}`) +} + +// maxWriteContent 是单条记忆的内容上限(字符)。 +const maxWriteContent = 2000 + +func (t *writeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) { + var a struct { + Scope string `json:"scope"` + Type string `json:"type"` + Content string `json:"content"` + ExpiresInDays float64 `json:"expires_in_days"` + } + if err := json.Unmarshal(args, &a); err != nil { + return tools.Result{Content: "memory_write: 参数不是完整 JSON 对象", IsError: true}, nil + } + scope := strings.TrimSpace(a.Scope) + if scope == "" { + scope = t.scope + } + if scope == "" { + scope = "user" + } + if !validScopes[scope] { + return tools.Result{Content: fmt.Sprintf("memory_write: scope %q 必须是 user 或 project", scope), IsError: true}, nil + } + typ := strings.TrimSpace(a.Type) + if !validTypes[typ] { + return tools.Result{Content: fmt.Sprintf("memory_write: type %q 必须是 fact|preference|decision|episode|procedure 之一", typ), IsError: true}, nil + } + content := strings.TrimSpace(a.Content) + if content == "" { + return tools.Result{Content: "memory_write: content 必填且非空", IsError: true}, nil + } + if len([]rune(content)) > maxWriteContent { + return tools.Result{Content: fmt.Sprintf("memory_write: content 超过 %d 字符上限", maxWriteContent), IsError: true}, nil + } + var expires *time.Time + if a.ExpiresInDays > 0 { + t := time.Now().Add(time.Duration(a.ExpiresInDays*24) * time.Hour) + expires = &t + } + if err := t.store.Append(ctx, Entry{ + Scope: scope, Type: typ, Content: content, + Source: "agent", CreatedAt: time.Now(), ExpiresAt: expires, + }); err != nil { + return tools.Result{Content: "memory_write: " + err.Error(), IsError: true}, nil + } + return tools.Result{Content: fmt.Sprintf("已记住(scope=%s type=%s)", scope, typ)}, nil +} diff --git a/runtime/memory_n3_test.go b/runtime/memory_n3_test.go new file mode 100644 index 0000000..ada5627 --- /dev/null +++ b/runtime/memory_n3_test.go @@ -0,0 +1,96 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + "agent-native/runtime/llm" + "agent-native/runtime/memory" + "agent-native/runtime/tools" +) + +// ---- N3 WP5:循环消费点(注入 + memory_write 接线)---- + +type loopMemStore struct { + entries []memory.Entry + appended []memory.Entry +} + +func (s *loopMemStore) Recall(_ context.Context, q memory.RecallQuery) ([]memory.Entry, error) { + return s.entries, nil +} +func (s *loopMemStore) Append(_ context.Context, e memory.Entry) error { + s.appended = append(s.appended, e) + return nil +} + +// 端口注入的条目出现在请求副本系统段之后;nil 端口零变化; +// Run 启动注册 memory_write 且可调用。 +func TestLoopInjectsMemoryAndRegistersWrite(t *testing.T) { + store := &loopMemStore{entries: []memory.Entry{{Type: "fact", Content: "项目用 Go 1.27"}}} + reg := tools.NewRegistry(t.TempDir()) + var gotReq [][]llm.Message + res, err := Run(context.Background(), Config{ + Model: "fake", Tools: reg, Memory: store, MaxTurns: 2, + Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event { + gotReq = append(gotReq, append([]llm.Message{}, msgs...)) + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ + Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, + ToolCalls: []llm.ToolCall{{ID: "c1", Name: "memory_write", Arguments: `{"type":"fact","content":"主人喜欢简洁"}`}}, + }} + close(ch) + return ch + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + _ = res + if len(gotReq) < 1 { + t.Fatalf("请求次数 = %d", len(gotReq)) + } + req := gotReq[0] + // [记忆] 块在系统段之后、历史之前。 + sysEnd := 0 + for sysEnd < len(req) && req[sysEnd].Role == llm.RoleSystem { + sysEnd++ + } + var found bool + for i := 0; i < sysEnd; i++ { + if strings.Contains(req[i].Content, "[记忆]") && strings.Contains(req[i].Content, "项目用 Go 1.27") { + found = true + } + } + if !found { + t.Fatalf("记忆块未注入系统段:%#v", req) + } + // memory_write 已被注册并执行(脚本化模型每轮都点名它 → 至少一次)。 + if len(store.appended) < 1 || store.appended[0].Content != "主人喜欢简洁" { + t.Fatalf("memory_write 未执行:%+v", store.appended) + } +} + +// 空召回:不产生 [记忆] 块(注入静默跳过);nil 端口同语义。 +func TestEmptyRecallInjectsNothing(t *testing.T) { + reg := tools.NewRegistry(t.TempDir()) + var gotReq []llm.Message + if _, err := Run(context.Background(), Config{ + Model: "fake", Tools: reg, Memory: &loopMemStore{}, + Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event { + gotReq = append([]llm.Message{}, msgs...) + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}} + close(ch) + return ch + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}); err != nil { + t.Fatal(err) + } + for _, m := range gotReq { + if strings.Contains(m.Content, "[记忆]") { + t.Fatalf("空召回不得产生记忆块:%#v", gotReq) + } + } +}