关于 agent-host-chat-contributions
面向 Agent Host 聊天生命周期扩展的贡献模型开发指南。它说明如何设计、注册和排序独立行为,避免将横切逻辑塞回大型编排类。
主要功能
- 01判定行为是否适合贡献项
- 02定义七种生命周期钩子
- 03注册依赖注入的贡献项
- 04明确钩子执行顺序
- 05提供测试与服务依赖指引
适用场景
- 01为聊天生命周期添加独立行为
- 02实现请求准入或提示词处理逻辑
- 03调整贡献项顺序并更新回归测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ vscode --skill agent-host-chat-contributions安装后在 Claude Code 中输入/agent-host-chat-contributions确认已加载。
详细介绍
Agent Host Chat 贡献项
当某项独立行为跨越 Agent Host 聊天生命周期边界时,请使用贡献项模式。更改该模式前,请先阅读以下文件:
src/vs/platform/agentHost/common/agentHostChatContributionsService.tssrc/vs/platform/agentHost/node/agentHostChatContributionsService.tssrc/vs/platform/agentHost/node/chatContributions/TODO.mdsrc/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts
防止旧问题重现的规则
新的横切功能应放在贡献项中,而不是放在
AgentSideEffects或AgentService中。
通过拆分十七项行为,AgentSideEffects 已减少了 700 多行。请勿再将其他生命周期副作用、出站提示词附加内容、恢复的轮次补充内容、准入检查或操作观察器放回这两个大型编排类中。AgentSideEffects 负责分发生命周期事件,并保留路由和发送机制;AgentService 负责会话生命周期,并调用数据填充和备忘录清除逻辑。请参阅 agentSideEffects.ts 和 agentService.ts。
这也是一项代码审查规则:向这两个文件中的任意一个添加代码,都需要明确说明这是路由、正确性不变量、与提供方相关的行为,还是分发器调用点。“加在这里比较方便”不是理由。
编码前先做判断
如果某项行为符合以下测试,请将其编写为贡献项:
- 它是否响应钩子已命名的生命周期时刻——轮次请求继续执行、轮次完成、操作已分发、轮次即将发送、轮次列表已恢复,或聊天已恢复?
- 是否可以由一个显式注册、通过依赖注入的单元负责,而无需更改协议路由或提供方映射?
- 它是否能按明确的顺序与其他行为组合?
例如:准入门控、本地命令拦截、检查点和变更集处理、队列排空、GitHub 引用附加、标题处理、持久化用量、会话输入需求聚合、会话标记持久化、聊天草稿、聊天自身的配置、工作树公告、未读状态、富链接指引和聊天界面指引。它们在 node/chatContributions/ 下的目录就是参考实现。
以下情况不要使用贡献项:
node/agentSideEffects.ts中的子 Agent 信号路由和缓冲属于路由基础设施和正确性不变量。node/agentSideEffects.ts中的轮次 ID 重映射也属于路由基础设施。- 三个 SDK 事件映射器和三个附件序列化器都与提供方相关。
- 恢复期间应用的与提供方相关的覆盖行为,例如在
_doRestoreSession中,使用IAgent提供的会话元数据为草稿设置模型。移动这项行为会将提供方元数据引入IHydrationContext,而它只有一个消费者。
如果答案是“状态更改前必须正确路由每个信号”,或“行为因 SDK 而异”,那它就不是贡献项。未完成的工作和持续适用的注意事项记录在 node/chatContributions/TODO.md 中。
保持钩子接口精简
IAgentHostChatContribution 在 common/agentHostChatContributionsService.ts 中有七个钩子。只有在现有载荷都无法表达该行为时,才添加新方法。
该集合曾从六个钩子缩减为四个:
onTurnConsumable改为onTurnEnd,并使用TurnEndReason.kind === 'localCommand'。onUserMessage改为onOutgoingTurn。
优先为现有类型添加判别字段或载荷(例如新增一个 TurnEndReason 变体),而不是添加钩子。一个只有一个调用方和一个实现方的单一用途钩子,正是导致上述两项移除的典型问题。不要仅仅为了封装一次直接调用而创建抽象。
此后添加的三个钩子都满足一个明确标准:现有载荷都无法表达它们,因为它们在不同的时机触发或来自不同的来源,而不只是数据不同。
onHydrateChat会在聊天进入目录之前,恢复由主机管理的聊天状态。onHydrateTurns则会在提供方返回轮次后惰性运行;如果复用它,恢复的对等聊天在打开之前就会一直没有标题。onIncomingRequest会在轮次到达提供方之前拦截它,并可以拒绝该请求。观察者钩子无法拒绝请求。onDidDispatchAction会观察由服务器分发以及被拒绝的操作,而从结构上讲,onDidApplyClientAction无法看到这些操作。
曾考虑为 onHydrateChat 在现有钩子中添加 { kind: ... } 判别字段,但最终否决了这一做法:判别字段指南适用于同一时刻内的载荷变体,就像 TurnEndReason 用于区分单个轮次结束时的不同结果一样。
结构与注册
为每个贡献在 node/chatContributions/<feature>/ 下创建单独的目录。随着功能增长,该目录负责管理其实现、辅助代码和测试。每个贡献都必须具备:
static readonly id,且在所有注册项中唯一。- 明确指定的
order,即使它目前看起来与其他项相互独立。 - 一个构造函数,其第一个参数为
IAgentHostChatContributionContext,后面接注入的服务。 - 在
node/chatContributions/builtInChatContributions.ts中进行一次注册。
以下是现有 Markdown 计划富链接贡献的完整结构,改编自 markdownPlanRichLinks/markdownPlanRichLinksContribution.ts:
export class MarkdownPlanRichLinksContribution extends Disposable implements IAgentHostChatContribution {
static readonly id = 'markdownPlanRichLinks';
readonly order = 100;
constructor(
protected readonly _context: IAgentHostChatContributionContext,
@IAgentConfigurationService private readonly _agentConfigService: IAgentConfigurationService,
) {
super();
}
onOutgoingTurn(turn: IOutgoingTurn): ISendContribution | undefined {
return this._agentConfigService.getRootValue(platformRootSchema, AgentHostMarkdownPlanRichLinksEnabledConfigKey)
? { instructions: [createMarkdownPlanRichLinksInstruction(turn.chat)] }
: undefined;
}
}
使用 contributions.registerContribution(MarkdownPlanRichLinksContribution) 将其构造函数添加到唯一的内置列表中。该列表由 node/agentHostContributions.ts 中的 activateAgentHostContributions 激活;不要添加其他注册位置。分发器通过 IInstantiationService 构造每个贡献,拒绝重复 ID,并在取消注册时释放该贡献。
七个钩子
| 钩子 | 触发时机 | 不得假设 | 现有示例 |
|---|---|---|---|
onIncomingRequest | 轮次请求要求继续交由某个 provider 处理时,在发生任何副作用之前触发。同步执行,且失败时默认拒绝。 | 它负责决定是否准入,因此抛出异常会导致请求被拒绝,而不是跳过。返回 accept、handled 或 reject;第一个非 accept 的结果会生效,后续贡献不会再被查询。 | localCommand 50,turnAdmission 100 |
onTurnEnd | 已启动的轮次可能达到的任何终止状态,以及轮次开始前被拒绝的请求。 | 区分 rejected 和 error:前者没有已启动的轮次需要收尾,也没有检查点需要捕获。不要抛出异常。如果没有活动轮次时收到 Agent 发出的终止操作,它不会触发,因为 reducer 会对这些情况执行空操作。 | checkpointAndChangeset、queueDrain、githubReferences、sessionTitle、markUnread |
onDidApplyClientAction | 客户端分派的操作已应用到宿主状态。 | 它看不到服务端分派或被拒绝的操作。它看到的任何操作都已确定完成归约。 | queueDrain 跟踪排队中的发送者;sessionTitle 持久化用户的标题更改。 |
onDidDispatchAction | 来自任何来源的操作已分派,且其结果已确定。 | 它也会传递从未完成归约的已拒绝操作——请检查 rejectionReason。同时实现此钩子和 onDidApplyClientAction,会使每个客户端操作都被看到两次。 | sessionInputNeeded、persistedTurnUsage、chatConfig、sessionFlags |
onOutgoingTurn | 准入和 provider 查找完成后、发送轮次之前等待调用。 | 被拒绝的轮次和 noAgent 错误不会触发此钩子。IOutgoingTurn 携带完整的 Message;各贡献可按顺序替换其文本并添加指令,但不能替换已提交的附件、模型、Agent、来源或元数据。注入上下文时优先使用消息文本通道:更改指令会使 provider 提示词缓存失效并增加用户成本。不要绕过发送路径。 | markdownPlanRichLinks、chatSurface、sessionTitle、sideChat |
onHydrateTurns | provider 为聊天返回完整的已恢复轮次列表时。 | 它不限于默认聊天,且必须为下一阶段返回一个列表。不要假设输入是未经处理的 provider 输出,因为更早的贡献可能已对其进行转换。 | turnDelegation、persistedTurnUsage、worktreeAnnouncement、sideChat |
onHydrateChat | 正在恢复聊天时立即触发,且发生在聊天进入会话目录之前。 | 它运行时没有 provider——这是一次仅读取元数据的操作;而且此时聊天尚不存在,因此在这里获取的聊天 memento 可能会在注册失败后继续存在。 | sessionTitle、chatDraft、chatConfig |
调用位置在 node/agentSideEffects.ts、node/agentHostTurnStarter.ts 和 node/agentService.ts。
顺序会影响行为
order 至关重要。分发器会先处理值较小的项;只有值相同时,才按注册顺序处理。顺序针对每个钩子分别计算,因此,水合阶段可以独立于出站轮次复用 100 系列。参见 node/agentHostChatContributionsService.ts。
使用明确的顺序来保证所需的执行序列:
LocalCommandContribution在onIncomingRequest中的顺序为 50,TurnAdmissionContribution为 100。这个顺序至关重要:本地命令始终会在只读保护之前拦截请求,因此在只读或已归档的聊天中,/rename仍然有效。CheckpointAndChangesetContribution的顺序为 100,以便在后续变更集相关工作之前捕获检查点。QueueDrainContribution和SessionInputNeededContribution的顺序均为 200。- 适用时,
GitHubReferencesContribution和ChatSurfaceContribution的顺序为 300。 SessionTitleContribution的顺序为 400,MarkUnreadContribution和SideChatContribution为 500,ChatDraftContribution为 600,ChatConfigContribution为 650,SessionFlagsContribution为 700。
markUnread 和 sideChat 的顺序均为 500,且两者都实现了 onTurnEnd,因此它们的相对顺序取决于注册顺序。新增贡献项时,应使用不同的顺序,避免加入这个并列组。
此前已经发生过一次无声的顺序回归。test/node/chatContributions.test.ts 中的内置序列回归测试用于保护原有的轮次结束、出站轮次和水合序列。主动更改顺序时,请添加或更新这些测试。
依赖服务,而不是不断扩大的上下文
切勿向 IAgentHostChatContributionContext 添加回调或服务。它只包含贡献项标识和由中央逐出机制管理的记忆状态。应通过 @Injectable 服务进行协作。
IAgentHostChatContributionHost 是通往 AgentSideEffects 的有意保持精简的桥接层,仅包含 hostLaunchKind 和 sendTurnMessage。它曾扩展到七个成员,之后通过将协作者提升为真正的服务,精简回两个:
IAgentHostSessionTitleControllerIAgentHostProviderServiceIAgentHostTelemetryReporterIAgentHostTurnTrackerIAgentHostToolCallTrackerIAgentHostLocalCommands
IAgentHostWorktreeIsolation 也可以注入。“它不可注入”这一结论是由服务注册顺序导致的误判,后来已被证伪。请从 node/agentHostServices.ts 开始,追踪到 node/agentHostBootstrap.ts,再到 node/agentServiceComposition.ts 中的 createAgentServiceComposition。在实例化路径构造贡献项之前,先注册真正的服务;不要通过上下文或主机桥接层偷偷传递服务。
添加服务并非没有代价:大约有六个手动构建的测试 ServiceCollection 会构造 AgentSideEffects 或调用 registerBuiltInChatContributions,每个都需要注册新服务,否则会因 UNKNOWN service <id> 失败。还有两个相关陷阱,都曾实际出现过:
- 仅当测试注册的类扩展了
Disposable时,才将其包装在disposables.add(...)中;包装普通对象会在清理时抛出d.dispose is not a function。 - 如果协作者没有状态,优先使用一个接收
ServicesAccessor的普通函数,通过IInstantiationService.invokeFunction调用,并完全跳过 DI。node/agentHostTurnStarter.ts可作参考:将它做成服务毫无益处,却导致每个依赖图都要增加一项注册。
备忘录有生命周期
在 common/agentHostChatContributionsService.ts 中使用 createChatMementoKey 或 createSessionMementoKey 创建键。上下文按贡献项划分,因此两个贡献项中相同的调试名称不会冲突。
- 聊天被释放时,会清除聊天备忘录。
- 所属会话被释放时,会清除会话备忘录和所有提供的聊天备忘录。
- 额外的键段属于标识的一部分。可将它们用于有界标识,例如排队消息 ID。
- 不再需要某个额外键段的值时,调用
deleteMemento。 将其可观察对象设为undefined不会移除其映射条目,该条目会一直保留到聊天或会话被释放。因此,对于长期存在的聊天,每个键段值都会累积一个条目。 onHydrateChat在聊天注册之前运行,因此此时创建的聊天备忘录可能会比随后注册失败的聊天存活更久。目前没有贡献项这样做,但在处理好这个问题之前,不要成为第一个这样做的贡献项。
queueDrain/queueDrainContribution.ts 展示了在移除和消费排队消息时必须调用删除操作。集中清理逻辑位于 node/agentHostChatContributionsService.ts,由 node/agentService.ts 调用。
故障隔离,以及一个例外
对于每个观察者钩子,分发器都会分别记录并隔离故障:
- 某个贡献项的
onTurnEnd、onDidApplyClientAction或onDidDispatchAction抛出异常,不会阻止后续贡献项运行。 onOutgoingTurn贡献项失败时,不会添加任何指令,也不会阻止发送或后续贡献项运行。onHydrateTurns或onHydrateChat贡献项失败时,会有意将先前的值传递给下一阶段。丢失聊天历史、标题或草稿,比丢失一项增强更糟。
onIncomingRequest 会采取相反的策略,失败时默认拒绝。 如果贡献项在此处抛出异常,请求会在 validation 阶段以 internalError 被拒绝。这个门控机制用于确保 UI 隐藏撰写框;如果默认放行,有缺陷的客户端或远程客户端就可能在会话的隔离工作树可能已不在磁盘上的情况下执行工作。丢失一项增强尚可承受;让请求绕过守卫则不可接受。
出于同样的原因,它也有意保持同步:门控必须在发送路径执行任何 await 之前作出判断,这样它读取的状态才不会在判断与生效之间发生变化。目前宿主执行的所有准入检查都已是同步的。我们最初尝试了异步版本,但它会将只读守卫的读取延后一个微任务,导致三个 agentSideEffects 测试失败;这恰好说明门控应该位于第一个 await 之前。
在能改善功能的地方实现常规错误处理,但绝不要依赖抛出异常来控制生命周期流程。确切的分发行为见 node/agentHostChatContributionsService.ts。
测试与提取验证
在 test/node/chatContributions.test.ts 中添加针对分发器的测试,覆盖钩子分发、顺序、备忘录清理和故障隔离。同一文件还负责内置序列的回归测试。
从 AgentSideEffects 提取现有行为时,保持现有的 test/node/agentSideEffects.test.ts 测试套件不变。测试通过,就证明提取保留了原有语义;应单独测试新的分发器机制,而不是为了适应新结构而改写旧断言。
有一个曾两次导致问题的注意事项:一些手动构建的测试图会在未调用 registerBuiltInChatContributions 的情况下构造 AgentSideEffects,因此一旦行为移入贡献项,它们就会悄无声息地停止测试该行为。提取导致此类测试套件失败时,应补上缺失的注册,使测试图与生产环境的接线方式一致——不要削弱断言。失败意味着测试验证的是不完整的测试图,而不是提取有误。