- 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 待同步
90 lines
4.4 KiB
Go
90 lines
4.4 KiB
Go
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
|
||
}
|
||
}
|