执行方案阶段 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 旋钮管道)。
254 lines
13 KiB
Go
254 lines
13 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)
|
||
// 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
|
||
}
|