Files
Cyrene_v2/cmd/cyrene/wire_runtime.go
T
AskaEth e9afca9b29
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(cyrene): 拆 wire.go 926 行 → 5 文件(纯搬移,零行为变化)
执行方案阶段 0 · G0-A:组合根 926 行是全仓第一大超限文件,且被 10+ 条裁决争用
(1.4/12.4.1、03.4.3、06.4.2、07.4.4、10.4.5、12.4.2、13.4.3、14.4.1、14.4.3、16.4.2)
⇒ 不先拆,后续全部被迫串行且必然破 500 行红线。

- wire.go(181):入口与共享 —— const 块、app struct、newApp
- wire_runtime.go(362):loop/agent/model/memory/capacity/trace(buildPersonas、wireMemory、
  wireRuntime、buildCapacity、onCapacity、onToolAudit、defaultPersonaID、defaultInstructions…)
- wire_services.go(158):exec/task/iot/session/history/automations/backthink
  (wireAuxiliary、pushOutletConfig、close、resolveDataDir、resolveSQLitePath)
- wire_channels.go(172):渠道/桥(ownerRegistrar、wireChannels、runtimeChannels、reviewFor…)
- wire_frontend.go(117):gateway/webui/观测扇出(capacityEmitter、wireFrontend)

纯搬移机械证明:去 package/import/空行后两侧 **811=811 行、排序后 diff 为空、md5 相同**;
顶层声明集合 30 条守恒;唯一新增 = 各文件的 package 行与 import 块(原 import 块按声明归属
重分配给新文件)。未重命名/重排/改注释。

验证:go build/vet 绿;go test ./cmd/... 绿;linux/amd64 + darwin/arm64 交叉编译绿;
五文件 181/362/158/172/117 行均 ≤500;gofmt -l cmd/cyrene/ 无输出。
注:全量 go test 当前有 1 处红 —— internal/contract/enums 的
TestModeTableMatchesFrontendModes 读 frontend/web/src/lib/types.ts 找 MODES,而该文件
正被并行的前端拆分任务搬走;与本提交无关,随后修正该测试的读取路径。
2026-10-04 10:50:35 +08:00

363 lines
14 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// buildPersonas loads the persona definitions and registers them, plus the
// default character and the owner profile used by channel identity resolution.
func (a *app) buildPersonas() error {
a.personas = personareg.New()
dirPersonas, err := loader.LoadDir(defaultPersonasDir)
if err != nil {
a.log.Warn("persona dir load skipped", "error", err)
} else {
for _, p := range dirPersonas {
if rerr := a.personas.RegisterPersona(p); rerr != nil {
a.log.Warn("persona register skipped", "persona", p.ID, "error", rerr)
}
}
}
if owner, oerr := user.LoadOwnerProfile(defaultOwnerPath); oerr == nil {
a.owner = owner
} else {
a.log.Warn("owner profile load skipped", "error", oerr)
}
// Load the default persona explicitly if it was not under the personas dir.
defaultID := "cyrene"
if a.owner != nil && a.owner.DefaultPersona != "" {
defaultID = a.owner.DefaultPersona
}
if _, ok := a.personas.Persona(defaultID); !ok {
if p, lerr := loader.Load(filepath.Join(defaultPersonasDir, defaultID+".yaml")); lerr == nil {
_ = a.personas.RegisterPersona(p)
} else {
a.log.Warn("default persona load skipped", "persona", defaultID, "error", lerr)
}
}
if p, ok := a.personas.Persona(defaultID); ok {
char := &personareg.Character{ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID}
if cerr := a.personas.RegisterCharacter(char); cerr != nil {
a.log.Warn("default character register skipped", "error", cerr)
}
}
a.log.Info("personas ready", "count", a.personas.CountPersonas(), "default", defaultID)
return nil
}
// wireMemory builds the long-term memory stack: store, embedding manager, vector
// index, project index and the retriever wired into the io knowledge tool.
func (a *app) wireMemory(ctx context.Context) error {
store, err := memory.NewStore(ctx, a.db, a.log)
if err != nil {
return fmt.Errorf("memory store: %w", err)
}
a.memStore = store
embedManager := embedding.New(a.cfg.Embedding)
embedder := embedManager.Embedder()
index, err := vector.New(a.dbCfgForEmbed(), embedder, a.log)
if err != nil {
a.log.Warn("vector index unavailable, falling back to in-memory semantic", "error", err)
} else {
a.vindex = index
}
projStore, err := projectindex.New(ctx, a.db, a.log)
if err != nil {
a.log.Warn("project index unavailable", "error", err)
projStore = nil
}
a.projStore = projStore
opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)}
if a.vindex != nil {
opts = append(opts, memory.WithVectorIndex(a.vindex))
}
if projStore != nil {
opts = append(opts, memory.WithProjectIndex(projStore))
}
a.retriever = memory.NewRetriever(store, opts...)
return nil
}
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
// and its loop, tracing, persistence and the orchestration scheduler.
func (a *app) wireRuntime() error {
// The LLM provider stack is hot-swappable: it loads models.json (or falls
// back to .env) and the panel save endpoint reloads it without a restart
// (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack
// implements it) for the channel-bridge review model (design/12 §6 策略四).
runtimeStack := model.NewRuntimeLLM(runtimeconfig.DefaultPath(resolveDataDir(a.cfg)), a.cfg.LLM, a.log)
a.modelAdapter = runtimeStack
a.llm = runtimeStack
dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout))
if derr != nil {
return fmt.Errorf("tool dispatcher: %w", derr)
}
a.dispatcher = dispatcher
if err := basic.Register(a.caps); err != nil {
return fmt.Errorf("register basic tools: %w", err)
}
ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log))
if ioErr != nil {
return fmt.Errorf("build io tools: %w", ioErr)
}
for _, t := range ioTools {
if rerr := a.dispatcher.Register(t); rerr != nil {
a.log.Warn("io tool register skipped", "tool", t.Name(), "error", rerr)
}
}
a.wireVision()
guards := guardrail.New()
instructions := a.defaultInstructions()
ag, aerr := agent.New(instructions, a.dispatcher, guards,
agent.WithModel(runtimeStack, runtimeStack),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if aerr != nil {
return fmt.Errorf("agent: %w", aerr)
}
personaID := a.defaultPersonaID()
store := a.memStore
// One agent per role, all sharing the dispatcher/guards/adapter above, so a session
// can answer as any role (design/22 §4.2 方案 A); the loop picks per turn.
agents, defaultAgent := a.personaAgentPool(runtimeStack, a.dispatcher, guards, ag)
// Spans fan out to the JSONL stderr recorder and — once wireFrontend has built
// the gateway — to its observability buffer (cmd/cyrene/observability.go).
a.traceWriter = newAppTraceWriter()
tracer := trace.New(a.traceWriter)
// Realtime flow visualization (design/19 §1): the publisher accumulates the
// loop's trace spans into a per-session flow graph and publishes
// ws.graph_update on each span transition. It is gated to the owner's web
// sessions ("web" and web sub-sessions), so IM / stranger / sub-agent turns
// never leak into the admin panel. A nil flow keeps the loop's prior behavior.
flowPub := loop.NewFlowPublisher(
func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) },
func(sessionID string) bool {
// Publish the web/main flow graph (web chat + IM owner-private mirror) AND the
// autonomous-backthink session, so the backthink page's flow link graph renders
// too (design/20 §2). Other isolated channel sessions (sess-xxx) stay filtered.
return sessionID == history.MainSessionID ||
strings.HasPrefix(sessionID, history.MainSessionID+":") ||
sessionID == backthink.DefaultSessionID
},
)
checkpoint, perr := persist.New(context.Background(), a.db, a.log)
if perr != nil {
return fmt.Errorf("checkpoint store: %w", perr)
}
// Vision routing: the loop rewrites an image-bearing user message per the
// vision Router — a vision-capable main model sees the image directly,
// otherwise the vision/OCR tool's text description replaces it (design/06 §0).
visionRouter := vision.NewRouter(
vision.WithVisionTool(visionToolName),
vision.WithOCRTool(ocrToolName),
)
visionResolver := vision.NewMessageResolver(
visionRouter,
func() vision.ModelInfo {
return vision.ModelInfo{Name: a.cfg.LLM.VisionModel, SupportsVision: a.cfg.LLM.VisionModel != ""}
},
a.dispatcher.Invoke,
)
capDecider := a.buildCapacity()
// The persona emotion observer (design/01 §2) is built here because it is the
// loop's turn-boundary dependency: after every settled turn the bound persona's
// emotion machine and affection move, and the returned mood label is written
// onto Result.Mood and the assistant message (design/16 §3.3).
a.emotion = a.buildEmotionObserver()
// Reasoning knobs from the default persona (budget / effort / thinking broadcast),
// which are also the per-turn defaults: the loop re-resolves them for each turn's
// role through PersonaKnobs, so a session bound to another role uses that role's
// budget instead.
thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions()
a.loop = loop.New(loop.Options{
Agent: defaultAgent,
Agents: agents,
Tools: a.dispatcher,
Capacity: capDecider,
OnCapacity: a.onCapacity,
OnAudit: a.onToolAudit,
OnTurnEnd: a.onTurnEnd,
Memory: store,
Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(),
Checkpoint: checkpoint,
Tracer: tracer,
Flow: flowPub,
Guards: guards,
PersonaID: personaID,
PersonaName: a.personaName,
PersonaKnobs: a.personaKnobs,
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Vision: visionResolver,
Thinking: thinking,
ReasoningEffort: reasoningEffort,
ThinkingShowToUser: thinkingShow,
Log: logger.WithSource(a.log, "runtime/loop"),
})
a.scheduler = orchestration.New(orchestration.Options{
MaxParallel: defaultMaxParallel,
PerAgentTimeout: defaultSubagentTimeout,
Loop: a.loop,
Bus: a.bus,
Memory: store,
Tracer: tracer,
// A sub-agent dispatched as another role (subagent_spawn `persona`, design/22
// §8-2) may only name a role this process can actually run with its own
// instructions, i.e. one the pool above has an agent for; anything else is
// rejected instead of silently answering in the default role's voice.
PersonaExists: personaExists(agents),
// The sub-agent's span and its inner llm/tool steps fold into the spawning
// session's live graph, so a branch shows up as a swimlane of that session's
// flow instead of opening a second graph (design/19 §2).
Flow: flowPub,
Approver: audit.NewApprover(a.log),
Logger: logger.WithSource(a.log, "orchestration"),
AgentID: defaultAgentID,
})
return nil
}
// buildCapacity constructs the awakened-capacity decider when enabled, or nil
// (which keeps the loop's default all-tools behavior). It is opt-in via
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
// falls back to built-in defaults on a parse error.
func (a *app) buildCapacity() *capacity.Decider {
if !a.cfg.Capacity.Enabled {
return nil
}
sets, err := parseCapacityToolSets(a.cfg.Capacity.ToolSetsJSON)
if err != nil {
a.log.Warn("capacity tool sets parse failed, using built-in defaults", "error", err)
sets = nil
}
return capacity.New(capacity.Config{
Enabled: true,
MinConfidence: a.cfg.Capacity.MinConfidence,
ToolSets: sets,
}, capacity.WithLogger(a.log))
}
// onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken
// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken`
// frame. The loop only emits on a real state transition (never every round), and
// the event carries the originating session id so it routes to that session, not
// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}.
func (a *app) onCapacity(ev capacity.Event) {
logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event",
"type", ev.Type, "from", ev.FromState, "to", ev.ToState,
"tool", ev.Tool, "at", ev.At, "session", ev.SessionID)
if a.capacitySink == nil {
return
}
a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool)
}
// onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the
// design/11 §3 security gate in a group/stranger environment into the gateway
// audit trail, so the rejection is "audit 可查" and the frontend can render the
// small "xx 工具在群聊被拒" note (design/11 §8). The gateway is built after the
// loop (wireFrontend), so the sink reads a.gw at call time — by then the app is
// fully assembled and any Run is served through the gateway.
func (a *app) onToolAudit(ev loop.AuditEvent) {
if a.gw == nil {
logger.WithSource(a.log, "cmd/cyrene").Warn("loop audit dropped: gateway not ready",
"who", ev.Who, "what", ev.What, "result", ev.Result)
return
}
a.gw.AuditEvent(ev.Who, ev.What, ev.Result)
}
// parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of
// state→allowed tool name list. A blank string yields nil (built-in defaults); a
// null value for a state means unrestricted (all tools).
func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var m map[string][]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil, fmt.Errorf("capacity tool sets: %w", err)
}
out := make(map[capacity.State][]string, len(m))
for k, v := range m {
out[capacity.State(k)] = v
}
return out, nil
}
// dbCfgForEmbed returns the DB config used by the vector index, which opens the
// same SQLite file as the primary pool.
func (a *app) dbCfgForEmbed() config.DBConfig {
cfg := a.cfg.DB
cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg))
return cfg
}
// defaultPersonaID returns the id of the default persona, falling back to
// "cyrene".
func (a *app) defaultPersonaID() string {
if c, ok := a.personas.DefaultCharacter(); ok {
return c.PersonaID
}
return "cyrene"
}
// defaultInstructions returns the default persona's system prompt followed by the
// personality/values/speechStyle/catchphrase/background sections (design/01
// §2.2), falling back to its role text or a minimal instruction when neither is
// set.
func (a *app) defaultInstructions() string {
if p, ok := a.defaultPersona(); ok {
return instructionsFromPersona(p)
}
return fallbackInstruction
}