Files
Cyrene_v2/cmd/cyrene/persona_agents.go
T
AskaEth da3a386daf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
- P0-5 护栏不再是空壳:挂 WithSensitiveInfo(阻断) + 长度上限(输入 262144 / 输出 65536 字符,依据=
  组装完成的请求体量级与「不误伤正常对话」) + 关键词**仅在配置时**挂;正常对话有用例证明放行
- P0-11 上下文压缩默认启用:CONTEXT_MAX_TOKENS 默认 32768(=loop.DefaultMaxContextTokens,两常量由测试锁死),
  0/负 = 关闭(行为退回接线前);长对话真压缩且摘要消息进请求首位、最近一轮保留
- P0-10 身份说明书(owner.yaml identityFile)注入人格上下文,并且**收窄为仅主人会话**:
  · 新增最小钩子 loop.Options.PersonaKnowledgeFor(env):**按该轮会话环境**解析(不看提示词、不看会话 id),
    块只进每轮视图、**不进 messages**(不进 checkpoint、不进记忆提取面)
  · 开关 OWNER_IDENTITY_INJECTION:owner_only(默认) / always(仅自测) / off;非法值与读不动 → WARN + 回退默认(不静默)
  · **顺手关掉第二条同型泄漏**:channelbridge 群聊参与度判定器的 PersonaPrompt 此前带身份正文 ⇒ 现改为纯人格指令
  · 回归:缺文件/目录/未配置逐字节等于旧输出;静态指令与角色池 Instructions() 全不含正文;
    文件里点名的陌生账号仍 IsOwner==false(身份知识绝不参与权限判定)
- 复核:build/vet 绿 + cmd/config/loop 包测试绿;四类断言(主人含 / **群聊与陌生人不含** / always·off 两档 /
  回归不变)+ 4 条变异反证(含把判定改成恒真 → 提示词里真的出现身份说明书)均已还原
- 未做(登记):knowledge 块启动时读一次(改文件需重启);`.env.example` 的 OWNER_IDENTITY_INJECTION 行随后补;
  `internal/user/owner.go` 注释与 design/01 §2.2、design/11 §0 待同步
2026-10-04 14:30:26 +08:00

90 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// This file builds the per-role agent pool (design/22 §4.2 方案 A). An
// agent.Agent's instructions are immutable after construction, so "the session's
// role decides the system prompt" is implemented as one agent per role, all
// sharing the same dispatcher, guardrail pipeline, model adapter and options —
// only the instruction string differs. The loop picks the pool entry for the
// turn's role on every step (internal/runtime/loop/persona.go), so a role switch
// takes effect on the next turn without rebuilding the loop.
//
// Requirements from design/22 §5: prompt, memory, emotion and thinking must all
// be resolved from the same role. The pool owns the prompt; the loop owns the
// other three (personaFor / knobsFor), so a turn can never mix two roles.
// personaAgentPool returns the per-role agent pool plus the agent to run turns
// with: the pooled agent for the default role when the pool has one, otherwise the
// caller's fallback agent, so a deployment with no registered character keeps
// exactly the pre-multi-character single agent.
func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline, fallback *agent.Agent) (map[string]*agent.Agent, *agent.Agent) {
pool := a.buildPersonaAgents(llm, dispatcher, guards)
if pooled, ok := pool[a.defaultPersonaID()]; ok {
return pool, pooled
}
return pool, fallback
}
// buildPersonaAgents returns the role → agent pool: every registered character's
// persona gets an agent carrying that persona's system instruction
// (instructionsFromPersona, the very same assembly point as the default agent —
// see personaInstructions), design/01 §2.2. It returns an empty map when no
// persona is registered, so the loop keeps its single default agent.
//
// 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入
// (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与
// 陌生人私聊(design/11 §0 红线)。
//
// Only personas reachable through a registered character are pooled: a character
// is the selectable unit (1 character ↔ 1 persona), so a persona with no character
// can never be bound to a session. A session bound to an unknown id therefore
// falls back to the default agent, and its memory scope still follows the bound
// id — a stale binding degrades to the default prompt rather than failing the
// turn.
func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline) map[string]*agent.Agent {
log := logger.WithSource(a.log, "cmd/cyrene")
pool := make(map[string]*agent.Agent, a.personas.CountPersonas())
for _, c := range a.personas.List() {
if _, ok := pool[c.PersonaID]; ok {
continue
}
p, ok := a.personas.Persona(c.PersonaID)
if !ok || p == nil {
continue
}
// 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels /
// personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。
stack := llm.ForPersona(c.PersonaID)
ag, err := agent.New(a.personaInstructions(p), dispatcher, guards,
agent.WithModel(stack, stack),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if err != nil {
log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err)
continue
}
pool[c.PersonaID] = ag
}
return pool
}
// personaExists returns the resolver the orchestration layer validates a dispatched
// sub-agent's target role with (design/22 §8-2). A role is dispatchable exactly when
// the pool has an agent for it, which is precisely the condition under which the loop
// would answer with that role's instructions. Every other answer — an unregistered
// persona, a persona with no character, an empty pool — makes the dispatch fail with
// orchestration.ErrUnknownPersona instead of quietly running the sub-agent as the
// default role.
func personaExists(pool map[string]*agent.Agent) func(personaID string) bool {
return func(personaID string) bool {
_, ok := pool[personaID]
return ok
}
}