Documentation
¶
Overview ¶
deny.go —— 协调者拒绝时下发给模型的正文渲染。
职责:
- 提供拒绝理由正文的唯一渲染点
边界:
- 不决定「送不送」「怎么送」:同帧送达在 claude adapter,带外注入在 agentd
为什么单独抽出来:同一段话有两个出口——claude 经 permDecision.Message 与裁决 同帧送达,其余 executor 经 manager 的带外注入。两处措辞若各写各的,同一件事 在不同 executor 上读起来会像两回事,而这段话正是要让模型改变做法的那段。
fallback.go —— 「回合结束但没有协议 trailer」时的共用裁决。
职责:
- 构造无 trailer 且 git 有新提交时的回合结果(一律 OK=false)
- 构造该结果的失败原因文案,保证判定依据 / git 实况 / 正文尾部三者齐全
边界:
- 不查 git(那是 GitTurnStatus),不判断 hasNew:调用方把结论传进来
- 不处理 !hasNew 与 git 查询失败两条分支:它们仍转 question,由各 adapter 自行处置(各家的空文本守卫与原生提问抑制不同,强行统一会丢掉这些差异)
- 纯函数:不打日志。判定结果由各 adapter 在调用点记录(那里才有 taskID)
为什么这段判定必须共用:四个 adapter 曾各写一份,已经漂移——opencode 的 summary 取回合末 200 字,grok/codex 取一句固定文案,同一个判定给协调者看的 东西完全不同。四份副本各自漂移正是 B74 这类问题的温床。
frames.go —— 结构化回合帧落盘到 frames.jsonl。
职责:
- 把回合内容(正文/思维链/工具调用/工具结果/事件引用/回合边界)编码成 proto.Frame,逐行追加进任务目录的 frames.jsonl
- 维护任务内的帧号 seq 与回合号 turn(进程重启后从文件恢复)
- 对工具入参/输出做头尾截断,并如实记录原始长度
边界:
- 不认识任何具体 executor(与 AppendRender 同一层,是它的姊妹件)
- 不解释帧内容、不做过滤判定:谁该写 reasoning、谁不该,由 adapter 决定
- 不轮转、不清理:frames.jsonl 随任务目录走(done 不删任务目录)
- 不碰 render.log:两路输出彼此独立
为什么每次写都开关文件而不是长持文件句柄:与 AppendRender 完全一致的形态, 省掉 Close 的生命周期(adapter 重建、进程重启、任务归档三条路径都要管), 而帧的写入频率与 AppendRender 同量级,开销可以忽略。
headtail.go —— 帧字段的头尾截断。
职责:把超长的工具入参/输出压成「头 + 省略标记 + 尾」,并报告原始长度 边界:纯函数,不打日志、不做 I/O;不认识帧结构,只处理字符串
为什么头尾都留而不是只留头:报错信息与 stack trace 几乎总在输出**尾部**, 纯头部截断会刚好切掉最有用的那一段——那正是审核者要看的东西。
Package turn 提供 executor 无关的「回合协议」:教模型协议的 prompt 模板、 解析模型输出的 trailer、回合取证与文本工具。
职责:
- RenderPrompt:把实现计划渲染成带回合纪律(提问/收尾/不切分支)的启动 prompt
- ParseTrailer:从回合末文本宽容提取协议 JSON(ask/finish)
- GitTurnStatus:trailer 缺失时以「是否有新提交」作事实裁决
- 文本截断与 render.log 追加等两 adapter 共用的小工具
边界:
- 不认识任何具体 executor(opencode/grok/claude),不发请求、不起进程
- 不做状态机迁移、不写 store:只做纯变换与两个受限 I/O(git 只读、日志追加)
为什么 prompt 模板与 ParseTrailer 必须同包:教模型协议的 prompt 与解析协议的 代码是同一契约的两半,分居两处必然出现「改纪律只改一半」的漂移——两个 executor 的协调者会看到不一样的东西。
Index ¶
- Constants
- func AppendRender(renderLogPath, delta string) error
- func ClampQuestion(text string) string
- func DenyGuidanceText(reason string) string
- func GitTurnStatus(repoPath, startCommit string) (branch, commit string, hasNew bool, err error)
- func HeadTail(s string, head, tail int) (out string, truncated bool, orig int64)
- func NoTrailerFailReason(branch, commit, text string) string
- func NoTrailerResult(sessionID, branch, commit, text string) *executor.Result
- func RenderPrompt(taskID, planContent, disciplineBlock string) (string, error)
- func TailRunes(s string, n int) string
- func TruncateMarked(s string, n int) string
- func TruncateRunes(s string, n int) string
- type FrameWriter
- func (w *FrameWriter) BeginTurn(reason, instructions string) error
- func (w *FrameWriter) EventRef(refSeq int64, eventType string) error
- func (w *FrameWriter) NextPart() string
- func (w *FrameWriter) Reasoning(part, delta string) error
- func (w *FrameWriter) Text(part, delta string) error
- func (w *FrameWriter) ToolCall(part, tool, input string) error
- func (w *FrameWriter) ToolResult(part, status, output string) error
- type Trailer
Constants ¶
const ( // FrameFieldHead 是帧字段保留的头部字节预算。 FrameFieldHead = 4 << 10 // FrameFieldTail 是帧字段保留的尾部字节预算。 FrameFieldTail = 4 << 10 )
const FramesFileName = "frames.jsonl"
FramesFileName 是任务目录内帧文件的固定名字。
const ProtocolRules = `` /* 501-byte string literal not displayed */
ProtocolRules 是回合制协议铁律的原文,供启动 prompt 与 codex 的常驻指令复用。
只保留一份文本是为了避免首回合消息与常驻指令漂移;收尾纪律是 turn.ParseTrailer 的前提,漂移会让完成判定失去协议依据。
const QuestionTextLimit = 8000
QuestionTextLimit 是交给协调者的回合文本上限。兜底分类会把整个回合原文当 question 发出,一个失控的长回合会直接灌进工单行与协调者终端;全文始终在 任务目录的 render.log 里,截断不丢证据。
为什么导出:opencode 的 regression_group_a_test.go 直接断言这个上限, 搬包后它得能从 turn 引到同一个值——两处各写一个 8000 就会悄悄漂移。
Variables ¶
This section is empty.
Functions ¶
func AppendRender ¶
AppendRender 把 delta 追加到 renderLogPath(不存在则创建,权限 0644)。
注意:调用方通常在高频文本增量路径上调用本函数,失败应只 Warn 不中断回合 ——可见性是增强能力,不值得为它挂掉任务。
func ClampQuestion ¶
ClampQuestion 把兜底分类产出的整段回合文本收敛到 QuestionTextLimit, 超出时追加尾缀指明全文去处。
为什么**不能**复用 TruncateMarked:两者的「全文在哪」不同,尾缀因此必须不同。
- TruncateMarked 用于 permission 文本,全文在工单里(B6 契约:工单存全文、 事件截断),协调者 `handoff show` 就能拿到,`…(已截断)` 足够;
- 本函数用于 question 文本,全文**不在工单里**,只在任务目录的 render.log。 不指路 = 协调者拿到半截文本且不知道去哪找全文,证据链断掉。
这段尾缀是逐字从 opencode 现有实现搬来的,opencode 的 regression_group_a_test.go 断言 `strings.Contains(ev.Text, "render.log")`, 改字面量即回归。
func DenyGuidanceText ¶ added in v0.3.0
DenyGuidanceText 渲染「操作被拒 + 理由 + 别再重试」的正文。
参数:reason 为协调者给出的原因,调用方保证已 trim 且非空 返回:可直接下发给模型的正文
注意:末句「不要重复发起同一请求」不是客套——不给这句,模型被拒后最常见的 下一步就是原地再试一次同样的操作,白烧一个回合。
func GitTurnStatus ¶
GitTurnStatus 返回工作区当前分支、HEAD commit,以及相对 startCommit 是否有新提交。
参数:
- repoPath: 任务工作目录(仓库或 worktree 路径)
- startCommit: 回合起点的 HEAD;空串表示起点未知,此时 hasNew 恒为 false
返回:分支名、HEAD hash、是否有新提交、错误
为什么需要它:模型可能不守收尾纪律(不输出 trailer)。此时唯一可信的是 git 实况——有新提交才可能是「干完了」,没有就该交协调者,绝不替模型宣布完成。
func HeadTail ¶ added in v0.3.0
HeadTail 把 s 压成「头 head 字节 + 截断标记 + 尾 tail 字节」。
参数:
- s: 原始字符串
- head: 头部保留的字节预算(按 rune 边界向内收缩,不会切出半个字符)
- tail: 尾部保留的字节预算(同上)
返回:
- out: 结果字符串;未截断时与 s 相同
- truncated: 是否确实发生了截断
- orig: s 的原始字节数(无论是否截断都返回真实值)
注意:head+tail 已能覆盖整串时原样返回——否则会出现「截断后比原文还长」 (多了一个标记)这种荒唐结果。
func NoTrailerFailReason ¶
NoTrailerFailReason 构造「回合未输出协议 trailer」的失败原因文案。
参数:
- branch, commit: GitTurnStatus 查到的 git 实况
- text: 回合正文全文(本函数负责截尾)
返回:一条同时包含判定依据、git 实况、正文尾部的文案
注意:三者缺一,协调者就得回去翻日志——这条要求来自 spec §3.2,不是格式偏好。
func NoTrailerResult ¶
NoTrailerResult 构造「无 trailer 但 git 有新提交」时的回合结果。
参数:
- sessionID: executor 会话标识,供续接与归档
- branch, commit: GitTurnStatus 查到的 git 实况
- text: 回合正文全文
返回:OK=false 的结果,git 实况保留在结构化字段里
为什么是 OK=false 而不是 OK=true:模型没有宣布完成,handoff 不替它宣布。 翻转不给协调者增加任何一次操作——OK 与 !OK 都落到 waiting_review, 而 done 与 continue 在该状态下都合法。变的只是那条事件从「已完成,摘要如下」 (邀请协调者不看 diff 就 done)变成「有新提交,但模型未按纪律宣布完成」 (要求看一眼)。代价为零,收益是不再有假完成。
注意:Branch/CommitHash 必须继续填。翻转若把 git 实况降级成一段自由文本, 协调者与任何下游都无法再结构化地取用它。
func RenderPrompt ¶
RenderPrompt 渲染带回合纪律的启动 prompt。
参数:
- taskID: 任务 ID,写入 prompt 标题行
- planContent: 实现计划全文(dispatch 侧已把 --prompt 附加指令拼在其后), 原样嵌入「实现计划」段,本函数不再二次拼接
- disciplineBlock: 按执行者裁出的执行纪律块;空串表示不注入,产物不含纪律块标记
返回:渲染后的 prompt 全文;模板执行失败时返回错误
func TruncateMarked ¶
TruncateMarked 按 rune 截断到 n,确实截断时追加 executor.TruncationMarker。
为什么必须带标记:上层据此 fail-closed——权限文本含标记说明裁决者看到的是 不完整命令,危险片段可能落在截断之外,黑名单与廉价模型都不可信。
Types ¶
type FrameWriter ¶ added in v0.3.0
type FrameWriter struct {
// contains filtered or unexported fields
}
FrameWriter 把结构化回合帧追加进任务目录的 frames.jsonl。
并发安全:seq 分配与写入在同一把锁内完成,保证「帧号顺序 == 文件字节顺序」。 这不是性能优化的牺牲品——按 offset 续读的客户端依赖这条不变式对齐。
nil 安全:全部方法对 nil 接收者是空操作。构造失败时 adapter 直接持有 nil, 调用点不必到处判空——可见性失败不该在正常路径上撒判空代码。
func NewFrameWriter ¶ added in v0.3.0
func NewFrameWriter(taskDir string, log *slog.Logger) (*FrameWriter, error)
NewFrameWriter 打开(或准备创建)taskDir 下的 frames.jsonl,并恢复 seq/turn。
参数:
- taskDir: 任务目录(agentd 在 DataDir/tasks/<id> 下创建)
- log: 日志入口,可为 nil(测试里常传 nil)
返回:可用的 FrameWriter;只有 taskDir 不可读时才返回错误。
注意:文件不存在是正常起点(seq=0, turn=0),不是错误。
func WriterFor ¶ added in v0.3.0
func WriterFor(taskDir string, log *slog.Logger) (*FrameWriter, error)
WriterFor 返回 taskDir 的**共享** FrameWriter(进程级按解析后路径去重)。
第一次为某个 taskDir 调用时构造并登记;之后同目录的所有调用(adapter 的 r.frames 与事件钩子)拿到同一个实例,保证「一个目录一个 seq 分配者」。
参数:taskDir(任务目录)、log(日志入口,可为 nil)。返回与 NewFrameWriter 相同:可用的 writer;只有 taskDir 不可读时才返回错误。
注意:文件不存在是正常起点(seq=0, turn=0),不是错误。
func (*FrameWriter) BeginTurn ¶ added in v0.3.0
func (w *FrameWriter) BeginTurn(reason, instructions string) error
BeginTurn 开启新回合:turn 自增、part 计数归零,并写一条 turn_start 帧。
reason 只应是 "dispatch"(Adapter.Start)或 "send"(Adapter.Send)。 instructions 是 send 时的指令/应答原文(dispatch 传 "")——写进帧供前端 渲染审核者气泡;日志里只记长度不记原文,避免把长指令刷进日志。
为什么 turn 自增与 turn_start 的写入必须在同一个临界区:SSE reader 是跨回合 长命的,上一回合的尾包可能正与 Send 并发。若先放锁再写 turn_start,并发帧 会带着新 turn 号排在 turn_start **之前**落盘,前端把正文画到回合分隔线上面。
func (*FrameWriter) EventRef ¶ added in v0.3.0
func (w *FrameWriter) EventRef(refSeq int64, eventType string) error
EventRef 写一条控制面事件的引用帧。
只存 seq 与类型名,不复制 payload:payload 的真相在 events 表,复制一份 就有了两份会漂移的真相。
func (*FrameWriter) NextPart ¶ added in v0.3.0
func (w *FrameWriter) NextPart() string
NextPart 分配一个回合内唯一的 part 标识(p01、p02…)。
上游流自带 part / block / item 标识时**优先沿用上游的**,本方法只服务 那些没有标识的流——两个来源混用不会撞车,因为 p 前缀是本方法独有的。
func (*FrameWriter) Reasoning ¶ added in v0.3.0
func (w *FrameWriter) Reasoning(part, delta string) error
Reasoning 写一条思维链增量帧。
注意:本方法只负责落盘。「思维链不能进回合正文」是 adapter 的判定, 不在这里——本包不认识回合正文。
func (*FrameWriter) Text ¶ added in v0.3.0
func (w *FrameWriter) Text(part, delta string) error
Text 写一条模型正文增量帧。
func (*FrameWriter) ToolCall ¶ added in v0.3.0
func (w *FrameWriter) ToolCall(part, tool, input string) error
ToolCall 写一条工具调用帧;input 超长时头尾截断。
func (*FrameWriter) ToolResult ¶ added in v0.3.0
func (w *FrameWriter) ToolResult(part, status, output string) error
ToolResult 写一条工具结果帧;output 超长时头尾截断。
type Trailer ¶
type Trailer struct {
Question string // ask 类型:需要人决策的问题
Branch string // finish 类型:提交所在分支
Commit string // finish 类型:提交 hash
Summary string // finish 类型:50 字内摘要
}
Trailer 是从回合末消息文本提取出的协议数据。
func ParseTrailer ¶
ParseTrailer 从回合末文本宽容提取协议 JSON(ask/finish)。
提取分两级:
- 主路径:最后一个非空行,从该行第一个 { 起解码一个 JSON 值(容忍前缀 与后缀正文)
- 回退:主路径无果时,取最后一个「以 { 开头」的行按整行解码(旧规则)
返回:
- kind: "ask"(附 Question)| "finish"(附 Branch/Commit/Summary)| "none"
- t: 解析出的协议数据;kind 为 "none" 时为零值
注意:
- 放宽只作用于最后一个非空行:正文中间复述协议 JSON 不会被误当成结论
- 找不到或 JSON 损坏时返回 "none",绝不 panic(模型输出不可信,防御在边界上做)
- 纯函数:不打日志,由调用方记录提取结果