# Agent Note: 同会话 Goal Round 驱动器

Status: implemented

[English](2026-07-19-same-session-goal-round-driver.md) | 中文

## 问题

目标领域可以保留目标，模型可见工具也可以变更其生命周期，但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态桥接到普通 agent loop（智能体循环），同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话，也不能把每个人类轮次都视为自主迭代。

这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.followup()` 监听器可能接纳陈旧工作、与人类提示词同时运行、超出上限消耗资源，或在回放后未经新授权自行重启。

## 决策

位于 `packages/goal/goal-round-driver/` 的 `@deepseek-ai/dsh-goal-round-driver` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。它以每个实时 `Agent` 的确切对象身份为单位维护进程内调度状态，并且最多预留一个自动 Goal Round。

层次关系为目标（Goal）→ Goal Round → 轮次（Turn）→ 步骤（Step）。Goal Round 是外层继续执行策略的一次迭代；它会成为一个归属于目标的会话轮次，而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是 Goal Round，也绝不会增加 `roundsStarted`。

该插件没有配置项。`maxGoalRounds` 由 `dsh-goal` 解析并持久化；「相同阻塞条件」的门槛由 `dsh-tool-goal` 解析并写入提示词。若驱动器重复声明这些可调值，一个策略就会出现多个所有者。

### 预留与接纳

当 agent 空闲、没有竞争中的排队工作，且当前目标为 `active` 加 `armed` 时，驱动器会先将待处理的目标变更持久化到检查点，并在等待完成后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`，它会记录代码为 `round-limit` 的 `blocked`；否则，它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词，再以 `GoalMessageSource` 调用 `Agent.followup()`。提示词用 JSON 引号编码目标描述，使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。

`agent/pre-step` waterfall（瀑布式事件）是进入栅栏。Round 编号为正数的目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed，并且该 Round 仍是下一个编号时才会进入。插件在委托下游监听器前检查一次，在下游返回后再检查一次。第二次检查防止异步监听器编辑或暂停目标后，旧提示词仍会进入。

只有最终产生的 `user/message` 才是已进入的 Goal Round，并推进目标折叠。陈旧预留会关闭一个被阻塞的零步骤轮次；驱动器会把它标记为陈旧，不计入 Round 数。若下游策略拒绝并非由陈旧状态导致，目标会进入 blocked，而不会绕过该策略自动重试。

### 人类工作与修订竞争

预留的 `MessageId` 会区分驱动器自己的完整记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度；自动提示词待处理时进入的普通工作会使该预留陈旧，因此混合的已领取批次会拒绝自动提案。Goal Round 进入后到达的普通工作会保留在队列中，成为下一个独立轮次；只有 agent 再次空闲后才重新考虑继续执行。

目标在 Round 内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影，并且只在新修订仍为 active 与 armed 时继续。因此，模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。

### 结算

驱动器按下表分类一个已经关闭、归属于目标的轮次：

| 轮次结果 | 动作 |
|---|---|
| 持久化的 `completed` | 目标仍 active/armed 且未到上限时继续 |
| 取消已预留/接纳的 Goal Round，或该 Round 产生 `aborted` 结果 | 暂停并解除激活 |
| 代码为 `RATE_LIMIT` 或 `QUOTA` 的 `error` | 以 `usage-limited` 代码阻塞 |
| 其他 `error` | 以 `turn-error` 代码阻塞 |
| `max-tokens` | 以 `max-tokens` 代码阻塞 |
| 持久化检查点失败 | 解除激活，但不改变持久化阶段 |
| `disposed` 或 `interrupted` | 解除激活 |
| 插件新增的未知结果 | 阻塞并等待检查 |

异常结果都不会请求自动重试。之后的人类提示词可以用任何语言要求继续；模型读取已停止目标并调用目标工具的 resume 动作，记录新修订并重新激活继续执行。

### 持久性与取消约定

每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`，随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告；即使并发的一次性注入已追加后续轮次，驱动器仍会找到对应的同一个已关闭轮次，把失败关联到对应的同一次尝试，并在下一次空闲决策前解除激活。

广义取消会清除待处理的收件箱工作，并中止活跃的循环阶段。目标驱动器通过收件箱领取／丢弃事件和持久化的 aborted 轮次结束事件来跟踪预留消息。由于轮次现在会在首次领取前打开，取消可以关闭已领取的零步骤尝试；驱动器会把该尝试标记为已取消，并让随后的空闲边沿暂停目标，与已接纳尝试的处理方式相同。没有匹配目标尝试的取消只会移除进程内激活态。若暂停变更抛错，驱动器会回退到解除激活，避免已取消的自动工作重新启动。

`Agent.cancel()` 仍是唯一的公共广义取消动词。若消费方依赖这些顺序，实现该接口的自定义 `Agent` 必须遵守收件箱、轮次结束、状态与完全停稳的顺序。

### 进程生命周期

`GoalService.disarm(agent)` 只移除进程内激活态。它不写会话事件、不改变修订号，也不发出目标变更。驱动器在加载现有 agent、持久性存在不确定性以及卸载前调用该方法；之后的 `resume` 才是模型可见的持久激活边沿。

驱动器的事件监听器和完全停稳后关闭的流程嵌套在同一个有序 Cordis effect 中。Cordis 会并发卸载同级 effect；若监听器和清理分别注册，异步 disposer 仍在排空时提示词栅栏就可能已被移除。组合 effect 会先关闭接纳、解除目标激活、取消已接纳尝试，并等待 agent 与驱动器都完全停稳；之后才注销监听器。

紧邻插件开始卸载前，收件箱接纳可能赢得微任务竞争。在这种情况下，轮次甚至首个请求都可能已经开始，且该 Round 仍会持久化计入额度；卸载一旦开始，取消就会中止它，不会再调度后续回合，目标保持 active 但 disarmed。若假装已经观测到的接纳从未发生，就会破坏回放计数。

## 测试

单元测试使用真实 agent loop 与会话服务，只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、无关人类工作取消、暂停失败回退、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败（包括后续一次性注入）、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出，以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。

无密钥 ACP（Agent Client Protocol）快照通过 `cordis.yml` 挂载已发布的自动化应用，以及真实目标领域、目标工具、目标驱动器、agent loop、持久化和回放适配器。一个源自人类的轮次创建并检查一个包含两个 Round 的目标；第一个自动轮次正常停止，ACP 随后取消刻意停滞的第二个 Round 并记录持久暂停。规范化的协议层 transcript（文本记录）和外部 JSONL 断言证明只有一个会话、Round 来源依次为 `1, 2`、生命周期变更与回放计数精确，并且没有把 `echo-agent` 当作应用替身。

核心取消测试固定通知顺序与隔离：只有有效取消才会通知；观察者可以在清空收件箱前排入替代工作；抛错不能阻止后续观察者；空闲调用不会发出事件。

## 考虑过的替代方案

- **在 `dsh-agent-loop` 内添加目标循环**——不予采纳，因为公共队列、提示词、会话、取消和状态约定已经足够，具体循环分支还会赋予某种策略特权。
- **使用 `agent/turn-continuation` 把每个 Round 变成另一个步骤**——不予采纳，因为 Goal Round 是外层策略迭代，必须拥有自己的持久用户提示词、轮次边界、Round 计数和失败结算。
- **持久化待处理预留**——不予采纳，因为崩溃无法证明进程内队列已经达到接纳点；只有持久 `user/message` 才计入 Round。
- **自动重试提供方或持久化错误**——不予采纳，因为重试会消耗资源，需要显式授权；停止阶段与之后的人类恢复更简单，也可观察。
- **每个 Round 都 fork 对话历史或生成新 agent**——本包不采用，因为此目标明确属于同会话工作。新 agent 的 Ralph 执行仍是基于 subagent 与工作流原语的独立工作流插件。
- **把每个会话轮次当作 Round 计数**——不予采纳，因为人类澄清和无关工作共享会话，但不共享自动工作预算。

## 后果

- 目标继续执行仍是可移除插件，具体循环只新增一个通用的「取消前观察」通知。
- 回放可以从精确目标来源和提示词重建每个已接纳 Round；被拒绝的预留不会产生虚假的预算消耗。
- 人类消息和生命周期变更可以在有文档约束的竞争中胜出，而不破坏修订号或计数器。
- 恢复和 fork 会一直保持惰性，直到人类的语义意图促使模型记录 resume 变更。
- 保守的失败映射可能要求在暂时性错误后手动继续，但绝不会隐藏自动重试。

## 已知限制与暂缓事项

- 完成证据和阻塞条件的语义等价性仍由模型判断。独立评估器、完成证书或由验证器驱动的停止策略延期到独立策略插件。
- 本包不提供 Ralph 风格的新 agent 尝试、上下文重置、跨 Round 评估反馈或工作流级并行；它们属于独立的 Ralph 工作流工具。
- Cordis 卸载异步开始。已经被收件箱接受的条目可能先进入一个计入额度的 Round 并启动一个请求，之后卸载取消才生效；关闭排空会阻止所有后续 Round。
- `maxGoalRounds` 只是已接纳 Round 的上限。token、费用、挂钟时间和提供方使用预算需要独立策略。
- 自定义 `Agent` 实现必须产生文档规定的会话事件、状态边沿、取消通知和完全停稳语义；仅凭 TypeScript 结构兼容无法验证运行时顺序。
