- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
296 lines
16 KiB
Go
296 lines
16 KiB
Go
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
|
||
}
|