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