package gateway import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // This file owns the reconstruction of a turn's conversation context from the // persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02 // §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent // message alone, which left the model with no sight of the conversation it was // continuing; the assistant utterances of earlier turns also carried no record of // which role had spoken them, so a role switched into a session could read another // role's words as its own. // // The seeding fixes both halves: a fresh turn carries the session's most recent // persisted messages, and every assistant message is labelled with the role that // spoke it (model.Message.SenderName) so the loop's context assembly can attribute // it (loop/attribution.go). Only labels travel on the messages — no body is // rewritten. // // Scope note: the seeded window is the conversation, so the loop's per-step // background memory extraction now reads those recent messages instead of the // single newly sent one. Extracted items are still filed under the turn's role // (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is // deduplicated by the extractor, but a fact first stated while another role was // active can now be picked up by the current role if it was never extracted at the // time. Narrowing the extraction input to the current turn's messages is a // separate decision and is left as a follow-up. // transcriptContextCount bounds how many persisted messages a fresh turn carries // into the model context. It mirrors the orchestrator's continuation tail bound // (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the // loop's own context budget still governs compaction beyond that. const transcriptContextCount = 20 // transcriptReader is the narrow history view the seeding needs. *history.Store // satisfies it; a store that does not (a test fake, or a gateway built without // persistence) simply yields no seeded context, so the orchestrator keeps running // exactly as before. type transcriptReader interface { Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error) } // startTurnMessages returns the conversation a fresh turn runs on: the session's // seeded transcript followed by the messages the client just sent. When the // transcript is unavailable the caller's messages are returned untouched, which is // the pre-existing behavior. func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message { seed := o.sessionTranscript(ctx, sessionID) if len(seed) == 0 { return messages } return concatMessages(seed, messages...) } // sessionTranscript reads the session's most recent persisted messages and maps // them to model messages (oldest first). A nil/unreadable store or a read error // yields nil so a chat is never blocked by a metadata lookup, and a session with // no history yields nothing — both degrade to the pre-existing "new message only" // context. func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message { if o.history == nil || sessionID == "" { return nil } reader, ok := o.history.(transcriptReader) if !ok { return nil } recent, err := reader.Recent(ctx, sessionID, transcriptContextCount) if err != nil { o.log.Debug("turn transcript read", "err", err, "session", sessionID) return nil } out := make([]model.Message, 0, len(recent)) for _, h := range recent { out = append(out, transcriptMessage(h)) } return out } // transcriptMessage maps one persisted chat message onto the model message a turn // continues from. An assistant message carries the role that spoke it (the stored // persona_id of the turn) as its speaker label, so the context can attribute it to // that role even after the session was switched; a non-assistant message keeps its // stored sender label (the third-party display name of design/16 §4.6) and is never // relabelled as a speaking role. func transcriptMessage(h history.Message) model.Message { role := historyRoleToModel(h.Role) msg := model.Message{ Role: role, Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}}, } if h.Image != "" { msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image}) } if role == model.RoleAssistant { msg.SenderName = h.PersonaID return msg } msg.SenderName = h.SenderName return msg } // historyRoleToModel maps a persisted chat role to the model's role vocabulary, // defaulting an unknown role to the user lane. func historyRoleToModel(role string) model.Role { switch role { case history.RoleAssistant: return model.RoleAssistant case history.RoleSystem: return model.RoleSystem default: return model.RoleUser } } // assistantTailMessage builds the continuation-tail entry for one settled turn's // reply, labelled with the role that spoke it (design/22 §7-5): the tail is the // in-memory twin of the seeded transcript, so a role switched in later must be able // to tell an earlier role's reply apart from its own words on the very next queued // turn. func assistantTailMessage(text, personaID string) model.Message { msg := model.TextMessage(model.RoleAssistant, text) msg.SenderName = personaID return msg }