Files
Cyrene_v2/internal/runtime/loop/loop.go
T
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx)
  → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处)
  · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构
  · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致)
- **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉
  awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。
  新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障)
- 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop
  流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同);
  **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑)
- 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略,
  需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
2026-10-04 13:07:52 +08:00

200 lines
7.2 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 实现 Cyrene v2 运行时默认的 agent 执行引擎
// (DevDocs/design/04-运行时.md §1)。它拥有 package agent 刻意
// 不做的那个循环:从记忆组装上下文、驱动模型至多
// MaxRounds 个 step、在每次调用的超时下执行所有被请求的工具调用、
// 把工具结果回灌给模型、每步持久化一个 checkpoint、
// 发出结构化 trace、下发流式增量并运行
// 记忆提取钩子。
//
// 循环以注入依赖而非具体接线来配置,
// 因此它保持可用 mock 做叶子测试(mock 适配器、mock 护栏与 mock
// 工具,加上真实的内存 memory/persist store 与带缓冲的 trace 记录器)。
package loop
import (
"context"
"errors"
"io"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"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"
)
// 界定循环的常量。它们是编译期常量,使运行路径中
// 不出现魔法数字。
const (
// DefaultMaxRounds 是 Options.MaxRounds 非正时使用的
// 工具循环上限。它与 agent 包自身的默认值一致。
DefaultMaxRounds = 8
// StopReasonMaxRounds 报告循环在达到最终答案前
// 用尽了轮次预算。
StopReasonMaxRounds = "max_rounds"
// StopReasonToolLoopTerminated 报告关键工具循环安全网在
// 同一个工具调用跨连续轮次被完全重复后
// 停止了 run。
StopReasonToolLoopTerminated = "tool_loop_terminated"
// DefaultToolParallelism 限制循环以并行模式运行一批时
// 并发执行的工具调用数,未配置显式上限时生效。
DefaultToolParallelism = 4
)
// Loop 返回的哨兵错误。用 errors.Is 检测它们;它们
// 被直接返回或以 %w 包装,使底层原因始终可用。
var (
// ErrNilAgent 在既未配置默认 agent(Options.Agent)
// 也未配置按角色 agent 池(Options.Agents)时由 Run 返回。
ErrNilAgent = errors.New("loop: nil agent")
// ErrNoAdapter 在 agent 构建时未带 model Adapter 时
// 由 Run(流式路径)返回。
ErrNoAdapter = errors.New("loop: no model adapter")
)
// Result 是一次已完成 Run 的结果。
type Result struct {
// Text 是最终的 assistant 文本;循环在无最终答案的情况下
// 耗尽预算时为空。
Text string
// Steps 是执行的模型步数。
Steps int
// StopReason 归类循环如何结束:一个 agent StopReason* 值或
// StopReasonMaxRounds。
StopReason string
// ToolCallsExecuted 是循环调用的工具调用数,含
// 失败的那些。
ToolCallsExecuted int
// Mood 是该 turn 的人格情绪标签,由
// OnTurnEnd 钩子返回(未接线钩子时为 "")。持久化层把它
// 带到 assistant 消息上(design/16 §3.3)。
Mood string
}
// Loop 是默认的 agent 执行引擎。它经 New 一次性配置,
// 只要注入的依赖(agent、dispatcher、store、recorder、tracer)自身
// 并发安全(按契约它们是),就安全支持并发 Run 调用。
// Run 把所有循环状态保留在栈上。
type Loop struct {
agent *agent.Agent
agents map[string]*agent.Agent
tools *tool.Dispatcher
capacity *capacity.Decider
onCapacity func(capacity.Event)
memory *memory.Store
retriever *memory.Retriever
working *memory.WorkingMemory
extractor memory.Extractor
checkpoint checkpointStore
tracer *trace.Tracer
flow *FlowPublisher
guards *guardrail.Pipeline
personaID string
personaName func(personaID string) string
personaKnobs func(personaID string) (LoopKnobs, bool)
personaAwakening func(personaID string) capacity.Awakening
timeLocation *time.Location
timeZoneName string
maxRounds int
toolExec string
toolParallelism int
hooks ToolLoopHooks
onStream func(model.StreamEvent)
thinking *model.Thinking
reasoningEffort string
thinkingShow *bool
onAudit func(AuditEvent)
onTurnEnd TurnEndHook
maxContextTokens int
compactor Compactor
transformContext func(context.Context, []model.Message, int) []model.Message
imageResolver ImageResolver
log *slog.Logger
}
// New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时
// 选用 DefaultMaxRounds;Log 为 nil 时替换为标记来源
// "runtime/loop" 的丢弃 logger。每个依赖都可以为 nil,并容忍到
// 需要它的那一步(与底层包的运行时 nil 容忍一致);
// 未配置 agent 时 Run 返回 ErrNilAgent。
func New(opts Options) *Loop {
maxRounds := opts.MaxRounds
if maxRounds <= 0 {
maxRounds = DefaultMaxRounds
}
toolExec := opts.ToolExecution
if toolExec == "" {
toolExec = toolExecParallel
}
toolParallelism := opts.ToolParallelism
if toolParallelism <= 0 {
toolParallelism = DefaultToolParallelism
}
compactor := opts.Compactor
if compactor == nil {
compactor = defaultCompactor{}
}
timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log))
return &Loop{
agent: opts.Agent,
agents: opts.Agents,
tools: opts.Tools,
capacity: opts.Capacity,
onCapacity: opts.OnCapacity,
memory: opts.Memory,
retriever: opts.Retriever,
working: opts.WorkingMemory,
extractor: opts.Extractor,
checkpoint: opts.Checkpoint,
tracer: opts.Tracer,
flow: opts.Flow,
guards: opts.Guards,
personaID: opts.PersonaID,
personaName: opts.PersonaName,
personaKnobs: opts.PersonaKnobs,
personaAwakening: opts.PersonaAwakening,
timeLocation: timeLocation,
timeZoneName: timeZoneName,
maxRounds: maxRounds,
toolExec: toolExec,
toolParallelism: toolParallelism,
hooks: opts.Hooks,
onStream: opts.OnStream,
thinking: opts.Thinking,
reasoningEffort: opts.ReasoningEffort,
thinkingShow: opts.ThinkingShowToUser,
onAudit: opts.OnAudit,
onTurnEnd: opts.OnTurnEnd,
maxContextTokens: opts.MaxContextTokens,
compactor: compactor,
transformContext: opts.TransformContext,
imageResolver: opts.Vision,
log: sourcedLog(opts.Log),
}
}
// MaxRounds 返回为该循环配置的工具循环上限。
func (l *Loop) MaxRounds() int { return l.maxRounds }
// Agent 返回注入的默认 agent(Options.Agent)。按角色池
// 经该 turn 的 context 触达,而不是经这个 getter。
func (l *Loop) Agent() *agent.Agent { return l.agent }
// sourcedLog 返回标记模块来源 "runtime/loop" 的 logger,
// 把 nil logger 替换为丢弃 logger,使循环总能安全地记录日志。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, "runtime/loop")
}