package main import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // Section headers for the persona fields that design/01 §2.2 lists as previously // inert. They are appended as separate system-prompt sections after the identity // block so each one can be tuned without touching the identity text. const ( personalityHeader = "【性格】" valuesHeader = "【价值观】" speechStyleHeader = "【说话风格】" catchphraseHeader = "【口头禅】" backgroundHeader = "【背景】" ) // personaSection is one appended block: a bracketed header plus its body. type personaSection struct { header string body string } // defaultPersona returns the default character's persona, or false when no // character/persona is registered. func (a *app) defaultPersona() (*loader.Persona, bool) { c, ok := a.personas.DefaultCharacter() if !ok { return nil, false } p, ok := a.personas.Persona(c.PersonaID) if !ok || p == nil { return nil, false } return p, true } // identityInstructionFor returns the persona's identity block — the only part // injected before design/01 §2.2: identity.systemPrompt, else identity.role, else // the minimal fallback. func identityInstructionFor(p *loader.Persona) string { if p.Identity.SystemPrompt != "" { return p.Identity.SystemPrompt } if p.Identity.Role != "" { return p.Identity.Role } return fallbackInstruction } // instructionsFromPersona builds the persona's system instruction: the identity // block, followed by the personality/values/speechStyle/catchphrase/background // sections that used to have no consumer (design/01 §2.2). Blank fields are // skipped so no empty section is emitted, and a persona with none of them yields // exactly the identity block — byte for byte the pre-existing output. func instructionsFromPersona(p *loader.Persona) string { base := identityInstructionFor(p) sections := personaSections(p) if len(sections) == 0 { return base } var b strings.Builder b.WriteString(base) for _, s := range sections { b.WriteString("\n\n") b.WriteString(s.header) b.WriteString("\n") b.WriteString(s.body) } return b.String() } // personaSections renders the five optional sections in their fixed order // (personality → values → speechStyle → catchphrase → background), skipping any // that is blank. func personaSections(p *loader.Persona) []personaSection { out := make([]personaSection, 0, 5) appendSection := func(header, body string) { if body = strings.TrimSpace(body); body == "" { return } out = append(out, personaSection{header: header, body: body}) } appendSection(personalityHeader, p.Personality) appendSection(valuesHeader, bulletList(p.Values)) appendSection(speechStyleHeader, p.SpeechStyle) appendSection(catchphraseHeader, p.Catchphrase) appendSection(backgroundHeader, p.Background) return out } // bulletList renders string items as a markdown bullet list, skipping blanks; an // all-blank list yields "" so its section disappears entirely. func bulletList(items []string) string { lines := make([]string, 0, len(items)) for _, item := range items { if item = strings.TrimSpace(item); item != "" { lines = append(lines, "- "+item) } } return strings.Join(lines, "\n") } // defaultThinkingOptions returns the reasoning knobs the loop should inject for // the default persona: the thinking directive, the reasoning_effort value and the // thinking-broadcast switch. It is the default half of the per-role resolution // the loop performs every turn (Options.PersonaKnobs, see personaKnobs below), so // the configured default and the per-turn lookup can never drift apart. // // The thinking directive is non-nil only when the persona configures something // that affects the request (a budget, an effort, or an explicit on/off mode). // showToUser deliberately does NOT create one: it is a presentation switch, and a // non-nil directive with no switch would enable provider thinking as a side // effect. All three are zero when no persona is registered, which leaves the // loop's pre-existing behavior untouched. func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) { knobs, ok := a.personaKnobs(a.defaultPersonaID()) if !ok { return nil, "", nil } return knobs.Thinking, knobs.ReasoningEffort, knobs.ThinkingShowToUser } // personaKnobs resolves a role's reasoning knobs for the loop // (loop.Options.PersonaKnobs, design/22 §4.2): the loop asks for the role of the // turn it is about to build a request for, so a session bound to role Y runs with // Y's thinking budget / effort / broadcast switch instead of the default role's. // Resolving from the role id rather than pushing a value into every caller's // SessionEnv means every path — Web REST/WS/voice, IM, backthink, sub-agents — // gets the right knobs from one input. ok=false means "unknown role", and the loop // keeps its configured defaults. func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) { if personaID == "" { return loop.LoopKnobs{}, false } p, ok := a.personas.Persona(personaID) if !ok || p == nil { return loop.LoopKnobs{}, false } thinking, effort := personaThinking(p) return loop.LoopKnobs{ Thinking: thinking, ReasoningEffort: effort, ThinkingShowToUser: p.Thinking.ShowToUser, }, true } // personaName resolves a role id to its display name for the model context's // speaker attribution (loop.Options.PersonaName, design/22 §7-5). The loop names a // speaking role as "「昔涟」(xilian)" instead of a bare id, so the model can follow // who said what after a session switched roles. An empty or unknown id resolves to // "" and the loop falls back to the id alone — attribution never disappears just // because a name could not be resolved, and nothing here is an identity or // permission input. func (a *app) personaName(personaID string) string { if personaID == "" { return "" } p, ok := a.personas.Persona(personaID) if !ok || p == nil { return "" } return strings.TrimSpace(p.Name) } // personaThinking maps the persona's `thinking` section onto the model-layer // knobs (design/01 §2.1 / design/04 §3.2). It returns (nil, "") when nothing // affects the request. func personaThinking(p *loader.Persona) (*model.Thinking, string) { effort := strings.TrimSpace(p.Thinking.Effort) enable := p.Thinking.Enable() if p.Thinking.BudgetTokens == 0 && effort == "" && enable == nil { return nil, "" } return &model.Thinking{BudgetTokens: p.Thinking.BudgetTokens, Enable: enable}, effort }