Add the memory port with loop recall injection and the memory_write tool (N3 WP5) #34

Merged
weichen merged 1 commits from feature/n3-memory-wp5 into main 2026-10-07 16:22:14 +00:00
5 changed files with 399 additions and 0 deletions
+36
View File
@@ -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 {
+83
View File
@@ -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()
}
+91
View File
@@ -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)
}
}
+93
View File
@@ -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
}
+96
View File
@@ -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)
}
}
}