断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成 WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮 看不到,于是「她拍了个子代理出去,但没有下文」。 回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言): - 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅 `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头 并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在; - 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`), 不是「她说过的话」。 落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空 (注记不属于任何角色、也不属于任何一路回合)。 记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰 `memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker 同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM 提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。 可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与 「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者 顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。 测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文 有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线; 结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多 一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。 不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、 不注入人设、不改 Docs(契约归一建议报塔)。
486 lines
23 KiB
Go
486 lines
23 KiB
Go
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: loopMemoryExtractor(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
|
||
}
|
||
}
|