Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`
Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed`
[The prompt-variables Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency
English
Problem
The prompt-variables Agent Note makes dsh-tool-subagent DERIVE its model-facing wording from its provider: SubagentProvider.inheritsParentContext (spawn/ACP false, fork true) drives both the tool description and the prompt parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule.
Resolving the provider at the tool plugin's apply time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and Entry.init() does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" (defensive patterns).
Decision
The registry announces provider membership as typed events, and the consumer mirrors them instead of assuming order:
subagent/provider-added(provider)— a provider became resolvable in thectx.subagentsregistry. Emitted on registration.subagent/provider-removed(name)— a provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Emitted from the registration's disposer.
dsh-tool-subagent mirrors its named provider's lifecycle: it registers the tool when the provider is (or becomes) available — deriving the wording from that provider at that moment — unregisters the tool when the provider goes away, and re-derives on re-registration (HMR reload). While the provider is absent the tool does not exist, which cannot lie to the model. There is deliberately NO load-order requirement left to document: the events make the ordering question disappear instead of pinning it.
The events also complete the seam's vocabulary: ctx.subagents is a named registry on which multiple delegation backends coexist (spawn, fork, acp), and a registry whose contents other plugins derive state from should announce membership changes as typed events rather than requiring polling or load-order faith.
Alternatives considered
- Resolve the provider at
applytime and throw when absent — rejected because "list backends first" would claim a Loader ordering guarantee that does not exist. - Retrying the lookup (poll until the provider appears) — converges eventually but invents a private readiness protocol beside the one the framework already has (effect registration + disposal); it also cannot notice a provider LEAVING, so HMR would strand a tool whose wording describes a disposed backend.
- Section-only subagent wording, lazily resolved at assemble time — tolerates any load order too, but moves tool-choice guidance out of the DESCRIPTION, contradicting the ownership rule the prompt-variables Agent Note establishes (per-tool semantics and when-to-use belong in the description). Reactive registration keeps the description authoritative AND order-free.
- Keying wording off the provider NAME instead of the provider object —
providerNameis itself config, so a renamed provider silently gets the wrong words; deriving from the resolved provider's owninheritsParentContextcannot drift.
Consequences
- Consumers deriving state from a named provider react to
subagent/provider-added/-removedinstead of reading the registry atapplytime;dsh-tool-subagentis the reference implementation. - Addition fails loud; removal is contained per listener. An addition listener may unwind registration. Removal runs during disposal, so one throwing listener is logged without starving later mirrors or disrupting teardown.
start()still resolves the provider by name for every run, preventing stale tools from calling a removed backend. See the events catalog and producer/consumer map. - A window where the tool is absent. Between backend disposal and re-registration (an HMR reload), the model sees no subagent tool. This is the honest state — the alternative is a tool that dispatches into nothing — and the tool registry's
tools/changeemit keeps prompt assembly current. - Two waiting fibers sharing a
toolNameis an invalid config caught late. If two loads ofdsh-tool-subagentname different providers but the sametoolName, both wait, and whichever provider arrives first registers; the second registration throws only when ITS provider arrives.TODO(subagent-dup-toolname)in the plugin records this blast radius; the tool registry's duplicate-name rejection remains the backstop.
中文
问题
提示词变量 Agent Note 让 dsh-tool-subagent 从其提供方派生面向模型的措辞:SubagentProvider.inheritsParentContext(spawn 和 ACP(Agent Client Protocol)为 false,fork 为 true)同时驱动工具描述和 prompt 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。
如果在工具插件的 apply 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 Entry.init() 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见防御性模式)。
决策
注册表将提供方的成员变化作为类型化事件广播,消费方镜像这些事件而非假设顺序:
subagent/provider-added(provider):一个提供方在ctx.subagents注册表中变为可解析。在注册时发出。subagent/provider-removed(name):一个提供方离开注册表(其插件 fiber 被 dispose(资源释放)——卸载或 HMR(热模块替换)重载)。从注册的 disposer 中发出。
dsh-tool-subagent 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞——当提供方离开时注销工具,并在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不会对模型撒谎。这里有意不留下任何需要文档化的加载顺序要求:事件让顺序问题消失,而非将其钉死。
这些事件还完善了 seam 的词汇:ctx.subagents 是一个命名注册表,多个委派后端(spawn、fork、acp)在其上共存;一个其他插件从中派生状态的注册表,应当以类型化事件广播成员变化,而非要求轮询或依赖加载顺序。
曾考虑的替代方案
- 在
apply时解析提供方,不存在则抛异常:否决。「先列后端」这一要求声称了 Loader 并不存在的顺序保证。 - 重试查找(轮询直到提供方出现):最终能收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它也无法感知提供方离开,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。
- 仅在 section 中放置 subagent 措辞,在组装时惰性解析:同样能容忍任意加载顺序,但将 tool-choice 引导移出了描述,与提示词变量 Agent Note 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。
- 根据提供方名称而非提供方对象确定措辞:
providerName本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的inheritsParentContext派生则不会漂移。
后果
- 从命名提供方派生状态的消费方响应
subagent/provider-added/-removed事件,而非在apply时读取注册表;dsh-tool-subagent是参考实现。 - 添加时大声失败;移除时按监听器隔离。 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。
start()仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见事件目录与生产者/消费方映射。 - 工具不存在的窗口期。 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表发出的
tools/change事件会使提示词组装保持最新状态。 - 两个等待中的 fiber 共享同一
toolName是无效配置,被延迟捕获。 如果两个dsh-tool-subagent加载实例分别指定了不同的提供方但相同的toolName,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的TODO(subagent-dup-toolname)记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。