feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。 - ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线) - ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3) - ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId - Docs/api-client 版本 +1 - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
This commit is contained in:
1 parent
3732c538cc
commit
5969bba3bd
22 files changed
+1396
-23
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file owns the speaker attribution of the model context (DevDocs/design/22
|
||||
// §7-5). A process now hosts several roles at once and a session keeps its
|
||||
// conversation when its role is switched, so the model must be able to tell which
|
||||
// utterances in the context came from which role: the assistant messages of a
|
||||
// previous role must not read as "something I said", and the role answering now
|
||||
// must recognize itself.
|
||||
//
|
||||
// Two hard rules shape the rendering:
|
||||
// - The attributed metadata never enters a message body. A user message is
|
||||
// free text from the outside world, so letting it carry system-looking markup
|
||||
// would hand whoever writes it a chance to impersonate a system instruction
|
||||
// (design/11 §10). Attribution therefore travels as its own system messages,
|
||||
// never as a prefix inside user or assistant text.
|
||||
// - The rendering only ADDS system messages. It never reorders, rewrites or
|
||||
// drops a message, so the assembled prompt keeps its established shape: the
|
||||
// agent's instructions stay the first system message and the time / working
|
||||
// memory / memory blocks keep their existing order (assembleContext owns the
|
||||
// placement; this file only supplies the extra segment).
|
||||
//
|
||||
// The per-message speaker is read from the structured model.Message.SenderName
|
||||
// lane, because model/** is frozen in this batch and a Message has no dedicated
|
||||
// persona field. Only assistant messages are read: for an assistant turn the
|
||||
// sender IS the role that spoke it, while a labelled user message keeps its
|
||||
// pre-existing meaning (the third-party sender marker of design/16 §4.6), which
|
||||
// this file deliberately leaves alone.
|
||||
|
||||
// Attribution markers. Both are compile-time constants so no magic string
|
||||
// appears in the run path and a test can assert the exact wire shape.
|
||||
const (
|
||||
// roleContextMarker opens the leading segment that declares the speaking role.
|
||||
roleContextMarker = "[会话角色] "
|
||||
// speakerSwitchMarker opens a boundary note inserted where the speaker of the
|
||||
// conversation changes.
|
||||
speakerSwitchMarker = "[发言归属] "
|
||||
)
|
||||
|
||||
// speakerOf returns the role id labelled as the speaker of m, or "" when the
|
||||
// message carries no attribution. Only assistant messages are considered: a user
|
||||
// message's SenderName is the third-party display name lane of design/16 §4.6 and
|
||||
// has nothing to do with persona selection.
|
||||
func speakerOf(m model.Message) string {
|
||||
if m.Role != model.RoleAssistant {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.SenderName)
|
||||
}
|
||||
|
||||
// anySpeakerLabel reports whether any message of the conversation carries a
|
||||
// speaker label. When none does, attribution rendering is a complete no-op: a
|
||||
// single-role conversation (or a caller that never labels its messages) keeps the
|
||||
// exact pre-multi-character prompt it had before.
|
||||
func anySpeakerLabel(msgs []model.Message) bool {
|
||||
for _, m := range msgs {
|
||||
if speakerOf(m) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attributeConversation renders the role context for one model step. It returns
|
||||
// the leading role declaration (ok = false when the conversation carries no
|
||||
// speaker label at all), and the conversation with a boundary note inserted
|
||||
// wherever the labelled speaker changes.
|
||||
//
|
||||
// The note announces the speaker for every labelled message that follows it until
|
||||
// the next note, which is exactly the granularity the model needs to answer "第 N
|
||||
// 条是谁说的" without touching a single message body. Rendering happens at request
|
||||
// build time from the live slice, so a window that was truncated or compacted in
|
||||
// the meantime can never end up with a message under the wrong speaker: a dropped
|
||||
// note is regenerated, and no note claims a position it does not have.
|
||||
func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) (model.Message, bool, []model.Message) {
|
||||
if !anySpeakerLabel(msgs) {
|
||||
return model.Message{}, false, msgs
|
||||
}
|
||||
current := l.personaFor(ctx)
|
||||
out := make([]model.Message, 0, len(msgs)+2)
|
||||
prev := ""
|
||||
for _, m := range msgs {
|
||||
if s := speakerOf(m); s != "" && s != prev {
|
||||
out = append(out, model.TextMessage(model.RoleSystem, l.speakerSwitchText(s, current)))
|
||||
prev = s
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return l.roleContextMessage(current), true, out
|
||||
}
|
||||
|
||||
// roleContextMessage builds the leading segment that declares which role is
|
||||
// answering, so a role that was just switched in has a strong sense of its own
|
||||
// identity on top of its instructions (design/22 §7-5). It states the boundary
|
||||
// rule for the notes below: an attributed utterance belongs to the role it was
|
||||
// labelled with, not to the role answering now.
|
||||
func (l *Loop) roleContextMessage(current string) model.Message {
|
||||
var b strings.Builder
|
||||
b.WriteString(roleContextMarker)
|
||||
if current != "" {
|
||||
b.WriteString("你正以")
|
||||
b.WriteString(l.speakerLabel(current))
|
||||
b.WriteString("的身份对话。")
|
||||
}
|
||||
b.WriteString("本会话此前由不同角色相继发言:下面凡是标了「发言归属」的发言,都只属于标注的那个角色——" +
|
||||
"不要把别个角色说过的话当成你自己说过的,也不要继承他们的身份、口吻或立场;" +
|
||||
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
|
||||
return model.TextMessage(model.RoleSystem, b.String())
|
||||
}
|
||||
|
||||
// speakerSwitchText builds the boundary note for one speaking role. A stretch of
|
||||
// the answering role's own history is stated as "来自你自己" (so the model keeps
|
||||
// continuity with what it said before a switch), every other role is stated as
|
||||
// belonging to that role and explicitly not to the answering one.
|
||||
func (l *Loop) speakerSwitchText(personaID, current string) string {
|
||||
label := l.speakerLabel(personaID)
|
||||
if personaID != "" && personaID == current {
|
||||
return speakerSwitchMarker + "以下发言来自你自己——" + label + "。"
|
||||
}
|
||||
return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。"
|
||||
}
|
||||
|
||||
// speakerLabel renders a role reference for the model: the role's display name
|
||||
// with its id when a name resolver knows the role, the id alone otherwise. It
|
||||
// never returns an empty string, so a note always names someone.
|
||||
func (l *Loop) speakerLabel(personaID string) string {
|
||||
name := ""
|
||||
if l.personaName != nil {
|
||||
name = strings.TrimSpace(l.personaName(personaID))
|
||||
}
|
||||
if name == "" || name == personaID {
|
||||
return "「" + personaID + "」"
|
||||
}
|
||||
return "「" + name + "」(" + personaID + ")"
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file covers design/22 §7-5 from the model's side: a conversation whose
|
||||
// assistant messages were spoken by different roles must reach the provider with
|
||||
// that attribution visible, without a single character being added to a message
|
||||
// body and without disturbing the assembled prompt's established order.
|
||||
|
||||
// speakerMsg builds one conversation message labelled with the role that spoke
|
||||
// it, exactly the way the gateway's transcript seeding labels an assistant turn
|
||||
// (model.Message.SenderName carries the speaking role id).
|
||||
func speakerMsg(role model.Role, personaID, text string) model.Message {
|
||||
m := model.TextMessage(role, text)
|
||||
m.SenderName = personaID
|
||||
return m
|
||||
}
|
||||
|
||||
// attributedLoop builds a two-role loop: instructions say which role answered, and
|
||||
// the name resolver mirrors the persona registry's id→display-name mapping.
|
||||
func attributedLoop(t *testing.T, ad model.Adapter) *Loop {
|
||||
t.Helper()
|
||||
xilian := personaAgent(t, "你是昔涟。", ad)
|
||||
yuki := personaAgent(t, "你是由纪。", ad)
|
||||
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
|
||||
return New(Options{
|
||||
Agent: xilian,
|
||||
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
|
||||
PersonaID: "xilian",
|
||||
PersonaName: func(id string) string { return names[id] },
|
||||
})
|
||||
}
|
||||
|
||||
// seedMemory writes one persona-scoped memory item for personaID so the round's
|
||||
// retrieval has something to inject.
|
||||
func seedMemory(t *testing.T, store *memory.Store, personaID, content string) {
|
||||
t.Helper()
|
||||
if _, err := store.Add(context.Background(), memory.MemoryItem{
|
||||
PersonaID: personaID, Content: content,
|
||||
Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed memory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// systemTexts returns the text of every system message of req, in order.
|
||||
func systemTexts(req model.Request) []string {
|
||||
out := make([]string, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == model.RoleSystem {
|
||||
out = append(out, messageText(m))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// indexOfSystemContaining returns the index of the first message whose text
|
||||
// contains want, or -1.
|
||||
func indexOfSystemContaining(req model.Request, want string) int {
|
||||
for i, m := range req.Messages {
|
||||
if strings.Contains(messageText(m), want) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestAttribution_SpeakersReachTheModelRequest is the acceptance case of
|
||||
// design/22 §7-5: the session now speaks as Y while the context still holds
|
||||
// utterances by X. The request the adapter receives must (a) open with Y's own
|
||||
// instructions, (b) declare which role is answering, (c) mark where the speaker
|
||||
// changed so "第 N 条是谁说的" is decidable, and (d) leave every message body
|
||||
// untouched — the attribution travels only in system messages.
|
||||
func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在呢"}}}}
|
||||
l := attributedLoop(t, ad)
|
||||
|
||||
msgs := []model.Message{
|
||||
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
|
||||
speakerMsg(model.RoleAssistant, "xilian", "还有一件事"),
|
||||
model.TextMessage(model.RoleUser, "继续"),
|
||||
speakerMsg(model.RoleAssistant, "yuki", "我在呢"),
|
||||
model.TextMessage(model.RoleUser, "你好"),
|
||||
}
|
||||
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-y", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := lastChatRequest(t, ad)
|
||||
|
||||
// (a) the role that answers now still owns the first system message.
|
||||
if got := messageText(req.Messages[0]); got != "你是由纪。" {
|
||||
t.Fatalf("first system = %q, want the bound role's instructions", got)
|
||||
}
|
||||
|
||||
sys := systemTexts(req)
|
||||
// instructions + the role-context segment + one boundary note per speaker
|
||||
// stretch (X's two assistant turns are one stretch).
|
||||
if len(sys) != 4 {
|
||||
t.Fatalf("system messages = %d (%q), want 4 (instructions + role context + two speaker boundaries)", len(sys), sys)
|
||||
}
|
||||
if sys[0] != "你是由纪。" {
|
||||
t.Fatalf("system[0] = %q, want the instructions untouched at the head", sys[0])
|
||||
}
|
||||
if !strings.HasPrefix(sys[1], roleContextMarker) {
|
||||
t.Fatalf("system[1] = %q, want the role context segment", sys[1])
|
||||
}
|
||||
for _, want := range []string{"「由纪」(yuki)", "不同角色相继发言"} {
|
||||
if !strings.Contains(sys[1], want) {
|
||||
t.Fatalf("role context %q missing %q", sys[1], want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sys[1], speakerSwitchMarker) {
|
||||
t.Fatalf("role context %q must not be mistaken for a boundary note", sys[1])
|
||||
}
|
||||
if !strings.HasPrefix(sys[2], speakerSwitchMarker) || !strings.Contains(sys[2], "「昔涟」(xilian)") || !strings.Contains(sys[2], "不是你说的") {
|
||||
t.Fatalf("system[2] = %q, want X's boundary note marking its utterances as not yours", sys[2])
|
||||
}
|
||||
if !strings.HasPrefix(sys[3], speakerSwitchMarker) || !strings.Contains(sys[3], "来自你自己") || !strings.Contains(sys[3], "「由纪」(yuki)") {
|
||||
t.Fatalf("system[3] = %q, want Y's own boundary note naming the answering role", sys[3])
|
||||
}
|
||||
|
||||
// (c) the boundary sits directly before the first utterance it covers, and the
|
||||
// second X utterance inherits the same speaker without a second note.
|
||||
firstX := indexOfSystemContaining(req, "我记住了")
|
||||
if firstX < 0 || req.Messages[firstX-1].Role != model.RoleSystem {
|
||||
t.Fatalf("X's first utterance at %d is not preceded by a boundary note (%+v)", firstX, req.Messages)
|
||||
}
|
||||
if idx := indexOfSystemContaining(req, "「昔涟」(xilian)"); idx > firstX {
|
||||
t.Fatalf("X boundary at %d must precede its utterance at %d", idx, firstX)
|
||||
}
|
||||
// The answering role's own history is re-opened by its own boundary note.
|
||||
self := indexOfSystemContaining(req, "我在呢")
|
||||
if self < 0 || !strings.Contains(messageText(req.Messages[self-1]), "来自你自己") {
|
||||
t.Fatalf("Y's utterance is not preceded by its self boundary: %+v", req.Messages)
|
||||
}
|
||||
|
||||
// (d) no body was rewritten: user text stays verbatim and carries no metadata,
|
||||
// and the assistant's own words are unchanged.
|
||||
var users []string
|
||||
for _, m := range req.Messages {
|
||||
if m.Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
users = append(users, messageText(m))
|
||||
}
|
||||
if len(users) != 2 || users[0] != "继续" || users[1] != "你好" {
|
||||
t.Fatalf("user bodies = %q, want the verbatim [继续 你好] (no system metadata in the user lane)", users)
|
||||
}
|
||||
for _, u := range users {
|
||||
if strings.Contains(u, speakerSwitchMarker) || strings.Contains(u, roleContextMarker) {
|
||||
t.Fatalf("user body %q carries system attribution metadata", u)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{"我记住了", "还有一件事", "我在呢"} {
|
||||
if idx := indexOfSystemContaining(req, body); idx >= 0 && strings.Contains(messageText(req.Messages[idx]), "「") {
|
||||
t.Fatalf("assistant body %q was rewritten with attribution", messageText(req.Messages[idx]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleContext_AttributionKeepsBlockOrder pins the placement rule: the role
|
||||
// declaration is added AFTER the established leading blocks (time, working memory,
|
||||
// memory) and immediately before the conversation, so the memory/time block order
|
||||
// and the instructions-first rule are untouched.
|
||||
func TestAssembleContext_AttributionKeepsBlockOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestMemoryStore(t)
|
||||
seedMemory(t, store, "yuki", "她喜欢冰美式")
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "嗯"}}}}
|
||||
xilian := personaAgent(t, "你是昔涟。", ad)
|
||||
yuki := personaAgent(t, "你是由纪。", ad)
|
||||
l := New(Options{
|
||||
Agent: xilian,
|
||||
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
|
||||
PersonaID: "xilian",
|
||||
PersonaName: func(id string) string { return map[string]string{"xilian": "昔涟", "yuki": "由纪"}[id] },
|
||||
Retriever: memory.NewRetriever(store),
|
||||
Timezone: "Asia/Shanghai",
|
||||
})
|
||||
|
||||
msgs := []model.Message{
|
||||
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
|
||||
model.TextMessage(model.RoleUser, "继续"),
|
||||
}
|
||||
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-order", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := lastChatRequest(t, ad)
|
||||
|
||||
timeIdx := indexOfSystemContaining(req, "当前时间")
|
||||
memIdx := indexOfSystemContaining(req, "冰美式")
|
||||
declIdx := indexOfSystemContaining(req, roleContextMarker)
|
||||
noteIdx := indexOfSystemContaining(req, speakerSwitchMarker)
|
||||
lastUser := -1
|
||||
for i, m := range req.Messages {
|
||||
if m.Role == model.RoleUser {
|
||||
lastUser = i
|
||||
}
|
||||
}
|
||||
if timeIdx < 0 || memIdx < 0 || declIdx < 0 || noteIdx < 0 || lastUser < 0 {
|
||||
t.Fatalf("missing block: time=%d mem=%d decl=%d note=%d user=%d (%+v)", timeIdx, memIdx, declIdx, noteIdx, lastUser, req.Messages)
|
||||
}
|
||||
if !(memIdx < declIdx && declIdx < noteIdx && noteIdx < lastUser) {
|
||||
t.Fatalf("block order = time:%d mem:%d decl:%d note:%d user:%d, want instructions < time < memory < declaration < notes < user", timeIdx, memIdx, declIdx, noteIdx, lastUser)
|
||||
}
|
||||
if got := messageText(req.Messages[0]); got != "你是由纪。" {
|
||||
t.Fatalf("first system = %q, want the instructions untouched at the head", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttribution_UnlabeledConversationIsUntouched locks the backward-compatible
|
||||
// half: a caller that labels nothing (every single-role path, IM, backthink, the
|
||||
// autonomous passes) gets exactly the prompt it had before, with no role segment
|
||||
// and no boundary notes.
|
||||
func TestAttribution_UnlabeledConversationIsUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "done"}}}}
|
||||
l := attributedLoop(t, ad)
|
||||
|
||||
msgs := []model.Message{
|
||||
model.TextMessage(model.RoleAssistant, "我记住了"),
|
||||
model.TextMessage(model.RoleUser, "继续"),
|
||||
}
|
||||
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-plain", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := lastChatRequest(t, ad)
|
||||
if len(req.Messages) != len(msgs)+1 {
|
||||
t.Fatalf("messages = %d, want %d (instructions + conversation, nothing added)", len(req.Messages), len(msgs)+1)
|
||||
}
|
||||
for _, s := range systemTexts(req) {
|
||||
if strings.Contains(s, roleContextMarker) || strings.Contains(s, speakerSwitchMarker) {
|
||||
t.Fatalf("unlabeled conversation got attribution %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ type Loop struct {
|
||||
flow *FlowPublisher
|
||||
guards *guardrail.Pipeline
|
||||
personaID string
|
||||
personaName func(personaID string) string
|
||||
personaKnobs func(personaID string) (LoopKnobs, bool)
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
@@ -157,6 +158,7 @@ func New(opts Options) *Loop {
|
||||
flow: opts.Flow,
|
||||
guards: opts.Guards,
|
||||
personaID: opts.PersonaID,
|
||||
personaName: opts.PersonaName,
|
||||
personaKnobs: opts.PersonaKnobs,
|
||||
timeLocation: timeLocation,
|
||||
timeZoneName: timeZoneName,
|
||||
|
||||
@@ -36,16 +36,7 @@ type asyncExtractor interface {
|
||||
// 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 {
|
||||
personaID := l.personaFor(ctx)
|
||||
if l.retriever == nil || personaID == "" {
|
||||
return messages
|
||||
}
|
||||
env := sessionEnvFrom(ctx)
|
||||
block, err := l.retrieveMemoryBlock(ctx, env, personaID, queryFromMessages(messages))
|
||||
if err != nil {
|
||||
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
|
||||
return messages
|
||||
}
|
||||
block := l.memoryBlock(ctx, messages)
|
||||
if block == "" {
|
||||
return messages
|
||||
}
|
||||
@@ -55,6 +46,25 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod
|
||||
return out
|
||||
}
|
||||
|
||||
// memoryBlock renders the round's memory block, or "" when there is nothing to
|
||||
// inject (no retriever/persona, a retrieval error, or no relevant memory). It is
|
||||
// split out from injectMemory because the assembly places other leading blocks
|
||||
// between the memory block and the conversation (the role context of design/22
|
||||
// §7-5), so it needs the block itself rather than a pre-prefixed slice.
|
||||
func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string {
|
||||
personaID := l.personaFor(ctx)
|
||||
if l.retriever == nil || personaID == "" {
|
||||
return ""
|
||||
}
|
||||
env := sessionEnvFrom(ctx)
|
||||
block, err := l.retrieveMemoryBlock(ctx, env, personaID, queryFromMessages(messages))
|
||||
if err != nil {
|
||||
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
|
||||
return ""
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
// retrieveMemoryBlock returns the memory injection block for env and the turn's
|
||||
// role. The owner-full env keeps the pre-existing Inject behavior. The restricted
|
||||
// env (group chat / stranger) injects only project-scope memory (人人可读, not
|
||||
@@ -105,9 +115,17 @@ func formatMemoryBlock(results []memory.Result, marker string) string {
|
||||
// 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 present, renders the role/speaker attribution of the conversation (design/22
|
||||
// §7-5), 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.
|
||||
//
|
||||
// The existing order is preserved exactly: the leading blocks are the per-round
|
||||
// time, the working-memory summary and the memory block, in that order; the role
|
||||
// declaration sits right after them (immediately before the conversation it
|
||||
// describes) and the speaker boundary notes are inserted into the conversation
|
||||
// itself. A conversation with no speaker label renders no attribution at all, so
|
||||
// every single-role path keeps the prompt it had before multi-character support.
|
||||
func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message {
|
||||
var leading []model.Message
|
||||
// Per-round "current time" so the model naturally knows the wall clock (not a
|
||||
@@ -122,11 +140,16 @@ func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, ro
|
||||
leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s))
|
||||
}
|
||||
}
|
||||
leading = append(leading, l.injectMemory(ctx, messages)...)
|
||||
|
||||
out := leading
|
||||
if block := l.memoryBlock(ctx, messages); block != "" {
|
||||
leading = append(leading, model.TextMessage(model.RoleSystem, block))
|
||||
}
|
||||
decl, ok, view := l.attributeConversation(ctx, messages)
|
||||
if ok {
|
||||
leading = append(leading, decl)
|
||||
}
|
||||
out := append(leading, view...)
|
||||
if l.transformContext != nil {
|
||||
out = l.transformContext(ctx, leading, round)
|
||||
out = l.transformContext(ctx, out, round)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ type Options struct {
|
||||
// scopes memory retrieval and extraction, the system prompt fallback and the
|
||||
// emotion hook. Blank skips memory injection/extraction.
|
||||
PersonaID string
|
||||
// PersonaName, when non-nil, resolves a role id to its display name for the
|
||||
// context's speaker attribution (design/22 §7-5): the role declaration and the
|
||||
// boundary notes name a speaking role as "「昔涟」(xilian)" instead of a bare id,
|
||||
// so the model can follow "who said this" without a lookup table of its own. An
|
||||
// unknown role, an empty name or a nil resolver all fall back to the id — the
|
||||
// attribution never disappears because a name could not be resolved.
|
||||
PersonaName func(personaID string) string
|
||||
// PersonaKnobs, when non-nil, resolves the reasoning knobs for a role at the
|
||||
// start of every request build (design/22 §4.2), so a session bound to role Y
|
||||
// uses Y's thinking budget / effort / broadcast switch rather than the default
|
||||
|
||||
@@ -61,6 +61,15 @@ func WithSessionEnv(ctx context.Context, env SessionEnv) context.Context {
|
||||
return context.WithValue(ctx, sessionEnvKey{}, env)
|
||||
}
|
||||
|
||||
// SessionEnvFrom returns the environment attached to ctx by WithSessionEnv, or the
|
||||
// owner-full default when none was attached (backward compatible). It is the read
|
||||
// side of WithSessionEnv, for a caller that receives only a context — a Chatter
|
||||
// implementation that must know the turn's role/isolation boundary, or a test
|
||||
// asserting what an inbound path resolved. The value is display/scope metadata
|
||||
// only: identity and permission were already decided by the actor metadata before
|
||||
// the env existed (project red line).
|
||||
func SessionEnvFrom(ctx context.Context) SessionEnv { return sessionEnvFrom(ctx) }
|
||||
|
||||
// sessionEnvFrom returns the env attached to ctx, or the owner-full default when
|
||||
// none was attached (backward compatible).
|
||||
func sessionEnvFrom(ctx context.Context) SessionEnv {
|
||||
|
||||
Reference in new issue
Block a user