oh-my-openagent:senpi-task 中 task_send 的 always-steer 契约设计与验证方法
2026/9/18 18:04:04 网站建设 项目流程

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/devf2ae25041

文档随后列出了四条成功判据(Success criteria),这也是整个验证方案的验收标准:

  1. task_send不再暴露deliver_as属性,且普通的子任务消息无条件请求 steer;
  2. 运行中子任务(running-child)与"已完成的驻留子任务"(finished-resident)两种行为,能通过真实任务工具表面(而非 mock)工作,且不依赖任何投递选项;
  3. 焦点测试、senpi 兼容性、typecheck、build 与改动文件的诊断全部干净;
  4. 评审者批准,并按仓库要求的 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 个任务工具构成:

工具工厂函数位置
taskcreateTaskTooltools/task/tool.ts
task_sendcreateTaskSendTooltools/control/send.ts
task_cancelcreateTaskCancelTooltools/control/cancel.ts
task_outputcreateTaskOutputTooltools/output/output.ts

分工规则是:task只负责派生;转向、驻留会话复活、团队消息与 shutdown 审批统一走task_send;子任务输出读取走task_outputtask_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 中只有tomessageteam_run_idsummaryall_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)

steerhandle.steer()(打断当前回合、立即注入),followUphandle.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 的三个侧面:

  1. 静态契约createTaskSendTool返回的parameters.properties键列表中不含deliver_as,工具description中不出现deliver_asfollowUpinterrupt任何一个词——把"模型可见面"的收敛做到了字符串级断言;
  2. 动态行为:用记录型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"
  3. 渲染面renderTaskSendCall渲染出的行包含task_send to:st_1与消息摘要,但不包含deliver:token——终端 UI 上同样不能泄露已移除的选项。

渲染实现印证了这一点:renderers.ts 的taskSendCallLine只拼接task_sendto:<目标>与消息摘录三段,代码路径上根本不存在投递 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 两个真实表面场景支撑,底层分别落在引擎的steerRunningreviveTerminal分支;判据 3 对应的包级 QA 入口为tsgo --noEmit -p packages/senpi-task/tsconfig.jsonbun 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:SendDeliverySendInputSendOutcome的完整类型面;
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询