Files
Cyrene_v2/internal/runtime/loop/run.go
T
AskaEth 4a937bd269 feat(flow): toolCalls 覆盖率追到全部编排回合(L3)—— 图带权威回合标识
- 新增 StartRoundWithTurnID(ctx, owner, text):从 ctx 取权威回合标识(与 chat_messages.turn_id
  同值);Run 的唯一生产建图入口切到它;原 StartRound 语义逐字不变(1 行包装)
- 归属桶(并行按路分图)保持不变;落盘查询键 lane_turn_id 对单路也有值 ⇒ 消息面按等值查得到图
- 无权威标识时**保持缺席**(不拿桶里的路标识顶上:不许臆造)
- 不需要迁移 0024(0023 的列+索引已在位;老单路行标识根本没存过,回填=编造)

形状变化(已登记契约):graph.id 对单路也追加 #{回合标识}(web-7 → web-7#turn-…),
顺带修掉「重启后单路轮次 upsert 掉旧行」的隐患。3 个变异自检(退回老入口/无视 ctx/臆造回退)。
2026-10-06 14:12:25 +08:00

246 lines
14 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)
// 同时把本回合**角色**发布给工具(tool/turncontext.go):knowledge 这类
// 需要角色作用域的工具据此查到「以谁的名义」检索。不发布的话,模型省略
// 可选参数 persona 时工具会把空角色透给记忆存储,换回一条它无从纠正的
// ErrMissingPersona(切片 KB1 的真因)。取值与提示词/记忆同源(personaFor),
// 因此工具看到的就是本轮说话的角色,不会出现两个角色。
ctx = tool.WithTurnPersona(ctx, l.personaFor(ctx))
ctx = withLoopSession(ctx, sessionID)
// 同一份会话标识再发布给**模型层**(model/session_ctx.go):适配器跑在循环
// 之下、拿不到 loop 的 key,而每次模型调用的用量样本要按会话归档
// (llm_usage.session_id —— 切片 U1 建了列与索引,却因为缺这一跳而恒为 NULL)。
// 空会话 id 不挂载 ⇒ 落库写 NULL,与接线前逐字一致。
ctx = model.WithSessionID(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,并让父级自身的节点出现断档。
//
// 归属取**桶**(`{会话}[#{路}]`,见 FlowOwnerKey):真并行时本 Run 属于某一条路,
// 两路因此各起各的图、各收各的图 —— 后起的那一路绝不重置前一路(design/15
// §2.5.10-1)。单路时桶逐字等于 sessionID,行为与引入本切片之前完全一致。
//
// 这一路的**权威回合标识**(design/15 §2.5.9)取自 ctx:编排器给每一路都盖了它
// (普通单路回合与队首续跑路算,见 `WithTurnID` / `beginLegLocked`),因此图上
// `turnId` 与落盘行 `lane_turn_id` 与 `chat_messages.turn_id` 同值同源 ——
// 消息面「这条回复当时调了什么工具」据此按**等值**查回本轮的图(`toolCalls[]`)。
// ctx 上没有它时(后台思考 / 未接编排器)如实缺席,绝不臆造。
if _, isBranch := flowBranch(ctx); l.flow != nil && !isBranch {
owner := flowOwner(ctx, sessionID)
l.flow.StartRoundWithTurnID(ctx, owner, lastUserText(msgs))
defer func() { l.flow.EndRound(owner, 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)
// 并行路的进行态摘要(design/15 §2.5.6):本会话此刻另有一路在跑时,把
// 「结构化、有界」的进行态作为一条**独立的 system 消息**追加进本轮视图,
// 使本路知道要独立作答、不复述对方。未挂该 ctx 键的运行(单路 / 子代理 /
// 后台思考 / 未接网关)零成本、逐字不变;它只进视图、不写回 msgs ⇒ 不进
// checkpoint、不进记忆提取输入面(与 steer 注入同一口径)。
view = l.appendParallelNotice(ctx, sessionID, step, view)
if len(steered) > 0 {
view = append(view, steered...)
}
// 装配期解析文档部件(design/24 §2.3-①、§4):每条携带文档部件的消息在
// 这里被**确定性**换成「清单行 + 正文」文本块,因此文档部件绝不抵达编码器
// (S2 的编码器对文档 fail-loud,漏一个就是一次 400)。位置与 Run 开头的
// resolveImages 同族,区别是它作用于**本步的模型可见视图**而不是消息窗口:
// 窗口只留引用(§2.4),正文每次按引用读 sidecar 注入,checkpoint 因此不会
// 被正文撑大;回合中途经 steer 并入的消息(含附件)也在同一处被解析。
view, docRefs := l.resolveDocuments(ctx, view)
res.recordDocuments(docRefs)
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)
}