Files
Cyrene_v2/cmd/cyrene/wire_runtime.go
T
AskaEth 8e6c77ad10
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。
- internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错
- cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值)
- plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动
- 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址)
- .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」
- 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
2026-10-05 20:29:21 +08:00

463 lines
22 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
}
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)
}
// io 工具集的选项(含 web-search 的端点与超时裁决)在 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,
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
// (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
}
}