Add the memory port with loop recall injection and the memory_write tool (N3 WP5)
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)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user