Files
Cyrene_v2/internal/runtime/loop/run.go
T
AskaEth 59a1ac4056 feat(model): llm_usage.session_id 真正落库(补完 U1 缺口)
通道:新增 model/session_ctx.go 的 WithSessionID / SessionIDFrom(与 tool.WithTurnPersona
同族的 ctx 范式,避开 model 反向 import loop 的环),loop 在 Run 顶部播种;适配器
record(ctx,...) 接会话;usageSessionArg 空串映射为 NULL。
兼容:sink 未注入或会话未播种时行为逐字不变;既有 model 测试零断言改动即全绿。
文件:model/{session_ctx.go(新), usage.go, usage_store.go, openai.go, anthropic.go,
usage_session_test.go(新)} + loop/{run.go, usage_session_test.go(新)}。
变异三处:去 loop 打标 / 写侧不绑会话 / 适配器不读 ctx ⇒ 各自红行,还原复绿。
契约与日志字段集未动;读侧不按 session 过滤,面板汇总口径不变。
2026-10-05 08:04:49 +08:00

229 lines
13 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,并让父级自身的节点出现断档。
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...)
}
// 装配期解析文档部件(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)
}