@dsh-external/dsh-code-pipeline
DSH bundle plugin:为 code-pipeline agent 预设(PTC Code Mode 流水线)动态注入
阶段子代理工具,并允许在设置页配置各阶段子代理使用的模型。
解决的问题
code-pipeline 预设原本把 3 个 dsh-tool-subagent 行(subagent_plan /
subagent_impl / subagent_review)以及每个阶段的 provider/model/persona/toolFilter
静态钉死在 agent.cordis.yml 里。改模型 = 改 YAML = 重启会话。
本插件把「注册哪些工具」与「用哪个模型」解耦:
- 工具注册(静态):监听
agent/created,对组合了 code-pipeline 预设的
ROOT 代理,在其自身作用域(agent.ctx)注册 3 个阶段工具。子代理(阶段代理)
不注入——它们的 persona / 只读工具面由父代理通过 subagents.start 请求传入。
- 模型选择(动态):每个阶段工具每次调用时读取设置命名空间
code-pipeline($DSH_HOME/settings.yaml 的 code-pipeline 节),即时生效。
- 设置页(浏览器):Settings → 代码流水线,每阶段配置
enabled /
provider / model / reasoningEffort / maxConcurrency,provider/模型列表来自
GET /dsh-code-pipeline/options(不可用时相应字段禁用并提示,不允许手输);
每阶段卡片还显示「当前运行 N / 上限 M / 已创建 K」与已创建子代理清单,数据来自
GET /dsh-code-pipeline/status(每 5 秒轮询)。
角色边界(硬约束)
subagent_plan 只做规划:不审查、不审计、不审批代码;
subagent_review 只做审查:不做规划、不做设计;
- 两个阶段的 persona 都明确写出该边界,并在收到另一类任务时要求子代理声明自己的
角色并拒绝执行;工具的 description 也标注了「PLANNING ONLY / REVIEWING ONLY」,
防止主代理把审查任务误派给 plan、把规划任务误派给 review。
- 阶段不可用 = 结束任务:阶段工具调用报错(阶段被禁用/未配置、provider/凭据
缺失、provider 未注册、子代理启动失败)时,工具错误信息携带明确的
「STOP and report to the user」指令,预设 persona 的 invariants 也硬性规定
主代理不得自己接手任务(不代做实现/规划/审查、不换路由、不找替身),
而是告知用户原因并等待决定。
例外(不算阶段不可用,不得终止任务):插件自己的运行并发闸门与宿主的
「同时存活子代理」容量上限——运行并发超限、宿主
ACTIVATION_LIMIT_REACHED /
subagent/delivery-unavailable(dsh 0.1.6-alpha.2 起)。这些是瞬时拒绝,阶段本身
健康;错误文案会明确写「NOT stage unavailability」并给出「等名额释放后在后续步骤重试/派发
新子代理」的出路(见「每阶段并发上限与并行派发」)。
中途改需求:pipeline_followup(插入,不排队)
阶段子代理已经派发并开始干活后,用户改了需求 → 主代理用 send_message 只能
靠模型自己找到子代理 id;pipeline_followup 是流水线自己的"插话"工具:
- 参数
child:latest(本代理最近派发的阶段子代理)| 阶段键
plan / impl / review(含中文别名 规划/计划/实现/评审/审查)| 完整
subagentId(session-...,也支持唯一前缀);
- 参数
message:要插入的需求变更文本(完整、自包含——子代理没有本对话上下文);
- 参数
files(必填):本轮要读的路径,一行一个,与三个阶段工具的 files 共用同一套契约
与渲染。真清单会渲染成「先用一个 run_code 程序把每个路径读完」的硬指令;单个 -
显式声明「本轮没有候选清单」(子代理自己做侦察)。省略 ⇒ 拒绝并点名 files。为什么必填:
复用路径曾经只发一段自由文本,一个被复用去干另一个 workstream 的实现子代理于是花了
7 步 / 15 次 read 去重新发现一份主代理手里已有的清单——files 在派发路径上修过一次
(0.3.5/0.3.6),这是把它补回复用这条路径;
- 参数
compact(可选,默认 false):在投递之前压缩该子代理自己的历史
(走该预设 realm 私有的 compaction 服务——agentPresets.serviceFor(agent, "compaction"),
超时 10 分钟)。只应在「复用会把干扰带进来」时用(四条可判定判据见预设的
「Reuse the stage subagents you already have」小节);代价是抹掉它超出摘要的历史记忆并
放弃前缀缓存。压缩失败 ⇒ 什么都不投递(错误文案明说 inbox 未变,可改用 compact: false
投递);compactNow 返回「没有可压区间」不算失败,投递照常。冷子代理(重启后本进程未唤醒)
无法压缩,唤醒也救不了——子代理答完就回到冷态:两条宿主约束结构性互斥(压缩需要活着的
agent,而活着的 agent 要么正在回合中(compactNow 抛 busy),要么刚被一次投递冷唤醒、inbox
已经满了)。实测本安装 16 次压缩(11 次手动 /compact + 5 次自动)没有一次属于阶段子代理,
所以复用时 compact: true 不可用:同一工作流的真正续轮就接受干扰;如果是新的独立工作流,直接给它派发新子代理(0.4.0 起没有创建名额上限),并说明选了哪一个;
- 行为:调用宿主原生
subagents.sendMessage(alpha.4 语义 = steer/插入)——
运行中的子代理在下一个模型步骤就看到该消息(不排进队列等当前回合结束);
子代理已空闲/已结束时会唤醒开新回合处理;
- 投递方式可配置(设置 → 代码流水线 →「子代理消息投递」):默认固定插入
(
sendMessage/steer,运行中最近步骤即收到);切到固定排队后走原生
human-queue 通道(subagents.prompt,当前回合结束后按顺序处理);
- 资格:与其他阶段工具一致,只对组合了
code-pipeline 预设的 ROOT 代理注入;
子代理身份校验由宿主 lineage 授权(非本代理直属子代理会被拒绝并报错)。
- 它同时是多轮评审复用的通道:评审第 2 轮起用
pipeline_followup 续用同一个评审
子代理(见下节「多轮评审复用」)——同一个投递通道,child 传该评审子代理的 subagentId。
多轮评审复用(续用同一个评审子代理)
多轮评审(plan → impl ↔ review 里的 review 轮次)不再每轮新开一个评审子代理:第一轮
之后的所有轮次通过已有的 pipeline_followup 续用第一轮那个评审子代理。
这是「同一任务只创建一次」这条统一协议在评审阶段的具体形态:预设的
「Reuse the stage subagents you already have (SAME child, later rounds)」把
plan / impl / review 三阶段都写进同一条协议(同一 workstream/任务第一次用阶段工具派发,之后每一轮都用
pipeline_followup 发给已有的那个子代理;而一个新的独立 workstream/任务一律新派自己的子代理——
0.4.0 起没有创建总量上限,只有「同时运行数」上限会让新派发等一个名额)。
没有新增任何设置项(复用既有 maxConcurrency,现在它是纯运行并发上限)。
- 为什么:子代理从空会话起步——每轮新开
subagent_review 都要重新吃一遍「计划 +
完整 diff + 历史结论」,而且新会话没有前缀缓存可命中;续用同一个子代理时,这些都在它的
会话里(前缀命中缓存),新一轮只需投递增量物料。
- 怎么做(persona 的硬性协议,见预设「Repeat review rounds reuse the SAME reviewer」):
- 第 1 轮照常
subagent_review(计划 + 实现摘要 + 完整 diff),并记住它返回的
subagentId —— 那个子代理就是本任务的评审者(写进 todo_write 流程,防上下文压缩丢失);
- 第 2 轮起改用
pipeline_followup:child 传该 subagentId(本会话只有一个评审子代理时
可用 child: "review"),message 里写清「这是第几轮 + 完整的新 diff + 每条编号问题的
处理说明 + 回复契约(APPROVED / CHANGES REQUIRED: + 编号问题)」;计划与历轮 diff
不要再传(评审子代理自己还留着,重复传正是复用要省掉的开销);
- 结论仍以「完成通知」形式回到本会话,与首轮完全一致——主代理侧流程不变。
- 边界:
- 该评审子代理还在跑(结论未到)时不要续发新回合——先等完成通知(与"不要重复派发
进行中的阶段"同一条规则);
- 新任务用新的
subagent_review,绝不复用别的任务的评审子代理;
- 多个独立目标并行评审时,每个目标一个评审子代理,按各自的
subagentId 续用;
pipeline_followup 报「没有匹配的阶段子代理」时(例如 dsh 重启后插件进程内的派发台账
被清空),退回一次带完整物料(含计划)的 subagent_review 即可——这是回退,不是
「阶段不可用」,不要因此终止任务;但若该阶段已达运行上限(见下节),这次回退也要等一个名额释放——此时先等完成通知、或在后续步骤重试;
- 宿主侧依据:
pipeline_followup 走 subagents.sendMessage,空闲/已 settle 的子代理会被
唤醒成新一轮(宿主 steer 语义:idle driver starts a turn;queue 模式同理排一个新回合),
且再次 settle 时父会话照常收到完成通知。
评审物料只允许写系统临时目录($env:TEMP)
主代理为了把大的变更集从 subagent_review(diff=…) 参数里卸下来,可能用
Out-File 把 diff 写到项目根目录(如 .review_*.diff,已多次实测发生)。
三个阶段工具的 description 现带绝对物料卫生纪律:工作区任何位置
(根目录 / 子目录 / .pipeline-tmp/)都不允许创建任何物料/中间文件
(*.diff、.review_*、变更集文件等);确需落盘时只允许写入
$env:TEMP\dsh-code-pipeline,且必须在本次调用返回前删除。
注意:子代理自己的输入框仍会排队(宿主 subagents.prompt 硬编码
mode: 'continuable',且输入栏对子代理会话关闭了 steering)——这是宿主行为,
插件侧无法改变;改需求请走主对话 → 代理调用 pipeline_followup。
安装
方式一:一行命令安装(推荐,GitHub 分发)
dsh plugin --profile web add github:ErrorLst/dsh-code-pipeline
- 该命令在 web profile 下执行
pnpm add github:ErrorLst/dsh-code-pipeline;安装成功后
reconcile 会读取包内 dsh.bundle.patch 声明,自动把
@dsh-external/dsh-code-pipeline 追加进 dsh.profile.bundles(无需手动登记)。
- 重启
dsh web 即挂载生效(bundle 层在启动时组合,客户端 bundle 在启动时扫描)。
- 预设自动安装 / 自动同步:
$DSH_HOME/.agent-presets/code-pipeline 缺失时从包内
preset/code-pipeline/ 拷贝;已存在时,只要包内预设的内容变了(即插件升级)就自动覆盖它,
覆盖前留一份 temp 备份。两次升级之间你对已安装副本的本地改动会被保留(见「预设文件」)。
方式二:本地开发安装
dsh plugin --profile web add link:<本仓库绝对路径>
# 或:dsh plugin --profile web add <本仓库绝对路径>
- 与方式一相同:
dsh plugin add 自动完成依赖安装与 bundle 登记,无需手动编辑
dsh.profile.bundles;默认 profile 名为 web,其他用
dsh plugin --profile <name> add ...。
- 启动后预设同样自动安装;本仓库以
link: 挂载,改 lib/ 后重启 dsh 生效,
客户端改动刷新页面即可。
卸载
dsh plugin --profile web remove @dsh-external/dsh-code-pipeline
从依赖与 bundle 层移除;预设目录($DSH_HOME/.agent-presets/code-pipeline)不会被
删除,需要时手动删除即可。
预设文件(preset/)
code-pipeline 预设的组合内容(主代理 persona 与流水线协议、Code Mode 展示、
禁用通用 subagent/subagent_fork、delegation 组等)随本仓库在
preset/code-pipeline/ 目录维护(agent.cordis.yml + preset.yml)。
-
自动安装 / 自动同步(0.4.3 起):插件启动时若发现 $DSH_HOME/.agent-presets/code-pipeline
缺失,会从包内 preset/code-pipeline/ 自动拷贝;若已存在,则比较安装目录里 .dsh-bundle.json
记录的「上次同步指纹」与包内预设的当前指纹(sha256,覆盖 agent.cordis.yml + preset.yml 等全部文件):
- 指纹一致 → 不动用户文件(两次插件升级之间你对已安装副本的本地改动得以保留);
- 指纹不一致(插件升级,或首次没有记录)→ 自动用包内预设覆盖已安装副本、写回新指纹,
并把覆盖前的副本备份到
$TMPDIR/dsh-code-pipeline-preset-backup/<preset>-<version>-<ts>/。
覆盖后设置页的「压缩触发比例」会照常对账写回,不需要手动重设。
-
手动补装 / 手动回退(自动同步失败,或想拿回某次覆盖前的版本时):
Copy-Item -Recurse -Force "$PSScriptRoot\preset\code-pipeline" "$env:DSH_HOME\.agent-presets\code-pipeline"
($env:DSH_HOME 默认 C:\Users\<user>\.dsh;覆盖前的备份路径在同一条启动日志里。)
-
生效时机:新会话/新子代理生效(dsh 的 standing 挂载按组合文件的变化时间戳
重建);已经在运行的会话不会自动切换——需要换新预设请开新会话。
-
插件与预设的版本对应:插件保证与仓库内 preset/ 副本一致的那一版预设协同工作,
并在包内预设变化时自动同步(见上)。启动日志里若出现
installed preset ... differs from the bundled copy,说明你在已安装副本上做过的本地改动
与包内不同(设置页写入的压缩比例不计入这个比较)——注意:下一次插件升级会覆盖这些改动。
预设要求
- 预设中不得再包含静态的
stage-plan / stage-impl / stage-review 行
(由插件注入,避免重名/双重定义)。
- 其余组成(persona、Code Mode 展示、只读过滤语义、禁用通用
subagent/subagent_fork、禁用 tool-workflow、delegation 组)保持仓库
preset/ 副本的样子。
- 与上游内置
ptc 同步的宿主行不要删:例如 0.1.5-alpha.2 起新增的
- id: present(@deepseek-ai/dsh-tool-present)——阶段子代理写的文件要靠
它由主代理登记为「本轮交付物」;删掉后模型侧再无交付声明工具(persona 里的
交付要求会指向一个不存在的工具)。
- 仓库内的
preset/code-pipeline/ 就是唯一维护源:对预设的任何修改请先改这里,
再同步拷贝到 $DSH_HOME\.agent-presets\code-pipeline。
人工闸门(plan 之后)
build flow 的闸门是对话内自然闸门,不用 ask_user_question 弹卡片(卡片不支持
Markdown 渲染,长计划会挤压展示):
- plan 阶段返回后,主代理把完整计划以正常 Markdown 回复直接呈现在对话中,
然后结束回合等待用户输入;
- 用户下一条消息即闸门答复:批准(approve / 批准 / 同意 / ok / 可以 / 开始 /
没问题 等,且无新增要求)→ 进入实现阶段;其他任何内容视为修订反馈 → 并入计划
重新呈现;修订没有次数上限——每一轮修订都只发生在你给出新方向之后,循环由你控制,直到你批准或明确叫停。
运行规则以 preset/code-pipeline/agent.cordis.yml 的 pipeline protocol 为准。
派发消息整体落盘(单个临时文件)
主代理调用阶段工具时,插件把完整派发消息——prompt/task 与该阶段所有物料字段
(context / plan / constraints / implementationSummary / diff / focus)合并后的
全文——整体写入一个临时文件(os.tmpdir()/dsh-code-pipeline/ 下,文件名带阶段前缀和
UUID),子代理提示中仅保留
<dispatch message (N lines, M chars)> written to temp file: <path> — read the WHOLE file with the read tool
引用。子代理只需 read 一次即可拿到全部消息:不会因长 diff 在派发/模型上下文中被截断,
也避免了逐字段多文件的读取负担。
- 默认全部落盘(
config.spillAllFields: true,设置页可关);关闭后回退阈值模式:
仅当消息超过 config.largeFieldLines(默认 100)行时才落盘。
- review 的
diff 硬校验不变:原始值必须含 @@ 块头(完整补丁文本)——校验在落盘
之前执行,统计摘要 / “见 git show”引用仍被拒绝。
- 临时文件在启动时自动清理(超过 24 小时的删除)。
长任务与后台派发
阶段工具没有工具级超时(未声明 timeoutMs,不会触发官方 timeout policy);但前台等待
受当前回合/调度生命周期约束,长跑阶段可能被回合边界截断(宿主 run_code 的墙钟默认 120 s、
部署上限 600 s,传 timeoutMs 可顶到部署上限)。
- 后台模式(默认,推荐):
run_in_background 省略/为 true——立即返回
{"kind":"continuable","subagentId":"..."} 并结束回合;阶段子代理独立会话继续运行,
完成后 runtime 自动向本会话发送通知(含结果与最终回复);
- 前台模式(仅短任务):
run_in_background: false——等待阶段结果;注意
run_code 程序的墙钟默认 120 s、部署上限 600 s(传 timeoutMs 可顶到上限),
超过会截断等待并取消子代理,所以只有几分钟内能完成的小任务才用前台;
- 状态可见:
list_agents(running / idle / ready)、send_message 继续子代理;
完成通知里就带子代理的 outcome 与最终回复(没有独立的 history 工具,
所以阶段子代理必须把完整结论写进最终回复),GUI 子代理视图同步展示;
- 长任务(预计超过当前回合可承受时长)请用后台模式,收到完成通知后再继续下一步。
默认值
所有阶段默认统一走 deepseek-official / deepseek-flash(= DeepSeek-V41-Flash,
dsh 0.1.5-rc.1 起宿主 agent-default-model 的默认模型 id;旧 id
deepseek-v4-flash 仍在默认目录中,但已不是默认,且宿主目录可被
settings.yaml 的 llm-deepseek.models 收窄——插件默认值必须留在默认目录内):
| 阶段 | 默认 provider | 默认 model | 默认并发上限 | 角色 |
|---|
| plan | deepseek-official | deepseek-flash | 0(不限制) | 只读,仅规划 |
| impl | deepseek-official | deepseek-flash | 0(不限制) | 全工具面,仅实现 |
| review | deepseek-official | deepseek-flash | 0(不限制) | 只读,仅审查 |
已保存过阶段配置的会话不受影响:settings.yaml 的 code-pipeline.stages 里显式
写下的 provider/model 始终优先于这里的默认值。
0.4.0 起 maxConcurrency 只表示「同时运行」的并发上限(默认 0 = 不限制,零回归)。
旧版本那个「同一 (父会话 × 阶段) 已创建(含已结束)总量」闸门已移除:新工作流一律派发
自己的新子代理;只有同一工作流的后续轮次才用 pipeline_followup 续用。
无 fallback 孪生工具:阶段 provider/凭据/启动失败时直接报错并报告,不自动换路由。
read 读取窗口下限(readWidenMinLines)默认 200(0.4.7 起;0.4.5 曾为
2000、0.4.6 曾为 500):本预设代理发出的 read,limit 低于该值会在执行前被
就地拓宽到该值,超过 2000 的治愈为 2000;2000 = 一律整窗,0 = 关闭。
见「read 读取窗口拓宽(一次多读)」。
思考等级(reasoningEffort)
每阶段可在设置页配置「思考等级」。选项按所选模型的实际支持面列出——host
端点通过 llm.resolveModelInfo(provider, model) 读取每个模型的
reasoning.efforts(deepseek 系为 off/low/high/max,GLM-5.3 为 low/high/max);
信息不可用时用兜底交集 [low, high, max]。换 provider/模型时自动重置为「继承默认」,
避免把模型不支持的等级写入配置(运行时对不支持的等级会直接拒绝调用)。
留空 = 继承 provider 路由级默认(如 llm-deepseek.reasoningEffort、
llm-pi-ai 路由的 reasoning)。实现方式:工具派发时给子代理 options 打
stageKey 标记;插件在官方扩展点 agent/request waterfall 中,对命中阶段且已
配置思考等级的子代理注入 reasoningEffort;留空则完全不动调用配置。
每阶段并发上限与并行派发
- 设置项:Settings → 代码流水线 → 每个阶段卡片的「最大并发子代理数(同时运行)」。
口径是运行上限:按
(父会话 × 阶段) 统计该阶段同时运行(宿主 activity = running)
的子代理数。0 = 不限制(默认)。
- 按会话独立:上限由每个父会话各自判定——A 会话跑满该阶段不会占用 B 会话的名额;
账本(运行中 + 在途创建)与宿主
listChildren(parent.id) 核对都以父会话为键。跨会话只做
展示用的合计,绝不参与准入判定。
- 没有「已创建总量」上限(0.4.0 移除):一个新的独立工作流永远可以派发自己的新子代理,
不论这个阶段之前创建过多少个。旧版本用创建总量闸门强制「复用优先」,代价是把新工作流硬塞给
一个已经做过别的 workstream 的冷子代理(无法压缩,每步重发整段历史):实测一轮 63 步 /
9.7M tokens、每步重发约 152k,还只能靠一句「忽略之前的内容」在 prompt 里硬压——上下文删不掉。
现在改由「新工作流派新孩子」这条协议承担:
impl 的同一条工作流后续轮次(评审问题、改需求、
墙钟续跑)继续用 pipeline_followup 回到同一个子代理;新的独立工作流直接用本阶段的阶段工具派发。
- 派发被运行上限拦下怎么办:这是瞬时策略拒绝,不是阶段不可用——等一个子代理结束
(运行名额在子代理结束时释放),然后在后续步骤里把剩余工作流各自派成新子代理;正在运行的
同一工作流孩子可以用
pipeline_followup 插话(不创建)。不要为了避开等待就把新工作流塞给
一个不相干的子代理。
- 运行闸门的准入判定(两步):
- 同步先到先得:用插件账本(运行中 + 本次启动预留)判定,超限立即拒绝;
通过则同步占位。判定必须完全同步——PTC 的
Promise.all 会让同一阶段的多个
调用同时进入 execute,若等 await 之后再判定,两个并发调用会互相把对方
算进名额而双双被拒(开发时实测到这个缺陷,已修)。
- 异步核对:再用宿主
subagents.listChildren(parent.id) 的
activity === "running" 核对真实运行数(捕获账本不知道的子代理:重启前派发的、
被 pipeline_followup 唤醒的),偏保守时可以拒绝一个刚准入的调用;同时
用结果修剪账本里已 settle 的条目(自愈)。宿主没有 listChildren 或查询失败
时退回账本,并用 live Agent 的 status === "idle" 修剪。
超限时工具拒绝本次派发,错误信息明确标注「这是瞬时策略拒绝,不是阶段不可用」——
主代理应等名额释放后在后续步骤重派(同一工作流的后续轮次用 pipeline_followup),
不得按 UNAVAILABLE 规则终止任务,也不要把「复用某个不相干的子代理」当成唯一出路。
- 动态修改:工具每次调用都读设置,所以保存后下一次派发立即生效,无需重启。
调高立即放开;调低不会中断正在运行的子代理,只是在新派发时按新值拦截,直到
运行数降到新值以下。设置页每 5 秒轮询
/dsh-code-pipeline/status:其中 running / pending
是单会话最多(与 limit 同口径,因为设置页是全局卡片、无法只显示某一个会话),
sessions / totalRunning / totalPending 是跨会话合计(仅供诊断)——卡片因此显示
「当前运行(单会话最多)N / 单会话上限 M;共 K 个会话在跑(合计 T)」,不会把跨会话的
合计拿去比上限。created / available 是全部会话的信息字段,端点缺失时优雅降级。
- = 宿主 的当前可见行;归属按优先级判定:
本进程台账 → 活子代理的 → label 的 前缀(0.2.0 起阶段工具自动给
加该前缀,所以宿主持久面上的 label 也是阶段标记)。,只靠持久面的
label 前缀 / live 也能把在跑的子代理计入正确阶段(0.4.0 起运行闸门用同一条归属路径,
不再只看本进程台账)。
每阶段墙钟预算(超时自动中断 + 收尾报告)
- 设置项:Settings → 代码流水线 → 每个阶段卡片的「墙钟预算(分钟)」;
0 = 不限制(默认)。
口径是该阶段单次派发的最长运行时间,不做跨派发累计。
- 为什么需要:宿主对子代理没有回合 / 步数 / 时长上限(agent-loop 的 Config 只有
maxParallelToolCalls;dsh-tool-call-timeout-policy 只管单次工具调用),一个跑飞的 impl
只能由模型自己决定停下,于是长时间烧 token、工作区停在半成品。
- 超时后插件做什么(15 秒一轮巡检账本):
0. 软警告(预算 80%):
SOFT_WARN_RATIO = 0.8 处先给仍在运行的子代理插一条 steer 消息(subagents.sendMessage):「预算只剩 X 分钟,开始收尾:做完手上这一处、不要开新工作、只跑必要检查,结束前给一段状态报告」。它在最近一个模型步骤就能看到,多数情况会自己收敛、不必掐;只发一次,失败只记日志、不影响硬路径。子代理不在跑(idle)时不发——steer 对 idle 目标是「开一个新回合」。
- 中断:
subagents.interrupt(childId, { kind: "ancestor", agent: parent }) —— 只结束
当前回合;Activation、未领取的 inbox、已发布的下级都保留,所以之后仍可用
pipeline_followup 把剩下的活儿交回同一个子代理(前缀还在,命中缓存)。
- 索取收尾报告:等它真正停下(有界轮询 ≤ 15 秒)后,经 host-protocol 的
delivery: "queue" 通道排队投递一条自包含指令,要求只输出文本:已完成(含精确文件路径)/
每处改动的状态(完整 · 半成品)/ 未完成项 / 风险与未验证项 / 建议(续跑 · 拆分 · 回退)。
父代理收到的完成通知因此带一份可用现状,而不是只有 left no closing message。
- 收尾回合也有宽限(3 分钟):再超时就第二次中断 —— 硬停,不再收尾(防止“收尾又跑飞”)。
- 对主代理的语义:预算到点是「被中断 + 收尾」,不是阶段不可用 —— 三条阶段工具的
description 已写明:收到
was stopped before it finished 的完成通知后,先等收尾报告通知,
再决定「用 pipeline_followup 续跑同一个子代理 / 把剩余工作拆小重新派发 / 停下来报告用户」。
- 计时口径(每次派发各自独立):每个阶段工具调用都创建一个新的子代理,各自从派发时刻独立计时、互不影响(并行的多个 workstream 也是各算各的);预算在派发时快照,改设置只影响之后的派发。
同一个子代理被
pipeline_followup 续跑时:目标已停下(settled / 收尾回合结束 / 已硬停)→ 重新起算墙钟,并按当时设置取新预算(工具回执带 wallClockRearmed: true)——续跑不是绕过止损线的手段:新预算用完照样会再被掐,同一阶段两次墙钟中止按「停」处置;目标还在跑(steer 插话)→ 不重置,原预算照常到期。lost 条目(宿主缺 interrupt / 父代理已销毁)不复位,下一次新派发重新计时。
- 边界:
- 预算在派发时读入账本:调低不会中断已派发的子代理,只对之后的派发生效(与并发上限同语义)。
- 中断是协作式的:子代理正卡在长工具调用里时要等它观察到取消信号,实际停止可能有延迟。
- 父会话已销毁、宿主缺
subagents.interrupt、或授权失败时,账本标记 lost 并只告警,不重试。
- 账本是进程内的:若
subagent/end 事件丢失(账本仍认为在跑),看门狗对已结束的子代理
最多做一次 no-op 中断 + 一次收尾唤醒,随后相位推进(wrapup → 宽限 → stopped),不会反复唤醒。
- 收尾回合会重新计入该阶段并发数(宿主
activity = running),并受 3 分钟宽限约束。
read 读取窗口拓宽(一次多读)
问题:模型(尤其阶段子代理)常对一个文件「几十行几十行」地翻——一个 run_code
程序读 50 行,思考一步,再开一个程序读下 50 行。每多一步都要把整段上下文重发一遍
(实测 impl 子代理单轮 62 步、97% 的 token 是上下文重发),而多读的那点内容只随上下文
重发一次:步数才是大头。persona 的 CONTEXT ECONOMICS 与读卫生提醒
([read-hygiene])都只能「劝」,模型照样小窗分页。
做法(机制保证,不靠自觉):插件在宿主的 tools/execute around-waterfall 上
(官方 around-dispatch 扩展点,dispatchScheduledExecution 把 exec 作为共享可变对象
传进瀑布)对本预设代理——主会话 root(组合了 code-pipeline 预设)+ 阶段子代理
(派发时打在 agentOptions 上的 stageKey 标记)——发出的 read 做执行前参数
改写,在 next() 之前就地改写 exec.arguments:
limit 低于下限 → 拓宽到下限(默认 200;调到 2000 = read 工具上限,等价于整窗 / 省略 limit);
limit 超过 2000 → 治愈为 2000(read 工具对超上限直接报错;0.3.7 实测一次
limit: 2500 让 25 个 read 全部失败、下一步全部重读);
limit 缺省(宿主默认就是整窗)或已 ≥ 下限 → 不动,尊重模型的显式选择。
默认 200(0.4.5 曾为 2000、0.4.6 曾为 500,实测反馈后继续调低):本预设所有代理都是
PTC(Code Mode),read 只在 run_code 程序内部发生——嵌套工具结果只进程序、不进
模型历史,拓宽本身零 token 成本;200 足以拦下「10~60 行小窗试探」,想更宽调高即可
(2000 = 一律整窗)。结果自带行号与 totalLines,窗口被拓宽是自描述的。post-execute
的读卫生观察看到的是拓宽后的参数(同一个 exec 对象),拓宽过的读按满窗记账,不会被
误判成「小窗口分页」。非本预设的会话不受影响;判定或改写抛错时退回原始参数,绝不
阻塞读取本体。
告知(0.4.7)——机制拦的是单次大小,模型仍可能主动写出一串小窗读(同一个
run_code 程序里对同一文件先读 1020 行、再读 5060 行,窗口互相重叠)。所以除机制外,
插件还把「读有下限」写进 agent 的常识,三处注入:
- 预设 persona(root 代理):
### Step economy 新增一条——读有下限(默认 200),
小 limit 会被自动拓宽,别把一个文件切成连续的小窗读;整文件(省略 limit)或
≥ 下限的范围,一个区域只读一次;拿到的窗口比要的宽是正常的,直接用。
- 三段阶段 persona(TOOL GOTCHAS):同步「NEVER chunk one file into successive
small reads」——同一文件的多次 sub-floor 读只是在重复抓重叠窗口。
- files 派发块(
renderFilesBlock):注入实时下限值(「Your READ FLOOR is N
lines …」)——设置页改完,下一次派发(含 pipeline_followup)就带新数值,
下限为 0 时改注「拓宽已关闭,按需请求窗口」。
设置:Settings → 代码流水线 → 「read 读取窗口下限(行)」(readWidenMinLines),
默认 200;2000 = 一律整窗;0 = 关闭拓宽。改动立即对后续 read 生效,无需重启
(新数值随下一次阶段派发注入子代理提示)。
验证:冒烟 W1–W14(小 limit 拓宽 / 超上限治愈 / 下限可调可关 / 非本预设代理
不动 / 非 read 工具不动 / offset 等其余参数保留 / 判定服务抛错不阻塞读取 /
设置页 save 依赖回归 / 派发块实时下限注入·设置跟随·关闭注明 / 预设 persona 下限条目)。
重要实现事实(与官方 dsh 源码核对)
dsh-tool-subagent 的 execute 本质是 ctx.subagents.start('spawn', { ..., agentOptions, persona, toolFilter, maxDepth }) —— 模型等是调用时参数。
dsh-subagent 创建子代理时:composeFrom(childCtx, parent.ctx) 继承父代理
预设;persona → 子代理 deployment:persona-prefix 提示段(0.1.3 起该段由
deployment:persona 拆成 prefix/suffix,见下条);toolFilter →
childCtx.tools.restrict(...);agentOptions.provider/model 优先于父代理路由。
- dsh 0.1.3 起的行配置与协议变更(本插件已适配):
@deepseek-ai/dsh-persona 的配置字段由 text 改为 prefix(必填)+
suffix(可选);旧 text 会让该行激活失败,整个预设挂载报
agent-preset/invalid。预设内用 prefix(section 序号与旧 text 相同)。
subagents.prompt(pipeline_followup 的 queue 通道)的载荷新增必填
delivery: 'queue' | 'steer',mode 固定 'continuable'——宿主 control schema 的
唯一合法判别符就是 z.literal('continuable')(packages/subagent/subagent/src/control.ts:23),
不存在 mode: 'queue' 这种形状;插件只探测两项(带 delivery 的当前形状 → 不带
delivery 的旧形状),首个被接受的形状即采用。
- 会话格式 v2 把助手流内联进
assistant/message / assistant/attempt 的
data.stream(与本插件无直接关系,但会话读取类插件需注意)。
- dsh 0.1.6-alpha.1 的包名变更(本插件已适配):工作流引擎
@deepseek-ai/dsh-workflow-worker-thread
→ @deepseek-ai/dsh-workflow-ptc(行 id 同步改为 workflow-ptc),实现改为在沙箱化的 PTC
Node 进程里执行工作流(脚本仍保留 agent() / parallel() / pipeline() / phase() / log())。
旧包名不再解析,残留一行就会让整份预设挂载失败:实测报 agent-presets: preset "code-pipeline" failed to mount: row "workflow-worker-thread" names a plugin that cannot be resolved,会话 resume 直接
失败(gateway/internal)。上游 ptc 预设把它与 tool-ralph 一起 disabled;本预设为 ralph
保留引擎(不 disabled),tool-workflow 仍 disabled。
- 工具注册的层由注册时 ctx 的作用域决定(实测:预设 standing 挂载不向其他
会话泄漏);通过
agent.ctx 注册落入该代理自身层,代理销毁自动回收。
tools.restrict 只过滤继承层(global + 祖先),不过滤代理自身层 —— 因此
阶段工具只注入 ROOT 代理,避免子代理的自有层被其只读过滤豁免。
- prompt 层的评审复用依赖的宿主语义(0.1.5-rc.1 源码核对): 走
→ :子代理仍
驻留则 steer 到最近步骤(:空闲的 driver 会开一个新回合),不驻留则
按 (provider/model/persona/toolFilter)重建会话,且
保证再次 settle 时父会话仍收到完成通知 —— 所以「续用同一个
评审子代理」在现有工具下即可成立,无需新增任何工具或参数。
本地验证(冒烟脚本)
仓库不带测试框架,只有一个人可读的假 ctx 冒烟脚本(零测试依赖,直接跑):
pnpm install # 或 npm install:只为解析 @deepseek-ai/schemastery
node test/watchdog.smoke.mjs # 等同于 npm test
test/watchdog.smoke.mjs 用假 ctx(假 agents / subagents / webServer / settings 源 +
可控 Date.now)加载真实的 lib/index.js,覆盖 206 项断言:阶段工具与 pipeline_followup
注册、阶段工具 description 带 WALL-CLOCK BUDGET、plan 工具带 WORKSTREAMS 契约(impl/review
不带)、预算 0 既不中断也不软警告、80% 处发一次软警告(steer 到该子代理、不重复发、
不在跑时不发)、到点中断一次(目标 id + ancestor 授权)、收尾指令经 delivery: "queue"
投递、收尾宽限用尽第二次中断(硬停)、自行 settle 的子代理不被中断、宿主缺 interrupt /
父代理缺失时只告警、以及 GET /dsh-code-pipeline/status 的 budgetMinutes / timedOut /
longestRunningMs 字段、以及续跑的计时口径(运行中插话不重置、原预算按时到期、续跑重新起算并按新预算到期、settle 后续跑也重新起算)。
0.1.19 起这个脚本还校验宿主调用的实参形状——假 startContinuable(spec) 要求
spec.signal instanceof AbortSignal 且 spec.request.prompt[0].type === 'text';假
prompt(payload, signal) 与假 sendMessage(_parent, childId, content, options) 都要求 signal 存在
(宿主对它们调用 signal.throwIfAborted()),形状不符即抛出与宿主同形的 TypeError ——
把「漏传尾部 transport 实参」从静默失败变成脚本变红。新增断言:
followupMode: 'queue' 的 pipeline_followup 全路径(走 subagents.prompt,成功返回 +
回执 messageId 透出 + 载荷 mode: 'continuable' / delivery: 'queue')、
探测表只成功调用一次(首项即被接受,无第 2 次尝试)、
探测表回退的尝试序列(两种已知形状都被 gateway/bad-request 拒绝时,尝试序列恰为
[continuable + delivery, continuable]——已删除的第三项 mode: 'queue' 不被尝试;
断言读的是假 prompt 在判定接受之前记下的载荷流水。计数断言只能拦住更长的探测表
(把第三项加回来就会多出一次尝试),序列断言额外钉住每次尝试的形状——回退项必须是不带
delivery 的 continuable(只改形状、例如给第二项加 delivery: 'steer',计数仍是 2,
计数断言察觉不到,形状只能靠序列断言拦住)、
宿主改写文案时仍能回退(message 不再以 invalid payload for subagent.prompt 开头、
但 details.issues 仍在 → OR 兜底照常继续探测并成功投递)。
0.2.0 起断言数 36 → 141(新增 105 项;0.4.0 改写 A/D 后为 197),分四类:
A. 运行并发闸门(0.4.0 改写,原「创建数量硬闸门」)——cap=1 时第 1 个派发成功、第 2 个(第 1 个仍在跑)
被运行上限拦下、第 1 个结束后第 3 个成功(证明没有创建总量闸门)、被拒路径下宿主创建入口
startContinuable 不被多余调用、运行上限文案带「新工作流派新孩子 + 同一工作流用 pipeline_followup」出路、
cap=0 连派 3 个全部成功(零回归)、Promise.all 并发 3 个只放行 1 个(同步预留生效)、
持久面里运行中的行按 <stage>/ 前缀 / live stageKey 归属计入并发数;
B. 压缩顺序与失败路径——compact: true 时 compactNow 一定早于投递、恰好一次压缩 + 恰好一次投递、
压缩服务走 agentPresets.serviceFor(child, "compaction") 且首参是目标子代理、返回「无可压区间」
不算失败(compacted 不置 true、投递照常)、busy / summary 失败码一律抛错且两条投递通道
都没动、serviceFor 返回 undefined 或没有 compactNow 的对象同样拒绝且未投递、
冷子代理报错时说明结构原因(压缩要有活着的 agent,而活着的 agent 要么正在回合中、要么刚被一次投递冷唤醒)并指向「不带 compact 投递 + 接受干扰,或仍有名额时另派」,不承诺重试(且根本没调用 compactNow);
C. 别名按 seq 稳定——rearmStageBudget 改写 entry.at 之后 latest / 阶段别名仍指向
最后派发的那个;
D. status 字段与阶段描述——created / available 的形状与内容、三条阶段 description 里的运行上限措辞与 compact 指引。
计划的工作流切分(Workstreams)与并行 impl
并行 impl 的前提是互不重叠的文件所有权——同一份文件被两个实现者同时改会互相覆盖。这条契约落在两处:
- plan 阶段(阶段 persona + 工具 description):计划必须以
## Workstreams 表结尾(id / goal / owned files(精确路径或 glob)/ depends on / acceptance check),
或者一句话 Workstreams: single workstream(小改动 / 单文件 / 本质上串行)。硬规则:任一文件只能出现在一个
workstream;共享串行点(package.json、lockfile、index/barrel、迁移、生成物)收进最后一个 integration
workstream(依赖其余);一个 workstream 必须值得独占一个子代理(大致 >1 个文件或 >15 分钟),不要把一件
连贯的改动静默拆成无法各自验证的碎片;每个 workstream 自带验收检查。反过来也要有上界:一个 impl child 按 steps × context 计费,把多组不相关交付物(契约 + 服务 + 门禁 + 外壳 + 配置 + 文档)捆成一个 workstream 会把它拖成 50+ 步、数百万 token 的重发——必须拆成多个 workstream。
- 主代理(预设 persona):当计划声明 ≥2 个「文件不相交且无依赖」的 workstream、且任务属 T2(并行写是例外而非默认)时,可以在一个程序里并行派发
每个独立 workstream 一个
subagent_impl(Promise.all,并行度受该阶段运行上限约束:
超出时剩余工作流在后续步骤各自新派、等名额释放,不要塞给不相干的已有子代理);
有依赖或共享文件的顺序执行,integration 最后跑。
评审阶段按 workstream 各自捕获路径受限 diff(git diff HEAD -- <该 workstream 的 owned paths>)交给各自的
subagent_review——并行期间同一工作区的 git diff HEAD 会混入别人的改动。切分不清楚或看起来不对时,
让 plan 阶段改计划,不要自己发明切分。
- 为什么值得:独立目标并行会缩短 wall-clock;每个子代理的会话更短、更早收敛,大任务的总 token 通常也更省
(代价是每个子代理各付一次 system/persona/派发消息,所以小任务不切)。
变更记录
-
0.4.7(把「读有下限」告知 agent:预设 / 阶段 persona / 派发块实时数值注入;默认下限 500 → 200):
- 问题:机械拓宽只保证「单次读够大」,模型不知道有下限,仍会主动写出一串小窗读——实测同一个
run_code 程序里对同一文件先读 1020 行、再读 5060 行,窗口互相重叠,程序内重复抓取同一片内容。
- 做法(三处告知注入):① 预设 persona 的
### Step economy 新增「Reads carry a FLOOR(default 200):低于下限的 limit 会被自动拓宽——别把一个文件切成连续小窗读;整文件或 ≥ 下限的范围一个区域只读一次,拿到的窗口比要的宽是正常的」;② 三段阶段 persona 的 TOOL GOTCHAS 同步「NEVER chunk one file into successive small reads」(同一文件的多次 sub-floor 读只是重复抓重叠窗口);③ renderFilesBlock 新增第二参数 readFloor,files 派发块(阶段派发与 pipeline_followup 共用)注入实时下限值「Your READ FLOOR is N lines …」,设置页改完下一次派发即生效,下限 0 时改注「拓宽已关闭」。
- 做法(默认 200):
READ_WIDEN_DEFAULT_LINES 500 → 200(200 足以拦下「10~60 行小窗试探」;想要整窗调 2000)。schema / 客户端兜底 / 提示文案同步。
- 预设改动:
agent.cordis.yml 内容变化 → 升级启动时指纹自动同步覆盖已安装预设(覆盖前留 temp 备份)。
- 验证:冒烟新增 W13(派发块注入实时下限·默认值断言 / 反小窗指令 / 阶段 persona 同步 / 设置 500 → 数值跟随 / 下限 0 → 注明关闭)、W14(预设 persona 含「Reads carry a FLOOR」+ 默认值)。断言数 224 → 230、0 failure。
- 版本 0.4.6 → 0.4.7。
-
0.4.6(修复「read 读取窗口下限」保存无效 + 默认下限 2000 → 500):
- 问题①(保存无效):设置页改完下限点保存,重开仍是旧值。根因是
lib/client.js 的 save useCallback 依赖数组漏了 readWidenMinLines——闭包捕获的是初始渲染值(兜底 2000),点保存永远把旧值写回 settings.yaml(stages / followupMode / compactionThresholdRatio 都在依赖里,唯独新加的字段漏了,所以只有它失效)。
- 问题②(默认过激):0.4.5 的默认下限 2000(= 工具上限,一律整窗)对超大文件一次拉满,实测反馈偏激进。
- 做法:
save 依赖数组补上 readWidenMinLines(W12 回归断言钉死);默认 READ_WIDEN_DEFAULT_LINES 2000 → 500(覆盖典型源码文件大半,又不对超大文件一次拉满;2000 = 一律整窗,0 = 关闭),schema / 客户端兜底 / 提示文案 / 冒烟断言同步按常量断言。已保存过 2000 的安装:显式存储值优先于默认,需在设置页重存一次(或删掉 settings.yaml 里 code-pipeline.readWidenMinLines 那行以继承新默认)。
- 验证:冒烟 W2/W3/W9b/W10 改按
plugin.READ_WIDEN_DEFAULT_LINES / READ_TOOL_MAX_LINES 断言(默认值不再硬编码);新增 W12(save 依赖数组包含 readWidenMinLines + 客户端兜底与插件默认一致)。断言数 222 → 224、0 failure。
- 版本 0.4.5 → 0.4.6。
-
0.4.5(read 读取窗口拓宽:执行前把小 limit 拓宽到下限,超上限治愈为 2000):