feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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:
AskaEth committed 2026-10-03 21:01:33 +08:00
1 parent 3732c538cc
commit 5969bba3bd
22 files changed
+1396 -23

No files matched your search

+141
View File
@@ -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 + ")"
}
+245
View File
@@ -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)
}
}
}
+2
View File
@@ -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,
+39 -16
View File
@@ -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
}
+7
View File
@@ -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
+9
View File
@@ -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 {