Files
Cyrene_v2/cmd/cyrene/wire_runtime.go
T
AskaEth 63236c9b00
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00

472 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"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 构建模型适配器、工具 dispatcher、护栏、agent 与其 loop、
// 追踪、持久化与编排 scheduler。
func (a *app) wireRuntime() error {
a.attachToolPolicyStore()
// The LLM provider stack is hot-swappable: it loads models.json (or falls
// back to .env) and the panel save endpoint reloads it without a restart
// (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack
// implements it) for the channel-bridge review model (design/12 §6 策略四).
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()
// 敏感操作代审(design/11 §4;裁决 13.4.3):terminal 这类敏感工具在 loop 的
// 工具准入门上经主代理代审(复用 orchestration 的 Approver),再由网关把
// 结果落进 audit_logs 并广播 design/11 §4 的小字。同一个 Approver 也交给
// 编排层(子代理代审),使两条路径共用一处审计。
approver := audit.NewApprover(a.log)
approvalGate := a.buildApprovalGate(approver)
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,
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
// (file 的 read 自主、write 代审),见 toolAdmissionHooks。
Hooks: a.toolAdmissionHooks(approvalGate),
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: approver,
Logger: logger.WithSource(a.log, "orchestration"),
AgentID: defaultAgentID,
})
return nil
}
// attachToolPolicyStore 接上工具策略的落盘载体(design/11 §3;裁决 1.3/10.4.2)。
//
// 它必须在任何工具注册**之前**调用:registry 接到 store 时会立刻 Load 落盘
// 策略并套到已注册能力上,之后 Register 进来的能力(MCP 热装、插件刷新)也会
// 拿到落盘值——「重启后策略仍在」正是靠这两条路径合起来成立。
//
// 失败只记 WARN 并保留默认值:默认(两个布尔 false = owner-only)就是最严的
// 一侧,读不出一份策略绝不放宽策略,也不因为策略存储不可用而拖垮启动
// (写路径仍会诚实报错,不会假装保存成功)。
func (a *app) attachToolPolicyStore() {
log := logger.WithSource(a.log, "cmd/cyrene")
store, err := registry.NewSQLitePolicyStore(a.db, a.log)
if err != nil {
log.Warn("tool policy store unavailable, keeping owner-only defaults", "error", err)
return
}
if err := a.caps.AttachPolicyStore(context.Background(), store); err != nil {
log.Warn("tool policy load failed, keeping owner-only defaults", "error", err)
}
}
// buildApprovalGate 构建敏感操作代审门(design/11 §4;裁决 13.4.3)。
//
// 归属解析取 Run 上下文里的会话 id 与该 turn 的角色名,使前端小字既投到
// 正确的会话、也写明是谁想动手(design/11 §4 的「xxx(角色名)」)。会话 id 用
// loop.FlowOwnerFromContext:子代理的敏感调用归属到**派生它的那个会话**
// (与 design/19 §2 的 flow 归属同一口径),所以小字出现在主人正在看的对话里,
// 而不是一个没人查看的子代理 id 下。
func (a *app) buildApprovalGate(approver *audit.Approver) *restrict.Gate {
return restrict.NewGate(
auditReviewer{approver: approver},
func(ctx context.Context) (string, string) {
return loop.FlowOwnerFromContext(ctx), a.approvalActor(ctx)
},
restrict.WithGateLogger(a.log),
)
}
// toolAdmissionHooks 构建 loop 的工具准入钩子(design/11 §4;裁决 13.4.3):
// 敏感调用先经主代理代审,代审记录交给网关落 audit_logs 并广播前端小字;
// 未获代审的调用以软结果回灌给模型(绝不执行)。非敏感调用直接放行。
//
// 只装 CallAdmit:敏感与否取决于**该次调用的参数**(file 的 read 自主、
// write 代审),批量钩子(BatchAdmit)看不到参数,做不出这个区分。
// 组合根按 loop 的既有契约装配,因此这条链也覆盖子代理的敏感调用
// (编排层派生的 loop 共享同一组钩子)。
func (a *app) toolAdmissionHooks(gate *restrict.Gate) loop.ToolLoopHooks {
return loop.ToolLoopHooks{
CallAdmit: func(ctx context.Context, call agent.ToolCall) *loop.ToolCallVeto {
decision := gate.Admit(ctx, call.Name, call.Args)
if decision.Approval != nil {
// a.gw 在 wireFrontend 才建好;RecordApproval 对 nil 接收者安全。
a.gw.RecordApproval(ctx, *decision.Approval)
}
if !decision.Allow {
return &loop.ToolCallVeto{Block: true, Reason: decision.Reason}
}
return nil
},
}
}
// auditReviewer 把 orchestration/audit 的 Approver 适配成代审门的 Reviewer
// (design/11 §4;裁决 13.4.3 要求复用该 Approver,而不是另起一套代审)。
// 它只把 Approval 收敛成 (allowed, error),前端小字文案仍由
// restrict 复用 audit.Approval.Explain 的固定句式产出。
type auditReviewer struct{ approver *audit.Approver }
// ReviewAndApprove 执行一次代审;approver 缺席时复用编排层的 ErrNoApprover
// (门据此 fail-closed 拦截敏感操作,绝不静默放行)。
func (r auditReviewer) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
if r.approver == nil {
return false, fmt.Errorf("review-and-approve: %w", orchestration.ErrNoApprover)
}
ap, err := r.approver.ReviewAndApprove(ctx, actor, action, describe)
if err != nil {
return false, fmt.Errorf("review-and-approve: %w", err)
}
return ap.Allowed, nil
}
// approvalActor 解析代审小字里的「谁想动手」:该 turn 绑定的角色名,
// 未绑定时回落到默认角色;角色名解析不出来就用角色 id(小字宁可用 id
// 也要说清是谁,但绝不编造一个名字)。
func (a *app) approvalActor(ctx context.Context) string {
pid := loop.SessionEnvFrom(ctx).PersonaID
if pid == "" {
pid = a.defaultPersonaID()
}
if name := a.personaName(pid); name != "" {
return name
}
return pid
}
// 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
}