Files
Cyrene_v2/cmd/cyrene/persona_agents.go
T
AskaEth 8890c30bb5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
  · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
  · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
  · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
  · 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
  每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
  否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
  (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
  (design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00

86 lines
4.1 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
// (identityInstructionFor + the personality/values/speechStyle/catchphrase/
// background sections, design/01 §2.2). It returns an empty map when no persona is
// registered, so the loop keeps its single default agent.
//
// 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(instructionsFromPersona(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
}
}