Files
deepseek-harness/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md
T

7.4 KiB
Raw Blame History

Agent Note: Agent 生命周期与所有权约定

Status: implemented

English | 中文

问题

ACPAgent Client Protocol)与 tool-bash 的若干限制是同一个所有权约定缺失的症状:插件可以通过 ctx.agents 创建或恢复 agent(智能体),但无法独立拥有和 dispose(资源释放)单个 agent,而长时间运行的 bash 任务在执行器中也没有稳定的所有者。ACP 在断连时中止并等待 agent,却无法仅注销该会话的 agent;session/cancel 无法取消已入队但尚未开始的工作;tool-bash 将任务所有权保存在插件本地的 Map 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。

决策

三项约定变更:队列感知的取消、AgentHandle 释放器,以及 bash 所有者令牌。

1. 队列感知的 Agent.cancel(cause?)

Agent 接口新增 cancel() 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 abort() 一同交付;后者后来因无人使用而移除,使 cancel() 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 agent/cancel-requested,携带类型化的 user | parent cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。whenIdle() 会在取消后达到完全停稳,ACP 的 session/cancel 映射到 user显式轮次取消决策规定了当前的 cause、signal 生命周期与协作式结算约定。

2. AgentHandle 异步释放器

ctx.agents.create/resume(以及 AgentFactory 接口)返回 AgentHandle = { agent: Agent; dispose(): Promise<void> }。释放器是一种消费方能力——仅持有裸 Agent 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个记忆化的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 disposed)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 AgentLoop fiber 所有(handle 被丢弃)。ACP 在其 SessionRecord 中保存每个全新会话的释放器,并在断连或插件拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目。在与关闭的竞态中落败的创建流程会 dispose 其尚未发布的 handle。

拆除顺序对持久性至关重要,实现将会话生命周期折叠进 agent 的单个复合 Cordis effectSessionStore.prepare/enter/announce,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(Promise.all),这会让会话存储的 append 发布钩子移除与循环关闭时的 session/flush 竞争,从而丢失关闭的 turn/end;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + await agent.done 在会话分离之前),因此无论 handle 的 dispose() 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 agent/disposedsession/disposed 通知无法拒绝该链或跳过后续拆除。

3. Service Definition 中的 Bash 所有者令牌

后台任务所有权从 tool-bash 插件本地的 Map<string, Agent> 移入执行器。BashExecRequest 新增可选的 owner?: string;解析后的 BashExecSpec 将其作为必需但可空的 owner: string | undefined 携带(被遗忘的 owner 是可见的 undefined,而非静默缺失的属性)。执行器把 token 存在任务上,并通过新的 BashExecutor.ownerOf(id): string | undefined 方法暴露它(不放在公开的 BashTask 上——只有一条读取路径,没有冗余 API)。tool-bash 完全删除其 Map:它在 start 时将 exec.agent?.id(共享的注册表/会话 id)盖章为 owner,bash_output/bash_kill 则以 !== undefined 语义把 ctx.bash.ownerOf(id) 与调用方 token 比较(空字符串 token 仍是真实 owner)。完成通知通过扫描 ctx.get('agents')?.list() 查找 agent.id === ownerToken 的存活 agent(经 ctx.get 读取——onTaskDone 运行在 bash fiber 这一外部 fiber 上,直接使用 ctx.agents proxy 会抛异常)。由于所有权现在保存在执行器的任务上(随 dsh-bash fiber dispose),它能跨越 tool-bash HMR 重载,关闭旧的 XXX(tool-bash-owner-hmr) 缺口。(onTaskDone 监听器仍受 tool-bashapply effect 约束,因此落在重载间隙的完成仍会丢失一条通知——既有的重载间隙丢失——但所有权隔离本身已经不受 HMR 影响。)

验证

以下不变式已经成立,并由测试固定:

  • ACP 断连或插件拆除后,任何由桥接层拥有的会话都不留下已注册 agent 或会话存储条目,包括与连接关闭竞争的创建流程。
  • 已入队的提示词启动前执行 session/cancel,能阻止该提示词运行;后来接受的提示词仍是独立的已入队轮次。
  • tool-bash HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。
  • 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 AgentLoop 插件 fiber 所有。

会话所有者令牌在存活 agent 中唯一

bash 所有者 token 比较依赖共享的 Agent.id/SessionId 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布时会依次登记会话和 agent;SessionStore.enter() 拒绝重复的存活会话 id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问策略token 比较)留在 Consumer tool-bashbash 能力只存储不透明的 owner 字符串且从不解释它——这是正确的 Service Definition / Service provider / Consumer 拆分。

曾考虑的替代方案

  • 公开的 BashTask.owner 字段而非 BashExecutor.ownerOf(id) Service Definition 方法:否决。一条读取路径即可,无需冗余 API。
  • 为 agent 的会话生命周期使用兄弟 Cordis effect:否决。fiber 卸载时并发释放兄弟 effect(Promise.all),store 拥有的 append 发布钩子的移除与循环的关闭 session/flush 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 turn/end
  • cancel() 之外另设一个仅中止步骤的 abort():最初发布过,后因无人使用而移除;cancel() 是唯一的公开停止原语(见公开停止接口 Agent Note)。

后果

本变更有意触及公开接口(AgentAgentFactory、bash seam),而非作为 ACP 的局部补丁。同步 agent 交付仍然简单;异步生命周期路径是增量添加的,供需要它的所有者使用。