package loop import ( "context" "log/slog" "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" ) // Options 配置一个 Loop。所有字段都是可选的,nil 会一直被容忍到 // 真正需要该字段的那一步:Run 必须有 agent(否则 // Run 返回 ErrNilAgent);缺少工具 dispatcher 会使被请求的 // 调用以错误形式回灌;缺少 retriever/persona 会跳过记忆 // 注入;缺少 extractor/store 会跳过记忆钩子;缺少 // checkpoint 会跳过持久化;缺少 tracer 会跳过追踪。 type Options struct { // Agent 是循环的执行单元:一个指令字符串,加上后端 // adapter、可选工具 dispatcher 与可选护栏。它是 // 默认 agent:角色在 Agents 中没有条目的每个 turn(或 // Agents 未设置时的每个 turn)都在它上面运行, // 因此其指令应是全局默认角色的指令。 Agent *agent.Agent // Agents 是按角色 agent 池(design/22 §4.2 方案 A):persona id → 承载 // 该角色系统提示词的 agent。循环每一步都为该 // turn 解析出的角色挑选条目,因此切换会话角色即切换 // 提示词,而无需重建循环(Agent 的指令不可变, // 因此每角色一个 agent 是维持该不变式的方式)。所有条目通常 // 共用同一个 dispatcher/护栏管线/adapter —— 只有指令不同。 // 条目缺失或为 nil、角色未知、池为空,都会回退到 // Agent。为 nil 时保持既有的单角色行为。 Agents map[string]*agent.Agent // Tools 是用于执行模型所请求调用的工具 dispatcher。为 // nil 时循环回退到 agent 自身的 dispatcher(若有)。 Tools *tool.Dispatcher // Capacity 非 nil 且启用时,把每轮广告给模型的工具 schema // 过滤为当前 capacity 状态的允许集 // (design/14 唤醒式能力渐进 §4):循环只向 // 模型暴露允许集 —— 是一个视图,不是硬边界(Invoke 不被拦截, // 因此紧急调用仍可用)。为 nil(默认)时保持既有 // 行为:每个已注册工具都被广告。 Capacity *capacity.Decider // OnCapacity 非 nil 时接收循环在判定一轮期间观察到的 // capacity_awaken 迁移事件(design/14 §4),使调用方能把 // 「能力点亮」的进展浮出到前端。为 nil = 空操作。 OnCapacity func(capacity.Event) // OnAudit 非 nil 时,循环每次在受限(群聊/陌生人)环境中 // 拒绝工具调用都会收到一个 AuditEvent(design/11 §3 §8), // 使该拒绝进入审计轨迹,且前端可以渲染 // 「xx 工具在群聊被拒」的小字。为 nil 时该拒绝仍会 // 作为软 not_allowed 结果回灌给模型,只是不记录到外部 sink。 OnAudit func(AuditEvent) // OnTurnEnd 非 nil 时,每个已落定的 turn(返回了 Result 的 // Run)调用一次,携带该 turn 的用户/assistant 文本以及循环 // 绑定的角色,使调用方能推进紧随人格的状态 —— // design/01 §2 的情绪机与好感度曲线。其返回值 // 记录在 Result.Mood 上并带到持久化的 assistant 消息。 // 为 nil = 循环不上报任何东西,Result.Mood 保持为空。 OnTurnEnd TurnEndHook // Memory 是用于持久化提取出的条目的长期记忆 store。 Memory *memory.Store // Retriever 把相关记忆注入上下文,每轮重新评估 //(见 assembleContext),使检索跟上最新对话。 Retriever *memory.Retriever // WorkingMemory 是每会话滑动窗口,供 // orchestrator 查看;循环不改写它。 WorkingMemory *memory.WorkingMemory // Extractor 在每一步之后导出要持久化的记忆条目。 Extractor memory.Extractor // Checkpoint 在每一步持久化循环状态的持久快照,并在之后 // 同一 sessionID 的 Run 中据此恢复被中断的 run //(design §1 durable execution)。*persist.Store 满足这个窄 // checkpointStore 接口;为 nil 时同时关闭持久化与恢复。 Checkpoint checkpointStore // Tracer 为每一步、每次 LLM 调用与每次工具调用发出结构化 span。 Tracer *trace.Tracer // Flow 非 nil 时,把 run 的 trace span 累积成每会话 // flow 图,并在每次 span 迁移时发布 ws.graph_update(design/19 // §1)。为 nil 时关闭实时 flow 可视化。 Flow *FlowPublisher // Guards 在流式路径上校验请求/响应载荷 —— 该路径 // 绕过了 RunStep。非流式路径上由 agent 在 RunStep 内部 // 运行自己的护栏。 Guards *guardrail.Pipeline // PersonaID 是循环配置的角色:没有显式 SessionEnv.PersonaID 的 // 每个 turn 都作为它运行(design/22 §2 裁决规则 4)。它 // 界定记忆检索与提取、系统提示词回退以及 // 情绪钩子的作用域。为空则跳过记忆注入/提取。 PersonaID string // PersonaName 非 nil 时,为上下文的发言人归属把角色 id // 解析为其显示名(design/22 §7-5):角色声明与 // 边界说明把发言角色称作 "「昔涟」(xilian)" 而不是裸 id, // 使模型无需自备查表就能跟进「谁说的」。未知角色、 // 空名或 nil 解析器都回退到 id —— 归属绝不 // 因为名字无法解析而消失。 PersonaName func(personaID string) string // PersonaKnobs 非 nil 时,在每次请求构建开始时为角色 // 解析推理旋钮(design/22 §4.2),使绑定到角色 Y 的会话 // 使用 Y 的思考预算 / 强度 / 广播开关,而不是默认 // 角色的。返回的 ok=false 表示「角色未知」并保持循环 // 配置的默认值(Options.Thinking / ReasoningEffort / ThinkingShowToUser)。 // 经角色 id 解析 —— 而非由调用方按 turn 传值 —— // 正是让所有路径(gateway、channelbridge、backthink、子代理)都由单一 // 输入落到正确角色旋钮的原因。为 nil 时每个 turn 都保持配置的 // 默认值,即多角色之前的行为。 PersonaKnobs func(personaID string) (LoopKnobs, bool) // PersonaAwakening 非 nil 时,为该 turn 的角色解析唤醒档位 // (design/14 §3,裁决 14.4.3),使绑定到角色 Y 的会话按 Y 档案里的 // `awakening` 块判定能力面 —— 而不是只吃默认角色的全局兜底 // (Capacity 的 Config.Awakening)。没有它,别的角色写了档位也会 // 「被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效的配置)。 // 返回零值 = 该角色无覆盖,回落到 Config.Awakening 与全局阈值(档案里 // 没写该块就是没有)。与 PersonaKnobs 同样经角色 id 解析,因此所有路径 // 都由同一个输入落到正确角色的档位。为 nil 时每个 turn 都不传逐调用覆盖, // 即接线前的行为。 PersonaAwakening func(personaID string) capacity.Awakening // PersonaKnowledgeFor 非 nil 时,每轮组装模型可见上下文时被调用一次, // 传入**该轮的会话环境**(SessionEnv),返回要作为前导系统块注入的 // 「人格知识」正文(本接线:owner.yaml 的主人身份说明书)。 // // 为什么必须按轮次 + 按会话解析,而不是静态写进 system 指令(agent 的 // 指令在装配期定死):静态注入会让这段知识进入该角色的**每一个**会话, // 包括群聊与陌生人私聊,并随请求发给模型供应商 —— 那正是 design/11 §0 // 红线禁止的(群聊 / 陌生人:不入主会话上下文、不泄露主人敏感信息)。 // 放在这里,调用方能按会话环境决定注入与否;返回空串(非主人会话 / // 开关关闭 / 未配置)即**完全不出现**,模型可见上下文与接线前逐字节一致。 // // 注入的块只是知识:它不参与任何身份或权限判定(那些只看渠道层 actor // 元数据 + 白名单),因此这里返回什么都不会改变谁能做什么。 PersonaKnowledgeFor func(env SessionEnv) string // Timezone 是用于渲染每轮「当前时间」系统消息的 IANA // 时区(如 "Asia/Shanghai")。为空则完全关闭时间注入 // (向后兼容);加载失败的名字同样关闭它。 Timezone string // MaxRounds 是工具循环上限。非正时选用 // DefaultMaxRounds(8)。 MaxRounds int // OnStream 非 nil 时把循环切换到流式路径:直接驱动模型 // adapter 的 ChatStream,每个结构化流事件都转发到 // 该回调(model.StreamEvent),使前端能独立渲染 // 正文文本、工具调用以及(最终)推理分段。 OnStream func(model.StreamEvent) // Thinking 携带注入循环构建的每个模型请求的推理旋钮 //(预算 / 显式开关),在没有按角色取值覆盖它时生效。 // nil 表示「不干预」,即既有行为。它是 // 角色没有解析出旋钮的 turn 的默认值(Options.PersonaKnobs)。 Thinking *model.Thinking // ReasoningEffort 是注入每个模型请求的兼容 OpenAI 的 // 强度旋钮。为空表示「不干预」。 ReasoningEffort string // ThinkingShowToUser 控制循环广播的思考帧:nil(默认) // 保持既有行为并推送它们;显式 false // 把 thinking_start/delta/end 挡在客户端流之外,而文本与工具 // 帧不受影响。这些帧仍被产出并仍抵达 // 模型/transcript 路径 —— 只有 UI 广播被门控,因此展示与 // 持久化保持解耦(design/04 §3.2)。 ThinkingShowToUser *bool // ToolExecution 选择一批工具调用如何运行:"parallel"( // 默认)或 "sequential"。为空表示并行。 ToolExecution string // ToolParallelism 限制 ToolExecution 为 "parallel" 时并发 // 运行的工调用数。非正时选用 DefaultToolParallelism(4)。 ToolParallelism int // Hooks 安装可选的按批次/按调用的工具循环钩子。每个字段都 // 容忍 nil;这些保留给「敏感操作代审」(design/11)以及 // 未来的异步工具路径。零值 Hooks 为空操作。 Hooks ToolLoopHooks // MaxContextTokens 是触发溢出压缩的上下文预算。 // 非正(零值)关闭压缩,保持循环 // 此前的行为。设为正值(如 // DefaultMaxContextTokens)则启用压缩:循环的估算 // 上下文超出预算时,它把更早的轮次摘要成一条前导摘要 // 消息,同时保持近期尾部(含尾部工具结果) // 完整(design §1.3)。容忍 nil:不设置它不改变任何行为。 MaxContextTokens int // Compactor 压缩超预算的对话。nil 选用内置的 // 结构化 compactor(不调模型);非 nil 值让调用方注入 // LLM 支持的或自定义的摘要器。它仅在 // MaxContextTokens 为正时被调用。 Compactor Compactor // Vision 非 nil 时,在 Run 开始时改写携带图像的用户消息, // 使主模型能对图像进行推理(design/06 §0)。具体 // resolver(vision.MessageResolver)通过 vision Router 决定 // 是保留图像给支持视觉的主模型,还是折叠为 // vision/OCR 工具产出的文字描述("先看图再对话")。为 nil 时 // 图像内容原样保留,直接透传给支持视觉的主模型。 Vision ImageResolver // DocumentResolver 非 nil 时,在**每步装配模型可见上下文**时把文档部件 // 确定性替换为抽取所得的正文或「仅清单」条目(design/24 §2.3-①、§4)。 // 文档部件因此永不抵达编码器(编码器那侧对文档 fail-loud);解析失败只 // 降级为诚实标记 + status=unreadable,绝不让回合失败(§4.4)。 // // 为 nil 时循环仍会把文档部件替换为「仅清单」块(清单恒在,§4.2-3), // 只是没有任何正文被注入 —— 因此即使忘了接线,也不会出现「文档部件抵达 // 编码器」这种 400,更不会出现空上下文。 DocumentResolver DocumentResolver // DocumentMaxInjectChars 是**单份附件**注入模型可见上下文的字符上限 // (切片 T1 §7.2)。0 选用 defaultDocumentInjectChars(64K 字符);组合根按 // 推导出的上下文预算给出实际值(DocumentInjectCharsFor),使大窗口部署真的 // 能吃到大附件。 // // 两个后果:① 装配期按它截取注入正文,清单行的续读游标因此**真的是**继续读 // 的起点(sidecar 存的是抽取全文);② 压缩的 token 估算按它给文档部件封顶 // (注入多少就按多少计入),文档回合不会被估成「免费」。 DocumentMaxInjectChars int // TransformContext 非 nil 时,在每个模型步之前立即被调用, // 传入组装好的消息列表(记忆注入与任何压缩 // 之后)以及当前轮次号(从 1 开始)。它可以投影、过滤 // 或重排模型所见的消息。它是循环中与 // OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。 TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message // Log 是基础 logger。为 nil 时替换为标记来源 // "runtime/loop" 的丢弃 logger。 Log *slog.Logger } // toolWhitelistKey 是每 Run 工具白名单的 context key(design/05 §7 // 裁决 02.4.1)。编排层在驱动子代理 Run 之前挂载它。 type toolWhitelistKey struct{} // WithToolWhitelist 把该次运行的工具白名单挂到 ctx 上(design/05 §7): // nil/空名单原样返回 ctx,即「缺省全开」——继承主 agent 的全量工具集, // 与挂载前的行为逐字节一致。名单会被复制,调用方后续修改不影响已挂载值。 func WithToolWhitelist(ctx context.Context, names []string) context.Context { if ctx == nil || len(names) == 0 { return ctx } return context.WithValue(ctx, toolWhitelistKey{}, append([]string(nil), names...)) } // ToolWhitelistFrom 返回 WithToolWhitelist 挂到 ctx 上的白名单; // 未挂载或为空返回 nil,语义为「全开」。它是 WithToolWhitelist 的读侧, // 供拿到 context 的调用方(子代理 runner、测试)观察本次运行的能力面。 func ToolWhitelistFrom(ctx context.Context) []string { if ctx == nil { return nil } names, _ := ctx.Value(toolWhitelistKey{}).([]string) return names } // toolWhitelistAllows 报告 tool 是否在名单内。 func toolWhitelistAllows(names []string, tool string) bool { for _, n := range names { if n == tool { return true } } return false } // whitelistVetoReason 是白名单外调用被否决后回灌给模型的软提示, // 与 design/11 §3 安全门的 not_allowed 文案区分开:这是子代理的 // 能力面收缩(design/05 §7),不是会话身份限制。 func whitelistVetoReason(name string) string { return "not_allowed: tool " + name + " is not in this sub-agent's tool whitelist" } // WithToolWhitelist 返回工具面被限定为 names 的 Loop 派生副本(design/05 // §7 裁决 02.4.1:显式列出 = 只放行所列)。它是浅拷贝:除 hooks 外的所有 // 依赖(agent、dispatcher、记忆、追踪)与原模板共享,因此并发 Run 契约 // 与原 Loop 一致;唯一的差异是 CallAdmit 钩子——白名单外的调用被否决为 // 携带原因的跳过结果回灌给模型(软结果,绝不 panic),白名单内的调用 // 继续交给模板上已配置的钩子链(先跑原钩子,被原钩子拦截的保持拦截)。 // 空/nil 名单返回原模板(全开)。编排层按子代理逐个派生,主会话的 // Loop 不受影响。 func (l *Loop) WithToolWhitelist(names []string) *Loop { if l == nil || len(names) == 0 { return l } cp := *l prev := l.hooks cp.hooks = ToolLoopHooks{ BatchAdmit: prev.BatchAdmit, AfterCall: prev.AfterCall, AfterOutcome: prev.AfterOutcome, CallAdmit: func(ctx context.Context, call agent.ToolCall) *ToolCallVeto { if prev.CallAdmit != nil { if v := prev.CallAdmit(ctx, call); v != nil && v.Block { return v } } if !toolWhitelistAllows(names, call.Name) { return &ToolCallVeto{Block: true, Reason: whitelistVetoReason(call.Name)} } return nil }, } return &cp }