Files
Cyrene_v2/cmd/cyrene/wire_runtime.go
T
AskaEth 378faf212e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
Z5(c2e413e)把 web-fetch 的降级链与注入点做完了,但「取正文」入口缺失 ⇒
组合根拿不到 BodyRenderer、这一档实际关着(当时如实登记)。本片补上:

- internal/capability/browser/render.go:新增 Manager.RenderText(ctx,url)
  (导出的进程内能力)—— 先过 GuardURL(与全仓同一份 SSRF 判定源)、复用
  withSession/session、等 readyState=complete 之后才取 body.innerText;
  渲染失败 = 分类错误,渲染成功但正文为空 = 空串+无错(不冒充成功/失败)。
- cmd/cyrene/wire_body_fallback.go:适配器 + 挂载点,把 Manager 适配成
  io.BodyRenderer 并用 WithBrowserFallback 挂上;web_runtime 里把浏览器
  接线**上移到 io 工具集之前**(顺序颠倒会静默关死这一档)。
- plugins/tools/browser/tool.go:新增 Tool.Manager(),供组合根取驱动句柄。

开关口径(裁决,依据 design/28 §1 M5 / §7 判据 4 / §8 B5):**有可用浏览器
就挂,没有就不挂**,不新增开关 ——
① 设计把 M5/B5 写成要落地生效的降级档,不是永远关着的空壳;
② 设计 §8 的可调项清单里没有这条降级档的开关,`.env` 已冻结 ⇒ 自造开关属
   「自行发挥」;
③ io 库层「不传 = 默认关」的语义一字未改,本片只把组合根从「永远关」改成
   「没来源就关」,并留带来源的 WARN(reason=no-usable-browser)。
⚠️ 若日后要「有浏览器也需显式开启」,得先给设计定开关落点,代码侧一行改回。

验收(主会话复跑 + 亲自变异):go build ./... + 三包 vet 绿;browser/io 两包
测试绿(cmd/cyrene 由子代理跑过全绿);我用 -overlay 把 GuardURL 的判定对象
换成常量 URL(绕过被拒目标)⇒ TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess
变红,证明判定确实**在起任何进程之前**跑。

⚠️ 生产端到端未验:按红线没重启后端、没起真 Chrome ⇒「真页面 → browser 取到
正文」这条链只有假 CDP + 组合根接线级证据。
⚠️ design/28 有 6 处表述与代码现状冲突(归属 web-fetch vs web-search、extract
动作归属、B5 排期、状态过期等),已登记待我同步文档。
2026-10-07 09:59:56 +08:00

486 lines
23 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"
"fmt"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"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/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"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 加载人格定义并注册它们,同时注册默认角色与渠道身份识别所用的
// 主人档案。
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)
}
// 若默认角色不在 personas 目录下,则显式加载一次。
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 构建长期记忆栈:store、嵌入管理器、向量索引、项目索引,以及接进
// io 知识工具的检索器。
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()
a.embedder = 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...)
// 记忆整理(design/23 §4):低峰定期后台任务,只产候选不写记忆。
// 它只需 memStore 与 embedder,因此在这里(记忆栈刚建好时)装配;
// 启动/停止与 backthink 并列(main.go / close)。
if err := a.wireMemoryTidy(); err != nil {
return err
}
// 入库前审查(design/23 §15.1 D3):记忆管理会话的后台队列,接在 store 的
// 唯一写入路径上。它与整理心跳并列启动(main.go / close)。
if err := a.wireMemoryReview(); err != nil {
return err
}
return nil
}
// wireRuntime 构建模型适配器、工具 dispatcher、护栏、agent 与其 loop、
// 追踪、持久化与编排 scheduler。
func (a *app) wireRuntime() error {
a.attachToolPolicyStore()
// 链路图本地落盘(design/19 §4 P3;切片 F2):先建存储,随后在下面交给发布器作
// 每轮收尾的落盘出口。它的常驻清扫由 main 的 startFlowGraphs 拉起(见
// wire_flow_graphs.go);网关的只读出口在 wireFrontend 注入**同一个**句柄。
a.wireFlowGraphs()
// LLM 提供商栈可热插拔:它加载 models.json(或回落 .env),面板的保存端点会
// 就地重载它而无需重启(design/04 §3.1)。a.modelAdapter 仍保持 Adapter 接口
// (运行时栈实现了它),供渠道 bridge 的复审模型使用(design/12 §6 策略四)。
modelsPath := runtimeconfig.DefaultPath(resolveDataDir(a.cfg))
// 启动即补齐一次(主人裁决):`.env` 只是默认/播种层,models.json 才是面板的
// 一等来源——把 `.env` 补齐进面板配置里缺失的键(面板已有的键绝不覆盖,组集合
// 完全由面板决定),幂等;失败只记日志、不阻断启动。
materializeModels(modelsPath, a.cfg.LLM, a.log)
// 用量落库出口(切片 U1):先建 sink,再建模型栈 —— 栈里**每一条**链
// (主/备/本地第三级/按角色跨协议)的每一步适配器都会带上它,因此任何一条
// 路径上的模型调用都计成本。库未接时 sink 为 nil,行为与之前逐字相同。
a.usage = a.newUsageSink()
llmOpts := make([]model.RuntimeOption, 0, 1)
if a.usage != nil {
llmOpts = append(llmOpts, model.WithRuntimeUsageSink(a.usage))
}
runtimeStack := model.NewRuntimeLLM(modelsPath, a.cfg.LLM, a.log, llmOpts...)
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)
}
// 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里):
// 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个
// 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在
// wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。
//
// ⚠️ 它必须排在 io 工具集**之前**(切片 Z5 的解封):io 的 web-fetch 降级档
// (design/28 §1 M5 / §8 B5)按「能力面里到底有没有可用浏览器」决定挂不挂,
// 而那条判据就是在这里被写进注册面的(`bodyFallbackOption()`,见
// wire_body_fallback.go)。装配顺序即这条判定的输入,颠倒过来降级档会静默关死。
a.wireBrowser()
// io 工具集的选项(含 web-search 的端点与超时裁决、web-fetch 的浏览器降级档)
// 在 wire_io_tools.go 一处组装:端点不可用时它交出 WithoutSearch(),使 web-search
// **不进注册面**(WS1-A)。
ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...)
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()
a.wireTranscode()
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
//
// 它必须在护栏**之前**算出来:请求侧字符上限是这份预算的函数(切片 G2/G2b:
// 输入上限 = max(256 Ki, 推导预算 token 数 × loop.CharsPerToken)),否则 1M 窗口下
// 单份 349525 字符的附件注入会先被硬编码的 262144 上限整回合拦死,多份附件更是
// 成倍顶到上限(G2b 把「单份」对齐成「N 份」)。
ctxBudget := a.contextBudget(envPath)
// 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 +
// 长度上限 + 仅当配置提供时的关键词表,见 wire_guardrail.go)。
guards := a.buildGuardrailPipeline(ctxBudget)
instructions := a.defaultInstructions()
// 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels /
// personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层
// (persona 为空时该视图与裸栈等价,行为不变)。
personaID := a.defaultPersonaID()
defaultStack := runtimeStack.ForPersona(personaID)
ag, aerr := agent.New(instructions, a.dispatcher, guards,
agent.WithModel(defaultStack, defaultStack),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if aerr != nil {
return fmt.Errorf("agent: %w", aerr)
}
store := a.memStore
// 每个角色一个 agent,共用上面的 dispatcher / 护栏 / 适配器,因此一个会话能以
// 任意角色作答(design/22 §4.2 方案 A);具体由 loop 按 turn 选取。
agents, defaultAgent := a.personaAgentPool(runtimeStack, a.dispatcher, guards, ag)
// span 分两路:一路写 JSONL(stderr 记录器),另一路在 wireFrontend 建好网关后
// 进入它的可观测性缓冲(cmd/cyrene/observability.go)。
a.traceWriter = newAppTraceWriter()
tracer := trace.New(a.traceWriter)
// 实时链路可视化(design/19 §1):发布器把 loop 的 trace span 累积成按会话的
// 链路图,并在每次 span 跃迁时发布 ws.graph_update。
//
// 图源白名单(裁决 16.4.2):它决定**哪些会话产出实时链路图**,也是后端能提供
// 完整链路导图的那一侧 —— 主人 web 会话族("web" 主会话 + "web:<uuid>" 次级会话,
// IM 主人私聊镜像也落主会话)与自主后台思考会话(design/20 §2)。子代理的 span 由
// loop.WithFlowBranch 并进**派生它的那个会话**,渲染成该会话图的一条泳道(design/19
// §2),因此也在这份白名单里。
//
// 注意口径(裁决 16.4.2):design/19 §1 的「仅发布/不发」说的是**用户侧前端**观察到
// 的现象,不是「后端可以少画图」——管理后台的图源由此保持完整,与用户端发布策略
// 是两件事。隔离的渠道会话(群聊/陌生人,sess-xxx)不在此白名单内:那是 design/19 §1
// 发布会话范围(裁决 01.4.5)的既有裁决,本批未改。nil flow 保持 loop 原行为。
flowPub := loop.NewFlowPublisher(
func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) },
flowSessionAllowed,
// 每轮收尾落一条(**不是每 span 一条**):白名单由上面的 allow 把守
// (EndRound 对白名单外的会话直接返回),因此落盘出口不重复判白名单。
// 未接线时交出真 nil 接口 ⇒ 发布器走纯内存路径(F1 之前的行为)。
loop.WithRoundRecorder(a.flowRoundRecorder()),
loop.WithFlowLogger(a.log),
)
// 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口
// (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面
// (wireFrontend)必须**是同一个对象**,否则面板补拉到的图与实时收到的图
// 会是两份状态。
a.flow = flowPub
checkpoint, perr := persist.New(context.Background(), a.db, a.log)
if perr != nil {
return fmt.Errorf("checkpoint store: %w", perr)
}
// 视觉路由:loop 按 vision Router 改写带图用户消息——真正能收图的主模型直接
// 看到图像,否则改用 vision/OCR 工具产出的文字描述替代它(design/06 §0)。
//
// SupportsVision 指「主模型能否收图」(裁决 14.4.1 修语义,C-7),取自当前生效
// 的 models.json 组的 `multimodal` 声明——**不是**取自 LLM_VISION_MODEL,后者配
// 的是 vision **工具**用的模型。它按调用读取,因此面板保存/重载在下一个 turn
// 即生效。
visionRouter := vision.NewRouter(
vision.WithVisionTool(visionToolName),
vision.WithOCRTool(ocrToolName),
)
visionResolver := vision.NewMessageResolver(
visionRouter,
func() vision.ModelInfo {
group := a.llm.Group()
return vision.ModelInfo{Name: group.Model, SupportsVision: group.Multimodal}
},
a.dispatcher.Invoke,
)
// 文档摄取(design/24 §2.3-①、§4):装配期把文档部件确定性替换为抽取所得
// 的正文或「仅清单」条目,文档部件因此永不抵达编码器。路径口径与 S1 的
// `POST /api/v1/documents` 同源 —— 原文在 `<DataDir>/uploads/documents`
// (gateway 的 documentSubDir),抽取结果缓存到 `<DataDir>/extracted`。
// 这里复用同一个 resolveDataDir 结果,两处指向同一棵目录树。
docDataDir := resolveDataDir(a.cfg)
docResolver := documents.NewResolver(documents.Config{
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Log: a.log,
})
capDecider := a.buildCapacity()
// 人格情绪观测器(design/01 §2)在此构建,因为它是 loop 的 turn 边界依赖:
// 每个 turn 落定后,该 turn 绑定人格的情绪机与好感度都会推进,返回的心情标签
// 写进 Result.Mood 与助手消息(design/16 §3.3)。
a.emotion = a.buildEmotionObserver()
// 推理旋钮取自默认人格(预算 / 强度 / 是否广播思考),它们同时也是按 turn 的
// 默认值:loop 会经 PersonaKnobs 按每个 turn 的角色重新解析,因此绑定其他角色
// 的会话用的是那个角色的预算。
thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions()
// 敏感操作代审(design/11 §4;裁决 13.4.3):terminal 这类敏感工具在 loop 的
// 工具准入门上经主代理代审(复用 orchestration 的 Approver),再由网关把
// 结果落进 audit_logs 并广播 design/11 §4 的小字。同一个 Approver 也交给
// 编排层(子代理代审),使两条路径共用一处审计。
//
// 代审的实质判定(主人 2026-10-05 裁决的口径 A):来源解析读编排层挂在
// 子代理 Run 上的任务目标(loop.SubagentTaskFrom,结构化元数据,绝不猜),
// 判定器用既有审查模型范式(services/approvalreview,同一个 model.Adapter)。
// 两者都不在时保持既有行为:代审只记录、不判定。
approver := audit.NewApprover(a.log,
audit.WithSubagentOrigin(loop.SubagentTaskFrom),
audit.WithCallJudge(approvalJudgeFor(a.modelAdapter, 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,
PersonaAwakening: a.awakeningFor,
// 主人身份说明书按**会话**注入(design/11 §0 红线):只有主人车道的
// 上下文里有它,群聊 / 陌生人私聊的返回空串 —— 那段知识绝不进它们的
// 上下文,也绝不随之发给模型供应商。开关与判定见 persona_identity.go。
PersonaKnowledgeFor: a.personaKnowledgeFor(envPath),
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
MaxContextTokens: ctxBudget.Budget.Tokens,
// 单份附件的注入预算随推导出的上下文预算走(T1 §7.2):1M 窗口下大附件
// 才真的进得来,而清单行的续读游标也因此真的是继续读的起点。
DocumentMaxInjectChars: ctxBudget.DocumentInjectChars,
WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Vision: visionResolver,
DocumentResolver: docResolver,
Thinking: thinking,
ReasoningEffort: reasoningEffort,
ThinkingShowToUser: thinkingShow,
TransformContext: a.browserShotTransform(visionResolver),
// 截图闭环(design/28 §7 判据 2 / B3):browser 拍照落盘后,由上一行那个钩子在
// **每个模型步之前**把该会话待送的图并入本轮视图(取走即消费)。它只改本轮所见 ⇒
// 截图不进 checkpoint、也不进后台记忆提取的输入面(design/28 §4 第 3 条)。
// 钩子捕获的就是上面那个 visionResolver,所以这一行必须在它之后;装配细节与
// 「删掉它没有任何运行期症状」这条理由见 wire_browser_context.go。
//
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
// (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,
// 以其他角色派发的子代理(subagent_spawn 的 `persona`,design/22 §8-2)只能
// 指名本进程确实能用该角色自己的指令跑起来的角色,即上面那个池子里有 agent
// 的角色;其余一律拒绝,而不是默默用默认角色的口吻作答。
PersonaExists: personaExists(agents),
// 子代理的 span 与它内部的 llm/工具步骤并进派生它的那个会话的实时图,因此
// 一条分支表现为该会话链路图里的一条泳道,而不是另开第二张图(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)
}
}
// buildCapacity / onCapacity 以及 capacity 的 JSON+TTL 解析都在
// wire_capacity.go(AGENTS §10 文件 ≤500 行)。
// onToolAudit 是 loop 的 OnAudit 出口:它把群聊/陌生人环境里被 design/11 §3 安全闸
// 拦下的工具调用转发进网关的审计流水,使这次拒绝「audit 可查」,前端也能渲染那行
// 「xx 工具在群聊被拒」小字(design/11 §8)。网关在 loop 之后才建好
// (wireFrontend),所以该出口在调用时才读 a.gw——那时应用已装配完整,任何 Run 都
// 经网关服务。
func (a *app) onToolAudit(ev loop.AuditEvent) {
if a.gw == nil {
logger.WithSource(a.log, "cmd/cyrene").Warn("loop audit dropped: gateway not ready",
"who", ev.Who, "what", ev.What, "result", ev.Result)
return
}
a.gw.AuditEvent(ev.Who, ev.What, ev.Result)
}
// parseCapacityToolSets / parseL3CacheTTL 在 wire_capacity.go 里。
// dbCfgForEmbed 返回向量索引所用的数据库配置——它打开的是与主连接池同一个
// SQLite 文件。
func (a *app) dbCfgForEmbed() config.DBConfig {
cfg := a.cfg.DB
cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg))
return cfg
}
// defaultPersonaID 返回默认人格的 id,取不到时回落 "cyrene"。
func (a *app) defaultPersonaID() string {
if c, ok := a.personas.DefaultCharacter(); ok {
return c.PersonaID
}
return "cyrene"
}
// defaultInstructions 返回默认人格的系统提示词,其后依次接 personality / values /
// speechStyle / catchphrase / background 各段(design/01 §2.2);两者都取不到时回落
// 到该角色的 role 文本或一句最小指令。
//
// 主人身份说明书**不在这里**(P0-10 收窄后按会话注入,见 personaKnowledgeFor):
// 静态拼进指令会让它进入该角色的每一个会话,含群聊与陌生人私聊。
func (a *app) defaultInstructions() string {
if p, ok := a.defaultPersona(); ok {
return a.personaInstructions(p)
}
return fallbackInstruction
}
// flowSessionAllowed 是实时链路图的**发布会话白名单**(design/19 §1 裁决 01.4.5
// + 裁决 16.4.2),也是后端唯一一条「哪些会话能产出链路图」的判据 ——
// `FlowPublisher.LastGraph`(契约 v0.71 的只读快照出口)读的就是按它建过图的那些
// 会话,因此白名单外的会话**既发不出实时帧、也查不到快照**。
//
// 三档(本项目当前实现口径,逐档都有理由):
// - `web`:Web 主会话(`history.MainSessionID`)—— 主人对话的正身;
// - `web:<uuid>`:Web 次级会话(IM 主人私聊镜像也落这个族);
// - `sess-backthink`:自主后台思考的固定内部会话(design/20 §2)。
//
// **隔离的渠道会话(群聊 / 陌生人,`sess-xxx`)不在此列** —— 那是防敏感信息
// 泄向管理面板的既有裁决,不是省事。子代理的 span 由 `loop.WithFlowBranch`
// 并进**派生它的那个会话**(design/19 §2),因此不需要单独一档。
//
// 抽成具名函数只为可测:白名单是裁决本身,删掉任何一档都必须有测试打红
// (见 wire_runtime_test.go 的 TestFlowSessionAllowed)。
func flowSessionAllowed(sessionID string) bool {
switch {
case sessionID == "":
return false
case sessionID == history.MainSessionID:
return true
case strings.HasPrefix(sessionID, history.MainSessionID+":"):
return true
case sessionID == backthink.DefaultSessionID:
return true
default:
return false
}
}