package gateway import ( "context" "errors" "log/slog" "net/http" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // errPersonaBindingUnsupported is returned by newSubSession when a caller asks for // a role-bound session but the injected history store cannot bind roles. The // handler maps it to 501 rather than pretending the binding landed. var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding") // This file owns the gateway's turn-environment resolution (DevDocs/design/22 // §2/§4.5): which role answers, expressed as the loop.SessionEnv the turn runs // under. // // Precedence (design/22 §2, top down): // 1. request-level override — NOT implemented in this batch (batch 1 exposes the // session-level binding; a per-request override is additive and unpersisted); // 2. session-level binding — chat_sessions.persona_id, resolved here; // 3. channel-level mapping — batch 2 (channels.json / channel_sessions); // 4. global default — expressed as PersonaID == "", which the loop resolves to its // configured default role (Options.PersonaID). That is what makes every // pre-existing session behave exactly as before. // // The env's OTHER fields are untouched: ActorEnv and MemWritable still come from // loop.OwnerEnv() for these owner-facing Web/voice paths. That is the identity red // line — who may do what is decided by the channel actor metadata (here: a JWT // owner), never by the role being played. Persona selection only changes the // prompt, the memory namespace, the emotion machine and the thinking budget. // sessionPersonaReader is the optional narrower view of HistoryStore used to read a // session's bound role. *history.Store implements it; a store that does not (a test // fake, or a store predating design/22) simply yields "" — the global default — so // the gateway keeps working. type sessionPersonaReader interface { SessionPersona(ctx context.Context, sessionID string) (string, error) } // sessionPersonaWriter is the optional narrower view of HistoryStore for writing a // session's role binding. A session's role is decided at creation (design/22 §8-1), // so no request path rebinds one any more — creation goes through subSessionCreator — // and the gateway no longer type-asserts this interface. It stays next to the reader // and the creator so the store's role-binding surface is documented in one place: // *history.Store still implements SetSessionPersona (design/22 §4.1) and the // admin/owner write path that would use it is a separate endpoint, not a PATCH. type sessionPersonaWriter interface { SetSessionPersona(ctx context.Context, sessionID, personaID string) error } // subSessionCreator is the optional narrower view of HistoryStore used to create a // session already bound to a role (POST /sessions {personaId}, design/22 §4.5). // *history.Store implements it; a store without it can only create an unbound // session, so a request that explicitly asks for a role is refused with 501. type subSessionCreator interface { NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) } // turnEnvResolver builds the loop session environment for one turn. It holds the // two read-only dependencies the resolution needs, so both the HTTP/WS handlers // and the hub resolve roles through the same code path (design/22 §4.5: one // resolver, not a hardcoded loop.OwnerEnv() per entry point). type turnEnvResolver struct { history HistoryStore personas PersonaStore log *slog.Logger } // owner returns the per-turn environment for one owner-facing turn of sessionID: // the full-access owner env (identity unchanged) plus the session's bound role. // A blank role means "inherit the global default"; the loop performs that final // fallback, so the gateway never has to know the default role's id. func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv { env := loop.OwnerEnv() env.PersonaID = t.sessionPersona(ctx, sessionID) return env } // sessionPersona returns the role bound to sessionID, or "" when the session // inherits the global default. An unknown session, a store without the reader, or // a read error all degrade to "" (the default) rather than failing the turn: a // chat must never be blocked by a metadata lookup, and the fallback is the // pre-multi-character behavior. func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) string { if t.history == nil || sessionID == "" { return "" } reader, ok := t.history.(sessionPersonaReader) if !ok { return "" } personaID, err := reader.SessionPersona(ctx, sessionID) if err != nil { if t.log != nil && !errors.Is(err, history.ErrSessionNotFound) { t.log.Debug("session persona read", "err", err, "session", sessionID) } return "" } return personaID } // defaultPersonaID returns the persona id of the library's default character, or // "" when no persona store is wired or no character is marked default. It is the // gateway-side view of owner.yaml:defaultPersona used where a role id is needed // without a session (the admin memory search fallback, design/22 §4.5). func (s *Server) defaultPersonaID() string { if s.personas == nil { return "" } for _, c := range s.personas.List() { if c.Default && c.PersonaID != "" { return c.PersonaID } } return "" } // requireKnownPersona rejects a personaId the registry does not know, answering // 400 and returning false. An empty id is always allowed (it means "inherit the // global default"), and a gateway without a persona store cannot validate, so the // binding is accepted there and simply resolves as an unknown role later. func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bool { if personaID == "" || s.personas == nil { return true } if _, ok := s.personas.Persona(personaID); !ok { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown personaId %q", personaID)) return false } return true } // newSubSession creates a sub session bound to personaID, using the role-aware // creator when the store provides one. A store without it can only create an // unbound session, so an explicit role request is refused (501) instead of // silently dropping the binding. func (s *Server) newSubSession(ctx context.Context, ownerID, personaID string) (string, error) { creator, ok := s.history.(subSessionCreator) if !ok { if personaID != "" { return "", errPersonaBindingUnsupported } return s.history.NewSubSession(ctx, ownerID) } return creator.NewSubSessionFor(ctx, ownerID, personaID) }