DeepSeek Harness 子代理生命周期观测增强:为 subagent/end 补充 lastAssistantMessage
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
导读
DeepSeek Harness 的 subagent(子代理)能力缝(capability seam)允许 Agent 将工作委托给子代理,并通过subagent/start/subagent/end生命周期事件向插件暴露运行过程。本文基于仓库中的 Agent Note 2026-06-30-subagent-observe-enrich.md 展开,讲解一次已落地的观测性增强:在结束事件SubagentRunEndInfo负载中新增lastAssistantMessage字段,使 hooks 桥接或原生插件无需持有运行句柄,即可获知子代理的最终输出。读完本文,你将理解该字段的选取规则、可选(optional)语义、事件时序,以及为何这次改动刻意保持 observe-only(纯观测、无控制流变更)。
背景:hooks 桥接需要知道"子代理产出了什么"
DeepSeek Harness 的 hooks 子系统提供拦截缝(interception seams),让插件能够在 Agent 生命周期的关键节点进行观测与门控(gate)。作为行业参照,Claude Code 与 Codex 都暴露了SubagentStart / SubagentStop钩子,其中 Claude Code 的 SubagentStop 会携带子代理的最终消息(final message)。
Harness 本身早已通过ctx.subagents服务发射subagent/start与subagent/end生命周期事件(详见 subagent 子系统文档 与 事件类型源码)。但问题在于:这两个事件的负载此前过于单薄——subagent/start仅携带runId、provider、id、local,subagent/end在此基础上只多了stopReason。对于一个 hooks 桥接层而言,这些信息不足以向外部(如 Claude Code 风格的 SubagentStop 处理器)报告"这个子代理到底产出了什么";想获取产出,桥接层不得不另行触达尚在运行中的 run 对象,这不仅增加耦合,也让生命周期事件本身的信息价值大打折扣。
设计决策:向 SubagentRunEndInfo 增加 lastAssistantMessage
该 Agent Note 的决策非常收敛——在SubagentRunEndInfo上新增lastAssistantMessage字段,承载子代理的最终输出。当前仓库源码中的类型定义印证了这一决策(packages/subagent/subagent/src/types.ts):
export interface SubagentRunEndInfo { /** Unique identity shared with the paired start event. */ readonly runId: SubagentRunId /** The same provider name carried by the paired start event. */ readonly provider: string /** The child agent's id. */ readonly id: SessionId /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] /** * The child's final assistant output, selected by the same rule as * {@link SubagentResult.output}; absent on infrastructure rejection or when * the child produced none. */ readonly lastAssistantMessage?: ContentBlock[] }字段语义:正常收尾与基础设施失败两种路径
- 正常收尾路径:子代理运行结算(settle)时,
lastAssistantMessage就是只读的、带类型的SubagentResult.output。观测者不持有 run,也能看到子代理产出的内容。 - 基础设施拒绝路径:当一次运行因为基础设施故障(如传输层崩溃)失败、根本没有产生
SubagentResult时,该字段不出现,事件以stopReason: 'error'报告结局。 - 无产出路径:子代理没有产出任何 assistant 内容时,字段同样被省略(见下方源码中
result.output.length === 0 ? {} : { lastAssistantMessage: result.output }的处理)。
借用不可变负载契约
SubagentRunEndInfo的所有字段均为readonly,lastAssistantMessage的元素类型ContentBlock[]同样按不可变数据对待。该契约建立在"提供方与监听方都是可信的同进程协作者"这一前提之上:负载是借用的(borrowed)不可变数据,监听者不得修改或长期持有可变引用。
事件模型保持不变:仍是纯 emit、严格 observe-only
这次增强刻意不动事件模型。subagent/end依然是一个普通的emit(而非拦截缝常用的 waterfall 模式),并且只用于观测,不具备任何控制流能力。
从源码(packages/subagent/subagent/src/lifecycle.ts)看,一次 one-shot 运行的生命周期对是这样发布的:
export function observeRun( emit: LifecycleEmitter, provider: string, parent: Agent, run: SubagentRun, ): SubagentRun { const identity = { runId: SubagentRunId(randomUUID()), provider, id: run.id, local: run.localAgent !== undefined, } // Attach the terminal observer before dispatching start. void run.result.then( (result) => { emit('subagent/end', { ...identity, stopReason: result.stopReason, ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, () => { emit('subagent/end', { ...identity, stopReason: 'error' }, parent) }, ) emit('subagent/start', identity, parent) return run }事件时序的三个关键性质
- 先挂接结束观察者,再同步发射 start:
observeRun在派发subagent/start之前,就把run.result的 then 回调注册好了。由于 Promise 反应回调必然在同步的 start 发射之后才执行,因此保证了start → end的严格先后顺序。 - start 之后即可解析子代理:异步的
SubagentService.start()将结果观察挂接到已就绪的 provider run 上、发射subagent/start,随后才返回 run。因此,进程内监听器可以在subagent/start通知期间通过ctx.agents.get(info.id)拿到已发布的子代理;而远程 provider(如 ACP、Claude Code 后端)则不需要在本地注册表中有对应条目,订阅者不应假设ctx.agents.get必然命中。 - 被拒绝的 start 不发射任何事件:如果 provider 的
start()被拒绝(如能力不支持、初始化失败),说明从未产生被接受的 run,此时既无subagent/start也无subagent/end,生命周期事件对保持完整。
监听器隔离(per-listener containment)
生命周期发射器对每个监听器做独立异常隔离(createLifecycleEmitter):某个回调同步抛错或返回 rejected promise,都会被记录日志而不会拖垮同级监听器、改变运行状态或破坏后续派发。一个坏订阅者既不会让活跃的 run 悬挂,也不会饿死后面的监听器。
备选方案与取舍:为什么只加这一个字段
Agent Note 记录了评审过程中被放弃的备选方案,理解这些取舍有助于把握本次增强的边界。
agentType 子代理类别标签(被放弃)
早期草案曾计划在请求与两个生命周期负载上同时增加agentType标签(对标 Claude Code 的subagent_type)。该方案在评审中被放弃,原因很干脆:agent_type是 Claude Code 的概念,并不适配 Harness 自己的缝——当前仓库没有任何代码解释这个字段,唯一可能的消费者只是 CC 方言桥接层。因此,CC 桥接在转发 SubagentStart/Stop 时,直接为agent_type匹配器填入 Claude Code 自己的默认值"general-purpose",而 Harness 侧只保留一个增强:lastAssistantMessage。
控制流 subagent/end(被推迟)
另一种"让 end 返回停/继续决策"的控制流方案被明确推迟,详见下节。
为什么是 observe-only:控制流版本需要什么
一个控制流subagent/end(像其他拦截缝那样,await 一个返回 stop/continue 决策的瀑布流)并非小改,它需要三件事:
- 把
subagent/end从 emit 重塑为 waterfall(拦截缝的 await 模式); - 重构
SubagentService.start,在结算(settle)之前 await 所有监听器; - 在进程内 provider 中实现
resume能力,让"continue"决策能真正重跑子代理。
这属于背景/转向(background/steering)子代理重构的范畴——正是能力缝 Agent Note 中已经推迟的同一个重构方向(该重构还会统一 subagent 与 bash 对长时运行工具的处理)。因此,本次增强只交付 hooks 桥接今天需要的观测部分,源码中以FIXME(subagent-continuation)/TODO锚点标记了控制流版本将来可能的落点。
后果与消费方式:hooks 桥接如何受益
订阅现有 emit 即可转发最终输出
一个 hooks 桥接层(或原生插件)现在可以直接订阅既有的subagent/end,把lastAssistantMessage转发给外部的 SubagentStop 处理器,不需要新增任何控制流接口。典型消费路径:
- 订阅
subagent/end,用runId与subagent/start配对(同一对事件的runId相同); - 读取
stopReason判断子代理如何收尾; - 若存在
lastAssistantMessage,即为子代理的最终输出,可转发到 Claude Code / Codex 风格的 SubagentStop 处理器;若字段缺失且stopReason === 'error',说明是基础设施失败,无产出可报告。
事件契约与文档同步
词汇表变更已同步到 subagent 子系统文档 的事件说明(subagent/start/subagent/end两节,见 subagent/end — emit 与 subagent/start — emit),事件目录(catalog)随之重新生成。需要说明的是:原 Agent Note 提到同步位置为docs/core-data-structures/subagent.md,当前仓库中该文档位于docs/subsystems/subagent.md,以当前实际路径为准。
无生产行为变化
事件触发的时机与之前完全一致,只是 end 负载多了一个可选字段,因此无需任何快照(snapshot)或端到端(e2e)测试变更。这体现了"可选字段向后兼容"的设计:旧监听器忽略新字段照常工作,新监听器依赖新字段也不会破坏旧路径。
源码印证:最终输出的选取规则与不变式校验
最终输出选取规则
lastAssistantMessage与SubagentResult.output共享同一条选取规则(packages/subagent/subagent/src/assistant-output.ts):
- 优先取最后一条非空 assistant 消息(空内容消息,包括仅含 usage 的消息,会被跳过);
- 若没有任何非空消息,则回退到累积的 assistant 文本流;
- 两者皆无时返回
undefined,事件中即省略该字段。
finalAssistantOutput会以增量折叠(fold)方式遍历事件,AssistantOutputFold支持按会话事件push或按文本流pushText两种输入,供不同传输(会话事件后端 vs ACP 内容块)复用同一条规则。
可续子代理(continuable)同样适用
同一语义也适用于可续子代理的 Activation 驻留时段:createActivationObserver的settle()在派发subagent/end时做同样的字段省略处理(output === undefined ? {} : { lastAssistantMessage: output })。冷恢复(cold resume)会被当作一个带新runId的新 epoch,观测者看到的是与 one-shot run 完全一致的词汇表。
不变式校验与测试
事件配对与字段合法性由不变式校验器守护(packages/subagent/subagent/src/invariant.ts):subagent/end必须与已发射的subagent/start通过runId配对,且身份字段(provider、id)不得漂移。对应测试位于 packages/subagent/subagent/tests/invariant.spec.ts,覆盖了"接受合法的 run 生命周期对"、"拒绝畸形/未配对的转换"以及"注册结束后仍接受已记录的历史 provider 名"等场景,同时验证了subagent/end负载构造(含stopReason: 'completed'默认值与覆盖项)的合法性。
小结
lastAssistantMessage是 DeepSeek Harness 子代理生命周期缝的一次小而克制的观测性增强:它以可选字段的形式,让subagent/end事件真正能够回答"子代理产出了什么",从而支撑 hooks 桥接与 Claude Code / Codex 方言适配,同时严格守住 observe-only 边界——不引入控制流、不改变事件时机、不需要快照更新。对于想要基于 subagent 生命周期构建观测、上报或外部适配的插件开发者而言,理解这条缝的词汇表与边界,是正确接入的第一步。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考