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