规格=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 由各自回合作答(不丢)
104 lines
5.7 KiB
Go
104 lines
5.7 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件实现「回合内转向注入」(design/15 §2.0/§2.4,裁决 13.4.5 目标态)。
|
||
//
|
||
// 不变量按 design/15 §2.0 实现,语义不在循环里自创:
|
||
//
|
||
// - **I1(同会话在途回合数恒为 1)**:转向只在**同一回合的边界**注入,绝不取消
|
||
// 当前回合、也绝不新开第二个回合。循环对这一点的保证是结构性的——它没有任何
|
||
// 「起新回合」的分支,注入只是把消息并进当前回合下一次模型调用的视图。
|
||
// - **I2(消息恰好被处理一次)**:注入=已消费。循环看不到消息 id
|
||
// (model.Message 没有 id 字段),因此「按 created_at + id 去重」与「从待处理
|
||
// 队列移除」都归 SteerSource 的实现方(网关会话编排器,它同时持有转录与
|
||
// pending 队列)。循环只保证同一个边界只问一次、按返回顺序追加。
|
||
// - **I3(转向不取消已发起的工具)**:注入点只在**回合内边界**——一次模型步结束
|
||
// 与它那一批工具调用结束**之后**、下一次模型调用**之前**。此刻没有任何工具在
|
||
// 飞,因此注入不可能取消任何已发起的工具;正在执行的工具永远跑完(与 Cline
|
||
// PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。
|
||
//
|
||
// 为什么注入只进**本轮视图**而不写回运行窗口:写回会让注入内容进入 checkpoint
|
||
// 快照与后台记忆提取的输入面(两者都读该回合的 msgs),而它们本应只看到该回合真正
|
||
// 的输入与工具结果。注入在每一步重新并入视图,因此对模型而言它仍是**同一个窗口**
|
||
// (一致性=同一窗口,design/15 §2.4),只是不落盘、不出现在记忆面。
|
||
|
||
// SteerRequest 是循环在回合内边界向 SteerSource 发起的一次「重读转录」请求。
|
||
type SteerRequest struct {
|
||
// SessionID 是本次 Run 驱动的会话。
|
||
SessionID string
|
||
// Step 是**即将开始**的模型步序号(从 1 开始):被返回的消息会出现在该步的
|
||
// 模型可见视图里,因此它就是网关 `steer_ack` 帧上报的 `atStep`。
|
||
Step int
|
||
}
|
||
|
||
// SteerSource 由调用方(网关会话编排器)提供,在指定的回合内边界重读该会话转录,
|
||
// 返回「本回合开始后新到、且尚未被本回合吃进」的用户消息,供循环追加进本轮视图。
|
||
// 返回空切片表示本次边界没有可注入的消息,是正常情形而非错误。
|
||
//
|
||
// 实现方负责去重与「已消费」口径(见文件头 I2):循环按返回顺序原样追加,不做也
|
||
// 无法做 id 去重。它必须是非阻塞的——它在 Run 路径上被调用;网络/存储读取有界,
|
||
// 读失败时返回空(该消息留在待处理队列,照旧作为自己的回合被回答,绝不静默丢失)。
|
||
type SteerSource interface {
|
||
Steer(ctx context.Context, req SteerRequest) []model.Message
|
||
}
|
||
|
||
// steerSourceKey 是回合内转向源的 context key。
|
||
type steerSourceKey struct{}
|
||
|
||
// WithSteerSource 把转向源挂到 ctx 上,使本次 Run 的每个回合内边界都能重读转录并
|
||
// 注入新到的用户消息(design/15 §2.4)。调用方(网关编排器)在**每个回合启动之前**
|
||
// 挂一份新的源:转向游标是按回合的状态,跨回合复用会让上一个回合已吃进的消息被
|
||
// 过滤掉或重复注入。
|
||
//
|
||
// nil 源是空操作,ctx 原样返回——未接线的调用方(子代理、后台思考、未接网关的循环)
|
||
// 行为逐字不变。
|
||
func WithSteerSource(ctx context.Context, src SteerSource) context.Context {
|
||
if ctx == nil || src == nil {
|
||
return ctx
|
||
}
|
||
return context.WithValue(ctx, steerSourceKey{}, src)
|
||
}
|
||
|
||
// SteerSourceFrom 返回 ctx 上挂的转向源,未挂载时为 nil。它是 WithSteerSource 的
|
||
// 读侧,供拿到 context 的调用方(网关测试、断言注入边界的实现)观察本次 Run 的
|
||
// 注入能力;nil 表示本次运行不做任何转向注入。
|
||
func SteerSourceFrom(ctx context.Context) SteerSource {
|
||
if ctx == nil {
|
||
return nil
|
||
}
|
||
src, _ := ctx.Value(steerSourceKey{}).(SteerSource)
|
||
return src
|
||
}
|
||
|
||
// steerAtBoundary 在一个回合内边界重读该会话转录,并把新到的用户消息合并进本回合
|
||
// 已注入列表。未接线转向源、ctx 已取消、或本次边界无新消息时,它原样返回 steered
|
||
// (无新消息是常态:工具批次之间通常没有用户插话)。
|
||
//
|
||
// 它绝不返回错误:转向是增益,任何一个边界的读取失败都不该让用户已经在跑的回合
|
||
// 失败——失败时返回空(见 SteerSource 契约),该消息留在待处理队列里等下一个回合。
|
||
func (l *Loop) steerAtBoundary(ctx context.Context, sessionID string, step int, steered []model.Message) []model.Message {
|
||
src := SteerSourceFrom(ctx)
|
||
if src == nil || ctx.Err() != nil {
|
||
return steered
|
||
}
|
||
msgs := src.Steer(ctx, SteerRequest{SessionID: sessionID, Step: step})
|
||
if len(msgs) == 0 {
|
||
return steered
|
||
}
|
||
// 注入的图片消息走与回合开始处同一条视觉改写(design/06 §0):否则一条中途
|
||
// 注入的图片会绕过 resolver,直接落到不支持视觉的主模型面前。
|
||
msgs = l.resolveImages(ctx, msgs)
|
||
// 链路图如实标记「本轮输入被中途追加过」(design/15 §5)。子代理 Run 不拥有
|
||
// 自己的图(它的 span 并入派生它的会话),因此分支 Run 不打这个标记。
|
||
if _, isBranch := flowBranch(ctx); !isBranch {
|
||
l.flow.MarkInputSteered(sessionID)
|
||
}
|
||
l.log.Info("steer injected", "session", sessionID, "step", step, "messages", len(msgs))
|
||
return append(steered, msgs...)
|
||
}
|