// 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/history" "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 // Documents 是本回合**装配期解析到的文档结论**(design/24 §4.4、§6.1): // 按服务端存储名去重、保留首次出现顺序,字段形状即 history.DocumentRef // (与落库/DTO 同一个结构体)。调用方据此把 status/chars/truncated 落进 // 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 / // 读不到」—— 没有它,抽取失败对客户端就是不可见的。 Documents []history.DocumentRef // docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次 // Result,绝不跨 Run 共享。 docSeen map[string]int } // 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 // personaKnowledgeFor 是按会话解析「人格知识」前导块的解析器 // (Options.PersonaKnowledgeFor);nil 时每轮都不注入任何块。 personaKnowledgeFor func(SessionEnv) string 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 documentResolver DocumentResolver documentInjectChars int 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{} } docInjectChars := opts.DocumentMaxInjectChars if docInjectChars <= 0 { docInjectChars = defaultDocumentInjectChars } if d, ok := compactor.(defaultCompactor); ok { // 内置 compactor 的文档部件估值必须与注入预算同口径:否则注入 683K 字符的 // 附件在压缩估算里只按 64K 计,压缩永远迟一步(切片 T1 §7.2)。 d.documentInjectChars = docInjectChars compactor = d } 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, personaKnowledgeFor: opts.PersonaKnowledgeFor, 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, documentResolver: opts.DocumentResolver, documentInjectChars: docInjectChars, 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") }