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 + ")" }