Files
Cyrene_v2/internal/runtime/loop/run.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

210 lines
11 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"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Run 驱动 agent 循环:每轮组装一份全新的模型可见上下文
// (从当前会话重新注入相关记忆,上下文超出配置预算时
// 可选地压缩更早的轮次),然后运行至多
// MaxRounds 个 step。每个 step 运行模型(配置了 OnStream 时
// 流式),所有被请求的工具调用在每次调用的超时下执行,
// 并以 RoleTool 消息回灌,使模型能继续。
// 每一步都持久化一个 checkpoint,每一步都作为
// state_transition span 追踪(每次调用带一个 llm_call 子 span 与
// tool_call span),且当 turn 落定时记忆 Extractor 在该
// turn 的对话上运行(design/03 §1:LLM extractor 跑在后台
// goroutine 上,因此提取绝不延迟回复),错误记录日志
// 而非致命。
//
// 回合边界发出 turn.start / turn.end 流式帧(design/04 §1.1):
// start 在任何工作之前,end 经 defer 覆盖 Run 的每个出口
// (最终答案 / 安全网终止 / 预算耗尽 / 步骤错误)。
//
// 当 turn 带着 Result 落定时,OnTurnEnd 钩子对该
// turn 调用一次(绝不按步调用),其情绪标签记录在 Result.Mood 上,
// 使循环外围的分层能推进人格状态并标记持久化的回复。
//
// 它返回最终答案;预算耗尽而无最终答案时,返回带
// StopReasonMaxRounds 的部分 Result。step 错误用 %w 包装,
// 使调用方能以 errors.Is 分类它们。
func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Message) (*Result, error) {
if ctx == nil {
ctx = context.Background()
}
if l.agent == nil && len(l.agents) == 0 {
return nil, fmt.Errorf("loop: run: %w", ErrNilAgent)
}
// 以 quiet 播种该 Run 的 capacity 累加器,使状态能跨多步
// 工具循环演进(quiet→awake→full);nil/被禁用的判定器
// 使其不被使用。它作用域限于本次 Run,因此并发 run 绝不共享。
// 会话 id 搭同一个 context,使 capacity_awaken 的发出能
// 定向到产出它的会话(design/14 §5)。
ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet})
// 同时播种该 Run 的「本轮工具允许集」持有者(design/14 §4,裁决
// 14.4.2):每轮由 restrictSchemas 发布模型可见的工具面,
// Dispatcher.Invoke 据此做前置软判。作用域同样限于本次 Run,
// 子代理 Run 各自播种一份,并发 run 绝不共享。
ctx = tool.WithRoundAllowSet(ctx)
ctx = withLoopSession(ctx, sessionID)
l.emitTurnBoundary(ctx, model.StreamKindTurnStart)
defer l.emitTurnBoundary(ctx, model.StreamKindTurnEnd)
// 恢复**崩溃遗留**的 run(design/04 §1 durable execution):该会话最新
// 快照若是崩溃(进程猝死、回合从未写出终态、收尾 defer 根本没执行)留下的
// 中途 tool_calls,就重建其上下文并从下一步继续,而不是从全新对话重启。
// 其余一切——不同的 sessionID、已完成 run 的终态、以及任何**已落定的
// 未完成退出**(取消 / 模型报错 / 工具报错 / 预算耗尽 / 护栏拦截)留下的
// 快照——都落到全新开始,让本次消息自己组最新上下文(主人 2026-10-04
// 裁决:只有崩溃才允许恢复;见 resumableStopReason 与 driveTurn 的收尾)。
msgs, startStep, _ := l.loadCheckpoint(ctx, sessionID, messages)
msgs = l.resolveImages(ctx, msgs)
res := &Result{}
// 分支 Run(子代理)把其 span 并入父会话的实时
// 图;只有所属的 Run 开始与结束轮次。没有它,子代理
// 会重置前端唯一的 flowGraph,并让父级自身的节点出现断档。
if _, isBranch := flowBranch(ctx); l.flow != nil && !isBranch {
l.flow.StartRound(sessionID, lastUserText(msgs))
defer func() { l.flow.EndRound(sessionID, res.Text) }()
}
return l.driveTurn(ctx, sessionID, msgs, startStep, res)
}
// driveTurn 运行至多 maxRounds 个 step 的主循环:每步组装上下文、
// 调用模型、执行被请求的工具并回灌。它在任何一种落定路径上返回
// 已 settle 的 Result;step 或工具批次的错误以 %w 包装返回。
func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Message, startStep int, res *Result) (*Result, error) {
toolState := &toolLoopState{prevFP: make(map[string]bool)}
step := startStep
// steered 累积本回合在**回合内边界**注入的用户消息(design/15 §2.4,裁决
// 13.4.5)。它们只并入每一步的模型可见视图,绝不写回 msgs —— checkpoint 快照
// 与后台记忆提取因此保持干净;又因为每个后续 step 都重新并入,注入内容对模型
// 而言始终是**同一个窗口**的一部分(一致性),后到的消息也绝不会顶掉旧窗口
// 已产出的内容(完整性,I1)。
var steered []model.Message
// 未落定退出的回合不留下「可恢复的在途态」(裁决 13.4.5:下一条消息必须
// 看到自己的最新上下文,而不是被放弃回合的旧窗口顶掉)。判别口径(主人
// 2026-10-04 裁决,「只有崩溃才允许恢复」):**崩溃**时本 defer 根本不执行,
// 中途快照留给下回合续接(design/04 §1 durable execution);**任何已落定的
// 未完成退出**(取消 / 模型报错 / 工具报错;预算耗尽、安全网终止与护栏拦截
// 经 settleAndReturn 写终态)都落定为不可恢复。故只按 res.StopReason 判别:
// 为空 = 本回合没落定,废弃其快照;非空 = 终态已写出,绝不改写。
defer func() {
if res.StopReason != "" {
return
}
reason := StopReasonAborted
if ctx.Err() != nil {
reason = StopReasonCanceled
}
l.abandonCheckpoint(ctx, sessionID, step, msgs, reason)
}()
for ; step <= l.maxRounds; step++ {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
}
// 会话超出上下文预算时压缩更早的轮次,
// 然后为本轮组装模型可见上下文(全新记忆
// 注入 + transform 钩子),使每一步都看到最新上下文。
msgs = l.compactIfNeeded(ctx, msgs)
// 回合内边界(design/15 §2.0 I3):上一个模型步与它那一批工具调用都已结束、
// 下一次模型调用还没开始 —— 此刻没有任何工具在飞,因此在这里注入不可能取消
// 任何已发起的工具;新到的用户消息则对**同一回合**的下一个模型步立即可见
//(design/15 §2.4 最新性)。循环绝不因此新开第二个回合(I1)。
steered = l.steerAtBoundary(ctx, sessionID, step, steered)
view := l.assembleContext(ctx, msgs, step)
if len(steered) > 0 {
view = append(view, steered...)
}
rc, roundSpan := l.span(ctx, "loop.step", trace.SpanKindStateTransition)
outcome, err := l.step(rc, sessionID, view)
if err != nil {
l.spanProp(roundSpan, "error", err.Error())
l.finishSpan(roundSpan, trace.SpanStatusError)
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
}
l.spanProp(roundSpan, "step", step)
l.spanProp(roundSpan, "stop_reason", outcome.stopReason)
l.finishSpan(roundSpan, trace.SpanStatusOK)
res.Steps = step
if outcome.final {
res.Text = outcome.text
res.StopReason = outcome.stopReason
return l.settleAndReturn(ctx, sessionID, msgs, step, outcome.stopReason, res)
}
batch, err := l.runToolCalls(rc, msgs, outcome.toolCalls, toolState)
if err != nil {
return nil, fmt.Errorf("loop: run: step %d: tools: %w", step, err)
}
msgs = batch.messages
res.ToolCallsExecuted += batch.executed
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
// 关键工具循环安全网(或终止钩子)不等真正的最终答案
// 就停止本轮。
if batch.terminate {
res.Text = criticalToolLoopTerminalReason
res.StopReason = StopReasonToolLoopTerminated
return l.settleAndReturn(ctx, sessionID, msgs, step, StopReasonToolLoopTerminated, res)
}
}
// 预算耗尽而无最终答案。
res.Steps = l.maxRounds
res.StopReason = StopReasonMaxRounds
return l.settleAndReturn(ctx, sessionID, msgs, l.maxRounds, StopReasonMaxRounds, res)
}
// settleAndReturn 落定一个已得出 Result 的回合:以该路径的
// stop reason 持久化终态 checkpoint、触发后台记忆提取、
// 上报 turn 钩子(情绪标签写回 Result.Mood),然后把结果交还调用方。
//
// 终态快照脱离 ctx 的取消信号写入:落定是一个**已经发生**的决定,若恰在
// 此时收到取消而写失败,最新快照会停在中途的 tool_calls 上,下一条消息便
// 会被旧窗口顶掉——与 driveTurn 收尾 defer 要挡的是同一类缺陷。
func (l *Loop) settleAndReturn(ctx context.Context, sessionID string, msgs []model.Message, step int, stopReason string, res *Result) (*Result, error) {
l.saveCheckpoint(context.WithoutCancel(ctx), sessionID, step, msgs, stopReason)
l.extractMemoryAsync(ctx, msgs, step)
l.settleTurn(ctx, sessionID, msgs, res)
return res, nil
}
// StopReasonCanceled 标记一个因用户中断(design/15 §3「停下/算了/换个话题」)
// 而放弃的回合。它不是可恢复的在途态:design/15 要求中断后立即按**最新**
// 上下文处理下一条消息,因此这种快照绝不能被下一条消息续接
// (见 driveTurn 与 abandonCheckpoint)。
const StopReasonCanceled = "canceled"
// StopReasonAborted 标记一个**非取消**的未落定退出:回合在得出终态之前因
// 模型报错、工具报错等原因把控制流交还了调用方。它与 StopReasonCanceled
// 一样使该会话的 checkpoint 不可恢复——主人 2026-10-04 裁决:只有崩溃
// (进程猝死、该回合从未写出终态快照,因而收尾 defer 根本没执行)才允许
// 下一回合续接旧窗口(见 driveTurn 与 abandonCheckpoint)。
const StopReasonAborted = "aborted"
// abandonCheckpoint 把一个未落定回合的 checkpoint 落定为不可恢复态
// (stop reason = reason),使该会话的下一条消息从它自己的最新上下文开始,
// 而不是被放弃回合的旧窗口顶掉(裁决 13.4.5:上下文一致性 / 完整性 /
// 最新性)。快照本身保留在 store 里,仍可查询。
//
// 写入必须脱离已取消的 ctx(否则 ExecContext 必然立即失败),因此这里用
// WithoutCancel 派生背景上下文,保留原 ctx 的值而丢弃取消信号。失败只记日志,
// 绝不让这个收尾动作把中断路径变成错误。
func (l *Loop) abandonCheckpoint(ctx context.Context, sessionID string, step int, msgs []model.Message, reason string) {
l.log.Info("checkpoint abandoned", "session", sessionID, "step", step, "stop_reason", reason)
l.saveCheckpoint(context.WithoutCancel(ctx), sessionID, step, msgs, reason)
}