
1. 定位:修订一个 run,而不是重造一个工作流
AmendWorkflow 的能力声明一句话说完:
Revise an existing dynamic-workflow run with a new script or revised settings: stop it if it is still running, import its finished work, and start the revision in the background.
工具描述里的核心承诺是省 token:
Starts a NEW run that supersedes the old one and imports its finished work as a cache, so only what you changed is paid for again.
与 CreateWorkflow 的差别刻意做到最小(amend-workflow.ts 头注释):只有两处 ------resolveInput 解析的是前驱 run 而不是保存的文件(省略的字段从前驱沿用),handler 调 port.amend(service 负责停在飞前驱、等结算、导入、启动)。编译、诊断路径、确认窗 display、backgrounded 输出契约与 CreateWorkflow 逐字共用。
为什么是独立工具而不是 CreateWorkflow 的参数 (contracts 头注释):修订是另一种动作、另一张卡------模型不是在启动什么,而是在改一个用户正看着的东西,而且前驱可能仍在跑。两者共用同一个输出形状,所以 executor 的后台追踪与 display 载荷只有一份实现。
2. 适用场景与家族分工
工具描述给的四条判据:
| 场景 | 动作 |
|---|---|
| run errored(脚本本身失败) | 修脚本后 amend------已成功的步骤全部从缓存导入,不为同一件事付两次钱。绝不用 CreateWorkflow 从头重写 |
| run completed 但要加一个阶段/精化 | amend------新增或改动的步骤实跑,没动的分文不花 |
| run 仍在跑且已看出脚本有问题 | 立即 amend,一次调用完成。不要先 TaskStop、不要等它跑完:本工具会停前驱、等结算、导入停止前已完成的一切。改得越早,重付的越少 |
| 只想改并发/子代理模型/名字 | amend 且只传那一个字段、path 与 script 都不给 |
与 ResumeWorkflowRun 的分工:resume 是"字节不变的续跑"(继续一个 stopped run,脚本一个字不改);amend 是"脚本或设定变了"。两条入口、两种语义,描述里互相指路。
3. 输入契约:只有 run_id 必填
ts
run_id: string, // 必填:任何本项目的 run------completed/errored/stopped/running
script?: string, // 整份修订脚本(与 path 至多给一个)
path?: string, // 修订脚本的文件(常态:前驱自己的脚本文件就地改完交回)
name?: string, // 新 run 显示名;缺省沿用前驱名
max_concurrency?: number | null, // 三态
subagent_model?: string | null, // 三态
每个可省略字段守同一条规则:省略即沿用前驱(description 注释解释了为什么三条 bullet 并排且脚本在最前------模型若以为脚本必填,就会为改一个数把几千 token 的脚本再抄一遍)。
三态字段的统一语义(只在 resolveInput 存活,之后归一成"一个数/一条规范形或没有"):
| 传入 | max_concurrency |
subagent_model |
|---|---|---|
| 省略 | 沿用前驱上界(再钳一次------前驱可能在另一台机器上起) | 沿用前驱选型,重新解析一遍(前驱可能几天前起的,模型可能已删------现在失败,而不是拖到子代理第一次开口时看起来像运行时故障) |
null |
解除(跑在天花板上) | 回到会话模型 |
| 值 | 设定(钳到 [1, 天花板]) |
设定(解析失败整次调用失败,什么都没停没建) |
沿用值解析失败时的文案必须说清它是继承来的 (resolveAmendSubagentModel 注释)------模型这次调用压根没提模型名,直接丢解析诊断,它会以为自己传错了参数然后原样重试。目录缺席时两种来源也分开处理:模型自己给的字符串照 CreateWorkflow 拒掉,沿用的原样带过(为一个这次没提到的字段让整次修订失败,是把宿主接线缺口记到用户头上)。
predecessor 不在模型的 JSON schema 里------它是 resolveInput 无条件覆盖回填 的事实块(name/status/stop_reason/owned_by_this_session/script_inherited),"伪造它是无效的"。
4. resolveInput:确认之前的一切
resolveAmendWorkflowInput(amend-workflow-resolve.ts)是全流程唯一一次读端口,在 hook、权限、确认窗之前同步完成------三处都要读"将要发生的执行事实",而它们都在 handler 之前。
顺序不能换 (注释给出了反例论证):前驱查找(不存在即 run_not_found,与脚本无关)→ 落定脚本 → 比字节。倒过来,一个指向不存在 run 的调用会先因文件读不出报错,模型就会去修一个根本不是问题的东西。
4.1 脚本的三条来路(amend-workflow-source.ts)
| 来源 | 处理 |
|---|---|
path(常态) |
读文件;frontmatter 元数据块剥掉且声明被忽略------修订不带实参(实参是前驱那次 run 的事实,随 journal 走) |
script |
内联整份,原样 |
| 都省略 | 经端口读前驱存档的那份字节(resume 重放的同一份);存档缺失(脚本落库前的老 run / 老宿主)当场失败 |
两个来源都给 → 入参级违规(code 400)。都不给在 CreateWorkflow 是违规,在这里合法------那是"沿用前驱的脚本"。
沿用的脚本要"认家" (resolveKeptScriptFile):前驱记下的脚本文件若此刻 读出来仍是这份字节,新 run 继续记这个文件(模型下一次修订仍去编辑它);文件没了或被改过就回 undefined,handler 照"不来自文件的脚本"写一份新草稿。铁律(handler 注释):run 记的永远是装着这一次脚本的文件------把一个内容已经不是这份脚本的文件记到新 run 上,会让诊断行号与"去编辑那个文件"都指向另一段代码。
4.2 script_unchanged 预检
对模型给的 path 且本次调用什么都没改(没同时设两个设定字段;null 算"传了",它是显式解除)时,与 port.getScript 的存档做字节比对------字节相同直接拒绝:
workflow_script_unchanged: ...is byte-for-byte the script run X was started with, so amending it would repeat that run exactly.
这道网抓的是忘了编辑。把脚本贴一遍、或明说"脚本不动"(两个来源都省略)都不是那个错误。文案同时给出三条出路:先编辑文件再交同一个 path;只改设定就不传 path;同脚本续跑走 ResumeWorkflowRun。
5. 确认与权限
- permission 与 CreateWorkflow 同一道门 :
alwaysAsk: true、askOptions: { allowAlways: "session" }------但本会话自己起的 run 免确认 (权限服务的 owner 规则在 always-ask 分支放行;判据就是回填的predecessor.owned_by_this_session)。用户停掉的、别的会话起的 run,先问用户。 - 确认窗预览与 CreateWorkflow 同一段代码 (
prepareAmendWorkflowApproval):先编译,编不过直接proceed放行给 handler 回诊断("窗开了也无物可批");编得过就画将要跑的那份脚本的图。display 的 kind 仍是create_workflow------图、草稿笔与诊断卡在 UI 侧只有一份实现,"这是修订"由工具名与入参的run_id/predecessor说出来。
6. 执行编排:port.amend 的时序
amendDynamicWorkflowRun(bootstrap/src/app/dynamic-workflow-run-submit.ts):
前驱引擎(在飞?) journal(持久事实) run service(bootstrap) handler(工具层) 前驱引擎(在飞?) journal(持久事实) run service(bootstrap) handler(工具层) #mermaid-svg-fcdmlyFptsg3VKdv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fcdmlyFptsg3VKdv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fcdmlyFptsg3VKdv .error-icon{fill:#552222;}#mermaid-svg-fcdmlyFptsg3VKdv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fcdmlyFptsg3VKdv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fcdmlyFptsg3VKdv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fcdmlyFptsg3VKdv .marker.cross{stroke:#333333;}#mermaid-svg-fcdmlyFptsg3VKdv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fcdmlyFptsg3VKdv p{margin:0;}#mermaid-svg-fcdmlyFptsg3VKdv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fcdmlyFptsg3VKdv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-fcdmlyFptsg3VKdv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fcdmlyFptsg3VKdv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-fcdmlyFptsg3VKdv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-fcdmlyFptsg3VKdv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-fcdmlyFptsg3VKdv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-fcdmlyFptsg3VKdv .sequenceNumber{fill:white;}#mermaid-svg-fcdmlyFptsg3VKdv #sequencenumber{fill:#333;}#mermaid-svg-fcdmlyFptsg3VKdv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-fcdmlyFptsg3VKdv .messageText{fill:#333;stroke:none;}#mermaid-svg-fcdmlyFptsg3VKdv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fcdmlyFptsg3VKdv .labelText,#mermaid-svg-fcdmlyFptsg3VKdv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-fcdmlyFptsg3VKdv .loopText,#mermaid-svg-fcdmlyFptsg3VKdv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-fcdmlyFptsg3VKdv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-fcdmlyFptsg3VKdv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-fcdmlyFptsg3VKdv .noteText,#mermaid-svg-fcdmlyFptsg3VKdv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-fcdmlyFptsg3VKdv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fcdmlyFptsg3VKdv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fcdmlyFptsg3VKdv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-fcdmlyFptsg3VKdv .actorPopupMenu{position:absolute;}#mermaid-svg-fcdmlyFptsg3VKdv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-fcdmlyFptsg3VKdv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-fcdmlyFptsg3VKdv .actor-man circle,#mermaid-svg-fcdmlyFptsg3VKdv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-fcdmlyFptsg3VKdv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 被拒(run_not_found/missing_boundaries)→ 不停任何东西,前驱照跑 alt 注册表里前驱在飞 amend({scriptText, predecessorRunId, ...}) preflightAmendImport(存在 ∧ 已完结 ask 边界齐全;不看状态) abort({ superseded: 新 runId }) await settlement(与 waitForTask 同一 promise,永不 reject) buildImportedCache(终态门 + 边界门 → ImportedRunCache) 结算后重读 spentTokens(用量起点,避免漏掉最后几轮花费) startNewRun:编译一次 → 注册表条目 → fire-and-forget launch { ok, runId, supersededRunId? }
设计要点(均有注释背书):
- 预检在停止之前:两条拒绝理由(run 不存在、边界记账不可信)都是前驱 journal 的性质,停止不改变它们------先判定再停,被拒的 amend 才不会留下一个被白白停掉的 run。
- 在飞判定读本进程注册表而不是 journal 状态:journal 说 running 而注册表没有,是别的(或死)进程的 run------本 service 停不了也等不到,按已结算处理,让导入构建的终态门说话。
- 确定性是正确性前提 (
dynamic-workflow-import.ts头注释):buildImportedCache是纯函数------同一份 journal 状态必须给出同一张表(无时间戳、无随机、遍历序来自 journal 插入序)。因为 resume 崩溃恢复时要用rebuildImportedCacheForResume从 journal 重建同一张表:修订 run 的 journal 只对"已到达的执行前缀"自含。 - 端口层绝不二次继承 :并发上界与子代理模型不从前驱继承------"省略即沿用"是工具面 的三态,resolveInput 已归一;端口再继承一次,"
null解除"就永远到不了这里。脚本文件同理绝不沿用前驱路径。 - 显示名沿用前驱:修订是同一件工作的下一版,卡片与通知里换个名字只会让用户以为是另一条工作流。
- 编译恰好一次 :
startNewRun内compileOnce,脚本哈希由 service 算------resume 的哈希校验、AmendWorkflow的沿用与script_unchanged预检对"这个 run 在跑什么"不能有两个答案(getScript注释)。
7. 缓存语义:什么被导入、什么重跑
buildImportedCache 读前驱 journal 构建 ImportedRunCache(actors 表 + world 队列):
- actor 按名字匹配 :名字是缓存身份键。匿名不收 (没有可比对的坐标);重名两个都不收------"挑一个不如都不挑:代价是这两个 actor 全新重跑,而错挑的代价是把另一个 actor 的会话前缀当成本 actor 的上文"。
- actor 缓存 = 最长全 completed ask 前缀 (按 actorSeq 连续,停在第一个非 completed 处)。失败的 ask 对新 run 无约束力 (修订常常就是为了越过一次失败),它自己不导入;但跳过它去导入其后的条目会走私上下文------被跳过那轮的问答仍在源会话转录里,缓存却声称它没发生过。
- 源诚实性检查 (
honorsBoundary):转录读面在场时核对源会话至少有边界那么长,短了整个候选弃掉(该 actor 全新重跑)。降级而不失败是构建的总基调------缺前缀/缺 persona/无转录都只是"这个 actor 没有缓存";唯一整体拒绝的是边界记账不可信 (missing_boundaries,严格到全表),因为那会让已收下的候选截断到错误位置。 - 缓存何时关闭 (工具描述):缓存保持开启,直到一个实跑的子代理第一次写工作区 (或一次实跑的
world.run执行)------从那一刻起,缓存的世界读取与"读过东西的 actor"的缓存 ask 描述的已是一个不存在的工作区,它们也转实跑;只回答过问题的 ask 继续从缓存结算。 - 怎么强制重跑 :编辑一条 ask 的文本(字节比对就不再命中)。配套写作纪律:名字保持稳定、可调常量别写进 ask 文本。
8. 结构化失败:Nothing was stopped or created
失败码表(amend-workflow-source.ts:25;数值只是日志位,判别键在 message 前缀):
| code | 键 | 场景 |
|---|---|---|
| 21 | workflow_amend_unavailable |
端口不带 amend 能力("capability gap, not a status problem") |
| 22 | workflow_amend_missing_boundaries |
前驱 journal 早于边界记账/边界未记------无回退,指引改走全新 CreateWorkflow |
| 23 | workflow_subagent_model_unresolved |
模型引用解析失败(含"沿用的那一个已不可用"的特殊文案) |
| 24 | workflow_script_unchanged |
字节级未改动预检命中 |
| 25 | workflow_script_file_unreadable |
path 读不出来 |
| 26 | workflow_amend_script_unavailable |
无存档可沿用(record/host 两种成因同一出路:把脚本交上来) |
| 400 | (来源违规) | script 与 path 同时给出 |
统一纪律:每条失败都以 "Nothing was stopped or created." 收尾------模型据此知道世界没有副作用,可以安全重试或改道。端口契约外的拒绝理由也回结构化失败(throw 是接线故障的通道),文案带原词供日志排查。
编译失败响应 的文案有两处精细设计(compileFailureResponse):沿用来的脚本编不过时先说清它是继承来的 ("模型这次调用没写这些行,光给诊断它会以为自己传错了参数");两种情形都补一句"Run X was not touched"("否则模型会以为它刚把一个在跑的 run 停掉了")。内联修订无论编译结果如何都落盘 成草稿------一段编不过的修订也有文件可改,结果点名草稿路径,下次修订就回到 path 轨道。
9. 输出与后续
输出形状是 CreateWorkflowOutput(同一 schema),status: "backgrounded"、backgroundTaskId = 新 runId。给模型的 response 按事实拼接:
- 哪条被替代:在飞前驱 → "Run X was still running: it has been stopped and superseded, and everything it finished before the stop is imported as cache. It will not send a notification of its own.";已结算前驱 → "Run X had already settled; its finished work is imported as cache."
- 脚本有没有变 :沿用时点明 "The script of run X started unchanged"------模型据此知道这次改的只是设定,不去翻一段自己没写过的新脚本。
- backgrounded 引导 (照 CreateWorkflow 的样子):给出新 id、说明仍在跑、结果以通知形式回来、显式劝阻轮询("Do not wait for it or poll it with TaskOutput; continue with other work unless the user asked you to wait.")。
- 附并发上界说明与脚本文件句子(内联修订点名新草稿,
path修订点名同一文件)。
10. 灰度门与家族
AmendWorkflow 属于动态工作流十工具族,受同一道灰度门控制(handlers/index.ts 的 DYNAMIC_WORKFLOW_TOOL_NAMES):只有显式 false 才整体下架,门同时管工具描述(灰度关时 Agent 描述里指向 CreateWorkflow 的那行一并抹掉)。配套的 dynamic-workflows 技能也随门禁用(见 skill-tool-guide 第 8 节)。
| 家族工具 | 职责 |
|---|---|
CreateWorkflow |
启动新 run(alwaysAsk,无免确认) |
AmendWorkflow |
修订既有 run:停飞前驱 + 导入缓存 + 启动修订(本会话 run 免确认) |
EvalWorkflowSnippet |
同步、只读、瞬态的脚本实验通道 |
SaveWorkflow / ListSavedWorkflows |
定义清单的写与读 |
ListWorkflowRuns / GetWorkflowRun |
历史内省(run_id 的发现面) |
ResumeWorkflowRun |
字节不变续跑(stopped run 的复活,alwaysAsk 非 yolo 不可) |
ListModels |
给 subagent_model 供模型目录 |
ResolveWorkflowQuestion |
子代理升级问答的主代理侧 |
UI 侧(tool-identity.ts)AmendWorkflow 归 workflow 家族,与 CreateWorkflow、submit_result 同族渲染。
11. 使用指引
给模型:
- run 报错时先想到 amend 而不是重写------每一个已成功的 ask 都在替你省钱;
- 修订的常态是
path:错误通知和 GetWorkflowRun 都已给出脚本文件路径,就地 Edit 再把同一个 path 交回来,一次修订只花一次 Edit; - 只改设定就两个脚本来源都不给;改并发/模型只在用户开口时做;
- 收到
script_unchanged说明你忘了编辑,或你应该改用 ResumeWorkflowRun; - steer 不适用于本工具:它同步编译 + 后台启动,15s 超时且不可取消(
cancellation.supported: false------"AmendWorkflow typechecks synchronously and cannot be cancelled")。
给运维/调试:
- "前驱还在跑却说我没停它":检查注册表与 journal 的归属------别的进程的 run 不归本 service 停,走终态门;
- 缓存没命中:核对子代理名字是否稳定、ask 文本是否字节一致、是否已有实跑写入工作区;
missing_boundaries:前驱 journal 早于 marker 列引入,无回退,走全新 run。
12. 相关文件索引
| 文件 | 职责 |
|---|---|
packages/core/src/tool/handlers/amend-workflow.ts |
handler、确认窗预览、输出拼接、工具元数据(头注释:与 CreateWorkflow 的两处差别) |
packages/core/src/tool/handlers/amend-workflow-resolve.ts |
resolveInput:前驱回填、三态归一、子代理模型解析(工具与 GUI 共用判定本体) |
packages/core/src/tool/handlers/amend-workflow-source.ts |
脚本三来路、沿用认家、script_unchanged 预检、错误码表 |
packages/core/src/tool/handlers/amend-workflow-description.ts |
工具描述:缓存机制、省略即沿用、确认规则 |
packages/contracts/src/tools/amend-workflow.ts |
输入契约(三态字段)、predecessor 事实块 |
packages/bootstrap/src/app/dynamic-workflow-run-submit.ts |
amendDynamicWorkflowRun:预检 → 停飞 → 导入 → 启动;startNewRun 共用启动尾 |
packages/bootstrap/src/app/dynamic-workflow-import.ts |
preflightAmendImport / buildImportedCache:纯确定缓存构建、三道门、actor 候选降级 |
packages/bootstrap/src/app/dynamic-workflow-run-service.ts |
端口实现、getScript(沿用/预检/resume 的同一字节来源)、七条不变式 |
packages/core/src/tool/handlers/create-workflow.ts |
共用的编译诊断、display、backgrounded 文案 |
packages/core/src/tool/handlers/workflow-drafts.ts |
内联修订的草稿落盘 |
packages/core/src/tool/handlers/index.ts |
灰度门(DYNAMIC_WORKFLOW_TOOL_NAMES) |
packages/shared/src/tool-identity.ts |
AmendWorkflow → 家族 "workflow" |