oh-my-openagent senpi-task always-steer 机制解析:task_send 无条件转向(steer)传递的设计与实现
2026/9/20 2:13:16 网站建设 项目流程

oh-my-openagent senpi-task always-steer 机制解析:task_send 无条件转向(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

导读

本文围绕 oh-my-openagent 中 senpi-task 子系统的 "always-steer" 机制展开:它重构了task_send工具的消息投递语义,从「默认以 followUp(跟进提示)投递、可选 steer(中途转向)」改为「普通文本消息无条件以 steer 方式注入运行中的子任务会话」。读者将理解这一语义变更的动机、公开 schema 的收敛方式、底层 steering engine 的运行中转向与驻留会话复活(revival)两条核心路径,以及仓库中对应的 RED/GREEN 证据链与回归门禁,可直接用于理解或复现该功能的演进过程。

一、背景:为什么 task_send 需要 always-steer

packages/senpi-task/src/steering/types.ts中,投递方式(delivery)被定义为两种取值:

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:向正在运行的子任务发送消息,等价于追加一条跟进提示,而非打断其当前回合;steer是「礼貌的中途注入」,需要显式选择。问题在于:followUp语义下,父任务发给子任务的「转向指令」只能等子任务当前回合结束才生效,导致编排(orchestration)中父任务无法及时纠正子任务方向,执行路径常出现「跑偏一整轮才被纠正」的现象。

always-steer 机制(证据目录 .omo/evidence/20260727-senpi-task-always-steer/)的目标正是消除这种语义分裂:普通文本消息永远以steer注入,公开工具面不再暴露投递方式选项

二、公开契约收敛:删除 deliver_as

2.1 变更核心

该改动属于 HEAVY 级别(证据 README 的 Scope 标注),原因是公开的task_sendschema 与任务会话消息投递语义发生变化。分支为fix/senpi-task-always-steer,基线为origin/devf2ae25041

改动的第一步是从公开输入 schema 中删除deliver_as。当前仓库中 send-schema.ts 定义的TaskSendParams已无此字段:

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." }), ), })

成员级(member-scoped)变体同样收敛为三字段:

export const MemberScopedTaskSendParams = Type.Object({ to: Recipient, message: PlainMessage, summary: Summary, })

2.2 为何是"不可表示"而非"校验拒绝"

自审文档(06-self-review.md)第 2 条点出了设计取向:TypeBox 仍是边界解析器,删除deliver_as后,「非法投递方式」在TaskSendInput类型层面变得不可表示(unrepresentable),而不是运行期再校验拒绝。这符合「使非法状态不可表示」的类型安全实践——从类型系统根上消灭了错误选项。

2.3 公开描述同步收敛

工具描述(send.ts 的DESCRIPTION)中也不再出现deliver_asfollowUpinterrupt等词,改为明确陈述新语义:

Plain-text messages always steer a running child immediately.

同时保留了对驻留会话(resident)复活语义的说明:对已停驻(parked)的进程内或分离 RPC 子任务发送普通文本消息,会在合格终态后恢复其会话;已被 kill、cancel 或丢失的子任务永不复活;非终态挂起(suspended)的子任务随父会话恢复。团队消息(team messages)同样总是转向注入接收方的当前回合。

三、运行时路由:普通消息无条件转发为 steer

3.1 runTaskSend 的实现

runTaskSend 的核心分支逻辑是:

if (typeof params.message === "string") { const outcome = await manager.sendToTask({ idOrName: params.to, message: params.message, deliverAs: "steer", // ← 无条件 steer ...(callerSessionId !== undefined ? { callerSessionId } : {}), ...(params.all_scope === true ? { allScope: true } : {}), }) // not_found 时回退到团队路由(runTeamSend) ... } if (params.message !== undefined) return routeStructuredMessage(params.to, params.message, params, teamRouting) return invalidArguments("message is required")

可以看到:字符串消息被硬编码为deliverAs: "steer",不再有任何可选分支;结构化消息(shutdown_request/shutdown_response)仍走routeStructuredMessage团队关闭流程;message缺失则返回invalidArguments("message is required")validateParams也从三个参数简化到一个(自审第 8 条),仅保留「关闭请求拒绝时必须携带 reason」的校验。

3.2 底层 engine 的两种交付语义

SendDelivery常量仍保留followUp,因为它服务于底层引擎的队列与继续执行路径(如 manager.ts 的continueTask仍接受deliverAs参数)。也就是说:删除的是"公开工具面"的选项,而非底层引擎的能力steerfollowUpSendOutcome中通过delivered: SendDelivery区分:

export type SendOutcome = | { readonly kind: "steered"; readonly task_id: string; readonly status: TaskStatus; readonly delivered: SendDelivery } | { readonly kind: "revived"; readonly task_id: string; readonly run_epoch: number } | { readonly kind: "queued"; readonly task_id: string; readonly queue_position: number } // ...

3.3 渲染层不再显示投递方式

用户可见的调用渲染(renderers.ts 的renderTaskSendCall)不再打印deliver:...后缀。测试 send-always-steer.test.ts 断言:

const line = component.render(120)[0] ?? "" expect(line).toContain("task_send to:st_1") expect(line).not.toContain("deliver:") expect(line).toContain("new direction")

四、RED/GREEN 证据链:三条行为敏感断言

4.1 RED 阶段(改动前)

01-red-focused-tests.txt 记录了三个典型失败,恰好对应契约的三个方面:

  1. schema 泄漏:公开参数仍含deliver_as——["to", "message", "deliver_as", "team_run_id", "summary", "all_scope"]
  2. 路由错误:普通task_send仍以deliverAs: "followUp"转发,而期望是"steer"
  3. 渲染残留:用户可见调用仍打印task_send to:st_1 deliver:followUp message:"new direction"

RED 测试命令:

bun test packages/senpi-task/src/tools/control/send-always-steer.test.ts

结果0 pass / 3 fail,退出码 1。证据文件还特别标注了一个细节:早期「环境相关失败」(Cannot find module '@code-yeongyu/senpi')被判定为无效 RED 并丢弃——只有在隔离 worktree 中安装依赖后捕获的行为敏感失败才是有效 RED。这是仓库测试纪律(见 .omo/rules/test-discipline.md)的体现。

4.2 GREEN 阶段(改动后)

02-green-focused-tests.txt 汇总了 6 个文件的 49 个测试:

bun test \ packages/senpi-task/src/tools/control/send-always-steer.test.ts \ packages/senpi-task/src/tools/control/send.test.ts \ packages/senpi-task/src/tools/control/send-team.test.ts \ packages/senpi-task/src/tools/control/send-unify-integration.test.ts \ packages/senpi-task/src/tools/control/renderers.test.ts \ packages/senpi-task/src/tools/control/member-scoped-renderer.test.ts

结果49 pass / 0 fail / 137 expect(),覆盖:公开 schema 无deliver_as、普通消息无条件转发deliverAs: "steer"、用户可见调用无deliver:、lead/member 团队路由仍转向运行中接收方、已完成驻留子任务仍能复活同一会话,以及渲染器宽度、韩文词边界、结构化关闭、scope 与错误分支。

4.3 契约级测试的断言细节

测试文件 的三个测试组成了 always-steer 的公开契约:

  • schema 断言parameterNames不含deliver_as,且工具描述不含deliver_asfollowUpinterrupt
  • 路由断言runTaskSend(manager, { to: "st_1", message: "new direction" }, "parent-1")后,捕获的SendInput必须精确等于{ idOrName: "st_1", message: "new direction", deliverAs: "steer", callerSessionId: "parent-1" }
  • 渲染断言:渲染行含task_send to:st_1与消息正文,但不含deliver:

五、真实运行面验证:两条核心执行路径

5.1 运行中子任务:steer 注入

03a-live-running-steer.txt 通过 Senpi RPC 端到端驱动(packages/omo-senpi/scripts/qa/task-rpc-e2e.mjs)验证。场景中字面上的task_send调用完全省略投递选项

{"name":"task_send","arguments":{"to":"p1","message":"steer: keep going"}}

二进制可观测结果steer_ack_mid_runPASS,即运行中的子任务在回合中途收到了转向注入并确认。同时通过的还有real_credentials_untouched_and_caller_env_ignored(真实凭据未被触碰、调用方环境被忽略)、process_mode_routes_to_rpc_runnerspawn_process_pid_and_session_jsonlcompletion_push_arrivesno_leaked_rpc_child_pidsleakedPids: 0

证据文件还如实披露了两个与本次改动无关的失败(kill_marks_error_killed_truereconcile_lost_terminates_orphan),并说明它们属于独立的 kill/reconcile fixture、不经过被改动的无选项task_send路径——体现证据链的诚实性。

5.2 已完成驻留子任务:resident 复活

03b-live-resident-revival.txt 使用直接 Bun 驱动,导入被改动的 senpi-task manager fixture 并调用runTaskSend,验证驻留会话复活:

  1. 启动manual-resident,其第一回合以最终响应RESIDENT-FIRST完成(status: "completed",residency: "resident",epoch: 0);
  2. 调用runTaskSend(manager, { to: taskId, message: "RESIDENT-REVIVED" }, "manual-parent")——同样不含任何投递选项;
  3. 复活后的回合完成,且同一 task id、同一驻留会话epoch: 0推进到epoch: 1,最终响应为RESIDENT-REVIVED
{ "send": { "kind": "revived", "task_id": "st_019fa2ea", "run_epoch": 1 }, "after": { "status": "completed", "residency": "resident", "epoch": 1, "final": "RESIDENT-REVIVED" } }

证据还披露了另一条被放弃的 E2E 路径(task-e2e.mjs):其 mock-provider 子进程在任何task_send调用前就以Connection error失败,因此该运行不作为功能证据,日志保留在task-e2e-debug/供评审者检视——同样是"只把有效证据当证据"的实践。

六、回归门禁与评审结论

6.1 五道回归门禁

05-regression-gates.txt 记录了改动合入前必须通过的关卡:

门禁命令结果
senpi-task 全量测试bun run --cwd packages/senpi-task test554 pass / 0 fail / 1786 expect(),77 文件
senpi 兼容性bun run --cwd <worktree> test:senpi(含插件重建、omo-senpitsgo --noEmit与测试套件)rebase 到origin/dev403 pass / 0 fail / 1174 expect(),70 文件
包级类型检查bun run --cwd packages/senpi-task typechecktsgo --noEmit退出码 0
适配层类型检查bun run --cwd packages/omo-senpi typecheck退出码 0
插件构建 + diff 检查bun run --cwd <worktree> build:senpi-plugin+git diff --check退出码 0,生成产物与源码输入一致

LSP 门禁(04-lsp.txt)因 daemon 超时不可用,证据明确以编译门禁作为替代,而不是虚构一个 LSP PASS。

6.2 自审与评审

06-self-review.md 按 11 项编程后评审标准逐项核对,关键结论:

  • 单一职责send-schema.ts拥有公开输入 schema,send.ts拥有路由,renderers.ts拥有渲染,新测试拥有 always-steer 公开契约;
  • 边界纯净:TypeBox 仍是边界解析器,删除deliver_as使非法投递方式不可表示;
  • 变体判别:结构化消息与结果变体仍以assertNever穷举 switch;
  • 无逃生舱:未引入任何any、抑制、非空断言或忽略的诊断;
  • 防御层:删除过时的投递校验分支,而非叠加冗余检查;
  • 无参数膨胀validateParams从三个参数简化为一个;
  • 测试可复现:回退 schema/运行时/渲染层改动即可复现三个 RED 失败。

规模检查命令:

bun run packages/omo-senpi/plugin/skills/programming/scripts/typescript/check-no-excuse-rules.ts <14 changed TypeScript files>

结果No violations in 14 file(s).评审驱动的清理还包括:删除控制层SendManagerSendResultDetails与渲染器中因公开选项移除而不可达的 interrupt/no-op 变体,同时保留底层 manager/steering interrupt API 供内部生命周期与对抗性测试使用。

七、使用视角:always-steer 对编排者的实际意义

从使用角度看,always-steer 语义让多智能体编排(orchestration)中的「中途纠偏」成为默认行为:

  • 运行中纠偏:父任务发送普通文本即注入子任务当前回合,无需记忆deliver_as选项;
  • 驻留会话复活:已完成(resident)的子任务收到消息后以同一会话续跑(如run_epoch从 0 推进到 1);
  • 团队消息:lead→member 消息总是转向注入接收方的当前回合;
  • 边界行为:kill/cancel/丢失的子任务永不复活;one-shot agent(如 plan-reviewer)在任何状态下拒绝task_send,需要新起一个实例;跨会话子任务需all_scope: true

这些边界在 send.ts 的DESCRIPTION中均有明确陈述,且由 send-team.test.ts、send-shutdown.ts 等测试与实现锁定。

八、相关证据与延伸阅读

  • 证据主文档:.omo/evidence/20260727-senpi-task-always-steer/README.md
  • RED 捕获:01-red-focused-tests.txt
  • GREEN 汇总:02-green-focused-tests.txt
  • 真实运行验证:03a-live-running-steer.txt、03b-live-resident-revival.txt
  • 回归门禁:05-regression-gates.txt
  • 自审报告:06-self-review.md
  • 核心实现:send.ts、send-schema.ts、steering/types.ts
  • 契约测试:send-always-steer.test.ts
  • 关联模块:senpi-task 包说明、omo-senpi 适配层

需要说明的是:指定路径.omo/plans/senpi-task-always-steer.md在当前仓库中不存在,本文以仓库中同主题的完整证据文档.omo/evidence/20260727-senpi-task-always-steer/(含 README、RED/GREEN 捕获、真实运行日志、回归门禁与自审)为绝对主体,结合packages/senpi-task源码进行印证与展开,内容完整覆盖并超越原证据文档的信息密度。

【免费下载链接】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),仅供参考

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

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

立即咨询