Files
Cyrene_v2/internal/runtime/loop/memory.go
T
Aska adad4e1f62
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(session): 群聊/陌生人记忆隔离——MemWritable 贯穿 loop(AGENTS.md 红线)
群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。

- loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话
  隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道
  消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。
- loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变;
  false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与
  ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写
  false 直接 return 跳过(不调用 extractor 不落库)。
- 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner
  行为不变);backthink/automations/orchestration 默认 owner 全量。
- 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿;
  loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。

遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope
过滤选项消除与 Inject 模板的重复(2处)。
2026-09-06 20:34:06 +08:00

192 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// injectMemory retrieves the persona's relevant long-term memories and prepends
// them as a leading system message, so the model can ground its answer in recalled
// context (design §6 / §2). It is re-run every round via assembleContext so the
// retrieval reflects the latest conversation rather than a first-turn snapshot. It
// returns the input messages unchanged when no retriever/persona is configured,
// retrieval fails, or nothing relevant is found.
//
// The session env on ctx gates the retrieval: the owner-full env keeps the full
// recall (unchanged behavior); the restricted env (group chat / stranger,
// design/02 §3, design/11 §2) is read-only isolation — only non-owner-private
// project memory is injected so no owner-sensitive memory leaks to a group or
// stranger.
func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message {
if l.retriever == nil || l.personaID == "" {
return messages
}
env := sessionEnvFrom(ctx)
block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages))
if err != nil {
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
return messages
}
if block == "" {
return messages
}
out := make([]model.Message, 0, len(messages)+1)
out = append(out, model.TextMessage(model.RoleSystem, block))
out = append(out, messages...)
return out
}
// retrieveMemoryBlock returns the memory injection block for env. The owner-full
// env keeps the pre-existing Inject behavior. The restricted env (group chat /
// stranger) injects only project-scope memory (人人可读, not owner-private) and
// marks the block as group-isolated so callers can tell an isolated read from the
// owner's full read.
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, query string) (string, error) {
if !env.Restricted() {
return l.retriever.Inject(ctx, l.personaID, query, 0)
}
results, err := l.retriever.Retrieve(ctx, l.personaID, query, 0)
if err != nil {
return "", err
}
shared := make([]memory.Result, 0, len(results))
for _, r := range results {
if r.Item.Scope == memory.ScopeProject {
shared = append(shared, r)
}
}
if len(shared) == 0 {
return "", nil
}
return formatMemoryBlock(shared, "群聊隔离"), nil
}
// formatMemoryBlock builds the leading system block from retrieved results. The
// non-empty marker prefixes the block so an isolated group read is distinguishable
// from the owner's full read.
func formatMemoryBlock(results []memory.Result, marker string) string {
var b strings.Builder
if marker != "" {
b.WriteString(marker)
b.WriteString(":")
}
b.WriteString("相关记忆(按相关度 × 衰减排序):")
for _, res := range results {
b.WriteByte('\n')
b.WriteString("- [")
b.WriteString(string(res.Item.Kind))
b.WriteString("] ")
b.WriteString(res.Item.Content)
}
return b.String()
}
// assembleContext builds the model-visible message list for one round (design
// §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it
// re-injects the persona's relevant long-term memory — retrieval is re-run every
// round from the current conversation, not cached from the first turn, so it
// supports cross-round refresh — folds in the per-session working-memory summary
// when present, and applies the optional TransformContext hook last so a caller
// can project/filter what the model actually sees. It returns the input unchanged
// when nothing is configured.
func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message {
var leading []model.Message
if l.working != nil {
if s := l.working.Summarize(); s != "" {
leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s))
}
}
leading = append(leading, l.injectMemory(ctx, messages)...)
out := leading
if l.transformContext != nil {
out = l.transformContext(ctx, leading, round)
}
return out
}
// compactIfNeeded compacts the conversation when its estimated context exceeds
// the configured MaxContextTokens. It is a no-op when compaction is disabled
// (non-positive MaxContextTokens), the compactor is nil, the estimate is within
// budget, or the compactor returns an error — a compaction failure is logged and
// the conversation is kept as-is rather than failing the run.
func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []model.Message {
if l.maxContextTokens <= 0 || l.compactor == nil {
return messages
}
compacted, err := l.compactor.Compact(ctx, messages, l.maxContextTokens)
if err != nil {
l.log.Warn("context compact", "error", err, "tokens", l.maxContextTokens)
return messages
}
if compacted == nil {
return messages
}
return compacted
}
// extractMemory runs the extraction hook (design §6) over the current
// conversation and persists any derived items. Errors are logged, never fatal, so
// a bad extraction cannot abort the run.
//
// In a restricted context (group chat / stranger, !MemWritable) extraction is
// skipped entirely: the conversation may read shared memory read-only and may
// never persist anything that could leak owner-sensitive information (design/02
// §3, design/11 §2).
func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) {
env := sessionEnvFrom(ctx)
if env.Restricted() {
l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv)
return
}
if l.extractor == nil || l.memory == nil {
return
}
items, err := l.extractor.Extract(ctx, messages)
if err != nil {
l.log.Warn("memory extract", "step", step, "error", err)
return
}
for _, it := range items {
if it.PersonaID == "" && l.personaID != "" {
it.PersonaID = l.personaID
}
if _, err := l.memory.Add(ctx, it); err != nil {
l.log.Warn("memory store", "step", step, "error", err)
}
}
}
// queryFromMessages derives a retrieval query from the conversation: the last
// user turn's text, falling back to the last message's text, then "".
func queryFromMessages(messages []model.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == model.RoleUser {
return messageText(messages[i])
}
}
if n := len(messages); n > 0 {
return messageText(messages[n-1])
}
return ""
}
// messageText returns the concatenated plain-text payload of a message's text
// parts, which is the shared view used to build a retrieval query.
func messageText(m model.Message) string {
var b strings.Builder
for _, part := range m.Content {
if part.Type != model.ContentTypeText {
continue
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(part.Text)
}
return b.String()
}