规格=design/15 末尾「13.4.5 目标态规格」(不变量 I1/I2/I3)。调研依据:豆包无公开技术文档(不编造); 同类成熟 Agent 产品无一做同会话并行回合,共同范式= queue + 回合内 steer(Claude Code / Cline PR #13958 / Codex Queue·Steer / open-webui / LangGraph)。 - 注入点=**回合内边界**(上一模型步 + 它那批工具调用都已结束、下一次模型调用还没开始): driveTurn 每 step 组装前调用一次 SteerSource(run.go)——完成 step 只能靠工具批次推进, 故唯一可注入处就是「工具批次结束之后」,循环里没有任何「起第二个回合」的分支(**I1 结构性成立**) - **I2 恰好一次**:注入只进**本轮 view、绝不写回 msgs**(checkpoint 与记忆提取面保持干净); 被注入的消息从 pending 移除,不会由自己的回合再答一次 - **I3 转向不取消工具**:注入调用点在 runToolCalls **之后**(此刻无工具在飞),循环从不因注入 cancel 任何 ctx; 边界序列有测试钉死(恰为 [1 2 …],无批次中途值) - 判定口径= **pending ∩ 转录**:候选是本会话 pending 条目,每个边界重读 history.Recent 验证已落库, 按**行 id** 去重、按 pending FIFO 注入。**不用时间戳**(Windows time.Now() 步长 ~0.44ms,连发常同毫秒并列, 任何 ms 基线都会漏注入或重复注入);跨重启不成立也不需要(在飞回合与 pending 都在内存) - 新下行帧 `steer_ack`:`{type, sessionId, data:{messageId, atStep}}`,语义=「已被本回合吃进、尚未被正面回答」 (驱动前端灰态);**不进 frameReachesAll** ⇒ fail-closed 只发 owner+admin(测试双钉);经 hub 广播、不经 bus - 组合根**零改动**:per-turn 状态用 ctx 承载(loop.WithSteerSource),编排器在回合启动/续跑时挂 - 守卫:session_orchestrator.go 加完接线后 505 行 ⇒ 按职责纯搬出 session_events.go(常量取值未改、行为零变化) - 复核:build/vet 绿 + **全树 0 FAIL** + linux/darwin 交叉编译绿;loop 6 例(含 3 子测)+ gateway 2 例 E2E; **4 条变异反证**(删 consumePending → 模型步数 4≠3;注入写回 msgs → 快照被污染;批次中途注入 → 边界 [1 1 2]; 把帧加进白名单 → viewer 收到)均已还原 - 未做(登记):IM 自有 Chatter 落库的消息不参与 steer(有意,避免重复作答);pending 条目无自落库 id ⇒ 配对用 (内容,图片) 逐行认领;同回合超 20 条用户消息时超出者留 pending 由各自回合作答(不丢)
174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
package loop
|
||
|
||
import (
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// graphUpdateSubject 是 FlowPublisher 发布每轮实时
|
||
// flow 图所用的 bus subject。gateway hub 订阅 ws.> 并将其
|
||
// 作为 WS 事件类型 graph_update 转发(design/19 §1)。
|
||
const graphUpdateSubject = "ws.graph_update"
|
||
|
||
// allowedSessions 是用于控制哪些会话可发布
|
||
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
|
||
// 泄露进管理面板。谓词为 nil 时允许所有会话。
|
||
type allowedSessions func(sessionID string) bool
|
||
|
||
// FlowPublisher 从循环的 trace span 累积每会话 flow 图,
|
||
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端
|
||
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
|
||
// (nil 发布者为空操作)且并发安全;会话图
|
||
// 在同一把互斥锁下串行化,因为并行的工具 span 会并发完成。
|
||
//
|
||
// 归属:一个会话拥有一个实时轮次。声明自己是另一会话分支的
|
||
// Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound,
|
||
// 而是把其 span 发布到所属会话下,因此子代理渲染为
|
||
// 父图的一条泳道,而不是第二张图(design/19 §2)。
|
||
type FlowPublisher struct {
|
||
mu sync.Mutex
|
||
publish func(subject string, payload any)
|
||
allow allowedSessions
|
||
graphs map[string]*trace.RunGraph
|
||
rounds map[string]int
|
||
}
|
||
|
||
// NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在
|
||
// 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph
|
||
// 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回
|
||
// false 则整个会话被丢弃)。两者都可以为 nil(nil publish 为空操作)。
|
||
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool) *FlowPublisher {
|
||
return &FlowPublisher{
|
||
publish: publish,
|
||
allow: allow,
|
||
graphs: make(map[string]*trace.RunGraph),
|
||
rounds: make(map[string]int),
|
||
}
|
||
}
|
||
|
||
// StartRound 为 sessionID 开始一张新图,用用户文本
|
||
// 播种一个 input 节点并发布初始帧。它重置该会话的上一
|
||
// 轮次,因此每个用户 turn 都是自己的一张图。
|
||
func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||
if sessionID == "" || !p.allowed(sessionID) {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
round := p.rounds[sessionID] + 1
|
||
g := trace.NewRunGraph(sessionID)
|
||
g.Start(round, time.Now())
|
||
g.SeedInput(inputText)
|
||
p.graphs[sessionID] = g
|
||
p.rounds[sessionID] = round
|
||
p.publishLocked(g)
|
||
}
|
||
|
||
// EndRound 用回复播种一个 output 节点并发布最终帧,
|
||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端
|
||
// 刷新时能重放最后一份快照。
|
||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||
if sessionID == "" || !p.allowed(sessionID) {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
g := p.graphs[sessionID]
|
||
if g == nil {
|
||
return
|
||
}
|
||
g.SeedOutput(outputText)
|
||
p.publishLocked(g)
|
||
}
|
||
|
||
// MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧
|
||
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点打 steered:true 标记」)。
|
||
// 它让链路图如实表达「这一轮的输入在回合中途被追加过」——图仍然只有一条主干
|
||
// (I1:同会话恒只有一个在飞回合),而不是被画成第二轮。
|
||
//
|
||
// nil 接收者、空会话、发布白名单外的会话、或该会话还没有图时都是空操作;标记
|
||
// 只并入既有 input 节点(Upsert 的合并语义),绝不新建节点、绝不改动它的标签。
|
||
func (p *FlowPublisher) MarkInputSteered(sessionID string) {
|
||
if p == nil || sessionID == "" || !p.allowed(sessionID) {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
g := p.graphs[sessionID]
|
||
if g == nil {
|
||
return
|
||
}
|
||
g.Upsert(trace.NodeSpec{
|
||
ID: "input",
|
||
Kind: "input",
|
||
Props: map[string]any{"steered": true},
|
||
})
|
||
p.publishLocked(g)
|
||
}
|
||
|
||
// SpanStarted 为一个刚开始的 span 记录一个运行中的占位
|
||
// 节点。state_transition span 会被图构建器省略。
|
||
func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
||
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
g := p.graphs[sessionID]
|
||
if g == nil {
|
||
return
|
||
}
|
||
if g.AddSpanStart(sp.ID, sp.Name, sp.Kind, sp.ParentID, sp.StartedAt) {
|
||
p.publishLocked(g)
|
||
}
|
||
}
|
||
|
||
// SpanFinished 把一个刚结束的 span 作为最终节点(状态 done/error)
|
||
// 并入会话图并发布。必须在 span 的 Finish 之后调用。
|
||
func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
g := p.graphs[sessionID]
|
||
if g == nil {
|
||
return
|
||
}
|
||
g.AddSpan(sp.Snapshot())
|
||
p.publishLocked(g)
|
||
}
|
||
|
||
// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。
|
||
func (p *FlowPublisher) publishLocked(g *trace.RunGraph) {
|
||
if p.publish == nil {
|
||
return
|
||
}
|
||
p.publish(graphUpdateSubject, g.Snapshot())
|
||
}
|
||
|
||
// allowed 报告某会话的图是否应当发布。
|
||
func (p *FlowPublisher) allowed(sessionID string) bool {
|
||
if p.allow == nil {
|
||
return true
|
||
}
|
||
return p.allow(sessionID)
|
||
}
|
||
|
||
// lastUserText 返回 messages 中最新用户消息的文本,用于
|
||
// 标注该轮的 input 节点。没有用户文本时返回 ""。
|
||
func lastUserText(messages []model.Message) string {
|
||
for i := len(messages) - 1; i >= 0; i-- {
|
||
if messages[i].Role != model.RoleUser {
|
||
continue
|
||
}
|
||
if t := messageText(messages[i]); t != "" {
|
||
return t
|
||
}
|
||
}
|
||
return ""
|
||
}
|