Files
Cyrene_v2/internal/runtime/loop/steer.go
T
AskaEth 3d8822c922
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
规格=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 由各自回合作答(不丢)
2026-10-04 14:53:40 +08:00

104 lines
5.7 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 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...)
}