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处)。
This commit is contained in:
1 parent
dbcf3df8a3
commit
adad4e1f62
7 files changed
+297
-6
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
@@ -13,13 +14,20 @@ import (
|
||||
// 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
|
||||
}
|
||||
block, err := l.retriever.Inject(ctx, l.personaID, queryFromMessages(messages), 0)
|
||||
env := sessionEnvFrom(ctx)
|
||||
block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages))
|
||||
if err != nil {
|
||||
l.log.Warn("memory inject", "error", err)
|
||||
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
|
||||
return messages
|
||||
}
|
||||
if block == "" {
|
||||
@@ -31,6 +39,51 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod
|
||||
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
|
||||
@@ -78,7 +131,17 @@ func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user