开发 .NET 扩展
.NET 扩展运行在 DotCraft 进程内部。MCP 服务器隔着边界与 DotCraft 通信,而 .NET 扩展是从内部参与内核组装:它在具名贡献点上贡献工具、提示词分节、中间件和生命周期观察者,并可以解析宿主服务,用 DotCraft 自身的部件搭建这些贡献。
本页面向插件开发者。插件的用户视角见插件与工具。所有插件共用的清单字段见插件市场。
CAUTION
.NET 扩展把完全受信任的代码加载进 DotCraft 进程,并获得该进程的文件系统、网络、凭据、原生互操作与操作系统权限。这里没有托管沙箱,也没有权限模型。安全性取决于你选择构建或信任哪些代码,而不是运行时边界。需要真正的信任边界时请使用 MCP。
可运行的 DotNetPluginSample 包含两个 bundle、覆盖全部公共 .NET 贡献点、通过宿主的预检与运行时校验构建产物,并为其中一个工具提供 Desktop 呈现。
使用 DotCraft 创建
让 $plugin-creator 在当前工作区创建 .NET 扩展。它会生成持久化的源码项目与标准开发 bundle:
.craft/plugin-projects/<plugin-id>/
├── src/
└── plugin/
├── .craft-plugin/plugin.json
└── lib/项目没有 .csproj,也不会还原 NuGet 包。Agent 编辑 src/**/*.cs 和 manifest,通过 DotNetPlugin.Inspect 查询准确的 API 签名与文档,再调用 DotNetPlugin.Build。构建过程使用当前宿主随附的公共插件 API 与 BCL 完成编译,执行 metadata preflight,原子发布 bundle 并将其激活。整个过程不需要外部 .NET SDK,也不访问网络。
构建成功后,精确的插件 id 与指纹只在当前宿主进程中获得执行资格,dotnet-plugin-trust.json 不受影响。DotCraft 重启后需要重新构建。对同一项目中已激活的相同指纹重复构建不会产生变更。
DotNetPlugin.Build 在编译前运行宿主固定的工具生成器,因此源码项目也支持 [Tool]、[GeneratedTool] 与 [ToolDeclaration]。生成文件只存在于内存中。源码项目不能提供 analyzer 或 generator。生成错误带有 phase: generate,不会替换此前已发布的 bundle 或活动 generation。
自定义 Harness 宿主需要支持源码开发时,在宿主项目中设置 <PreserveCompilationContext>true</PreserveCompilationContext>。Harness 会把必需的 Core 与 Agents XML 文档复制到构建和发布输出。发布单文件宿主时,还需设置 <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>,让编译器能够读取提取后的程序集、文档与引用包。
执行构建的 Turn 继续使用它已冻结的工具快照。新的插件工具从下一个 Turn 开始可用,构建本身不会调用它们。源码改动只在 Agent 调用 DotNetPlugin.Build 时生效。
准备预构建 bundle
.NET 扩展就是一个带 dotnet 贡献的普通 DotCraft 插件目录。对于外部构建的插件,请在安装前放入全部托管与原生依赖。发现、安装和激活过程不会还原 NuGet 包、运行 MSBuild 或编译源码。
acme.review-core/
├── .craft-plugin/
│ └── plugin.json
└── lib/
├── Acme.ReviewCore.Plugin.dll
├── Acme.ReviewCore.Plugin.deps.json
├── Acme.ReviewCore.Api.dll
└── 私有依赖入口程序集的 .deps.json 必须与它放在一起——加载上下文正是靠它解析 bundle 自带的一切。
{
"schemaVersion": 1,
"id": "acme.review-core",
"version": "1.0.0",
"displayName": "Review Core",
"description": "In-process review services.",
"capabilities": ["dotnet"],
"dotnet": {
"minHostVersion": "0.5.0",
"entryAssembly": "./lib/Acme.ReviewCore.Plugin.dll",
"entryType": "Acme.ReviewCore.Plugin",
"exportedApiAssemblies": ["./lib/Acme.ReviewCore.Api.dll"]
},
"dependencies": { "acme.review-base": "1.0.0" }
}| 字段 | 必填 | 含义 |
|---|---|---|
minHostVersion | 是 | 插件可运行的最低 DotCraft 宿主版本,格式为 MAJOR.MINOR.PATCH。 |
entryAssembly | 是 | 承载入口类型的托管程序集。 |
entryType | 是 | 入口类型的完整 CLR 名称:public、具体、非泛型,且有 public 无参构造函数。 |
exportedApiAssemblies | 否 | 供已声明依赖方绑定的契约程序集。入口程序集不能被导出。 |
dependencies | 否 | Provider 的最低兼容版本。只能与 dotnet 同时出现。 |
插件 id 以 ASCII 字母或数字开头,后续还可包含 .、_、: 或 -。只要存在 dotnet,version 就是必填。所有路径以 ./ 开头、不得越出插件根目录,并且必须指向构建好的 bundle 中已存在的文件。
引用 DotCraft.Core,但不要随包分发
插件 API 就是 DotCraft.Core 本身,外加它传递引用的 DotCraft.Agents 和 Microsoft.Extensions.AI。没有单独的 SDK 程序集。
<ProjectReference Include="path/to/src/DotCraft.Core/DotCraft.Core.csproj" Private="false" />在 DotCraft checkout 之外建立独立项目时,引用已发布的 DotCraft.Harness 包即可获得这些 API 与工具 analyzer。使用 checkout 项目引用构建时,还需引用 DotCraft.Generators.csproj,并设置 OutputItemType="Analyzer"、ReferenceOutputAssembly="false"。单独引用 Core 不会运行生成器。请面向同一宿主版本构建,bundle 中只保留插件及其私有依赖闭包。
加载上下文按简单名称把每个 DotCraft 程序集及其包闭包解析到宿主中已加载的副本,忽略 bundle 自带的版本。类型标识的单一性正来自于此:中间件贡献改写的 ChatMessage,与内核派发时使用的是同一个类型——哪怕 bundle 自带了 Microsoft.Extensions.AI.Abstractions.dll。随包分发这些程序集只会让 bundle 变大。其余内容都从 bundle 的 .deps.json 与相邻探测解析,并限制在 bundle 目录内。
绑定到宿主版本
由于内核的整个公共面就是插件 API,兼容性绑定的是宿主版本,而不是一份只增不改的契约。
minHostVersion是硬性门槛。低于它的宿主会让插件停在blocked,并报PluginHostVersionUnsatisfied,插件代码一行都不会运行。- 更新的宿主会尽力加载插件,并且不会就 bundle 编译时依赖的
DotCraft.Core报告任何信息。这种差异即便出问题,也是在首次使用时的成员解析阶段失败,而不是在加载阶段。 - 按宿主的每个 minor 版本重新编译。 一个宿主 minor 版本就是一个兼容目标,
minHostVersion就是插件声明自己面向哪个目标的方式。
实现入口点
在一个带 public 无参构造函数的 public 类型上实现 IDotCraftPlugin。宿主在每个激活 generation 中构造它一次。
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using DotCraft.Contributions;
using DotCraft.Plugins;
using DotCraft.Tools;
using Microsoft.Extensions.AI;
namespace DotCraft.Plugin.AcmeReview;
public sealed class Plugin : IDotCraftPlugin
{
public ValueTask ActivateAsync(
IPluginActivationContext context,
CancellationToken cancellationToken)
{
context.Contributions.Add<IToolSource>(new PluginTool());
return ValueTask.CompletedTask;
}
}
internal sealed class PluginTool : AIFunctionToolSource
{
public override string SourceId => "acme-review";
protected override IEnumerable<AIFunction> CreateFunctions(ToolPlanningContext context)
{
yield return DotCraft.GeneratedTools.Acme.ReviewCore.Plugin
.GeneratedToolFunctions.PluginTool_Status(this);
}
[GeneratedTool(Name = "acme_review")]
[Description("Reports whether Acme Review is active.")]
public string Status() => "Acme Review is active.";
protected override ToolPolicyHints GetPolicyHints(
AIFunction function,
ToolPlanningContext context) => new(ReadOnly: true);
}上面的生成命名空间假设入口程序集为 manifest 中的 Acme.ReviewCore.Plugin.dll。源码编译器同样使用 manifest 入口程序集的文件名作为程序集名。工具默认没有呈现,除非方法声明了 [ToolPresentation("acme.review-status")]。如需 Desktop 呈现,请为该标识贡献插件自有的渲染组件。
激活上下文承载了插件模型的两个方向:
| 成员 | 用途 |
|---|---|
Contributions | 仅激活期可用的注册方向。每个贡献都指明自己的贡献点,并返回一个归 generation 所有的句柄。 |
Services | 公共宿主应用服务经过过滤的只读视图。 |
Exports / Dependencies | 跨插件边界的类型化服务。仅激活期可用。 |
Lifetime | 受管资源、后台工作,以及 Stopping 令牌。 |
ContentRoot / DataRoot / WorkspaceRoot | 该 generation 的只读影子拷贝、插件自己的可写数据目录,以及 Workspace。 |
Settings | 本插件的有效设置,在 generation 激活时生成快照。 |
请把 ContentRoot 当作只读,可变状态放在 DataRoot 下。配置了 UserDataPath 时,宿主把 DataRoot 解析为 <UserDataPath>/plugin-data/<id>,否则解析为 <DataPath>/plugin-data/<id>。同一插件的 Hooks 与 LSP 进程也会通过 DOTCRAFT_PLUGIN_DATA 获得同一个目录。
把所有 Contributions.Add 调用放在 ActivateAsync 内。激活提交时宿主会封闭 registrar,之后从后台工作发起的调用会被拒绝。若要改变某个 generation 的贡献集合,请改变它的输入,并让运行时协调过程重启 generation。
用 Lifetime 持有资源,而不是贡献
拆卸会先撤销贡献句柄、触发 Stopping,再排空已进入的调用与后台工作,之后才释放原始贡献目标。共享资源用 context.Lifetime.Own 或 OwnAsync 注册。它们比贡献目标存活更久,贡献只借用而不拥有。
后台工作走 context.Lifetime.Run。它在激活提交后启动,拆卸开始时通过 Lifetime.Stopping 取消。裸线程、静态事件订阅、无人跟踪的 Task 和全局缓存都可能钉住可回收的加载上下文:路由仍会立即停止,但内存要等到残留引用释放后才能回收,很多时候只能等进程重启。
提供模型后端
实现 IModelBackend,在激活期间通过 IModelBackendSource 注册。 描述符声明后端 ID、支持的协议、认证方式和能力。 CreateProvider(ModelBackendContext) 为每个配置实例返回 IModelProvider。 后端 ID 必须唯一。
.NET 扩展示例 演示模型发现、聊天和交互式登录。启用并信任 Review Core 后,在配置中添加其后端:
{
"Providers": {
"review-demo": {
"BackendId": "acme.review-backend",
"AuthMethod": "demo"
}
},
"ProviderPreferences": {
"review-demo": { "Model": "review-echo" }
},
"ProviderId": "review-demo"
}通过 IProviderAuthenticator 实现登录,返回标准的 IChatClient 响应和工具调用来执行模型请求。 ModelBackendContext.Credentials 保存当前实例的凭据,token 刷新放在其 ModifyAsync 回调中。
Desktop 把后端实例与其他提供商列在一起,用同一个提供商编辑器创建,并通过共用的登录控件登录。 在终端中,dotcraft auth login review-demo 按配置的 AuthMethod 登录,--method <id> 选择后端声明的其他方式, --workspace <path> 指定启用该插件的工作区。AppServer 客户端通过 provider/auth/* 方法驱动同一流程。
请求、鉴权和生命周期要求见 后端契约。
编写强类型工具
插件工具与内置工具使用同一套生成方法包装器。工作区配置与服务放在构造函数里,JSON 参数与默认值交给生成器处理。AIFunctionToolSource 提供注册,宿主负责赋予插件的 PluginNative 身份与 generation 生命周期。普通强类型工具不需要实现 IToolRuntime,也不需要手工解析 JsonObject。
只有需要真实 Thread、Turn 或调用身份的工具才请求上下文:
[GeneratedTool(Name = "calling_thread")]
[Description("Report the calling thread's identity.")]
public ToolExecutionResult CallingThread(
ToolInvocationContext context,
[Description("Include the call identifier.")] bool includeDetails = false,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return ToolExecutionResult.Succeeded(includeDetails
? $"Invocation belongs to thread {context.ThreadId}, call {context.CallId}."
: context.ThreadId);
}像入口点示例一样,通过 CreateFunctions 暴露该方法的生成工厂。上下文与取消令牌由运行时注入,不是模型参数。最多声明一个非空上下文,不得添加默认值、ref/in/out 或 schema 属性。缺少宿主上下文时直接调用生成函数,会在业务方法执行前失败。运行时不会从规划状态补造身份。
ToolExecutionResult 是可选的:普通结果使用字符串或 DTO 即可。声明为 ToolExecutionResult、Task<ToolExecutionResult> 或 ValueTask<ToolExecutionResult> 时保留成功/失败语义,不会把业务失败包装成成功的 JSON 响应,也不会推断 DTO 输出 schema。工具捕获取消后显式返回的结果仍会保留,因此可以表达“执行可能已经开始,不宜重放”。
这不会扩大插件边界。结果复制出插件时,宿主只保留成功的文本与结构化 JSON,或失败的文本与错误。不会转发插件自有对象、富 AIContent、私有结果元数据或执行指令。调用身份支持业务层的归属检查,不是针对完全受信任插件代码的沙箱。动态 schema 与协议桥接仍可使用底层 IToolSource/IToolRuntime。
访问宿主服务
context.Services 是经过过滤的 IServiceProvider 视图。从中解析公共应用服务,用内核的部件组装行为:
using DotCraft.Sessions;
var sessions = (ISessionService?)context.Services.GetService(typeof(ISessionService))
?? throw new InvalidOperationException("ISessionService is unavailable.");插件激活时工作区运行时仍在启动,所以要在使用时才解析 ISessionService,不要在 ActivateAsync 中解析。具体做法见向 thread 推送事件。
该提供程序由宿主拥有且只读。插件不能注册、装饰或替换容器服务。这个视图不暴露根 provider、贡献注册表、服务作用域工厂、宿主生命周期或插件运行时控制面。永远不要释放解析得到的服务。只有插件自己创建的东西才需要释放,并且要交给 context.Lifetime。
消费面同样受版本绑定约束:今天能解析到的服务,由你编译时面向的宿主版本保证,而不是由只增不改的承诺保证。请在 generation 停止前解除对宿主服务的回调、事件订阅和其他引用,好让加载上下文能够卸载。
读取自己的设置
context.Settings 是本插件由 schema 约束的有效设置快照,在 generation 激活时捕获。在 manifest 中声明 "settings": "./settings.schema.json"。宿主会校验 schema 默认值与两个存储层,然后依次解析默认值、个人设置和工作区设置。对象递归合并,数组和标量替换下层值。
var limit = context.Settings.TryGetProperty("checklistLimit", out var value)
&& value.TryGetInt32(out var parsed) ? parsed : 3;插件预期使用的每个字段都应声明 schema 默认值。配置 mutation 会先 quiesce 当前 generation,写入后自动 reconcile 本插件及其必要依赖闭包。quiesce 失败不会写文件,写入失败会恢复原 generation。已捕获的激活上下文绝不会被原地修改。若反序列化到插件自定义类型,请让 serializer options 与 metadata 都归插件所有,以便 generation 能够卸载。
响应运行时信号
IThreadRuntimeSignalContributor 在宿主发布 thread 运行时信号之后接收它们,包括 Turn 的开始、完成、失败与取消,审批和用户输入请求及其处理结果,以及上下文压缩、回滚和维护状态的变化。它只负责观察,不能修改、延迟或否决已经发生的事情。
internal sealed class CompactionWatcher(ReviewJournal journal) : IThreadRuntimeSignalContributor
{
public Task OnThreadRuntimeSignalAsync(
ThreadRuntimeSignalContext context,
CancellationToken cancellationToken = default)
{
if (context.Signal == SessionThreadRuntimeSignal.ContextCompacted)
journal.Write($"compacted {context.ThreadId}");
return Task.CompletedTask;
}
}信号通过一个单读取者的有界队列送达贡献者,Turn 永远不会等待插件。处理较慢的贡献者会拖延排在后面的信号,队列满了之后最早未送达的信号会被丢弃。请尽快返回,把耗时工作交给插件自己的 Lifetime.Run 循环。抛出异常的处理器会被记录并跳过。信号只携带 thread id 与信号本身,需要 Turn id 时使用 ITurnLifecycleContributor。
向 thread 推送事件
插件如果在监听 DotCraft 之外的东西,比如编辑器、构建或设备,可以通过 ISessionService 把事件作为输入交给 thread。按 thread 的状态选择调用:
- 有 Turn 正在运行。
SteerTurnAsync把输入作为引导加入正在运行的 Turn,它会在下一次模型调用之前被读取。传入你预期的 Turn id,例如由ITurnLifecycleContributor.OnTurnStartedAsync记录下来的 id。该 Turn 已不再活跃时,调用会抛出InvalidOperationException。 - thread 可能处于空闲。
EnqueueTurnInputAsync把输入加入队列,TryStartNextQueuedTurnAsync在没有 Turn 运行时启动它。如果有 Turn 正在运行,输入会等待,并在该 Turn 完成后启动。
internal sealed class EditorEventSink(IServiceProvider services)
{
public async Task DeliverAsync(string threadId, string text, CancellationToken cancellationToken)
{
var sessions = (ISessionService?)services.GetService(typeof(ISessionService))
?? throw new InvalidOperationException("ISessionService is unavailable.");
using (TurnTriggerScope.Set(new TurnTriggerInfo { Kind = "app", Label = "Acme Editor" }))
{
await sessions.EnqueueTurnInputAsync(threadId, [new TextContent(text)], ct: cancellationToken);
}
await sessions.TryStartNextQueuedTurnAsync(threadId, cancellationToken);
}
}在 ActivateAsync 中用 context.Services 构造这个 sink,每次投递时再解析会话服务。插件激活时工作区运行时仍在启动,在这段时间内解析 ISessionService 会抛出 InvalidOperationException。通过 Lifetime.Run 注册的后台工作在激活后立即开始,如果它在开头一次性解析该服务,也可能落在同一段时间内。
TurnTriggerScope 为输入标注来源。在入队或引导调用外层设置它,排队的输入启动时会保留这个标注。kind 使用 app,Label 使用插件或工具名,客户端就会把这条输入显示为由该应用发送,而不是用户输入。宿主自己的 kind,例如 goal 和 hook,会改变 Turn 的运行方式。
你推送的输入会启动一个 Turn,这个 Turn 又会产生自己的信号和生命周期回调。插件如果对这些回调再推送输入,就可能无休止地唤醒 thread。只为外部变化推送输入,跳过与上一次投递相同的事件,并限制在没有用户输入打断的情况下连续唤醒的次数。对已归档的 thread,入队和引导都会抛出 InvalidOperationException。
完整的贡献点目录、排序规则、类型化导出、信任与 generation 生命周期见 .NET 扩展 API 与生命周期。
相关文档
- 桌面扩展——给同一个 bundle 加上 Desktop 界面。
- .NET 扩展架构规范——本页行为背后的规范原文。