Files
Cyrene_v2/internal/runtime/loop/options.go
T
AskaEth 1347a77d33 feat(loop): wire speak tool into the loop (M57)
- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured
  failure codes, persona/emotion stamped on the synthesis ctx, audio persisted
  through the store seam, structured result for the model.
- voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc
  adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink),
  and captureVoice (success beats failure, last one wins).
- options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points.
- request.go: advertise speak on both toolSchemas return paths.
- tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne /
  emitToolExecution / spanStatusForToolPhase / invokeParallel into their own
  file (tools.go was at the 500-line ceiling) and let speak run without a
  dispatcher.
- run.go: keep the voice outcome across tool batches (a later batch without
  speak must not erase it).

Known, deliberate: speak bypasses the capacity allow-set and skill-round
narrowing (it belongs to no built-in toolset), so it is also advertised in
quiet/awake; execution still passes the tool security gate, where speak is not
registered and non-owner actors are denied fail-closed.
2026-10-27 21:22:14 +08:00

305 lines
17 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"
"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
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
VoiceSynth VoiceSynth
VoiceStore VoiceStore
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
SpeakBudget func(runes int) int
// 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
}