Skip to content

.NET Plugin API 与生命周期

本参考页介绍 contribution contract、typed dependency、trust 与 generation lifecycle。若要创建并构建插件,请先阅读 构建 .NET 插件

三个能力层级作用于同一份贡献点装配结果:Tier A 按序追加一项,Tier B 在句柄存续期间遮蔽具名默认项,Tier C 返回最终结果

选择贡献点

贡献点目录就是全部贡献面。每个贡献点声明自己支持哪些能力层级:

层级作用
A —— 追加在已有条目旁增加一项,按 Order 升序排列,同值时以注册顺序决胜。
B —— 替换ReplaceTarget 遮蔽一个具名默认项。替换句柄一旦释放,默认项立即回归。
C —— 接管对某个贡献点的组装结果拥有最终决定权。它通过 Tier-B 机制实现,由接收已组装结果的契约承担。
贡献点契约层级
工具IToolSourceA
系统提示词分节ISystemPromptSection,Tier C 为 ISystemPromptAssemblerA、B、C
会话上下文条目IChatContextProviderA
Thread 提示词上下文IThreadSystemPromptContextProvider(仅 BaseInstructionsA
发送前上下文变换IAgentContextSourceAIContextProviderA、B
Chat 中间件IChatMiddlewareA、B
派发阶段policy、approval、recorder、normalizer 各阶段接口A、B
Thread 生命周期IThreadLifecycleContributorA
Turn 生命周期ITurnLifecycleContributorA
Thread 运行时信号IThreadRuntimeSignalContributorA
斜杠命令ICodeCommandA
工具约束IToolRestrictionA
压缩摘要ICompactionSummarizerB
可压缩工具ICompactableToolPolicyA、B
SubAgent 运行时ISubAgentRuntimeSourceA
Trace 汇聚ITraceSinkA
辅助生成器ICommitMessageSuggesterIWelcomeSuggesterB

失败处理由各贡献点决定。观察与扇出类贡献通常会记录并跳过失败项;result normalization、compaction 等权威变换可以让所属操作失败。预期失败应通过契约的结果类型返回,而不是抛异常。

Tier A —— 增加一个工具

插件通过 IToolSource 贡献工具——内核自己的工具源用的就是这个贡献点。一个源把每个工具声明为一份定义(标识、规范名、描述、JSON schema、策略提示)与一份运行时绑定(真正执行它的代码)的组合。

宿主在贡献边界上把自己插在中间:它把定义复制成自己的对象,把标识重新编成带 PluginNative 来源的 (pluginId, toolId),并把绑定换成只持有 (pluginId, generationId, toolId) 的代理,每次调用都重新解析你的源。因此冻结的工具快照里不含任何插件分配的对象:撤销贡献立即生效,而从旧快照派发的调用会以 tool_unavailable 失败。插件工具与内置工具一样获得 schema 校验、策略、审批、Hooks 和记录,PolicyHints 只是给宿主策略的提示,永远不会覆盖它。

csharp
internal sealed class SummaryTool(ReviewJournal journal) : IToolSource, IToolRuntime
{
    private const string ToolId = "review-summary";

    public string SourceId => "acme.review-core.summary";

    public ValueTask<IReadOnlyList<ToolRegistration>> GetRegistrationsAsync(
        ToolPlanningContext context,
        CancellationToken cancellationToken = default)
    {
        var definitionId = new ToolDefinitionId(
            ToolSourceKind.PluginNative,
            SourceId,
            new SourceToolId(ToolId));
        var registration = new ToolRegistration(
            new ToolDefinition(
                definitionId,
                new ToolName("review", "summary"),
                "Normalizes review text.",
                JsonSerializer.SerializeToElement(new
                {
                    type = "object",
                    properties = new { text = new { type = "string" } },
                    required = new[] { "text" },
                    additionalProperties = false
                }),
                policyHints: new ToolPolicyHints(RequiresApproval: false, ReadOnly: true)),
            new ToolRuntimeBinding(
                new RuntimeBindingId($"{SourceId}:{ToolId}:{context.Revision}"),
                definitionId,
                this,
                ToolBindingLeases.AlwaysAvailable,
                SourceId,
                context.Revision),
            ToolProjectionShape.StandardPair);
        return ValueTask.FromResult<IReadOnlyList<ToolRegistration>>([registration]);
    }

    public ValueTask<ToolExecutionResult> InvokeAsync(
        ToolInvocationContext context,
        JsonObject arguments,
        CancellationToken cancellationToken = default)
    {
        journal.Write("echo tool invoked");
        return ValueTask.FromResult(ToolExecutionResult.Succeeded(
            arguments["text"]?.GetValue<string>() ?? string.Empty));
    }
}

GetRegistrationsAsync 每次工具规划都会被调用一次,所以要保持轻量、无副作用,并只返回对收到的 ToolPlanningContext 有效的工具。一个源可以声明多个工具;同一插件内重复的工具 id 会被记为诊断并跳过,而不会让激活失败。

预期内的失败请用 ToolExecutionResult.Failed 配一个 ToolError 返回,这样它的稳定错误码得以保留。抛出异常的正文会被丢弃,模型只会看到未指明的工具失败。边界两个方向都只走 JSON:宿主把参数复制进去,把文本、结构化内容和错误复制出来。

Tier A —— 增加一个斜杠命令

插件通过 ICodeCommand 贡献斜杠命令:一个名字、可选的别名、一段描述,以及把一次调用展开成本轮输入文本的 Expand

csharp
internal sealed class TriageCommand(ReviewService service) : ICodeCommand
{
    public string Name => "triage";

    public string Description => "汇总待评审队列";

    public IReadOnlyList<string> Aliases => ["tri"];

    public string? Expand(CommandInvocation invocation) =>
        service.BuildTriagePrompt(invocation.Arguments);
}

贡献的命令就是一条“正文是代码”的 Markdown 自定义命令,宿主也在同样的地方伺服它:命令面板、command/listcommand/execute、ACP 斜杠命令列表都会自动收录,客户端无需任何改动。它只产出模型输入——直接回复用户仍由宿主自己的命令负责。

它不会遮蔽任何东西:内置命令、工作区 Markdown 命令、工作流命令在重名时都保留自己的名字。贡献之间由 Order 最小者取得名字,返回 null 表示放弃本次调用、交给下一个贡献回答。释放句柄即刻把命令从所有列举与解析中移除——贡献点是按调用读取的,不会有任何残留注册。

Tier B —— 替换具名默认项

内置提示词分节和中间件都以稳定的目标名注册。把 ReplaceTarget 设为其中之一,你的贡献就会在句柄存活期间遮蔽它:

csharp
context.Contributions.Add<ISystemPromptSection>(
    new ReviewResponseStyleSection(),
    new ContributionOptions(ReplaceTarget: SystemPromptSectionNames.ResponseStyle));

Agent 内置的记忆 Provider 也以同样的方式注册,目标名为 AgentContextSourceNames.Memory;替换它就等于接管整份系统提示词,而 AgentContextRequest.PromptInputs 会带上内置项本会用到的构建期取值——工具名、延迟加载的 MCP Server、SubAgent 档案分节、Skill 变体目标。这也是唯一一个返回 null 表示放弃而非抑制的目标:内置项会顶上,于是没有哪个 Agent 会在没有提示词的情况下运行。

“抑制”就是一个不产出内容的替换:返回 null 的分节会把内置项整体移除,不存在单独的移除动作。当两个替换指向同一个名字时,Thread 作用域胜过 Workspace 作用域,同一作用域内后注册者胜出;Order 只负责排列解析列表,从不参与替换的裁定。落败的一方记为 ReplaceConflict 诊断,而不会让贡献点失败。

Tier C —— 接管贡献点的输出

接管就是一个普通贡献点,只不过它的契约接收组装好的默认结果并返回最终结果——系统提示词对应的是 ISystemPromptAssembler。消费方采用解析列表中的最后一个贡献,因此“每个贡献点至多一个生效接管”是排序的结果,而不是注册表强制的规则。既没有排在更后、也没有声明替换的接管,只是单纯不生效。

顺序与作用域

顺序只在单个贡献点内有意义。相对内置项定位时,请使用该贡献点公开的 target 名称与 order 常量;除非契约另有说明,较小的值先执行。ContributionOptions.Scope 默认为 WorkspaceContributionOptions.ForThread(threadId) 把贡献限定到单个 Thread。Fork 出的 Thread 不继承 Thread 作用域贡献;若希望贡献跟随 Fork,请使用 Workspace 作用域,或在其 started 生命周期事件中重新注册。

按 Turn 或按调用求值的贡献点会在下一次求值时看到变更。被固化进每 Thread 状态的那些——工具快照、Agent 的管线与指令——则通过宿主的失效链重建。进行中的 Turn 会用它开始时的那套贡献跑完。

导出与消费类型化服务

把公共服务接口放进独立程序集,并在 exportedApiAssemblies 中列出。Provider 在激活期导出实现:

csharp
context.Exports.Add<IReviewService>(new ReviewService());

直接消费方声明它所需的 Provider 最低兼容版本,并在自己的激活期解析该接口:

json
{ "dependencies": { "acme.review-core": "1.0.0" } }
csharp
var review = context.Dependencies.GetRequired<IReviewService>("acme.review-core");

依赖用于协调 generation 生命周期与 API 共享,不负责解析私有包。服务仅能在激活期解析;Provider 先于消费方激活、后于消费方停止,因此请在 ActivateAsync 中捕获服务,并让所有工作受消费方 lifetime 管理。

导出签名应只使用插件自己导出的类型,以及双方共享的宿主程序集类型。在同一消费方的 Provider 集合内,导出 API 程序集的简单名称必须唯一;冲突会报 PluginApiAssemblyConflict

依赖版本表示同一兼容线内的最低版本。"acme.review-core": "1.2.0" 接受 1.2.0 及更高的 1.x 版本,但不接受 2.0.0。对于 0.x,major 和 minor 都必须相同:0.2.1 接受之后的 0.2.x,不接受 0.3.0。低于最低版本或位于其他兼容线的 Provider 会让消费方停在 blocked 并报 PluginDependencyUnsatisfied

在同一兼容线内升级时,Provider 必须保持每个导出 API 程序集的 identity 不变:简单名称、AssemblyVersion、culture 与 public-key token。破坏性 API 变更需要开启新的兼容线,并且通常应使用新的插件 id 与 API 程序集 identity。请声明能提供所用 API 的最低兼容版本。

信任

安装或启用 dotnet 插件都不会授予信任。已安装的插件只有在启用后,且当前 .NET 执行指纹已在机器本地权限文件中获得显式授权时才会运行;否则仍会被阻断。

  • 授权绑定精确的 id 与指纹。 客户端只按插件 id 请求信任;服务端把授权绑定到它实际接受的那份字节。同一个插件 id 可以同时保留多个已授权指纹。只有变更后指纹没有匹配授权时,插件才会变成 modified
  • 托管路径与契约数据也是指纹的一部分。 DotCraft 会对规范化的 .NET 声明、插件版本与依赖,以及非 Desktop bundle 文件树做哈希。在这些文件间移动字节会改变身份。原始 manifest 字节、仅用于部署的 .builtin 标记和 desktop/ 文件树不计入该指纹,因此只修改 Desktop 模块不会让 .NET 信任失效。
  • 权限文件独立于配置。 授权保存在全局配置旁的 dotnet-plugin-trust.json,但该文件不参与配置合并,Workspace 配置也不能授予信任。
  • 已安装插件不存在隐式信任层级。 每个已安装的 dotnet 插件都需要显式授权,宿主自带的 bundle 也不例外。
  • 撤销按指纹生效。 撤销只移除当前插件 id 与指纹这一对授权。如果活跃闭包依赖该授权,它会停止;同一 id 的其他指纹授权保持不变。

没有匹配授权时,已安装插件停在 blocked 并报 PluginUntrustedPluginTrustModified,而且不会创建任何加载上下文,因此它的代码一行都没有运行过。DotNetPlugin.Build 开发构建会改为让开发 bundle 的精确指纹获得进程内执行资格;该资格不会持久化,也不适用于已安装插件。

生命周期与更新

每次激活都会获得自己的可回收加载上下文和一个不透明的 generation id。激活从每 generation 的影子拷贝加载 bundle,因此已安装目录可以在某个 generation 仍存活时被替换。

状态含义
stopped插件已禁用,没有存活的 generation。
blocked尚未尝试,且原因明确:预检失败、minHostVersion 不满足、依赖不可用,或信任缺失/失效。不存在加载上下文。
activating正在构建候选 generation,其中任何内容都尚未发布。
active一个 generation 已提交并正在接收调用。
deactivating已关闭准入,generation 正在排空。
faulted已尝试,且失败方式明确:构造、激活或已注册的后台工作失败。
reclaiming功能上已停止、不再路由任何调用,但内存尚未归还。

blocked 不是终态,只要其成因可能已经改变——宿主升级、依赖激活、授予信任、重新安装——就会被重新评估。已安装的 faulted 插件通过停用再启用来重试;对于开发项目,请修正源码后再次运行 DotNetPlugin.Build

撤销是确定的,回收不是

停用会先撤销每一个贡献句柄,因此不会再有新调用到达该 generation;较旧的工具快照会收到 tool_unavailable。普通变更最多等待到运行时的 cleanup timeout。若插件工作忽略取消,功能拆卸会保持待完成状态:在它真正结束之前,该插件不能重新激活,它依赖的 Provider 也不能停止。

宿主关机时会等待功能拆卸实际完成,再释放 Provider 与宿主根容器;即使这超过 cleanup timeout 也一样。硬性的关机截止时间由服务管理器或其他外层进程负责。功能拆卸完成后,程序集内存回收才进入尽力而为阶段,此时不会阻塞替换、依赖拆卸或关机。leakedGenerationsrestartRecommended 会暴露加载上下文仍被钉住的 generation;其内存只能通过重启进程释放。

替换已安装 bundle

通过插件安装流程管理的 bundle 按以下方式更新:停用插件、替换文件、再次启用。对于 .craft/plugin-projects 下的项目,DotNetPlugin.Build 会完成发布和 generation 替换,不走这组客户端操作。

停用会先按消费方优先的顺序撤销 .NET generation 及其消费方。文件系统变更还会要求以根目录为依托的声明式贡献停止;若这一步失败,变更会返回 notAppliedPluginContributionQuiesceFailed,并保持 bundle 目录不变。重新启用会重新准入当前字节。

新字节属于新的 bundle,指纹也是新的。只有该精确指纹已获授权时它才会激活;否则信任会变成 modified,直到用户确认。版本号不变的内容变更同样会产生新的 generation。若新 bundle 随后停在 blockedfaulted,DotCraft 不会回滚到上一份 bundle。

客户端操作

安装、启用、trust 与移除是独立的 AppServer 操作。安装不会授予 .NET trust;一次已经 applied 的变更仍可能让插件保持 blocked。方法、wire result、blocker 与 remediation data 见 AppServer 协议

相关文档