oh-my-openagent:senpi-task 中 task_send 的 always-steer 契约设计与验证方法
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
本文基于仓库内的验证证据文档 .omo/evidence/20260727-senpi-task-always-steer/README.md 展开。它记录了 oh-my-openagent 的senpi-task包一次公共 API 行为变更:task_send工具从"可选投递模式"收敛为"无条件 steer(转向注入)"契约。读完本文,你将理解这次变更动机的契约层含义、deliver_as参数被移除后的源码级实现路径,以及如何用 RED/GREEN 焦点测试加真实任务表面(real task surface)两阶段场景,对一项工具行为变更做可复现的验证。
1. 变更范围:为什么这是一次 HEAVY 级改动
证据文档的 Scope 一节给出了改动的定级与上下文:
- Tier: HEAVY——定级理由写得很明确:
public task_send schema and task-session message delivery semantics change,即公共的task_send参数 schema 与任务会话的消息投递语义同时发生了变化。对 Agent 工具而言,schema 是模型可见的"公共 API",语义变更直接影响模型侧的调用行为,因此归为最高验证强度档。 - 分支:
fix/senpi-task-always-steer;基线:origin/dev的f2ae25041。
文档随后列出了四条成功判据(Success criteria),这也是整个验证方案的验收标准:
task_send不再暴露deliver_as属性,且普通的子任务消息无条件请求 steer;- 运行中子任务(running-child)与"已完成的驻留子任务"(finished-resident)两种行为,能通过真实任务工具表面(而非 mock)工作,且不依赖任何投递选项;
- 焦点测试、senpi 兼容性、typecheck、build 与改动文件的诊断全部干净;
- 评审者批准,并按仓库要求的 merge commit 方式合并 PR。
后两条属于工程流程要求,前两条才是技术核心:它们把"schema 收敛"与"语义统一"拆成了两个独立可验证的断言——参数消失是静态契约,无条件 steer 是动态行为。证据文档把两类断言分别映射到了不同的验证场景(见第 4、5 节),这个拆分方式本身对做工具 API 收敛的团队有参考价值。
2. 背景:senpi-task 的任务工具面
在深入变更之前,需要先理解task_send所处的位置。senpi-task包是omo-senpi的任务引擎:一个持久化任务状态机 + 记录存储 + 两种子进程 runner + 转向(steering)引擎 + 工具面。包的完整解剖可见 packages/senpi-task/AGENTS.md,其工具面由 4 个任务工具构成:
| 工具 | 工厂函数 | 位置 |
|---|---|---|
task | createTaskTool | tools/task/tool.ts |
task_send | createTaskSendTool | tools/control/send.ts |
task_cancel | createTaskCancelTool | tools/control/cancel.ts |
task_output | createTaskOutputTool | tools/output/output.ts |
分工规则是:task只负责派生;转向、驻留会话复活、团队消息与 shutdown 审批统一走task_send;子任务输出读取走task_output。task_send的变更之所以被定为 HEAVY,正是因为它同时承载"转向消息投递"这一核心语义。
3. 核心变更:deliver_as从公共 schema 中消失
3.1 公共参数 schema:投递选项不复存在
变更后的task_send公共参数定义在 send-schema.ts(TypeBox schema):
export const TaskSendParams = Type.Object({ to: Recipient, message: Type.Optional(Type.Union([PlainMessage, StructuredMessage])), team_run_id: Type.Optional(Type.String({ description: "Team run id for lead-to-member messages or shutdown messages." })), summary: Summary, all_scope: Type.Optional( Type.Boolean({ description: "Allow messaging a child owned by another session. Off by default." }), ), })对照证据文档的成功判据 1——"task_sendexposes nodeliver_asproperty"——当前 schema 中只有to、message、team_run_id、summary、all_scope五个属性,deliver_as确实不存在。其中message是"普通字符串 | 结构化 shutdown 对象"的联合类型(shutdown_request/shutdown_response),结构化消息走的是完全不同的 shutdown 路由,与投递模式无关。
3.2 运行时行为:普通消息硬编码为 steer
schema 去掉参数只是"模型看不见",真正的语义变更发生在执行路径。send.ts 中,runTaskSend处理普通字符串消息时,向 steering 引擎的调用把投递模式写死为"steer":
if (typeof params.message === "string") { const outcome = await manager.sendToTask({ idOrName: params.to, message: params.message, deliverAs: "steer", ...(callerSessionId !== undefined ? { callerSessionId } : {}), ...(params.all_scope === true ? { allScope: true } : {}), }) ... }也就是说:工具层永远以 steer 身份向引擎发消息,调用方(模型)没有任何选择空间。同时工具描述(send.ts 的DESCRIPTION常量)也同步改口:"Plain-text messages always steer a running child immediately.",并声明了复活语义——发往已停泊的 in-process 或 detached RPC 子任务(带转录和已记录的启动规格)的普通文本消息,会在符合条件的 finished 运行之后恢复该会话;而 killed/cancelled/lost 的子任务永不复活。
3.3 内部引擎仍保留两种投递模式——收敛的边界
一个值得注意的细节:deliverAs并没有从系统内部被删除,它只是从公共面退到了内部面。steering 引擎的类型定义(steering/types.ts)保留了完整的双模式:
export type SendDelivery = "steer" | "followUp" export type SendInput = { readonly idOrName: string readonly message: string readonly deliverAs?: SendDelivery readonly callerSessionId?: string readonly allScope?: boolean } // The SEND DEFAULT is "followUp": codex's followup_task routes a send to a running child as a // follow-up prompt, not an interrupting steer. "steer" is opt-in for polite mid-turn injection. export const DEFAULT_SEND_DELIVERY: SendDelivery = "followUp"从源码结构看,这次变更的准确含义是:引擎级默认仍是followUp(兼容 codex 风格的"追补提示"语义),但task_send这个工具面把"礼貌的回合内注入"固定为唯一路径。引擎内部followUp分支仍然存在,服务于不经由task_send的调用路径(例如测试或其他内部路由)。在 steering/engine.ts 的steerRunning中可以验证两条分支:
if (deliverAs === "steer") await handle.steer(message) else await handle.followUp(message)steer走handle.steer()(打断当前回合、立即注入),followUp走handle.followUp()(排队为追补提示)。工具面永远命中前者,这正是"always-steer"契约的动态含义。
此外引擎对pending状态的子任务会把消息写入持久化的pending_steering队列(engine.ts 的enqueuePending),每条队列项落盘deliver_as字段;子任务真正启动时,notifyStarted(engine.ts)按持久化顺序逐条排空,deliver_as === "steer"的条目同样调用handle.steer()。由于工具面现在只产生steer消息,"预发射排队(prelaunch steering)"这条路径上的投递模式也事实上统一了。
4. 验证场景一:RED/GREEN 焦点测试
证据文档的 "Planned exact scenarios" 第一部分给出了精确的焦点测试命令:
bun test packages/senpi-task/src/tools/control/send-always-steer.test.ts packages/senpi-task/src/tools/control/renderers.test.ts判据写明:PASS 是"实现完成后退出码为 0";RED 的形态是"生产代码修改前断言失败,因为deliver_as仍存在、默认发送以followUp抵达引擎、或渲染器打印deliver:"。这正是经典的 TDD 证据链:先写测试钉住目标契约,测试在旧代码上必然红,再动生产代码转绿。
对应的测试文件 send-always-steer.test.ts 包含三个测试,逐一映射成功判据 1 的三个侧面:
- 静态契约:
createTaskSendTool返回的parameters.properties键列表中不含deliver_as,工具description中不出现deliver_as、followUp、interrupt任何一个词——把"模型可见面"的收敛做到了字符串级断言; - 动态行为:用记录型
SendManager假件调用runTaskSend(manager, { to: "st_1", message: "new direction" }, "parent-1"),断言到达引擎的SendInput恰好是{ idOrName: "st_1", message: "new direction", deliverAs: "steer", callerSessionId: "parent-1" }——调用方没传任何投递选项,引擎收到的却必须是deliverAs: "steer"; - 渲染面:
renderTaskSendCall渲染出的行包含task_send to:st_1与消息摘要,但不包含deliver:token——终端 UI 上同样不能泄露已移除的选项。
渲染实现印证了这一点:renderers.ts 的taskSendCallLine只拼接task_send、to:<目标>与消息摘录三段,代码路径上根本不存在投递 token 的分支。
5. 验证场景二:真实任务表面(real task surface)
证据文档强调第二类场景必须"through the real task surface"——即通过task/task_send/task_output工具链的真实调用来验证,覆盖两种子任务状态:
5.1 运行中子任务(running child)
task({ prompt: "Report WORKING, wait for a steering instruction, then return the exact steered text.", subagent_type: "explore", run_in_background: true, name: "always-steer-running" }) task_send({ to: "always-steer-running", message: "STEER-PROBE-RUNNING" }) task_output({ name: "always-steer-running", mode: "full" })PASS 判据:send 调用不包含任何投递选项;结果报告 steer 投递(steer delivery);子任务转录中包含STEER-PROBE-RUNNING。
这个场景验证的是"steer 真的注入进了运行中的回合":子任务被指示先报告WORKING并等待转向指令,随后task_send注入探针文本,若投递模式仍是followUp(追补提示),探针会出现在下一个回合而非打断等待——转录中子任务"返回被转向的确切文本"这一行为只有 steer 语义才能解释。
5.2 已完成但驻留的子任务(finished resident)
task({ prompt: "Return RESIDENT-FIRST and stop.", subagent_type: "explore", name: "always-steer-resident" }) task_send({ to: "always-steer-resident", message: "Return RESIDENT-REVIVED and stop." }) task_output({ name: "always-steer-resident", mode: "full" })PASS 判据:第二次调用不含投递选项;复活同一个 task id / session;转录包含RESIDENT-REVIVED。
这条对应工具描述中的复活语义:子任务 finished 后其会话仍是驻留态(resident),task_send应触发复活而非拒绝或新建。从源码看,引擎sendToTask的主分支先经messageability判定(engine.ts),非 steer 模式走reviveTerminal路径(engine.ts),结果 kind 为revived并携带新的run_epoch。
5.3 配套读取面:task_output 的模式
两个场景都以task_output({ ..., mode: "full" })收尾。output.ts 的 schema 定义mode为"status" | "tail" | "full"三值联合(缺省status):status返回记录快照加最终响应;tail返回转录末尾tail_lines行(缺省 60);full返回整个转录(有上限,带头尾省略标记)。验证场景选full是为了让STEER-PROBE-RUNNING/RESIDENT-REVIVED探针在完整转录中可被机器校验。
6. 变更的边界与不改动的部分
从 send.ts 的完整执行路径可以界定这次"always-steer"到底改了什么、没改什么:
- 改:普通字符串消息到子任务(child)路径的投递模式——从"可选
deliver_as"变为硬编码steer; - 不改:结构化 shutdown 消息(
shutdown_request/shutdown_response)仍由routeStructuredMessage独立路由(send.ts),与投递模式无关; - 不改:子任务未找到时的团队兜底路由——当
manager.sendToTask返回not_found且配置了teamRouting时,普通消息会作为团队邮件经runTeamSend投递(send.ts);工具描述同时声明"Team messages always steer into the recipient's running turn",即团队消息的 steer 语义是既有行为,本次未动; - 不改:跨会话寻址规则——指向其他会话拥有的子任务默认拒绝,需显式
all_scope: true; - 不改:一次性 agent(如 plan-reviewer)在所有状态下一律拒绝
task_send。
这一边界划分的意义在于:always-steer 契约只作用于"父 → 子"的普通消息投递,团队的 mailbox 投递、shutdown 握手、跨会话权限等邻接语义全部保持原状,变更面因此可以被两条焦点测试文件(send-always-steer.test.ts+renderers.test.ts)与两个真实表面场景完整覆盖。
7. 证据文档的其他约定
文档末尾还有两个工程性约定值得留意:
- Observations / Why this is enough / Cleanup receipts 三节标注 "Pending":该文件是随 PR 推进填充的活证据文件——观察记录、充分性论证与清理回执在验证执行后回填。阅读此类证据文档时,"Pending" 意味着对应证据尚未落盘,不应视为已完成。
- Omitted 一节明确声明脱敏原则:"Secret-bearing environment values, provider credentials, and raw private logs will not be copied into evidence."(携带密钥的环境变量、provider 凭据与原始私有日志不进入证据文件)。这与包级文档 packages/senpi-task/AGENTS.md 中记录的 QA 规范(持久化记录带 redaction、安全测试)一脉相承。
8. 小结与延伸阅读
回到证据文档的四条成功判据,本文梳理的仓库证据与它们的对应关系是:判据 1 由 send-schema.ts(无deliver_as)+ send.ts(硬编码deliverAs: "steer")+ send-always-steer.test.ts(三层断言)共同支撑;判据 2 由 running-child 与 finished-resident 两个真实表面场景支撑,底层分别落在引擎的steerRunning与reviveTerminal分支;判据 3 对应的包级 QA 入口为tsgo --noEmit -p packages/senpi-task/tsconfig.json与bun test packages/senpi-task(见 packages/senpi-task/AGENTS.md 的 QA 一节);判据 4 属于评审流程,不在仓库静态证据范围内。
对于维护 Agent 工具面的团队,这篇证据文档示范了一个可复用的方法:当一次变更同时触及"模型可见 schema"与"运行时投递语义"时,用 RED/GREEN 焦点测试钉住契约的静态与动态侧面,再用不带 mock 的真实工具链场景验证端到端行为,并把脱敏与清理约束显式写进证据文件本身。
延伸阅读(均在当前仓库内):
- packages/senpi-task/AGENTS.md:senpi-task 包完整解剖,含状态机、存储、runner、生命周期、steering 与团队运行时;
- packages/senpi-task/src/steering/engine.ts:转向引擎实现,
sendToTask/steerRunning/enqueuePending/notifyStarted全链路; - packages/senpi-task/src/steering/types.ts:
SendDelivery、SendInput与SendOutcome的完整类型面; - packages/senpi-task/src/tools/output/output.ts:
task_output的 status/tail/full 三种读取模式。
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考