docs(cyrene): cmd/cyrene 存量英文注释中文化(22 个非测试文件,仅注释)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 四组并行完成:main/wire*.go、wire_capacity|channels|runtime、channels|persona_*|emotion|observability、
  voice|plugins|stream|skills|vision|subagent_tool|extractor|mcp|sessionread_tool|terminal_tool。
- 机械校验(git diff -U0 去 // 行与空行)仅剩 wire_frontend.go:84 一处行尾注释:代码前缀
  「\ta.gw = gw 」经 cat -A 与 HEAD 逐字节比对一致(allow 清单已声明)。
- 验收:隔离 worktree(HEAD + 本批 patch)go build/vet 绿、全树 58 包 ok、
  linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净。
- 备注:整树两次红均为负载敏感 flake(backthink / orchestration 的 timeout 类用例单独 -count=20 全过)。
This commit is contained in:
AskaEth committed 2026-10-04 16:58:06 +08:00
1 parent 5c2189efb0
commit 52f08dc343
22 files changed
+455 -586

No files matched your search

+55 -72
View File
@@ -1,18 +1,15 @@
package main package main
// This file builds the four built-in IM channel adapters from the runtime-editable // 本文件从运行时可变配置构建四个内置 IM 渠道适配器。有效来源是 channels.json
// configuration. The effective source is channels.json when it is present and // (存在且合法时优先,design/04 §3.1);否则回退到 env 渠道块(CHANNELS_JSON
// valid (design/04 §3.1); otherwise it falls back to the env channel block // 多账号 + 单账号 CHANNEL_<NAME>_* 变量),后者是最终兜底,使裸装环境在没有任何
// (CHANNELS_JSON multi-account + the single-account CHANNEL_<NAME>_* vars), which // 配置文件时也能启动。
// stays the final fallback so a bare install boots without any config file.
// //
// Each account becomes its own adapter instance. A single-account env adapter is // 每个账号各自成为一个适配器实例。单账号 env 适配器以裸平台名命名(如 "kook");
// named after the bare platform (e.g. "kook"); a multi-account entry is named // 多账号条目命名为 "kook:confID",并包在 namedAdapter 里,通过 Config() 暴露其
// "kook:confID" and wrapped in a namedAdapter that surfaces its allow/deny lists // allow/deny 名单,供渠道桥的黑/白名单闸门使用(design/16 §4.6 §5-6)。
// through Config() for the channel bridge's black-or-white gate (design/16 §4.6 // channelFactory 让桥在面板保存新配置时热替换某平台的适配器(Bridge.Reload),
// §5-6). The channelFactory lets the bridge hot-replace a platform's adapters when // 因此 token/地址/速率变更无需重启即可生效。
// the panel saves a new config (Bridge.Reload), so a token/address/rate change
// takes effect without a restart.
import ( import (
"fmt" "fmt"
@@ -32,11 +29,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
) )
// buildChannelAdapters constructs every channel adapter from the effective // buildChannelAdapters 从有效账号配置(channels.json → env 兜底)构建全部渠道
// account config (channels.json → env fallback) and returns the successfully // 适配器,并返回构建成功的子集。适配器构建从不发起网络连接,真正的连接发生在渠道桥
// built subset. Adapter construction never dials the network; actual connection // 里。f 持有路径/兜底/logger,使启动路径与热重载工厂读取同一来源。
// happens in the channel bridge. f holds the path/fallback/logger so boot and the
// reload factory read the same source.
func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter { func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback) accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil { if warn != nil {
@@ -49,17 +44,16 @@ func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter {
return out return out
} }
// channelFactory is the channelbridge.PlatformFactory wired into the bridge. It // channelFactory 是接入渠道桥的 channelbridge.PlatformFactory。它从**当前**有效
// rebuilds a platform's adapters from the CURRENT effective config (file → env), // 配置(文件 → env)重建某平台的适配器,因此面板保存后调用 Bridge.Reload(platform)
// so a panel save followed by Bridge.Reload(platform) picks the new config up // 即可让新配置生效,无需重启。
// without a restart.
type channelFactory struct { type channelFactory struct {
path string path string
fallback map[string][]config.ChannelAccount fallback map[string][]config.ChannelAccount
log *slog.Logger log *slog.Logger
} }
// Build returns the adapters for a platform from its effective account config. // Build 返回某平台按其有效账号配置构建出的适配器。
func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) { func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback) accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil { if warn != nil {
@@ -68,12 +62,11 @@ func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, erro
return buildPlatformAdapters(platform, accounts[platform], f.log), nil return buildPlatformAdapters(platform, accounts[platform], f.log), nil
} }
// Persona returns the role bound to one channel account of a platform (design/22 // Persona 返回某平台某个渠道账号所绑定的角色(design/22 §2 方案 b)。它读取与
// §2 方案 b). It reads the same effective config Build does — channels.json wins, // Build 相同的有效配置 —— channels.json 优先、env 兜底 —— 因此「面板保存 + 桥
// env is the fallback — so the panel's save plus the bridge's hot reload stay the // 热重载」始终是唯一的更新路径;它每次适配器连接调用一次,而不是每条消息调用一次。
// single update path, and it is called once per adapter connect rather than per // 平台/账号未知,或账号未配 personaId 时返回 ""(无渠道级绑定:改用会话绑定或全局
// message. An unknown platform/account, or an account without a personaId, returns // 默认)。
// "" (no channel-level binding: the session binding or the global default applies).
func (f *channelFactory) Persona(platform, accountID string) string { func (f *channelFactory) Persona(platform, accountID string) string {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback) accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil { if warn != nil {
@@ -87,9 +80,8 @@ func (f *channelFactory) Persona(platform, accountID string) string {
return "" return ""
} }
// buildPlatformAdapters builds every account of one platform into an adapter // buildPlatformAdapters 把某平台的全部账号各构建成一个适配器实例。某个账号构建
// instance. A failed account build is skipped with a warn (never fatal), matching // 失败只记一条 WARN 并跳过(绝不致命),与桥的「没有凭据 → 告警并继续」约定一致。
// the "no credentials → warn and continue" contract of the bridge.
func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter { func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter {
out := make([]adapter.PlatformAdapter, 0, len(accts)) out := make([]adapter.PlatformAdapter, 0, len(accts))
for _, acct := range accts { for _, acct := range accts {
@@ -103,33 +95,30 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
return out return out
} }
// channelAccountsFromEnv synthesizes the env fallback: the single-account // channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
// CHANNEL_<NAME>_* adapters plus any CHANNELS_JSON multi-account entries. It is // CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
// the .env fallback passed to runtimeconfig.LoadChannels when channels.json is // runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
// absent or invalid. .env only carries the necessary connection fallback // (token/base_url/...);业务与高级的按账号字段(receive_mode、webhook
// (token/base_url/...); business and advanced per-account fields (receive_mode, // path/verify_token/encrypt_key、allow/deny 名单、速率限制)在面板的渠道管理里
// webhook path/verify_token/encrypt_key, allow/deny lists, rate limits) are // 配置,落在 channels.json,绝不出现在 .env。
// configured in the panel's channel management and live in channels.json, never
// in .env.
// //
// A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only // 需要 token 的平台(kook/telegram/weixin-ilink)只有在其 token 环境变量已设置时
// when its token env var is set; with an unset token there is no account at all, // 才合成;token 未设置就完全不存在该账号,因此空 token 的占位符绝不会占用裸平台名。
// so an empty-token placeholder never claims the bare platform name. qq-obv11 and // qq-obv11 与 webhook 始终存在(它们不需要 token;由桥的 configured() 闸门决定是否
// webhook are always present (they do not need a token; the bridge's configured() // 拨号),所以渠道列表保持稳定。
// gate decides whether to dial) so the channel list stays stable.
func channelAccountsFromEnv() map[string][]config.ChannelAccount { func channelAccountsFromEnv() map[string][]config.ChannelAccount {
out := map[string][]config.ChannelAccount{} out := map[string][]config.ChannelAccount{}
// KOOK: only the necessary connection fields. receive_mode / webhook_path / // KOOK:只放必要的连接字段。receive_mode / webhook_path /
// webhook_verify_token / webhook_encrypt_key are business·advanced fields that // webhook_verify_token / webhook_encrypt_key 属于业务·高级字段,经由面板写入
// go to channels.json via the panel — .env does not fall back on them. // channels.json —— .env 不再对它们做兜底。
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" { if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{ out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
"bot_token": tok, "bot_token": tok,
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
}}) }})
} }
// Telegram: token (necessary) + optional webhook_url (connection). Business // Telegram:token(必要)+ 可选的 webhook_url(连接)。业务调优写入
// tuning goes to channels.json. // channels.json。
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" { if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{ out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
"bot_token": tok, "bot_token": tok,
@@ -175,10 +164,9 @@ func channelAccountsFromEnv() map[string][]config.ChannelAccount {
return out return out
} }
// namedAdapter wraps a platform adapter so a multi-account instance gets a unique // namedAdapter 包装平台适配器,使多账号实例获得唯一名字(platform:confID),并
// name (platform:confID) and surfaces its allow/deny lists through Config(), which // 通过 Config() 暴露其 allow/deny 名单 —— 渠道桥读取它来按账号执行平台的黑/白名单
// the channel bridge reads to enforce the platform's black-or-white lists per // (design/16 §4.6 §5-6)。其余方法全部委派给内层适配器。
// account (design/16 §4.6 §5-6). Everything else delegates to the inner adapter.
type namedAdapter struct { type namedAdapter struct {
adapter.PlatformAdapter adapter.PlatformAdapter
name string name string
@@ -186,9 +174,8 @@ type namedAdapter struct {
denyGroups []string denyGroups []string
allowUsers []string allowUsers []string
denyUsers []string denyUsers []string
// flow limits (design/12 §6 策略三/四). They are always surfaced through // 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
// Config() so the bridge can build a per-account ingress gate; the bridge maps // 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
// the zero default to the protective values and a negative value to "off".
groupMsgPerMin int groupMsgPerMin int
msgPerMin int msgPerMin int
maxMessages int maxMessages int
@@ -212,9 +199,8 @@ func (n *namedAdapter) Config() map[string]any {
return out return out
} }
// InboundHandler forwards to the inner adapter when it exposes an inbound HTTP // InboundHandler 在内层适配器暴露入站 HTTP 回调时转发给它;桥会对包装器做类型
// callback; the bridge type-asserts the wrapper and mounts the handler at // 断言,并把该 handler 挂到 WebhookPath。内层适配器不是 webhook 时返回 nil。
// WebhookPath. It returns nil for a non-webhook inner adapter.
func (n *namedAdapter) InboundHandler() http.Handler { func (n *namedAdapter) InboundHandler() http.Handler {
if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok { if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok {
return inner.InboundHandler() return inner.InboundHandler()
@@ -222,8 +208,8 @@ func (n *namedAdapter) InboundHandler() http.Handler {
return nil return nil
} }
// WebhookPath forwards the inner adapter's webhook route, returning "" when the // WebhookPath 转发内层适配器的 webhook 路由;内层不暴露该路由时返回 ""(桥据此
// inner adapter does not expose one (so the bridge skips it). // 跳过它)。
func (n *namedAdapter) WebhookPath() string { func (n *namedAdapter) WebhookPath() string {
if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok { if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok {
return inner.WebhookPath() return inner.WebhookPath()
@@ -231,9 +217,8 @@ func (n *namedAdapter) WebhookPath() string {
return "" return ""
} }
// buildAccountAdapter builds one account's adapter for a platform, decoding the // buildAccountAdapter 为某平台构建单个账号的适配器:把账号的通用 Config map 解码
// account's generic Config map into the platform's typed config and wrapping it so // 成该平台的强类型配置,并包装起来,使桥看到唯一的账号名及其 allow/deny 名单。
// the bridge sees a distinct account name and its allow/deny lists.
func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) { func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) {
name := platform name := platform
if acct.ID != "" { if acct.ID != "" {
@@ -256,11 +241,10 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
return nil, fmt.Errorf("decode weixin-ilink config: %w", err) return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
} }
// A weixin-ilink account may carry an empty bot_token: it is a pending // weixin-ilink 账号允许 bot_token 为空:这是一条待扫码登录的账号,面板通过
// scan-login account the panel drives via the login-qr/login-status admin // login-qr/login-status 管理端点驱动它(design/08 §4)。桥的 configured() 闸门
// endpoints (design/08 §4). The bridge's configured() gate skips it until // 会跳过它,直到扫码确认且 token 落库,因此这里空 token 是合法的 —— 应当构建
// the scan confirms and the token is persisted, so an empty token here is // 该适配器(未连接),而不是丢弃它。
// legitimate — build the adapter (disconnected) rather than dropping it.
w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink")) w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink"))
if err != nil { if err != nil {
return nil, fmt.Errorf("build weixin-ilink: %w", err) return nil, fmt.Errorf("build weixin-ilink: %w", err)
@@ -303,7 +287,7 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
}, nil }, nil
} }
// barePlatform returns the platform name for a possibly-suffixed "platform:confID". // barePlatform 返回可能带后缀的 "platform:confID" 的平台名。
func barePlatform(name string) string { func barePlatform(name string) string {
for i := 0; i < len(name); i++ { for i := 0; i < len(name); i++ {
if name[i] == ':' { if name[i] == ':' {
@@ -313,8 +297,7 @@ func barePlatform(name string) string {
return name return name
} }
// envString returns the value of key, or fallback when the variable is unset or // envString 返回 key 的值;环境变量未设置或为空白时返回 fallback。
// blank.
func envString(key, fallback string) string { func envString(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
+33 -42
View File
@@ -9,52 +9,45 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
) )
// This file wires the persona emotion state machine (design/01 §2: 情绪状态机 + // 本文件把 persona 情绪状态机(design/01 §2:情绪状态机 + 好感度曲线跟随
// 好感度曲线 follow the persona/character, never a behavior mode) into the agent // persona/角色,绝不跟随行为模式)接到 agent 循环的轮次边界上。循环保持与情绪无关:
// loop's turn boundary. The loop stays emotion-agnostic: it reports each settled // 它上报每个已结算轮次,并把返回的心情标签带到 Result.Mood,由持久化层写到助手消息上
// turn and carries the returned mood label onto Result.Mood, which the // (design/16 §3.3)。
// persistence layer writes onto the assistant message (design/16 §3.3).
// //
// Scope of this milestone (A1) — what is wired end to end: // 本里程碑(A1)端到端接通的范围:
// - every settled owner turn advances the bound persona's emotion machine and // - 每个已结算的主人轮次推进所绑定 persona 的情绪状态机与好感度(内存态,按 persona
// affection (in-memory, persona-keyed by the registry's emotion Manager); // 为键,由 registry 的 emotion Manager 持有);
// - the resulting mood rides the assistant message into chat_messages and the // - 由此得到的心情随助手消息进入 chat_messages 与 `message_persisted` 帧,而每次
// `message_persisted` frame, and each *change* is broadcast as a `mood` frame. // *变化* 都作为 `mood` 帧广播。
// //
// Not wired (design/01 does not specify it): the mood is not yet injected back // 未接通(design/01 未作规定):心情尚未反注入 system 提示,因此不会左右回复文本;
// into the system prompt, so it does not steer the reply text, and the emotion // 情绪状态也不跨重启持久化(design/01 两者都不要求;启动时状态重置为 persona 配置的
// state is not persisted across restarts (design/01 requires neither; the state // initialState)。
// resets to the persona's configured initialState on boot).
// moodEmitter is the gateway-facing sink for `mood` broadcasts. *gateway.Server // moodEmitter 是面向 gateway 的 `mood` 广播出口。*gateway.Server 实现它
// implements it (BroadcastMood); held as an interface so the composition root // (BroadcastMood);这里持有接口是为了让组合根能用假实现做单元测试,与
// stays unit-testable with a fake sink, mirroring capacityEmitter. // capacityEmitter 一致。
type moodEmitter interface { type moodEmitter interface {
BroadcastMood(personaID, mood string, affection int) BroadcastMood(personaID, mood string, affection int)
} }
// buildEmotionObserver assembles the persona emotion observer: the registry's // buildEmotionObserver 装配 persona 情绪观察者:registry 的 emotion Manager 提供
// emotion Manager supplies each persona's machine and affection, the registry // 每个 persona 的状态机与好感度,registry 提供 persona 配置的好感度步长
// supplies the persona's configured affection step (好感度曲线), and every change // (好感度曲线),每次变化都经 publishMood 推送到 Web UI。
// is pushed to the Web UI through publishMood.
func (a *app) buildEmotionObserver() *emotion.Observer { func (a *app) buildEmotionObserver() *emotion.Observer {
return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood) return emotion.NewObserver(a.personas.Emotions(), a.personas.AffectionStep, a.publishMood)
} }
// onTurnEnd is the loop's turn-boundary hook (design/01 §2): a settled turn is // onTurnEnd 是循环的轮次边界钩子(design/01 §2):已结算的轮次被施加到循环所绑定的
// applied to the persona the loop is bound to, and the mood after the turn is // persona 上,并把该轮之后的心情返回给助手消息。persona id 缺失时退回默认 persona,
// returned for the assistant message. An absent persona id falls back to the // 与循环的绑定方式一致。
// default persona, matching how the loop is bound.
// //
// Two gates keep this to real conversation turns: // 两道闸门确保只有真实对话轮次才触发:
// - only the user-facing conversation lane (the Web main session and its // - 只有面向用户的对话车道(Web 主会话及其子会话;IM 主人私聊走主会话,
// sub-sessions; IM owner-private chat rides the main session, design/16 §4.4) // design/16 §4.4)会推动心情。自主与委派的循环调用 —— 后台思考(sess-backthink)、
// moves the mood. Autonomous and delegated loop calls — background thinking // 子代理工作、隔离的 IM 会话 —— 不是主人对话的轮次,因此不得改写她的心情;
// (sess-backthink), sub-agent work, isolated IM sessions — are not turns of // - 受限(群聊/陌生人)对话是只读的:观察者拒绝写入,因此第三方永远无法左右她的
// the owner's conversation, so they must not rewrite her mood; // 心情或好感度(design/02 §3、design/11 §2)。
// - a restricted (group/stranger) conversation is read-only: the observer
// refuses the write, so a third party can never move her mood or affection
// (design/02 §3, design/11 §2).
func (a *app) onTurnEnd(end loop.TurnEnd) string { func (a *app) onTurnEnd(end loop.TurnEnd) string {
if a.emotion == nil || !isConversationLane(end.SessionID) { if a.emotion == nil || !isConversationLane(end.SessionID) {
return "" return ""
@@ -66,18 +59,16 @@ func (a *app) onTurnEnd(end loop.TurnEnd) string {
return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted())) return string(a.emotion.Observe(personaID, end.UserText, !end.Env.Restricted()))
} }
// isConversationLane reports whether a session belongs to the owner's live // isConversationLane 报告某会话是否属于主人的实时对话:固定的主会话 "web" 及其
// conversation: the fixed main session "web" and its "web:<uuid>" sub-sessions // "web:<uuid>" 子会话(design/16 §2.1 —— IM 主人私聊被路由进主会话)。
// (design/16 §2.1 — IM owner-private chat is routed into the main session).
func isConversationLane(sessionID string) bool { func isConversationLane(sessionID string) bool {
return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":") return sessionID == history.MainSessionID || strings.HasPrefix(sessionID, history.MainSessionID+":")
} }
// publishMood broadcasts one emotion change as a `mood` frame (design/07 §3, // publishMood 把一次情绪变化作为 `mood` 帧广播(design/07 §3、design/01 §2),
// design/01 §2) so the Web 心情 badge follows the persona without a refresh. It is // 使 Web 的心情徽标无需刷新即可跟随 persona。它只在心情确实变化时调用,因此该帧
// called only when the mood actually changed, so the frame stays news rather than // 是新闻而不是每轮噪声。出口在调用时读取,因为 gateway 构建在循环之后;它还不存在
// per-turn noise. The sink is read at call time because the gateway is built after // 时该变化只记日志并丢弃。
// the loop; before it exists the change is logged and dropped.
func (a *app) publishMood(update emotion.Update) { func (a *app) publishMood(update emotion.Update) {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
if a.moodSink == nil { if a.moodSink == nil {
+19 -25
View File
@@ -9,27 +9,23 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
) )
// memoryOwnerRoleID is the role id the composition root authors extracted // memoryOwnerRoleID 是组合根写入提取出的记忆时所用的角色 id;主人是唯一会写入
// memories with; the owner is the only actor whose private memory is written // 私有记忆的 actor(群聊/陌生人会话根本不提取,design/11 §2)。
// (group/stranger conversations never extract at all, design/11 §2).
const memoryOwnerRoleID = "owner" const memoryOwnerRoleID = "owner"
// buildMemoryExtractor selects the memory extractor the loop runs after each // buildMemoryExtractor 选择循环在每轮落定后运行的记忆提取器(design/03 §1
// settled turn (design/03 §1 LLM 异步自动提取): // LLM 异步自动提取):
// //
// - the LLM extractor when the provider stack has a usable active group, so // - provider 栈存在可用的活动组时用 LLM 提取器,由模型去噪并去重真正值得记的内容;
// the model denoises and deduplicates what is worth remembering; // - 否则用确定性的 ManualExtractor,使无 key 的部署仍能记录主人显式的「记住 X」
// - the deterministic ManualExtractor otherwise, so a keyless deployment still // 指令,而不是每轮都让模型调用失败。
// records the owner's explicit "记住 X" directives instead of failing a model
// call on every turn.
// //
// The extractor deliberately holds NO persona: one process now answers as several // 该提取器刻意不持有任何人设:一个进程现在会以多个角色作答(design/22 §4.2),
// roles (design/22 §4.2), so a persona baked in here would file every extracted // 若在此固化某个人设,就会把所有提取出的记忆都归到那一个角色下。循环在持久化时
// memory under that one role. The loop stamps each item with the role that // 用产生该轮的角色逐条打标(loop.persistMemories)。
// produced the turn when it persists it (loop.persistMemories).
// //
// The loop detaches the LLM extractor to a background goroutine (ExtractAsync); // 循环会把 LLM 提取器分离到后台 goroutine(ExtractAsync);见
// see internal/runtime/loop/memory.go. // internal/runtime/loop/memory.go。
func (a *app) buildMemoryExtractor() memory.Extractor { func (a *app) buildMemoryExtractor() memory.Extractor {
if llmGroupUsable(a.llm) { if llmGroupUsable(a.llm) {
a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider()) a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider())
@@ -39,14 +35,12 @@ func (a *app) buildMemoryExtractor() memory.Extractor {
return memory.NewManualExtractor("", memoryOwnerRoleID) return memory.NewManualExtractor("", memoryOwnerRoleID)
} }
// llmGroupUsable reports whether the active model group of the runtime stack can // llmGroupUsable 报告运行时栈的活动模型组是否真的可调用:模型与 base URL 是必需
// actually be called: a model and a base URL are mandatory, and either an API key // 项,且要么配置了 API key,要么端点是本地/内网地址 —— 后者无 key 调用属正常情况
// is configured or the endpoint is a local/private address, where a keyless call // (自托管的 OpenAI 兼容服务)。
// is the normal case (a self-hosted OpenAI-compatible server).
// //
// It is deliberately a cheap structural check, never a probe request: boot must // 它刻意只做廉价的静态检查,绝不发探测请求:启动不该花掉一次模型调用。看起来可用
// not spend a model call. A group that looks usable but fails at call time only // 但调用时才失败的组,只会记一条提取错误日志(提取失败从不致命)。
// logs an extraction error (extraction failures are never fatal).
func llmGroupUsable(llm *model.RuntimeLLM) bool { func llmGroupUsable(llm *model.RuntimeLLM) bool {
if llm == nil { if llm == nil {
return false return false
@@ -61,8 +55,8 @@ func llmGroupUsable(llm *model.RuntimeLLM) bool {
return keylessEndpoint(group.BaseURL) return keylessEndpoint(group.BaseURL)
} }
// keylessEndpoint reports whether baseURL points at a loopback or private-network // keylessEndpoint 报告 baseURL 是否指向回环或内网主机 —— 这类地址上缺少 API key
// host, where a missing API key is expected rather than a misconfiguration. // 是预期情况,而非配置错误。
func keylessEndpoint(baseURL string) bool { func keylessEndpoint(baseURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(baseURL)) parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil { if err != nil {
+20 -25
View File
@@ -1,12 +1,10 @@
// Command cyrene is the single Cyrene v2 binary. It assembles every subsystem // cyrene 命令是 Cyrene v2 的唯一二进制入口:它把全部子系统(配置、日志、数据库、
// (config, logging, database, bus, personas, model adapter, tool dispatcher, // bus、人格、模型适配器、工具分发器、记忆栈、agent 循环、编排、执行、IoT、渠道
// memory stack, agent loop, orchestration, execution, IoT, channel sessions and // 会话与适配器)组装进同一个进程,在单端口(:8080)上同时提供网关与内嵌前端,
// adapters) into one process and serves the gateway + embedded frontend on a // 并在收到终止信号时优雅退出。
// single port (:8080), shutting down gracefully on a termination signal.
// //
// Build with `go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`. // 构建:`go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`。
// The frontend must be built first so the SPA can be embedded: // 前端必须先构建,SPA 才能被内嵌:`cd frontend/web && npm run build`。
// `cd frontend/web && npm run build`.
package main package main
import ( import (
@@ -17,11 +15,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
// Embed the IANA timezone database so the configured TZ (config.TZ, default // 内嵌 IANA 时区数据库,让配置的 TZ(config.TZ,默认 "Asia/Shanghai")
// "Asia/Shanghai") resolves everywhere: Windows ships no system tzdata and Go // 在任何平台都能解析:Windows 不自带系统 tzdata,Go 也刻意不捆绑,缺了它
// deliberately does not bundle one, so without this the loop's time injection // 循环的时间注入在 Windows 上会静默失效(internal/runtime/loop/time.go 会记
// silently disables itself there (internal/runtime/loop/time.go logs // "timezone load failed")。代价约 450 KB,换来二进制自包含。
// "timezone load failed"). Costs ~450 KB and keeps the binary self-contained.
_ "time/tzdata" _ "time/tzdata"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
@@ -31,16 +28,15 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/webui" "git.yeij.top/AskaEth/Cyrene_v2/internal/webui"
) )
// envPath is the .env file merged at boot (development only; production uses // envPath 是启动时合并的 .env 文件(仅开发用;生产走 systemd/env_file)。
// systemd/env_file). A missing .env is not an error. // .env 不存在不算错误。
const envPath = ".env" const envPath = ".env"
// shutdownTimeout bounds how long the graceful teardown may take. // shutdownTimeout 是优雅退场的时长上界。
const shutdownTimeout = 15 * time.Second const shutdownTimeout = 15 * time.Second
// externalDistCandidates are the on-disk directories checked (in order) for a // externalDistCandidates 是前端覆盖目录的候选清单:按序检查,都不存在才回落到
// frontend override before falling back to the embedded dist. The first that // 内嵌 dist;命中的第一个生效。路径相对进程工作目录。
// exists wins. They are relative to the process working directory.
func externalDistCandidates() []string { func externalDistCandidates() []string {
return []string{ return []string{
filepath.Join("frontend", "web", "dist"), filepath.Join("frontend", "web", "dist"),
@@ -48,8 +44,8 @@ func externalDistCandidates() []string {
} }
} }
// resolveFrontendFS returns the filesystem serving the SPA: an external dist // resolveFrontendFS 返回用于伺服 SPA 的文件系统:存在外部 dist 目录时用它
// directory when present (deploy-time hot override), otherwise the embedded dist. // (部署期热覆盖),否则用内嵌 dist。
func resolveFrontendFS(log *slog.Logger) fs.FS { func resolveFrontendFS(log *slog.Logger) fs.FS {
for _, dir := range externalDistCandidates() { for _, dir := range externalDistCandidates() {
if info, err := os.Stat(dir); err == nil && info.IsDir() { if info, err := os.Stat(dir); err == nil && info.IsDir() {
@@ -68,8 +64,7 @@ func main() {
} }
} }
// run boots the application, starts every service and the gateway, then blocks // run 启动应用、拉起全部服务与网关,然后阻塞等待终止信号或网关失败。
// until a termination signal arrives or the gateway fails.
func run() error { func run() error {
cfg := config.MustLoad(envPath) cfg := config.MustLoad(envPath)
format := logger.FormatText format := logger.FormatText
@@ -87,8 +82,8 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
// Observability log tail: the logger's ring buffer is built above (before the // 可观测性日志追尾:logger 的环形缓冲在上面(网关之前)就建好了,
// gateway), so hand it to the gateway now that the gateway exists. // 现在网关已存在,把它交给网关。
wireGatewayLogBuffer(a, logRing) wireGatewayLogBuffer(a, logRing)
if err := a.automation.Start(ctx); err != nil { if err := a.automation.Start(ctx); err != nil {
+9 -11
View File
@@ -12,15 +12,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
) )
// mcpConnectTimeout bounds each MCP server's connect+discover handshake so a hung // mcpConnectTimeout 约束每个 MCP server 的连接+发现握手,使挂死的 server 降级为
// server degrades to a warning instead of blocking startup. // 一条告警,而不是阻塞启动。
const mcpConnectTimeout = 15 * time.Second const mcpConnectTimeout = 15 * time.Second
// wireMCP reads cfg.MCP.ServersJSON, connects to every enabled server, registers // wireMCP 读取 cfg.MCP.ServersJSON,连接每个已启用的 server,把发现到的工具注册进
// its discovered tools into the capability registry (KindTool so the agent can // 能力注册表(KindTool,使 agent 可调用它们),并暴露一个 KindMCP 能力,让管理面板
// call them), and surfaces a KindMCP capability so the admin plugin list // 的插件列表能枚举该 server。连接失败或未配置的 server 降级为一条告警,绝不阻塞
// enumerates the server. A server that fails to connect or is unconfigured // 启动。
// degrades to a warning and never blocks startup.
func (a *app) wireMCP() { func (a *app) wireMCP() {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
servers, err := a.mcpServers() servers, err := a.mcpServers()
@@ -45,7 +44,7 @@ func (a *app) wireMCP() {
} }
} }
// mcpServers parses the configured MCP servers from cfg.MCP.ServersJSON. // mcpServers 从 cfg.MCP.ServersJSON 解析出已配置的 MCP server。
func (a *app) mcpServers() ([]config.MCPServer, error) { func (a *app) mcpServers() ([]config.MCPServer, error) {
raw := strings.TrimSpace(a.cfg.MCP.ServersJSON) raw := strings.TrimSpace(a.cfg.MCP.ServersJSON)
if raw == "" { if raw == "" {
@@ -58,9 +57,8 @@ func (a *app) mcpServers() ([]config.MCPServer, error) {
return servers, nil return servers, nil
} }
// registerMCPServer connects to one server, registers its tools, and publishes a // registerMCPServer 连接单个 server,注册其工具,并以 "mcp:<name>" 发布一个
// KindMCP capability under "mcp:<name>". It returns the first error so callers // KindMCP 能力。它返回第一个错误以便调用方分类;注册中途失败时会清理已加入的内容。
// can classify it; on partial registration it cleans up what it added.
func (a *app) registerMCPServer(ctx context.Context, srv config.MCPServer) error { func (a *app) registerMCPServer(ctx context.Context, srv config.MCPServer) error {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
reg := mcp.NewRegistry(a.caps, mcp.MCPConfig{ reg := mcp.NewRegistry(a.caps, mcp.MCPConfig{
+28 -34
View File
@@ -10,20 +10,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
) )
// newAppTraceWriter builds the composition root's trace writer: the JSONL stderr // newAppTraceWriter 构建组合根的 trace writer:JSONL stderr 记录器(每个 span 的
// recorder (the local structured log of every span) plus room for the gateway's // 本地结构化日志),外加为 gateway 可观测性出口预留的位置 —— 后者只能在 gateway
// observability sink, which can only be registered once the gateway exists (see // 存在之后注册(见 wireGatewayTraceSink)。
// wireGatewayTraceSink).
func newAppTraceWriter() *traceFanout { func newAppTraceWriter() *traceFanout {
return newTraceFanout(trace.NewJSONRecorder(os.Stderr)) return newTraceFanout(trace.NewJSONRecorder(os.Stderr))
} }
// wireGatewayTraceSink registers the gateway's observability sink on the app's // wireGatewayTraceSink 把 gateway 的可观测性出口注册到应用的 trace 扇出上。它由
// trace fanout. It is called from wireFrontend — the first moment the gateway // wireFrontend 调用 —— 那是 gateway 首次存在的时刻 —— 因为 gateway 构建在发出 span
// exists — because the gateway is built after the loop that emits the spans. // 的循环之后。没有它,管理面板的 trace 环形缓冲区永远不会被喂数据,
// Without it the admin trace ring buffer is never fed and // GET /api/v1/admin/observability/trace 会永远返回空列表(design/19 §2)。在没有
// GET /api/v1/admin/observability/trace answers an empty list forever // tracer 或 gateway 的启动中是空操作。
// (design/19 §2). It is a no-op on a boot without a tracer or gateway.
func wireGatewayTraceSink(a *app, gw *gateway.Server) { func wireGatewayTraceSink(a *app, gw *gateway.Server) {
if a.traceWriter == nil || gw == nil { if a.traceWriter == nil || gw == nil {
return return
@@ -32,20 +30,18 @@ func wireGatewayTraceSink(a *app, gw *gateway.Server) {
a.log.Info("observability trace sink wired") a.log.Info("observability trace sink wired")
} }
// traceFanout is the composition root's trace.Writer. It fans every completed // traceFanout 是组合根的 trace.Writer。它把每个已完成的 span 扇出到启动时安装的
// span out to the sinks installed at boot (the JSONL stderr recorder) plus any // 出口(JSONL stderr 记录器),以及启动流程中稍后注册的出口:gateway 的可观测性
// sink registered later in the boot sequence: the gateway's observability buffer // 缓冲区要等 wireFrontend 构建好 gateway 才存在,而发出 span 的循环在 wireRuntime
// only exists once wireFrontend has built the gateway, while the loop that emits // 中更早就构建了。
// the spans is built earlier in wireRuntime.
// //
// It is safe for concurrent use — the loop finishes spans from several // 它可安全并发使用 —— 循环会从多个 goroutine 结束 span(按步、按工具调用、子代理)。
// goroutines (per-step, per-tool-call, sub-agents).
type traceFanout struct { type traceFanout struct {
mu sync.RWMutex mu sync.RWMutex
writers []trace.Writer writers []trace.Writer
} }
// newTraceFanout builds a fanout over the given writers. Nil writers are dropped. // newTraceFanout 在给定 writers 之上构建扇出。nil writer 会被丢弃。
func newTraceFanout(writers ...trace.Writer) *traceFanout { func newTraceFanout(writers ...trace.Writer) *traceFanout {
f := &traceFanout{} f := &traceFanout{}
for _, w := range writers { for _, w := range writers {
@@ -54,7 +50,7 @@ func newTraceFanout(writers ...trace.Writer) *traceFanout {
return f return f
} }
// Add installs one more sink. A nil writer is ignored. // Add 再安装一个出口。nil writer 被忽略。
func (f *traceFanout) Add(w trace.Writer) { func (f *traceFanout) Add(w trace.Writer) {
if w == nil { if w == nil {
return return
@@ -64,9 +60,9 @@ func (f *traceFanout) Add(w trace.Writer) {
f.writers = append(f.writers, w) f.writers = append(f.writers, w)
} }
// WriteSpan writes rec to every sink and returns the first error. Every sink is // WriteSpan 把 rec 写给每个出口并返回第一个错误。即使失败也仍会尝试所有出口,
// tried even after a failure, so one broken sink cannot hide a span from the // 因此单个坏掉的出口无法把 span 从其他出口那里藏起来;tracer 只记录返回的错误
// others; the tracer only logs the returned error (trace.Tracer.emit). // (trace.Tracer.emit)。
func (f *traceFanout) WriteSpan(rec trace.SpanRecord) error { func (f *traceFanout) WriteSpan(rec trace.SpanRecord) error {
f.mu.RLock() f.mu.RLock()
defer f.mu.RUnlock() defer f.mu.RUnlock()
@@ -82,12 +78,11 @@ func (f *traceFanout) WriteSpan(rec trace.SpanRecord) error {
return firstErr return firstErr
} }
// wireGatewayLogBuffer hands the composition root's log ring buffer to the // wireGatewayLogBuffer 把组合根的日志环形缓冲区交给 gateway,使
// gateway, so GET /api/v1/admin/observability/logs serves the recent backend log // GET /api/v1/admin/observability/logs 能提供最近的后端日志尾部。logger 构建在
// tail. The logger is built before the gateway (its records must be captured // gateway 之前(其记录必须从第一行日志起就被捕获),因此缓冲区在这里注入 ——
// from the first log line), so the buffer is injected here, the first moment the // gateway 首次存在的时刻 —— 与 wireGatewayTraceSink 对 span 的做法一致。在没有
// gateway exists — like wireGatewayTraceSink does for spans. It is a no-op on a // gateway 的启动中是空操作。
// boot without a gateway.
func wireGatewayLogBuffer(a *app, buf *logger.RingBuffer) { func wireGatewayLogBuffer(a *app, buf *logger.RingBuffer) {
if a == nil || a.gw == nil || buf == nil { if a == nil || a.gw == nil || buf == nil {
return return
@@ -96,14 +91,13 @@ func wireGatewayLogBuffer(a *app, buf *logger.RingBuffer) {
a.log.Info("observability log buffer wired", "capacity", buf.Len()) a.log.Info("observability log buffer wired", "capacity", buf.Len())
} }
// gatewaySpanSink adapts *gateway.Server to trace.Writer through its ObserveSpan // gatewaySpanSink 通过 *gateway.Server 的 ObserveSpan 入口把它适配成 trace.Writer,
// entry point, so an emitted span lands in the admin panel's trace ring buffer // 使发出的 span 落在管理面板的 trace 环形缓冲区**并且**计入 LLM/子代理指标计数器
// AND in the LLM/subagent metric counters (ObserveSpan appends to the buffer and // (ObserveSpan 既追加到缓冲区,也观测指标;缓冲区正是
// observes the metrics; the buffer is what GET /api/v1/admin/observability/trace // GET /api/v1/admin/observability/trace 所提供的内容)。
// serves).
type gatewaySpanSink struct{ srv *gateway.Server } type gatewaySpanSink struct{ srv *gateway.Server }
// WriteSpan feeds rec to the gateway. A missing server is a no-op. // WriteSpan 把 rec 喂给 gateway。server 缺失时是空操作。
func (g gatewaySpanSink) WriteSpan(rec trace.SpanRecord) error { func (g gatewaySpanSink) WriteSpan(rec trace.SpanRecord) error {
if g.srv == nil { if g.srv == nil {
return nil return nil
+23 -32
View File
@@ -8,22 +8,19 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
) )
// This file builds the per-role agent pool (design/22 §4.2 方案 A). An // 本文件构建按角色的 agent 池(design/22 §4.2 方案 A)。agent.Agent 的指令在构造
// agent.Agent's instructions are immutable after construction, so "the session's // 之后不可变,因此「会话的角色决定 system 提示」被实现为每个角色一个 agent:它们
// role decides the system prompt" is implemented as one agent per role, all // 共享同一个 dispatcher、guardrail 流水线、模型适配器与选项 —— 只有指令字符串不同。
// sharing the same dispatcher, guardrail pipeline, model adapter and options — // 循环在每一步为当轮角色挑选池中条目(internal/runtime/loop/persona.go),因此角色
// 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 // design/22 §5 的要求:提示、记忆、情绪与 thinking 必须全部由同一个角色解析。
// be resolved from the same role. The pool owns the prompt; the loop owns the // 池拥有提示,循环拥有另外三者(personaFor / knobsFor),因此一轮绝不会混用两个
// 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 // personaAgentPool 返回按角色的 agent 池,以及用来跑轮次的 agent:池中有默认角色时
// with: the pooled agent for the default role when the pool has one, otherwise the // 用池里的那个,否则用调用方的兜底 agent,因此未注册任何角色的部署仍保持与多角色
// caller's fallback agent, so a deployment with no registered character keeps // 之前完全一致的单 agent 形态。
// 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) { 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) pool := a.buildPersonaAgents(llm, dispatcher, guards)
if pooled, ok := pool[a.defaultPersonaID()]; ok { if pooled, ok := pool[a.defaultPersonaID()]; ok {
@@ -32,22 +29,19 @@ func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatche
return pool, fallback return pool, fallback
} }
// buildPersonaAgents returns the role → agent pool: every registered character's // buildPersonaAgents 返回「角色 → agent」池:每个已注册角色对应的 persona 都得到
// persona gets an agent carrying that persona's system instruction // 一个携带该 persona system 指令的 agent(instructionsFromPersona,与默认 agent
// (instructionsFromPersona, the very same assembly point as the default agent — // 是同一个装配点 —— 见 personaInstructions),design/01 §2.2。未注册 persona 时
// see personaInstructions), design/01 §2.2. It returns an empty map when no // 返回空 map,循环便保持其单一默认 agent。
// persona is registered, so the loop keeps its single default agent.
// //
// 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入 // 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入
// (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与 // (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与
// 陌生人私聊(design/11 §0 红线)。 // 陌生人私聊(design/11 §0 红线)。
// //
// Only personas reachable through a registered character are pooled: a character // 只有通过已注册角色可达的 persona 才入池:角色才是可选单位(1 角色 ↔ 1 persona),
// is the selectable unit (1 character ↔ 1 persona), so a persona with no character // 因此没有角色的 persona 永远无法绑定到会话。绑定到未知 id 的会话于是退回默认
// can never be bound to a session. A session bound to an unknown id therefore // agent,而其记忆作用域仍跟随所绑定的 id —— 过期的绑定退化为默认提示,而不是让
// 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 { func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline) map[string]*agent.Agent {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
pool := make(map[string]*agent.Agent, a.personas.CountPersonas()) pool := make(map[string]*agent.Agent, a.personas.CountPersonas())
@@ -74,13 +68,10 @@ func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatc
return pool return pool
} }
// personaExists returns the resolver the orchestration layer validates a dispatched // personaExists 返回编排层用来校验被派发的子代理目标角色的解析器(design/22 §8-2)。
// sub-agent's target role with (design/22 §8-2). A role is dispatchable exactly when // 某角色可派发,当且仅当池中有它的 agent,而这正是循环会用该角色指令作答的条件。
// the pool has an agent for it, which is precisely the condition under which the loop // 其他任何情况 —— 未注册的 persona、没有角色的 persona、空池 —— 都会让派发以
// would answer with that role's instructions. Every other answer — an unregistered // orchestration.ErrUnknownPersona 失败,而不是悄悄按默认角色运行子代理。
// 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 { func personaExists(pool map[string]*agent.Agent) func(personaID string) bool {
return func(personaID string) bool { return func(personaID string) bool {
_, ok := pool[personaID] _, ok := pool[personaID]
+35 -48
View File
@@ -8,9 +8,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
) )
// Section headers for the persona fields that design/01 §2.2 lists as previously // 以下段头对应 design/01 §2.2 中此前未被消费的人格字段。它们作为独立的 system
// 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 ( const (
personalityHeader = "【性格】" personalityHeader = "【性格】"
valuesHeader = "【价值观】" valuesHeader = "【价值观】"
@@ -19,14 +18,13 @@ const (
backgroundHeader = "【背景】" backgroundHeader = "【背景】"
) )
// personaSection is one appended block: a bracketed header plus its body. // personaSection 是一个追加块:方括号段头 + 正文。
type personaSection struct { type personaSection struct {
header string header string
body string body string
} }
// defaultPersona returns the default character's persona, or false when no // defaultPersona 返回默认角色的 persona;没有注册角色/persona 时返回 false。
// character/persona is registered.
func (a *app) defaultPersona() (*loader.Persona, bool) { func (a *app) defaultPersona() (*loader.Persona, bool) {
c, ok := a.personas.DefaultCharacter() c, ok := a.personas.DefaultCharacter()
if !ok { if !ok {
@@ -39,9 +37,8 @@ func (a *app) defaultPersona() (*loader.Persona, bool) {
return p, true return p, true
} }
// identityInstructionFor returns the persona's identity block — the only part // identityInstructionFor 返回 persona 的身份块 —— design/01 §2.2 之前唯一注入的
// injected before design/01 §2.2: identity.systemPrompt, else identity.role, else // 部分:优先 identity.systemPrompt,其次 identity.role,再退回最小兜底文本。
// the minimal fallback.
func identityInstructionFor(p *loader.Persona) string { func identityInstructionFor(p *loader.Persona) string {
if p.Identity.SystemPrompt != "" { if p.Identity.SystemPrompt != "" {
return p.Identity.SystemPrompt return p.Identity.SystemPrompt
@@ -52,11 +49,10 @@ func identityInstructionFor(p *loader.Persona) string {
return fallbackInstruction return fallbackInstruction
} }
// instructionsFromPersona builds the persona's system instruction: the identity // instructionsFromPersona 构建 persona 的 system 指令:身份块,后接此前无人消费的
// block, followed by the personality/values/speechStyle/catchphrase/background // personality/values/speechStyle/catchphrase/background 各段(design/01 §2.2)。
// sections that used to have no consumer (design/01 §2.2). Blank fields are // 空白字段会被跳过,因此不会产生空段;这些字段全空的 persona 产出与身份块完全一致
// 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 { func instructionsFromPersona(p *loader.Persona) string {
base := identityInstructionFor(p) base := identityInstructionFor(p)
sections := personaSections(p) sections := personaSections(p)
@@ -74,9 +70,8 @@ func instructionsFromPersona(p *loader.Persona) string {
return b.String() return b.String()
} }
// personaSections renders the five optional sections in their fixed order // personaSections 按固定顺序渲染五个可选段(personality → values → speechStyle
// (personality → values → speechStyle → catchphrase → background), skipping any // → catchphrase → background),跳过其中为空的。
// that is blank.
func personaSections(p *loader.Persona) []personaSection { func personaSections(p *loader.Persona) []personaSection {
out := make([]personaSection, 0, 5) out := make([]personaSection, 0, 5)
appendSection := func(header, body string) { appendSection := func(header, body string) {
@@ -93,8 +88,8 @@ func personaSections(p *loader.Persona) []personaSection {
return out return out
} }
// bulletList renders string items as a markdown bullet list, skipping blanks; an // bulletList 把字符串项渲染成 markdown 无序列表,跳过空白项;全空的列表产出 ""
// all-blank list yields "" so its section disappears entirely. // 使对应段彻底消失。
func bulletList(items []string) string { func bulletList(items []string) string {
lines := make([]string, 0, len(items)) lines := make([]string, 0, len(items))
for _, item := range items { for _, item := range items {
@@ -105,18 +100,15 @@ func bulletList(items []string) string {
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
// defaultThinkingOptions returns the reasoning knobs the loop should inject for // defaultThinkingOptions 返回循环应为默认 persona 注入的推理开关:thinking 指令、
// the default persona: the thinking directive, the reasoning_effort value and the // reasoning_effort 取值与 thinking 广播开关。它是循环每轮所做的按角色解析的默认
// thinking-broadcast switch. It is the default half of the per-role resolution // 一半(Options.PersonaKnobs,见下方 personaKnobs),因此配置的默认值与每轮查询
// 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 // 只有当 persona 配置了会影响请求的内容(预算、effort,或显式的开关模式)时
// that affects the request (a budget, an effort, or an explicit on/off mode). // thinking 指令才非 nil。showToUser 刻意不产生指令:它只是呈现开关,而一个没有
// showToUser deliberately does NOT create one: it is a presentation switch, and a // 开关的非 nil 指令会顺带打开供应商侧 thinking。未注册 persona 时三者均为零值,
// 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) { func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) {
knobs, ok := a.personaKnobs(a.defaultPersonaID()) knobs, ok := a.personaKnobs(a.defaultPersonaID())
if !ok { if !ok {
@@ -125,14 +117,12 @@ func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) {
return knobs.Thinking, knobs.ReasoningEffort, knobs.ThinkingShowToUser return knobs.Thinking, knobs.ReasoningEffort, knobs.ThinkingShowToUser
} }
// personaKnobs resolves a role's reasoning knobs for the loop // personaKnobs 为循环解析某个角色的推理开关(loop.Options.PersonaKnobs,
// (loop.Options.PersonaKnobs, design/22 §4.2): the loop asks for the role of the // design/22 §4.2):循环会为即将构建请求的那一轮询问其角色,因此绑定角色 Y 的
// turn it is about to build a request for, so a session bound to role Y runs with // 会话运行时用的是 Y 的 thinking 预算 / effort / 广播开关,而不是默认角色的。
// Y's thinking budget / effort / broadcast switch instead of the default role's. // 从角色 id 解析,而不是把取值推入每个调用方的 SessionEnv,意味所有路径 —— Web
// Resolving from the role id rather than pushing a value into every caller's // REST/WS/语音、IM、backthink、子代理 —— 都由同一个输入拿到正确的开关。
// SessionEnv means every path — Web REST/WS/voice, IM, backthink, sub-agents — // ok=false 表示「角色未知」,循环沿用其配置的默认值。
// 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) { func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) {
if personaID == "" { if personaID == "" {
return loop.LoopKnobs{}, false return loop.LoopKnobs{}, false
@@ -149,13 +139,11 @@ func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) {
}, true }, true
} }
// personaName resolves a role id to its display name for the model context's // personaName 把角色 id 解析成其展示名,供模型上下文的说话人归属使用
// speaker attribution (loop.Options.PersonaName, design/22 §7-5). The loop names a // (loop.Options.PersonaName,design/22 §7-5)。循环把发话角色写成
// speaking role as "「昔涟」(xilian)" instead of a bare id, so the model can follow // 「「昔涟」(xilian)」而不是裸 id,使模型在会话切换角色后仍能跟上谁说了什么。
// who said what after a session switched roles. An empty or unknown id resolves to // id 为空或未知时解析为 "",循环退回只写 id —— 归属不会因为名字解析不出就消失,
// "" 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 { func (a *app) personaName(personaID string) string {
if personaID == "" { if personaID == "" {
return "" return ""
@@ -167,9 +155,8 @@ func (a *app) personaName(personaID string) string {
return strings.TrimSpace(p.Name) return strings.TrimSpace(p.Name)
} }
// personaThinking maps the persona's `thinking` section onto the model-layer // personaThinking 把 persona 的 `thinking` 段映射到模型层开关(design/01 §2.1 /
// knobs (design/01 §2.1 / design/04 §3.2). It returns (nil, "") when nothing // design/04 §3.2)。没有任何内容影响请求时返回 (nil, "")。
// affects the request.
func personaThinking(p *loader.Persona) (*model.Thinking, string) { func personaThinking(p *loader.Persona) (*model.Thinking, string) {
effort := strings.TrimSpace(p.Thinking.Effort) effort := strings.TrimSpace(p.Thinking.Effort)
enable := p.Thinking.Enable() enable := p.Thinking.Enable()
+4 -5
View File
@@ -8,11 +8,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
) )
// wirePlugins builds the runtime plugin manager over the capability registry and // wirePlugins 在能力注册表与被 gitignore 的 plugins.json 之上构建运行时插件管理器,
// the gitignored plugins.json, then restores every persisted MCP server so an // 再恢复每个已持久化的 MCP server,使已安装的插件在重启后依然存在。启动时连不上的
// installed plugin survives a restart. A server that is unreachable at boot is // server 只记日志并保留在 plugins.json 中(主人可重试或卸载它);启动绝不因某个
// logged and left in plugins.json (the owner can retry or uninstall it); boot // 外部 server 不可用而失败。
// never fails because an external server is down.
func (a *app) wirePlugins(ctx context.Context) { func (a *app) wirePlugins(ctx context.Context) {
path := runtimeconfig.DefaultPluginsPath(resolveDataDir(a.cfg)) path := runtimeconfig.DefaultPluginsPath(resolveDataDir(a.cfg))
a.plugins = pluginmgr.NewManager(a.caps, path, logger.WithSource(a.log, "capability/plugin")) a.plugins = pluginmgr.NewManager(a.caps, path, logger.WithSource(a.log, "capability/plugin"))
+19 -25
View File
@@ -9,17 +9,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread"
) )
// wireSessionReadTool registers the design/22 §8-3 cross-session read tool, so the // wireSessionReadTool 注册 design/22 §8-3 的跨会话读取工具,使运行中的角色能把
// running role can pull another Web session's context as external, read-only // 另一个 Web 会话的上下文作为外部只读材料拉取过来(design/22 §8.1)。
// material (design/22 §8.1).
// //
// The capability is registered with its default reachability markers (both false), // 该能力以默认的可达性标记(两者皆 false)注册,从而保持仅主人可用:design/11 §3 的
// which keeps it owner-only: the design/11 §3 policy engine hides the schema from a // 策略引擎会对群聊/陌生人会话隐藏其 schema,循环的安全门也会在那里拒绝调用。工具
// group/stranger session and the loop's security gate rejects the call there. The // 自身的 fail-closed 检查(主人环境 + web/web:<uuid> 白名单)运行在那道门之后,
// tool's own fail-closed check (owner environment + the web/web:<uuid> whitelist) // 因此未授权的读取会被双重拒绝。审计链路就是循环的 gate/OnAudit,与其他每个工具
// runs behind that gate, so an unauthorized read is refused twice over. The // 完全一致 —— 这里不添加任何绕过路径。
// audit trail is the loop's gate/OnAudit, exactly as for every other tool — no
// bypass path is added here.
func (a *app) wireSessionReadTool() { func (a *app) wireSessionReadTool() {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
if a.history == nil { if a.history == nil {
@@ -43,11 +40,10 @@ func (a *app) wireSessionReadTool() {
log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true) log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true)
} }
// sessionReadLocation resolves the configured IANA zone for the material's // sessionReadLocation 解析材料时间戳所用的已配置 IANA 时区,使外部 transcript 与
// timestamps, so the external transcript reads in the same clock as the loop's // 循环逐轮注入的「当前时间」读同一个时钟(internal/runtime/loop/time.go)。未设置
// per-round "current time" injection (internal/runtime/loop/time.go). An unset zone // 时区或加载失败时返回 nil,保持工具的进程本地默认值 —— 读取不该因为一个展示设置
// or one that fails to load returns nil, which keeps the tool's process-local // 而失败。
// default — the read must not fail over a display setting.
func (a *app) sessionReadLocation(log *slog.Logger) *time.Location { func (a *app) sessionReadLocation(log *slog.Logger) *time.Location {
if a.cfg == nil || a.cfg.Timezone == "" { if a.cfg == nil || a.cfg.Timezone == "" {
return nil return nil
@@ -61,17 +57,15 @@ func (a *app) sessionReadLocation(log *slog.Logger) *time.Location {
return loc return loc
} }
// sessionReadPersonas adapts the persona registry to sessionread.PersonaNamer. The // sessionReadPersonas 把人格注册表适配为 sessionread.PersonaNamer。该工具包刻意不
// tool package deliberately does not import internal/persona/registry (it holds a // 导入 internal/persona/registry(而是持有一个窄接口,保持分层单向),因此由组合根
// narrow interface instead, keeping the layering one-way), so the composition root // 提供显示名查询。
// supplies the display-name lookup.
type sessionReadPersonas struct { type sessionReadPersonas struct {
reg *personareg.Registry reg *personareg.Registry
} }
// PersonaName implements sessionread.PersonaNamer: the persona's display name, or // PersonaName 实现 sessionread.PersonaNamer:返回人格的显示名;对未知/未注册的角色
// ok=false for an unknown/unregistered role so the tool shows the raw id rather // 返回 ok=false,使工具展示原始 id 而不是编造一个名字。
// than inventing a name.
func (p sessionReadPersonas) PersonaName(id string) (string, bool) { func (p sessionReadPersonas) PersonaName(id string) (string, bool) {
if p.reg == nil || id == "" { if p.reg == nil || id == "" {
return "", false return "", false
@@ -83,9 +77,9 @@ func (p sessionReadPersonas) PersonaName(id string) (string, bool) {
return persona.Name, true return persona.Name, true
} }
// DefaultPersonaID implements sessionread.PersonaNamer: the persona bound to the // DefaultPersonaID 实现 sessionread.PersonaNamer:返回库默认角色所绑定的人格,与
// library's default character, mirroring (*app).defaultPersonaID. An empty id means // (*app).defaultPersonaID 保持一致。空 id 表示材料渲染显式的「未设置角色」标记,
// the material renders the explicit "no role set" marker instead of guessing. // 而不是靠猜。
func (p sessionReadPersonas) DefaultPersonaID() string { func (p sessionReadPersonas) DefaultPersonaID() string {
if p.reg == nil { if p.reg == nil {
return "" return ""
+5 -6
View File
@@ -5,14 +5,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
) )
// defaultSkillsDir is the on-disk directory of skill YAML definitions. It is // defaultSkillsDir 是技能 YAML 定义的磁盘目录。它是可选的:目录缺失时只告警并
// optional: a missing directory skips skill loading with a warning. // 跳过技能加载。
const defaultSkillsDir = "configs/skills" const defaultSkillsDir = "configs/skills"
// wireSkills loads skill definitions from configs/skills and registers each into // wireSkills 从 configs/skills 加载技能定义,并把每个技能以 KindSkill 能力注册进
// the capability registry as a KindSkill capability so the plugin ecosystem can // 能力注册表,供插件生态枚举。加载+注册是 M5 的交付物;Runner.Run(端到端执行
// enumerate them. Load+register is the M5 deliverable; Runner.Run (executing a // 技能)落在后续里程碑,目前返回 ErrRunPending。
// skill end to end) lands in a later milestone and currently returns ErrRunPending.
func (a *app) wireSkills() { func (a *app) wireSkills() {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
list, err := skills.NewLoader(a.log).LoadDir(defaultSkillsDir) list, err := skills.NewLoader(a.log).LoadDir(defaultSkillsDir)
+10 -12
View File
@@ -6,21 +6,19 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
) )
// streamSubject is the bus subject the loop's OnStream closure publishes each // streamSubject 是循环的 OnStream 闭包发布每个结构化流事件所用的 bus subject。
// structured stream event to. The gateway hub subscribes to ws.> and carries a // gateway hub 订阅 ws.>,并为该 subject 保留一条专用分支,把 model.StreamEvent
// dedicated branch for this subject that maps the model.StreamEvent to the typed // 映射为带 seq 序号的下游强类型 WS 事件(message.*/thinking.*/tool_call.*)。
// downstream WS event (message.*/thinking.*/tool_call.*) with a seq number.
// //
// It MUST stay in sync with wsStreamSubject in internal/services/gateway/ws_hub.go; // 它必须与 internal/services/gateway/ws_hub.go 中的 wsStreamSubject 保持一致;
// the two packages cannot share a const without pulling the gateway into the // 两个包若共用同一个 const,会把 gateway 拉进主二进制的导入图,因此该字面量
// main binary's import graph, so the literal is duplicated and documented. // 在此重复一份并加以说明。
const streamSubject = "ws.stream" const streamSubject = "ws.stream"
// streamChunk is the loop's OnStream callback. It publishes one structured // streamChunk 是循环的 OnStream 回调。它把一个结构化流事件发布到 gateway 的流式
// stream event to the gateway's streaming subject so the hub fans it out to // subject,由 hub 以对应的强类型 WS 事件广播给每个客户端。未接 bus 时它为空操作,
// every client as the matching typed WS event. It is a no-op when no bus is // 且绝不阻塞循环:bus.Publish 是异步的,单个订阅缓冲写满时丢弃事件而不是让模型
// wired and never blocks the loop: bus.Publish is asynchronous and a full // 流停摆。
// per-subscription buffer drops the event rather than stalling the model stream.
func (a *app) streamChunk(ev model.StreamEvent) { func (a *app) streamChunk(ev model.StreamEvent) {
if a.bus == nil { if a.bus == nil {
return return
+15 -22
View File
@@ -7,20 +7,17 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
) )
// defaultSubagentTimeout bounds one sub-agent's whole run when nothing else // defaultSubagentTimeout 在别处都未配置时,约束单个子代理的整轮运行。
// configures it. DevDocs/design/05-子代理.md §7 requires a timeout and an // DevDocs/design/05-子代理.md §7 要求有超时与防失控上限;调度器两者都已实现
// anti-runaway bound; the scheduler already implements both (PerAgentTimeout, // (PerAgentTimeout、Abort/CancelAll),因此生产必须给出一个值,否则每代理超时
// Abort/CancelAll), so production must supply a value or the per-agent timeout // 形同虚设(调度器里的 0 表示「无超时」)。该值刻意给得宽松 —— 子代理与任何循环
// stays inert (the scheduler's zero means "no timeout"). The number is // 一样最多跑 8 轮工具 —— 并且是 SUBAGENT_TIMEOUT_SEC 之后的兜底(裁决 02.4.4:
// deliberately generous — a sub-agent runs up to 8 tool rounds like any loop — // 默认 10min,可配置;subagentToolTimeout 解析出生效值)。
// and is the fallback behind SUBAGENT_TIMEOUT_SEC (裁决 02.4.4: 默认 10min,
// 可配置;subagentToolTimeout resolves the effective value).
const defaultSubagentTimeout = 10 * time.Minute const defaultSubagentTimeout = 10 * time.Minute
// subagentToolTimeout resolves the dispatch tool's default sub-agent timeout: // subagentToolTimeout 解析分派工具的默认子代理超时:SUBAGENT_TIMEOUT_SEC 为正时
// SUBAGENT_TIMEOUT_SEC wins when positive, otherwise the 10-minute code default // 以它为准,否则采用代码里的 10 分钟默认值(design/05 §7 裁决 02.4.4)。每次分派
// applies (design/05 §7 裁决 02.4.4). A per-dispatch timeout_seconds argument // 传入的 timeout_seconds 参数仍会在工具层按次覆盖此值。
// still overrides this per call at the tool layer.
func (a *app) subagentToolTimeout() time.Duration { func (a *app) subagentToolTimeout() time.Duration {
if a.cfg != nil && a.cfg.Subagent.TimeoutSec > 0 { if a.cfg != nil && a.cfg.Subagent.TimeoutSec > 0 {
return time.Duration(a.cfg.Subagent.TimeoutSec) * time.Second return time.Duration(a.cfg.Subagent.TimeoutSec) * time.Second
@@ -28,17 +25,13 @@ func (a *app) subagentToolTimeout() time.Duration {
return defaultSubagentTimeout return defaultSubagentTimeout
} }
// wireSubagentTool registers the sub-agent dispatch tool with the dispatcher, so // wireSubagentTool 向分发器注册子代理分派工具,让 agent 在运行时终于能触达编排层
// the agent can finally reach the orchestration layer at runtime (before this, // (在此之前 (*orchestration.Scheduler).Spawn 没有生产调用方,design/05 只是库)。
// (*orchestration.Scheduler).Spawn had no production caller and design/05 was
// library-only).
// //
// The capability is registered with its default reachability markers (both // 该能力以默认的可达性标记(两者皆 false)注册,从而保持仅主人可用:design/11 §3 的
// false), which keeps it owner-only: the design/11 §3 policy engine hides the // 策略引擎会对群聊/陌生人会话隐藏其 schema,循环的安全门也会在那里拒绝调用。
// schema from a group/stranger session and the loop's security gate rejects the // design/05 §6/§7 未给群聊/陌生人分派后台 agent 的许可,且能生成子代理属于重操作,
// call there. Design/05 §6/§7 give no group/stranger allowance for spawning // 因此刻意选择安全默认值,而不是让它被意外开启。
// background agents, and being able to spawn one is a heavy operation, so the
// safe default is chosen deliberately rather than enabled by accident.
func (a *app) wireSubagentTool() { func (a *app) wireSubagentTool() {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
if a.scheduler == nil { if a.scheduler == nil {
+13 -17
View File
@@ -11,27 +11,26 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
) )
// terminalToolName is the registered name of the exec/terminal tool. // terminalToolName 是 exec/terminal 工具的注册名。
const terminalToolName = "terminal" const terminalToolName = "terminal"
// terminalTool is an sdk.Tool that runs a command through the grant-based exec // terminalTool 是 sdk.Tool,通过基于授权的 exec 沙箱(exec.Provider)运行命令。
// sandbox (exec.Provider). It is registered even when the sandbox is closed: // 即使沙箱关闭它也会被注册:此时 Execute 返回 exec.ErrExecutionDisabled,因此
// Execute then returns exec.ErrExecutionDisabled, so the agent gains the ability // agent 只有在主人显式启用沙箱后才获得执行代码的能力(默认关闭)。
// to run code only after the owner explicitly enables the sandbox (default off).
type terminalTool struct { type terminalTool struct {
prov exec.Provider prov exec.Provider
log *slog.Logger log *slog.Logger
} }
// Name returns the tool identifier. // Name 返回工具标识符。
func (t *terminalTool) Name() string { return terminalToolName } func (t *terminalTool) Name() string { return terminalToolName }
// Description explains what the tool does. // Description 说明工具做什么。
func (t *terminalTool) Description() string { func (t *terminalTool) Description() string {
return "Run a terminal command through the sandboxed exec provider and return its stdout, stderr and exit code." return "Run a terminal command through the sandboxed exec provider and return its stdout, stderr and exit code."
} }
// Parameters returns the input JSON Schema. // Parameters 返回输入 JSON Schema。
func (t *terminalTool) Parameters() (map[string]any, error) { func (t *terminalTool) Parameters() (map[string]any, error) {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
@@ -53,9 +52,8 @@ func (t *terminalTool) Parameters() (map[string]any, error) {
}, nil }, nil
} }
// Execute runs the command via the exec provider and returns its outcome. When // Execute 通过 exec provider 运行命令并返回结果。沙箱关闭时 provider 返回
// the sandbox is closed the provider returns exec.ErrExecutionDisabled, which the // exec.ErrExecutionDisabled,分发器会把它包装成可安全回喂给 LLM 的工具错误。
// dispatcher wraps into a tool error safe to feed back to the LLM.
func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) { func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any) m, ok := args.(map[string]any)
if !ok { if !ok {
@@ -85,9 +83,8 @@ func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) {
}, nil }, nil
} }
// timeoutArg converts the optional timeout argument (seconds) to a duration. A // timeoutArg 把可选的 timeout 参数(秒)转换为 duration。缺失或非正值返回 0,
// missing or non-positive value returns zero, letting the provider apply its own // 由 provider 应用自己的默认值。
// default.
func timeoutArg(v any) time.Duration { func timeoutArg(v any) time.Duration {
switch n := v.(type) { switch n := v.(type) {
case float64: case float64:
@@ -102,9 +99,8 @@ func timeoutArg(v any) time.Duration {
return 0 return 0
} }
// wireTerminalTool registers the terminal tool with the dispatcher so the agent // wireTerminalTool 向分发器注册 terminal 工具,使 agent 能发出沙箱内终端命令。
// can issue sandboxed terminal commands. It no-ops (with a warning) when no exec // 未接入 exec provider 时它只告警并空操作,以保证调用方安全。
// provider is wired, which keeps callers safe.
func (a *app) wireTerminalTool() { func (a *app) wireTerminalTool() {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
if a.execProv == nil { if a.execProv == nil {
+7 -9
View File
@@ -8,21 +8,19 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
) )
// visionToolName and ocrToolName are the registered names of the vision and OCR // visionToolName 与 ocrToolName 是 vision 与 OCR 工具的注册名。它们在此与工具注册
// tools. They are bound to the tool registration here and advertised to the // 绑定,并由工具分发器通告给 agent。
// agent by the tool dispatcher.
const ( const (
visionToolName = "vision" visionToolName = "vision"
ocrToolName = "ocr" ocrToolName = "ocr"
) )
// wireVision registers the vision and OCR tools and the vision model routing. // wireVision 注册 vision 与 OCR 工具以及 vision 模型路由。
// //
// The vision adapter is an OpenAI-compatible /chat/completions endpoint on the // vision 适配器是 OpenAI 兼容的 /chat/completions 端点,与聊天 LLM 共用同一 base
// same base URL as the chat LLM (LLM_BASE_URL), using the dedicated model // URL(LLM_BASE_URL),配置了专用模型(LLM_VISION_MODEL)时使用该模型。未配置
// (LLM_VISION_MODEL) when set. When no base URL is configured the tools are // base URL 时只告警并跳过这些工具,启动因此绝不阻塞;只要配置了 vision 模型
// skipped with a warning so startup never blocks; the model router is still // (在 wireRuntime 中),模型路由器仍会绑定到它。
// bound to the vision model (in wireRuntime) whenever one is configured.
func (a *app) wireVision() { func (a *app) wireVision() {
log := logger.WithSource(a.log, "cmd/cyrene") log := logger.WithSource(a.log, "cmd/cyrene")
if strings.TrimSpace(a.cfg.LLM.BaseURL) == "" { if strings.TrimSpace(a.cfg.LLM.BaseURL) == "" {
+3 -4
View File
@@ -5,10 +5,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
) )
// wireVoice builds the voice STT/TTS degrading chain from config and stores it // wireVoice 从配置构建语音 STT/TTS 降级链并挂到 app 上,供 WebSocket 的
// on the app for the WebSocket voice_input/voice_output paths to consume. The // voice_input/voice_output 路径使用。降级链依次为 云 → 本地 → edge-tts/espeak →
// chain degrades cloud → local → edge-tts/espeak → silent, so a missing endpoint // 静默,因此端点或 API key 缺失绝不会阻塞启动(设计上接受静默兜底)。
// or API key never blocks startup (the design accepts the silent fallback).
func (a *app) wireVoice() { func (a *app) wireVoice() {
a.voice = voice.New(a.cfg.Voice) a.voice = voice.New(a.cfg.Voice)
logger.WithSource(a.log, "cmd/cyrene").Info("voice chain ready", logger.WithSource(a.log, "cmd/cyrene").Info("voice chain ready",
+22 -28
View File
@@ -35,24 +35,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/internal/user"
) )
// defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner // defaultOwnerPath 与 defaultPersonasDir 指向随仓库分发的磁盘上人格/主人定义,
// definitions shipped with the repo; they are relative to the working directory. // 路径相对工作目录。
const ( const (
defaultOwnerPath = "configs/users/owner.yaml" defaultOwnerPath = "configs/users/owner.yaml"
defaultPersonasDir = "configs/personas" defaultPersonasDir = "configs/personas"
defaultSQLitePath = "./data/cyrene.db" defaultSQLitePath = "./data/cyrene.db"
fallbackInstruction = "You are Cyrene, a warm and attentive companion." fallbackInstruction = "You are Cyrene, a warm and attentive companion."
// Bound/knob constants. Compile-time so no bare magic number appears in the // 上界与旋钮常量。编译期定值,运行时路径上不出现裸魔法数字(项目规范 §10)。
// runtime path (project rule §10).
defaultMaxRounds = 8 defaultMaxRounds = 8
defaultMaxParallel = 4 defaultMaxParallel = 4
defaultAgentID = "orchestrator" defaultAgentID = "orchestrator"
defaultToolTimeout = 30 * time.Second defaultToolTimeout = 30 * time.Second
) )
// app holds every subsystem the single Cyrene binary assembles. It is built by // app 持有单二进制 Cyrene 组装的全部子系统:由 newApp 构建,关闭时由 close 拆除。
// newApp and torn down by close on shutdown.
type app struct { type app struct {
cfg *config.Config cfg *config.Config
log *slog.Logger log *slog.Logger
@@ -84,33 +82,29 @@ type app struct {
gw *gateway.Server gw *gateway.Server
channelBridge *channelbridge.Bridge channelBridge *channelbridge.Bridge
channels gateway.ChannelConfigStore channels gateway.ChannelConfigStore
// capacitySink broadcasts capacity_awaken transitions to the Web UI (design/14 // capacitySink 把 capacity_awaken 状态迁移广播给 Web UI(design/14 §5)。
// §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an // 由 *gateway.Server 满足(BroadcastCapacityAwaken);以接口形式持有,
// interface so the composition root is unit-testable with a fake sink. // 组合根才能用假出口做单元测试。
capacitySink capacityEmitter capacitySink capacityEmitter
// emotion applies each settled turn to the bound persona's emotion machine and // emotion 把每一轮落定的对话施加到所绑人格的情绪机与好感度上(design/01 §2),
// affection (design/01 §2) and hands the changes to moodSink. Nil when no // 并把变化交给 moodSink。没有注册人格时为 nil,此时不追踪、不持久化任何心情。
// persona was registered, in which case no mood is tracked or persisted.
emotion *emotion.Observer emotion *emotion.Observer
// moodSink broadcasts a mood change to the Web UI (design/07 §3). It is // moodSink 把心情变化广播给 Web UI(design/07 §3)。由 *gateway.Server 满足
// satisfied by *gateway.Server (BroadcastMood); held as an interface so the // (BroadcastMood);以接口形式持有,组合根才能用假出口做单元测试。
// composition root is unit-testable with a fake sink.
moodSink moodEmitter moodSink moodEmitter
// traceWriter fans finished trace spans out to the JSONL stderr recorder and, // traceWriter 把完成的 trace span 扇出到 JSONL stderr 记录器,网关出现后也送往
// once the gateway exists, to the gateway's observability buffer behind // 网关的可观测性缓冲(挂在 GET /api/v1/admin/observability/trace 后面,
// GET /api/v1/admin/observability/trace (design/19 §2). It is held so // design/19 §2)。持有它是为了让 wireFrontend 能往已在运行的 tracer 上注册
// wireFrontend can register the gateway sink on the already-running tracer. // 网关出口。
traceWriter *traceFanout traceWriter *traceFanout
} }
// newApp assembles the full Cyrene runtime from cfg and log. It opens the // newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线
// database, migrates the schema, wires the persona registry, model adapter, tool // 人格注册表、模型适配器、工具分发器、记忆栈、agent 循环、编排、执行、IoT、
// dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel // 渠道会话/适配器与网关,最后返回就绪的 app。
// sessions/adapters and the gateway, and returns the ready app.
// //
// Fatal wiring failures (database open/migrate, required auth, gateway) are // 致命的接线失败(数据库打开/迁移、必需的认证、网关)以错误返回;可选子系统则
// returned as errors; optional subsystems degrade gracefully so the binary can // 优雅降级,使得未配置模型密钥或外部渠道时二进制仍能启动。
// boot even when a model key or external channel is not configured.
func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) { func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) {
dataDir := resolveDataDir(cfg) dataDir := resolveDataDir(cfg)
databasePath := resolveSQLitePath(cfg, dataDir) databasePath := resolveSQLitePath(cfg, dataDir)
@@ -136,8 +130,8 @@ func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, er
caps: registry.New(), caps: registry.New(),
} }
// Release already-opened resources if any later wiring step fails, without // 后续任一接线步骤失败时,释放已经打开的资源,但不打整套服务关闭日志
// the full-service shutdown log (a partial boot is not a clean shutdown). // (半途启动不等同于一次干净退出)。
completed := false completed := false
defer func() { defer func() {
if completed { if completed {
+21 -29
View File
@@ -16,25 +16,20 @@ import (
// L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出 // L3 缓存 TTL 解析与 capacity_awaken 事件出口。它从 wire_runtime.go 拆出
// (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。 // (AGENTS §10 文件 ≤500 行;wire.go 拆分建议同样适用于本文件)。
// buildCapacity constructs the awakened-capacity decider when enabled, or nil // buildCapacity 在启用时构造唤醒式能力判定器;未启用则返回 nil(loop 保持默认的
// (which keeps the loop's default all-tools behavior). It is opt-in via // 「全工具」行为)。它由 CAPACITY_ENABLED 显式开启;允许集映射取自
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and // CAPACITY_TOOL_SETS,解析失败时回落到内置默认值。
// falls back to built-in defaults on a parse error.
// //
// The model-assisted L3 layer (裁决 14.4.4) is a second, independent switch // 模型辅助的 L3 层(裁决 14.4.4)是第二个、彼此独立的开关
// (CAPACITY_L3_ENABLED, default false): even with capacity on, L3 stays silent // (CAPACITY_L3_ENABLED,默认 false):即使能力层已开,L3 仍保持静默,除非被
// unless it was explicitly asked for, and its verdict cache TTL // 显式要求;其判定结果的缓存 TTL(CAPACITY_L3_CACHE_TTL,默认 5m)在此解析,
// (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader // 因为配置加载器只承载标量。
// only carries scalars.
// //
// The persona-level awakening override (裁决 14.4.3) enters the decision layer in // 人格级唤醒覆盖(裁决 14.4.3)分两处进入决策层。本函数填**全局**兜底
// two places. This function fills the *global* fallback (Config.Awakening) from the // (Config.Awakening),取自当前主角色 YAML 里的块(见 defaultAwakening):
// current main persona's YAML block (see defaultAwakening): it is what a persona // 未声明该块的人格继承的就是它。每个 turn 的取值由 awakeningFor 解析,loop 经
// declaring no block inherits. The per-turn value is resolved by awakeningFor, // loop.Options.PersonaAwakening 对该 turn 自己的角色调用它——没有它,非默认
// which the loop calls for every turn's own role through // 角色的 `awakening` 块会被解析、校验,然后被静默忽略(design/01 §2.1 禁止)。
// loop.Options.PersonaAwakening — without it, a non-default character's
// `awakening` block would be parsed, validated and then silently ignored
// (forbidden by design/01 §2.1).
func (a *app) buildCapacity() *capacity.Decider { func (a *app) buildCapacity() *capacity.Decider {
if !a.cfg.Capacity.Enabled { if !a.cfg.Capacity.Enabled {
return nil return nil
@@ -64,10 +59,9 @@ func (a *app) buildCapacity() *capacity.Decider {
return capacity.New(cfg, capacity.WithLogger(a.log)) return capacity.New(cfg, capacity.WithLogger(a.log))
} }
// parseL3CacheTTL parses the CAPACITY_L3_CACHE_TTL duration string. An empty // parseL3CacheTTL 解析 CAPACITY_L3_CACHE_TTL 这个时长字符串。空值选代码默认
// value selects the code default (5m, the landing suggestion of 裁决 14.4.4); an // (5m,裁决 14.4.4 的落地建议);无法解析的值会告警并回落到同一默认,而不是
// unparsable value is reported and falls back to the same default rather than // 静默关掉缓存。
// silently disabling the cache.
func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration { func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -82,9 +76,8 @@ func parseL3CacheTTL(raw string, log *slog.Logger) time.Duration {
return d return d
} }
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of // parseCapacityToolSets 解码 CAPACITY_TOOL_SETS 的 JSON 映射:态 → 允许的工具名
// state→allowed tool name list. A blank string yields nil (built-in defaults); a // 列表。空白串产出 nil(用内置默认);某态取 null 表示不受限(全部工具)。
// null value for a state means unrestricted (all tools).
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
if strings.TrimSpace(raw) == "" { if strings.TrimSpace(raw) == "" {
return nil, nil return nil, nil
@@ -186,11 +179,10 @@ func awakeningState(raw string) capacity.State {
} }
} }
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken // onCapacity 是 loop 的 OnCapacity 出口:它观测一次 capacity_awaken 跃迁,并以
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken` // `capacity_awaken` 帧推给 Web UI(design/14 §5)。loop 只在真实发生状态跃迁时
// frame. The loop only emits on a real state transition (never every round), and // 才发(绝非每轮都发);事件携带发起会话 id,因此路由到那个会话而非全局广播。
// the event carries the originating session id so it routes to that session, not // 载荷是前端的 AwakenPayload {fromState,toState,tool}。
// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}.
func (a *app) onCapacity(ev capacity.Event) { func (a *app) onCapacity(ev capacity.Event) {
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event", logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
"type", ev.Type, "from", ev.FromState, "to", ev.ToState, "type", ev.Type, "from", ev.FromState, "to", ev.ToState,
+35 -45
View File
@@ -16,13 +16,12 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/internal/user"
) )
// ownerRegistrar implements gateway.OwnerRegistrar: it persists a channel account // ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
// id into the owner whitelist (configs/users/owner.yaml) AND hot-updates the live // 白名单(configs/users/owner.yaml),**并**热更新渠道 bridge 持有的活主人档案,
// owner profile the channel bridge holds, so a weixin scan-login confirmation is // 因此微信扫码登录的确认会被立即认成主人——无需重启,也无需手工改配置。path 与
// recognized as the owner immediately — no restart, no manual edit. path and the // 活档案都在接线时捕获:启动加载的 *user.OwnerProfile 就是 bridge 判定 actor 时
// live profile are captured at wiring time: the boot-loaded *user.OwnerProfile is // 依据的同一个指针,所以在它上面调 RegisterAccount 对 ResolveActor 立即可见,
// the same pointer the bridge resolves actors against, so RegisterAccount on it is // 无需触碰 bridge。
// visible to ResolveActor without touching the bridge.
type ownerRegistrar struct { type ownerRegistrar struct {
path string path string
owner *user.OwnerProfile owner *user.OwnerProfile
@@ -36,10 +35,9 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil { if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil {
return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err) return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err)
} }
// Hot-effect: extend the live profile the bridge already points at. If the // 热生效:把这次登记扩到 bridge 已经指向的那份活档案上。若档案在启动时加载
// profile failed to load at boot (r.owner nil) the persist above still lands // 失败(r.owner 为 nil),上面的落盘依然写进磁盘、重启后即被识别;此处打日志
// on disk and is recognized after a restart; log so the operator knows the // 是为了让运维知道热路径当时不可用。
// hot path was unavailable.
if r.owner != nil { if r.owner != nil {
r.owner.RegisterAccount(platform, accountID) r.owner.RegisterAccount(platform, accountID)
} else { } else {
@@ -56,18 +54,14 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。 // 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner } func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
// wireChannels constructs the four IM channel adapters from environment // wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
// configuration, registers them so the admin API can enumerate them, and wires // API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
// them into the channel bridge so a configured adapter connects and routes // session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
// inbound messages through session→actor→loop→reply. Adapters without // 致命错误):拿空配置去连要么失败,要么(weixin-ilink)触发阻塞式扫码登录。
// credentials stay disconnected (logged, never fatal): connecting with an empty
// config would either fail or, for weixin-ilink, trigger a blocking scan-code
// login.
func (a *app) wireChannels() error { func (a *app) wireChannels() error {
// The channel config is runtime-editable: channels.json (when present and // 渠道配置可在运行时编辑:channels.json 存在且有效时以它为准,否则回落到 env
// valid) is authoritative, otherwise the env channel block is the fallback // 里的渠道块(design/04 §3.1)。工厂与配置读取读的是同一个文件,因此面板保存 +
// (design/04 §3.1). The factory and the store read the same file so a panel // 热重载能让启动口径与运行口径保持一致。
// save + hot-reload keeps boot and runtime consistent.
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg)) channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log} factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
adapters := buildChannelAdapters(factory) adapters := buildChannelAdapters(factory)
@@ -91,23 +85,21 @@ func (a *app) wireChannels() error {
History: a.history, History: a.history,
QuietStartHour: a.cfg.Background.QuietStartHour, QuietStartHour: a.cfg.Background.QuietStartHour,
QuietEndHour: a.cfg.Background.QuietEndHour, QuietEndHour: a.cfg.Background.QuietEndHour,
// Optional review model prunes low-value queued messages near the per-adapter // 可选的复审模型在接近单适配器上限时剔除低价值排队消息
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict. // (design/12 §6 策略四)。适配器为 nil 时,闸门回落到 FIFO 驱逐。
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")), Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
// Light is the design/18 §3 participation judge: a cost-controlled light LLM // Light 是 design/18 §3 的参与判定器:对触发命中的群聊/陌生人消息做一次
// decision on a triggered group/stranger message (role-name mention / probability // 成本受控的轻量 LLM 决策(角色名提及 / 概率 / 活跃窗口)。只有真正触发
// / active window). Only an actual trigger feeds the group context. // 才把消息喂进群聊上下文。
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")), Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
TriggerWords: a.defaultTriggerWords(), TriggerWords: a.defaultTriggerWords(),
PersonaPrompt: a.defaultInstructions(), PersonaPrompt: a.defaultInstructions(),
Engage: channelbridge.DefaultEngageConfig(), Engage: channelbridge.DefaultEngageConfig(),
// EngageFor is left nil (P2a); per-group overrides arrive with P2c. // EngageFor 留空(P2a);按群覆盖随 P2c 到来。
// Factory lets the panel save hot-replace a platform's adapters without a // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
// restart (design/04 §3.1).
Factory: factory, Factory: factory,
// ChannelPersona resolves an account's channel-level role from the same // ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
// config the factory builds from (design/22 §2 方案 b); the bridge caches it // (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
// per connected adapter and refreshes it on every reload.
ChannelPersona: factory.Persona, ChannelPersona: factory.Persona,
}) })
if berr != nil { if berr != nil {
@@ -135,10 +127,9 @@ func (a *app) wireChannels() error {
return nil return nil
} }
// runtimeChannels is the gateway-facing view of the runtime-editable channel // runtimeChannels 是「可运行时编辑的渠道配置」面向 gateway 的视图。Load 重读
// config. Load re-reads channels.json (file wins → env fallback) and Reload // channels.json(文件优先 → env 兜底),Reload 委托给 bridge 的热替换,因此面板的
// delegates to the bridge's hot-replace, so the panel's save/reload path drives // 保存/重载路径驱动的正是 bridge 启动时的同一份来源。
// the same source the bridge boots from.
type runtimeChannels struct { type runtimeChannels struct {
path string path string
fallback map[string][]config.ChannelAccount fallback map[string][]config.ChannelAccount
@@ -183,9 +174,9 @@ func (r *runtimeChannels) Reload(platform string) error {
return r.bridge.Reload(platform) return r.bridge.Reload(platform)
} }
// reviewFor builds the channel-bridge review model (design/12 §6 策略四), or nil // reviewFor 构建渠道 bridge 的复审模型(design/12 §6 策略四);无模型适配器可用时
// when no model adapter is available so the gate falls back to FIFO eviction — the // 返回 nil,让闸门回落到 FIFO 驱逐——绝不在适配器为 nil 时调用复审器(那会在
// reviewer is never invoked with a nil adapter (which would panic on .Chat). // .Chat 上 panic)。
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer { func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
if llm == nil { if llm == nil {
return nil return nil
@@ -193,9 +184,8 @@ func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
return channelbridge.NewLLMReviewer(llm, log) return channelbridge.NewLLMReviewer(llm, log)
} }
// engageLightFor builds the design/18 §3 participation judge, or nil when no model // engageLightFor 构建 design/18 §3 的参与判定器;无模型适配器可用时返回 nil,让
// adapter is available so the bridge's participation engine fails closed toward // bridge 的参与引擎朝「沉默」方向 fail-closed(触发命中但无判定器的消息绝不作答)。
// silence (a triggered message without a judge is never answered).
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge { func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
if llm == nil { if llm == nil {
return nil return nil
@@ -203,8 +193,8 @@ func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudg
return channelbridge.NewLLMLightJudge(llm, log) return channelbridge.NewLLMLightJudge(llm, log)
} }
// defaultTriggerWords returns the default persona's role-name reminder words // defaultTriggerWords 返回默认人格的角色名提醒词(design/18 §2),让渠道 bridge
// (design/18 §2) so the channel bridge knows which group messages cause a PROBING. // 知道哪些群聊消息会引发 PROBING。
func (a *app) defaultTriggerWords() []string { func (a *app) defaultTriggerWords() []string {
if c, ok := a.personas.DefaultCharacter(); ok { if c, ok := a.personas.DefaultCharacter(); ok {
if p, ok := a.personas.Persona(c.PersonaID); ok { if p, ok := a.personas.Persona(c.PersonaID); ok {
+24 -28
View File
@@ -11,15 +11,15 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
) )
// capacityEmitter is the gateway-facing sink for capacity_awaken broadcasts. // capacityEmitter 是面向网关的 capacity_awaken 广播出口。
// *gateway.Server implements it; tests substitute a fake to capture the args. // *gateway.Server 实现它;测试用假实现替换以捕获入参。
type capacityEmitter interface { type capacityEmitter interface {
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
} }
// wireFrontend builds the gateway with the embedded/external SPA filesystem and // wireFrontend 用内嵌/外部 SPA 文件系统与 JWT/RBAC 认证器构建网关。
// the JWT/RBAC authenticator. Session/Persona/Memories are wired to the concrete // Session/Persona/Memories 接到具体的管理器上,管理端/用户端的列表接口才会
// managers so the admin/user list endpoints return real data. // 返回真实数据。
func (a *app) wireFrontend() error { func (a *app) wireFrontend() error {
jwt := auth.New(a.cfg.Auth) jwt := auth.New(a.cfg.Auth)
apiKeys := auth.NewAPIKey(a.cfg.Auth) apiKeys := auth.NewAPIKey(a.cfg.Auth)
@@ -35,20 +35,19 @@ func (a *app) wireFrontend() error {
a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr) a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr)
auditStore = nil auditStore = nil
} }
// Opaque refresh tokens (B2) share the primary SQLite handle. A failure here // 不透明刷新令牌(B2)与主 SQLite 句柄共用同一连接。这里失败要优雅降级:
// degrades gracefully: login simply omits refreshToken and /auth/refresh // 登录只是不下发 refreshToken,/auth/refresh 报「未配置」,而不是让整个
// reports not-configured, rather than failing the whole boot. The store is // 启动失败。store 以接口形式持有,构造失败就保持 nil(若存成带类型的 nil
// held as the interface so a constructor failure leaves it nil (a typed nil // 指针,接口值非 nil,首次调用会 panic)。
// pointer in the interface would be non-nil and panic on first call).
var refreshStore gateway.RefreshGrantStore var refreshStore gateway.RefreshGrantStore
if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil { if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil {
a.log.Warn("refresh token store unavailable", "error", rerr) a.log.Warn("refresh token store unavailable", "error", rerr)
} else { } else {
refreshStore = store refreshStore = store
} }
// a.history is built in wireAuxiliary so both the channel bridge (wireChannels) // a.history 在 wireAuxiliary 里构建,让渠道桥(wireChannels)与网关共用同一个
// and the gateway share the same persistent store. History was a no-op dependency // 持久化存储。IM 通路之前 History 是个空转依赖,因此把它的创建顺序提前不会
// before the IM path, so nothing downstream depends on its creation order change. // 影响任何下游。
gw, err := gateway.New(a.cfg, gateway.Deps{ gw, err := gateway.New(a.cfg, gateway.Deps{
Log: logger.WithSource(a.log, "gateway"), Log: logger.WithSource(a.log, "gateway"),
Auth: jwt, Auth: jwt,
@@ -82,19 +81,17 @@ func (a *app) wireFrontend() error {
return fmt.Errorf("gateway: %w", err) return fmt.Errorf("gateway: %w", err)
} }
gw.SetVoice(a.voice) gw.SetVoice(a.voice)
a.gw = gw // assign BEFORE wiring hooks below, which capture a.gw / gw (nil-receiver panic otherwise) a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
// Observability trace sink (design/19 §2): now that the gateway exists, its // 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
// ring buffer must be fed by the loop's spans, or the admin trace/metrics // span 喂养,否则管理端 trace/metrics 接口会一直是空的。
// endpoints stay empty.
wireGatewayTraceSink(a, gw) wireGatewayTraceSink(a, gw)
if a.channelBridge != nil { if a.channelBridge != nil {
// Route the channel bridge's owner-private persisted messages into the same // 把渠道桥中「主人私聊」持久化的消息接入编排器所用的同一个
// `message_persisted` broadcast the orchestrator uses, so the Web client mirrors // `message_persisted` 广播,让 Web 客户端用同一套统一帧来镜像 IM 消息
// IM messages with one, uniform frame (design/16 §4.4). // (design/16 §4.4)。
a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted) a.channelBridge.SetOnPersisted(a.gw.BroadcastPersisted)
// design/16 §4.6 §6: a message dropped by the platform's allow/deny // design/16 §4.6 §6:被平台白/黑名单(allow/deny)丢弃的消息会记入网关审计
// black-or-white lists is recorded in the gateway audit trail so the // 轨迹,这样即使它从未到达循环,这次拒绝也「audit 可查」。
// rejection is "audit 可查" even though it never reaches the loop.
a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) { a.channelBridge.SetOnRejected(func(msg adapter.UnifiedMessage, reason string) {
a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason) a.gw.AuditEvent("bridge:"+msg.Platform, "channel.reject", reason)
}) })
@@ -102,11 +99,10 @@ func (a *app) wireFrontend() error {
a.capacitySink = gw a.capacitySink = gw
a.moodSink = gw a.moodSink = gw
// Wire the backthink proactive sinks now that the gateway hub (active-client // 网关 hub(活跃客户端通路)与渠道桥(IM 回落)都已在位,现在接线 backthink 的
// route) and the channel bridge (IM fallback) both exist. They were absent when // 主动推送出口。backthink.New 执行时它们都还不存在(hub 需要循环,桥需要循环与
// backthink.New ran (the hub needs the loop and the bridge needs the loop and the // 主人档案,这些都更晚才组装),若不在此处补接线,推送器会解析出 RouteNone。
// owner profile, all assembled later), so without this the pusher would resolve // 每个 setter 都在服务的生命周期锁下重建推送器。
// RouteNone. Each setter rebuilds the pusher under the service's lifecycle lock.
if a.backthink != nil { if a.backthink != nil {
a.backthink.SetActiveSink(gw) a.backthink.SetActiveSink(gw)
if a.channelBridge != nil { if a.channelBridge != nil {
+45 -55
View File
@@ -33,8 +33,8 @@ import (
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
) )
// buildPersonas loads the persona definitions and registers them, plus the // buildPersonas 加载人格定义并注册它们,同时注册默认角色与渠道身份识别所用的
// default character and the owner profile used by channel identity resolution. // 主人档案。
func (a *app) buildPersonas() error { func (a *app) buildPersonas() error {
a.personas = personareg.New() a.personas = personareg.New()
@@ -55,7 +55,7 @@ func (a *app) buildPersonas() error {
a.log.Warn("owner profile load skipped", "error", oerr) a.log.Warn("owner profile load skipped", "error", oerr)
} }
// Load the default persona explicitly if it was not under the personas dir. // 若默认角色不在 personas 目录下,则显式加载一次。
defaultID := "cyrene" defaultID := "cyrene"
if a.owner != nil && a.owner.DefaultPersona != "" { if a.owner != nil && a.owner.DefaultPersona != "" {
defaultID = a.owner.DefaultPersona defaultID = a.owner.DefaultPersona
@@ -77,8 +77,8 @@ func (a *app) buildPersonas() error {
return nil return nil
} }
// wireMemory builds the long-term memory stack: store, embedding manager, vector // wireMemory 构建长期记忆栈:store、嵌入管理器、向量索引、项目索引,以及接进
// index, project index and the retriever wired into the io knowledge tool. // io 知识工具的检索器。
func (a *app) wireMemory(ctx context.Context) error { func (a *app) wireMemory(ctx context.Context) error {
store, err := memory.NewStore(ctx, a.db, a.log) store, err := memory.NewStore(ctx, a.db, a.log)
if err != nil { if err != nil {
@@ -119,10 +119,9 @@ func (a *app) wireMemory(ctx context.Context) error {
func (a *app) wireRuntime() error { func (a *app) wireRuntime() error {
a.attachToolPolicyStore() a.attachToolPolicyStore()
// The LLM provider stack is hot-swappable: it loads models.json (or falls // LLM 提供商栈可热插拔:它加载 models.json(或回落 .env),面板的保存端点会
// back to .env) and the panel save endpoint reloads it without a restart // 就地重载它而无需重启(design/04 §3.1)。a.modelAdapter 仍保持 Adapter 接口
// (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack // (运行时栈实现了它),供渠道 bridge 的复审模型使用(design/12 §6 策略四)。
// implements it) for the channel-bridge review model (design/12 §6 策略四).
modelsPath := runtimeconfig.DefaultPath(resolveDataDir(a.cfg)) modelsPath := runtimeconfig.DefaultPath(resolveDataDir(a.cfg))
// 启动即补齐一次(主人裁决):`.env` 只是默认/播种层,models.json 才是面板的 // 启动即补齐一次(主人裁决):`.env` 只是默认/播种层,models.json 才是面板的
// 一等来源——把 `.env` 补齐进面板配置里缺失的键(面板已有的键绝不覆盖,组集合 // 一等来源——把 `.env` 补齐进面板配置里缺失的键(面板已有的键绝不覆盖,组集合
@@ -169,17 +168,16 @@ func (a *app) wireRuntime() error {
} }
store := a.memStore store := a.memStore
// One agent per role, all sharing the dispatcher/guards/adapter above, so a session // 每个角色一个 agent,共用上面的 dispatcher / 护栏 / 适配器,因此一个会话能以
// can answer as any role (design/22 §4.2 方案 A); the loop picks per turn. // 任意角色作答(design/22 §4.2 方案 A);具体由 loop 按 turn 选取。
agents, defaultAgent := a.personaAgentPool(runtimeStack, a.dispatcher, guards, ag) agents, defaultAgent := a.personaAgentPool(runtimeStack, a.dispatcher, guards, ag)
// Spans fan out to the JSONL stderr recorder and — once wireFrontend has built // span 分两路:一路写 JSONL(stderr 记录器),另一路在 wireFrontend 建好网关后
// the gateway — to its observability buffer (cmd/cyrene/observability.go). // 进入它的可观测性缓冲(cmd/cyrene/observability.go)。
a.traceWriter = newAppTraceWriter() a.traceWriter = newAppTraceWriter()
tracer := trace.New(a.traceWriter) tracer := trace.New(a.traceWriter)
// Realtime flow visualization (design/19 §1): the publisher accumulates the // 实时链路可视化(design/19 §1):发布器把 loop 的 trace span 累积成按会话的
// loop's trace spans into a per-session flow graph and publishes // 链路图,并在每次 span 跃迁时发布 ws.graph_update。
// ws.graph_update on each span transition.
// //
// 图源白名单(裁决 16.4.2):它决定**哪些会话产出实时链路图**,也是后端能提供 // 图源白名单(裁决 16.4.2):它决定**哪些会话产出实时链路图**,也是后端能提供
// 完整链路导图的那一侧 —— 主人 web 会话族("web" 主会话 + "web:<uuid>" 次级会话, // 完整链路导图的那一侧 —— 主人 web 会话族("web" 主会话 + "web:<uuid>" 次级会话,
@@ -204,15 +202,13 @@ func (a *app) wireRuntime() error {
return fmt.Errorf("checkpoint store: %w", perr) return fmt.Errorf("checkpoint store: %w", perr)
} }
// Vision routing: the loop rewrites an image-bearing user message per the // 视觉路由:loop 按 vision Router 改写带图用户消息——真正能收图的主模型直接
// vision Router — a main model that really accepts image input sees the image // 看到图像,否则改用 vision/OCR 工具产出的文字描述替代它(design/06 §0)。
// directly, otherwise the vision/OCR tool's text description replaces it
// (design/06 §0)。
// //
// SupportsVision means "主模型能否收图"(裁决 14.4.1 修语义,C-7), read from the // SupportsVision 指「主模型能否收图」(裁决 14.4.1 修语义,C-7),取自当前生效
// active models.json group's `multimodal` declaration — NOT from // 的 models.json 组的 `multimodal` 声明——**不是**取自 LLM_VISION_MODEL,后者配
// LLM_VISION_MODEL, which configures the vision *tool's* model. It is read per // 的是 vision **工具**用的模型。它按调用读取,因此面板保存/重载在下一个 turn
// call so a panel save/reload takes effect on the next turn. // 即生效。
visionRouter := vision.NewRouter( visionRouter := vision.NewRouter(
vision.WithVisionTool(visionToolName), vision.WithVisionTool(visionToolName),
vision.WithOCRTool(ocrToolName), vision.WithOCRTool(ocrToolName),
@@ -227,15 +223,13 @@ func (a *app) wireRuntime() error {
) )
capDecider := a.buildCapacity() capDecider := a.buildCapacity()
// The persona emotion observer (design/01 §2) is built here because it is the // 人格情绪观测器(design/01 §2)在此构建,因为它是 loop 的 turn 边界依赖:
// loop's turn-boundary dependency: after every settled turn the bound persona's // 每个 turn 落定后,该 turn 绑定人格的情绪机与好感度都会推进,返回的心情标签
// emotion machine and affection move, and the returned mood label is written // 写进 Result.Mood 与助手消息(design/16 §3.3)。
// onto Result.Mood and the assistant message (design/16 §3.3).
a.emotion = a.buildEmotionObserver() a.emotion = a.buildEmotionObserver()
// Reasoning knobs from the default persona (budget / effort / thinking broadcast), // 推理旋钮取自默认人格(预算 / 强度 / 是否广播思考),它们同时也是按 turn 的
// which are also the per-turn defaults: the loop re-resolves them for each turn's // 默认值:loop 会经 PersonaKnobs 按每个 turn 的角色重新解析,因此绑定其他角色
// role through PersonaKnobs, so a session bound to another role uses that role's // 的会话用的是那个角色的预算。
// budget instead.
thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions() thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions()
// 敏感操作代审(design/11 §4;裁决 13.4.3):terminal 这类敏感工具在 loop 的 // 敏感操作代审(design/11 §4;裁决 13.4.3):terminal 这类敏感工具在 loop 的
@@ -293,14 +287,12 @@ func (a *app) wireRuntime() error {
Bus: a.bus, Bus: a.bus,
Memory: store, Memory: store,
Tracer: tracer, Tracer: tracer,
// A sub-agent dispatched as another role (subagent_spawn `persona`, design/22 // 以其他角色派发的子代理(subagent_spawn 的 `persona`,design/22 §8-2)只能
// §8-2) may only name a role this process can actually run with its own // 指名本进程确实能用该角色自己的指令跑起来的角色,即上面那个池子里有 agent
// instructions, i.e. one the pool above has an agent for; anything else is // 的角色;其余一律拒绝,而不是默默用默认角色的口吻作答。
// rejected instead of silently answering in the default role's voice.
PersonaExists: personaExists(agents), PersonaExists: personaExists(agents),
// The sub-agent's span and its inner llm/tool steps fold into the spawning // 子代理的 span 与它内部的 llm/工具步骤并进派生它的那个会话的实时图,因此
// session's live graph, so a branch shows up as a swimlane of that session's // 一条分支表现为该会话链路图里的一条泳道,而不是另开第二张图(design/19 §2)。
// flow instead of opening a second graph (design/19 §2).
Flow: flowPub, Flow: flowPub,
Approver: approver, Approver: approver,
Logger: logger.WithSource(a.log, "orchestration"), Logger: logger.WithSource(a.log, "orchestration"),
@@ -404,15 +396,14 @@ func (a *app) approvalActor(ctx context.Context) string {
return pid return pid
} }
// buildCapacity / onCapacity / capacity JSON+TTL parsing live in // buildCapacity / onCapacity 以及 capacity 的 JSON+TTL 解析都在
// wire_capacity.go (AGENTS §10 文件 ≤500 行). // wire_capacity.go(AGENTS §10 文件 ≤500 行)。
// onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the // onToolAudit 是 loop 的 OnAudit 出口:它把群聊/陌生人环境里被 design/11 §3 安全闸
// design/11 §3 security gate in a group/stranger environment into the gateway // 拦下的工具调用转发进网关的审计流水,使这次拒绝「audit 可查」,前端也能渲染那行
// audit trail, so the rejection is "audit 可查" and the frontend can render the // 「xx 工具在群聊被拒」小字(design/11 §8)。网关在 loop 之后才建好
// small "xx 工具在群聊被拒" note (design/11 §8). The gateway is built after the // (wireFrontend),所以该出口在调用时才读 a.gw——那时应用已装配完整,任何 Run 都
// loop (wireFrontend), so the sink reads a.gw at call time — by then the app is // 经网关服务。
// fully assembled and any Run is served through the gateway.
func (a *app) onToolAudit(ev loop.AuditEvent) { func (a *app) onToolAudit(ev loop.AuditEvent) {
if a.gw == nil { if a.gw == nil {
logger.WithSource(a.log, "cmd/cyrene").Warn("loop audit dropped: gateway not ready", logger.WithSource(a.log, "cmd/cyrene").Warn("loop audit dropped: gateway not ready",
@@ -422,18 +413,17 @@ func (a *app) onToolAudit(ev loop.AuditEvent) {
a.gw.AuditEvent(ev.Who, ev.What, ev.Result) a.gw.AuditEvent(ev.Who, ev.What, ev.Result)
} }
// parseCapacityToolSets / parseL3CacheTTL live in wire_capacity.go. // parseCapacityToolSets / parseL3CacheTTL 在 wire_capacity.go 里。
// dbCfgForEmbed returns the DB config used by the vector index, which opens the // dbCfgForEmbed 返回向量索引所用的数据库配置——它打开的是与主连接池同一个
// same SQLite file as the primary pool. // SQLite 文件。
func (a *app) dbCfgForEmbed() config.DBConfig { func (a *app) dbCfgForEmbed() config.DBConfig {
cfg := a.cfg.DB cfg := a.cfg.DB
cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg)) cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg))
return cfg return cfg
} }
// defaultPersonaID returns the id of the default persona, falling back to // defaultPersonaID 返回默认人格的 id,取不到时回落 "cyrene"。
// "cyrene".
func (a *app) defaultPersonaID() string { func (a *app) defaultPersonaID() string {
if c, ok := a.personas.DefaultCharacter(); ok { if c, ok := a.personas.DefaultCharacter(); ok {
return c.PersonaID return c.PersonaID
@@ -441,9 +431,9 @@ func (a *app) defaultPersonaID() string {
return "cyrene" return "cyrene"
} }
// defaultInstructions returns the default persona's system prompt followed by the // defaultInstructions 返回默认人格的系统提示词,其后依次接 personality / values /
// personality/values/speechStyle/catchphrase/background sections (design/01 // speechStyle / catchphrase / background 各段(design/01 §2.2);两者都取不到时回落
// §2.2), falling back to its role text or a minimal instruction when neither is set. // 到该角色的 role 文本或一句最小指令。
// //
// 主人身份说明书**不在这里**(P0-10 收窄后按会话注入,见 personaKnowledgeFor): // 主人身份说明书**不在这里**(P0-10 收窄后按会话注入,见 personaKnowledgeFor):
// 静态拼进指令会让它进入该角色的每一个会话,含群聊与陌生人私聊。 // 静态拼进指令会让它进入该角色的每一个会话,含群聊与陌生人私聊。
+10 -12
View File
@@ -19,8 +19,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
) )
// wireAuxiliary builds the execution provider, task manager, IoT manager, // wireAuxiliary 组装执行提供者、任务管理器、IoT 管理器、渠道会话管理器、
// channel session manager, automations and background thinking services. // 自动化服务与后台思考服务。
func (a *app) wireAuxiliary() error { func (a *app) wireAuxiliary() error {
provider, err := exec.New(a.cfg.ExecSandbox, a.log) provider, err := exec.New(a.cfg.ExecSandbox, a.log)
if err != nil { if err != nil {
@@ -84,10 +84,9 @@ func (a *app) wireAuxiliary() error {
return nil return nil
} }
// pushOutletConfig maps the environment push-outlet block into the backthink // pushOutletConfig 把环境变量里的推送出口配置块映射为 backthink 的导出器配置。
// exporter config. The Getui credentials are read by config but not consumed: // 个推(Getui)凭据只被 config 读取、并不消费:备用 SDK 尚未接入,因此出口在
// the fallback SDK is not integrated yet, so the outlet logs the pending tier // 待接入该档位时记日志,而不是伪造一次推送。token 只透传,绝不写日志。
// instead of faking a push. The token is passed through and never logged.
func (a *app) pushOutletConfig() backthink.PushOutletConfig { func (a *app) pushOutletConfig() backthink.PushOutletConfig {
po := a.cfg.PushOutlet po := a.cfg.PushOutlet
return backthink.PushOutletConfig{ return backthink.PushOutletConfig{
@@ -99,7 +98,7 @@ func (a *app) pushOutletConfig() backthink.PushOutletConfig {
} }
} }
// close shuts down every subsystem in dependency order and releases resources. // close 按依赖顺序关闭每个子系统并释放资源。
func (a *app) close(ctx context.Context) { func (a *app) close(ctx context.Context) {
if a.backthink != nil { if a.backthink != nil {
_ = a.backthink.Stop(ctx) _ = a.backthink.Stop(ctx)
@@ -137,9 +136,8 @@ func (a *app) close(ctx context.Context) {
a.log.Info("cyrene shutdown complete") a.log.Info("cyrene shutdown complete")
} }
// resolveDataDir returns the configured data directory (from .env / env, via // resolveDataDir 返回配置的数据目录(来自 .env / 环境变量,经 cfg.DataDir);
// cfg.DataDir), falling back to the OS-appropriate default only when no // 只有在没有配置目录时才回落到与操作系统相称的默认值。
// directory is configured.
func resolveDataDir(cfg *config.Config) string { func resolveDataDir(cfg *config.Config) string {
if cfg.DataDir != "" { if cfg.DataDir != "" {
return cfg.DataDir return cfg.DataDir
@@ -147,8 +145,8 @@ func resolveDataDir(cfg *config.Config) string {
return platform.DefaultDataDir() return platform.DefaultDataDir()
} }
// resolveSQLitePath returns the SQLite path to use, deriving it under the data // resolveSQLitePath 返回实际使用的 SQLite 路径:当配置里仍是默认值时,
// directory when the configured default is left untouched. // 把它推导到数据目录下。
func resolveSQLitePath(cfg *config.Config, dataDir string) string { func resolveSQLitePath(cfg *config.Config, dataDir string) string {
p := cfg.DB.SQLitePath p := cfg.DB.SQLitePath
if strings.TrimSpace(p) == "" || p == defaultSQLitePath { if strings.TrimSpace(p) == "" || p == defaultSQLitePath {