Files
Cyrene_v2/internal/runtime/loop/options.go
T
AskaEth 7e7f53bb41
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。

- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
  渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
  `Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
  真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
  attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
  (顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
  `scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
  `ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
  链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
  优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
  调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。

验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。

⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
2026-10-04 11:30:39 +08:00

254 lines
13 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)
// 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
// 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
}