package loop import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // This file owns the loop's per-turn role resolution (DevDocs/design/22 §4.2): // one process now hosts several roles at once, so everything that used to be read // off the Loop's construction-time binding — the system prompt (via the agent), // the memory namespace, the emotion machine and the reasoning knobs — is resolved // from the turn's SessionEnv instead. // // The resolution is deliberately three tiny functions over ctx rather than a // field on the Loop: the Loop is shared by every concurrent session, so storing a // resolved role on it would race, while ctx already carries the per-turn env. // Every role-scoped read must go through these helpers, so prompt, memory, // emotion and thinking can never disagree about "who is answering". // LoopKnobs are the per-role model/reasoning knobs injected into every request the // loop builds. It mirrors the three construction-time options // (Options.Thinking / Options.ReasoningEffort / Options.ThinkingShowToUser) so a // caller can resolve them per role while the options keep their meaning as the // default. A nil Thinking and an empty ReasoningEffort mean "do not intervene", // and a nil ThinkingShowToUser means "broadcast thinking frames as usual". type LoopKnobs struct { // Thinking is the reasoning directive (budget / explicit switch) for the role. Thinking *model.Thinking // ReasoningEffort is the OpenAI-compatible strength knob for the role. ReasoningEffort string // ThinkingShowToUser gates the thinking frames broadcast for the role's turns. ThinkingShowToUser *bool } // personaFor returns the role the current turn speaks as (design/22 §2): the // session env's explicit role when the caller resolved one, otherwise the loop's // configured role (Options.PersonaID, the global default). Both the prompt/memory // and the emotion hook read it, so a turn can never mix two roles. func (l *Loop) personaFor(ctx context.Context) string { if pid := sessionEnvFrom(ctx).PersonaID; pid != "" { return pid } return l.personaID } // agentFor returns the agent that carries the current turn's system prompt: the // per-role agent pool entry when one exists, otherwise the loop's default agent // (design/22 §4.2 方案 A). A session bound to a role that has no registered agent — // an unknown/deleted persona, or a caller that wired no pool — falls back to the // default agent rather than failing the turn, and its memory scope still follows // personaFor, so the fallback can never silently re-scope memory. func (l *Loop) agentFor(ctx context.Context) *agent.Agent { if len(l.agents) > 0 { if ag := l.agents[l.personaFor(ctx)]; ag != nil { return ag } } return l.agent } // knobsFor resolves the reasoning knobs for the current turn: the resolver's // per-role values when it knows the role, otherwise the loop's configured // defaults. A resolver that does not recognize the role (ok = false) deliberately // keeps the defaults rather than zeroing the knobs, so an unregistered id behaves // like the default role instead of silently disabling reasoning. func (l *Loop) knobsFor(ctx context.Context) LoopKnobs { def := LoopKnobs{ Thinking: l.thinking, ReasoningEffort: l.reasoningEffort, ThinkingShowToUser: l.thinkingShow, } if l.personaKnobs == nil { return def } pid := l.personaFor(ctx) if pid == "" { return def } knobs, ok := l.personaKnobs(pid) if !ok { return def } return knobs }