Skip to content
Go back

用 GitHub Copilot SDK 接进 .NET 应用

假设你已经有一个跑在 Microsoft Foundry 上的多 agent 应用:五个分工不同的角色用 handoff 串起来,工具通过 MCP 调用,界面是 Blazor。现在你想让它在 Copilot 订阅下也能跑——需要改多少代码?

微软的 Justin Yoo 在 Build an interview coach app with the GitHub Copilot SDK 里给出的答案是:如果抽象层选对了,只需要换一个 provider。他用一个 .NET 示例 Interview Coach 演示了这件事:同一套 specialist 提示词、同一套 MCP 工具、同一个界面,Copilot 作为另一个模型来源接进来。

真正值得学的是这个「只换一层」是怎么做到的,以及在换的过程中,哪些边界必须重新声明。

Copilot SDK 到底是什么

GitHub Copilot SDK 暴露的是 Copilot CLI 背后那套引擎:一个已经投产的 agent 运行时,可以通过代码调用。它的官方定位很直白——「Agents for every app」,你定义 agent 行为,Copilot 负责规划、工具调用等环节。

几个当前事实值得先确认:

对应用开发者来说,它的价值不是「多一个模型供应商」,而是复用一套 agent 运行时:规划、工具调用循环、流式事件、会话与上下文压缩这些事你不用自己写。官方文档明确说不需要自己搭 orchestration。

三层职责,各管一件事

示例 Interview Coach 的结构是:Blazor 前端把消息发给 .NET agent 服务的 /ag-ui 端点,Agent Framework 运行选中的 agent 或 handoff 工作流,模型提出工具调用,应用代码通过 MCP 客户端执行,结果回给模型再流式返回前端。

这里有三层,职责不重叠:

负责什么
Copilot SDK模型交互、工具调用循环、会话生命周期
Agent Framework把 specialist 表示成 AIAgent,管理它们之间的 handoff
应用程序界面、业务流程、面试提示词、工具实现

原文把这句写得很准:Copilot 负责一个 specialist 的模型交互与工具执行,Agent Framework 负责连接 specialist 并管理交接,应用指令描述面试本身该怎么做。

所以「换 provider」之所以只动一处,是因为 Copilot 通过适配器参与了同一个 AIAgent 接口。behavioural_interviewer 的提示词里写着要用 STAR 方法(Situation、Task、Action、Result)、一次问一个问题、给出反馈并记录对话——这些不需要因为底层换模型而改写。Foundry 那条路径也保留着,两者是并列的 provider 选择。

适配器的形状很短:

private static AIAgent CreateCopilotRunAgent(
    CopilotClient client,
    string name,
    string description,
    string? model,
    string instructions,
    IList<AITool>? tools)
{
    return client.AsAIAgent(
        CreateCopilotSessionConfig(model, instructions, tools),
        ownsClient: false,
        name: name,
        description: description);
}

ownsClient: false 是关键的一行:包装出来的 agent 不接管共享 CopilotClient 的所有权,客户端生命周期仍由应用管理。如果这里用默认值,每次创建 agent 都会让包装层以为自己该负责销毁客户端。

给 Copilot 面试工具,而不是一个编码环境

这是整套设计里最容易被跳过、又最该看仔细的一段。

Copilot CLI 本身带着 shell、文件读写、抓取 URL 这些能力。一个面试教练没有理由执行 shell 命令,也不需要改应用的源码。示例的做法是在创建客户端时就把模式收窄:

builder.Services.AddSingleton(_ => new CopilotClient(new CopilotClientOptions
{
    BaseDirectory = Path.Combine(Path.GetTempPath(), "interview-coach-copilot"),
    GitHubToken = githubToken,
    Mode = CopilotClientMode.Empty,
    UseLoggedInUser = string.IsNullOrWhiteSpace(githubToken),
}));

CopilotClientMode.Empty 让内置的 shell、文件系统和编码工具保持关闭,agent 只能用适配器显式交给它的工具。示例的 provider 说明里也确认了这一点。

然后在会话配置里只列出自己的工具:

internal static SessionConfig CreateCopilotSessionConfig(
    string? model,
    string instructions,
    IList<AITool>? tools)
{
    var copilotTools = ToCopilotTools(tools);

    return new SessionConfig
    {
        AvailableTools = copilotTools.Select(tool => $"custom:{tool.Name}").ToList(),
        Model = string.IsNullOrWhiteSpace(model) ? Constants.DefaultModel : model,
        OnPermissionRequest = PermissionHandler.ApproveAll,
        SystemMessage = new SystemMessageConfig
        {
            Mode = SystemMessageMode.Append,
            Content = instructions,
        },
        Tools = copilotTools,
    };
}

三个字段各管一件事:Tools 提供自定义工具的定义和可调用处理器;AvailableTools 说明这个 agent 允许用哪些工具;custom: 前缀用来选中你提供的这些工具,而不是 Copilot CLI 的内置工具。SystemMessage.Mode = Append 表示把面试指令追加到系统提示后面,而不是整段替换。

这里有个命名规则容易踩坑,值得单独记住:通过 McpServers 配置的 MCP 工具,其运行时名字是 <server-key>-<tool-name>;在 AvailableToolsExcludedTools 里应该用带来源限定的 mcp:<server-key>-<tool-name>。而 CustomAgents[].ToolsDefaultAgent.ExcludedTools 里则直接用 <server-key>-<tool-name>。示例之所以用 custom:,是因为它的 MCP 工具是通过 Agent Framework 的 AITool 列表交进来的,不是走 SessionConfig.McpServers

「给了哪些工具」和「批不批准」是两套开关

原文有一句值得展开的判断:工具选择控制的是暴露面,但它不是一条完整的安全边界。这句话背后其实是两层机制,官方资料分开写,读的时候很容易混起来。

第一层是暴露。 Copilot SDK 的 FAQ 写得很直接:默认情况下,SDK 会暴露 Copilot CLI 的一手工具,效果类似用 --allow-all 跑 CLI;工具执行仍然受各 SDK 的权限处理器约束,应用可以批准、拒绝或自定义。也就是说,默认的「可用工具集」是宽的

第二层是批准。 每次工具执行前都会问 OnPermissionRequest;如果不提供处理器,权限请求会作为事件抛出来挂起,等消费方自己解决。Agent Framework 的 Copilot provider 文档因此从另一个角度描述同一件事:默认情况下 agent 不能执行 shell 命令、读写文件或抓取 URL,要开启这些能力得通过 SessionConfig 提供权限处理器。

两句话不矛盾,但含义完全不同:一个说「默认暴露得很多」,另一个说「默认什么也执行不了」。把两层分开看就清楚了——暴露决定有哪些工具摆在桌面上,批准决定每一次调用要不要放行。

示例的选择是:暴露层收得很紧(CopilotClientMode.Empty 加一份显式工具清单),批准层放得很松(PermissionHandler.ApproveAll)。这组合在示例里是合理的,因为它已经确保桌面上只有自己的工具;但它也说明为什么原文特别提醒「工具选择不是完整的安全边界」:如果哪天有人在 AvailableTools 里放进了内置工具,ApproveAll 会一并放行,权限层不会帮你兜住。

真要收紧,抓手有三个:

handoff 要在运行期补齐工具

Agent Framework 的 handoff 是网状拓扑,没有中心调度器,每个 agent 自己决定何时把控制权交出去。示例把五个角色串成一条主链,triage 既是入口也是兜底:

var workflow = AgentWorkflowBuilder
    .CreateHandoffBuilderWith(triageAgent)
    .WithHandoffs(triageAgent, [receptionistAgent, behaviouralAgent, technicalAgent, summariserAgent])
    .WithHandoffs(receptionistAgent, [behaviouralAgent, triageAgent])
    .WithHandoffs(behaviouralAgent, [technicalAgent, triageAgent])
    .WithHandoffs(technicalAgent, [summariserAgent, triageAgent])
    .WithHandoff(summariserAgent, triageAgent)
    .Build();

五个角色和它们各自的工具范围:

Agent职责可用的 MCP 工具
receptionist收集文档、建立会话MarkItDown、InterviewData
behavioural_interviewer问经历类问题并反馈InterviewData
technical_interviewer问岗位相关问题并讨论InterviewData
summariser复盘记录并产出最终反馈InterviewData
triage路由初始对话、处理方向变更

MarkItDown 把文档转成 agent 能用的文本,InterviewData 提供创建、读取、更新面试记录的操作。五个角色的 MCP 客户端按 key 注册(mcp-markitdownmcp-interview-data),工具在启动时通过 ListToolsAsync() 一次性取回。

问题出在这里:handoff 的交接工具是运行期注入的,不是静态工具。 Agent Framework 在调用某个 agent 时会额外提供转移控制权的工具和附加指令;如果只把初始的 MCP 工具交给 Copilot,这些交接能力就丢了。

示例的适配器用一个包装层解决:每次调用时重新创建轻量 agent 包装,把静态工具和运行期工具合并后再交给 Copilot。源码里的注释写得很清楚——handoff orchestration 通过 ChatClientAgentRunOptions 提供转移工具,而 Copilot 适配器当前会忽略这些 run options,所以需要按调用重建。

internal static IList<AITool> MergeCopilotTools(
    IList<AITool>? configuredTools,
    AgentRunOptions? options)
{
    var runTools = (options as ChatClientAgentRunOptions)?.ChatOptions?.Tools;

    return (configuredTools ?? [])
        .Concat(runTools ?? [])
        .DistinctBy(tool => tool.Name, StringComparer.Ordinal)
        .ToList();
}

MergeCopilotInstructions 做同样的事,把运行期指令追加到 specialist 的提示词末尾——triage 的初始指令需要它才能知道之前已经走到哪一步。合并后的工具按名字去重,避免同一个工具被交两次。这套处理对普通响应和流式响应都生效。

原文对这件事的定性很克制,也值得照抄这个态度:它描述的是 commit 68fd993 那一版实现,而仓库使用浮动包版本,所以这是「这一版实现需要适配器兜底」,不等于每个 Copilot SDK 版本都有这个限制。集成运行时和编排框架时,值得先确认框架在调用时到底注入了什么。

还有一点:AgentWorkflowBuilder 的这类构建 API 在示例里是关掉 MAAIW001 警告使用的,注释写明该类型仅用于评估、可能变更或被移除。把 handoff 编排用在生产前,要先确认它对当前版本的稳定承诺。

我把示例用到的 API 形状编译验证了一遍

我没有在本机跑起整套示例——它需要容器引擎、Aspire CLI,以及一个 Cosmos DB 模拟器容器。我做的验证范围更小但更硬:把示例和原文用到的关键 API 形状抄进一个 net10.0 控制台项目,装上当前版本(GitHub.Copilot.SDK 1.0.13、Microsoft.Agents.AI.GitHub.Copilot 1.21.0、Microsoft.Agents.AI.Workflows 1.21.0),编译结果为 0 个错误

覆盖到的形状包括:CopilotClientOptionsBaseDirectoryGitHubTokenModeUseLoggedInUserCopilotClientMode.EmptySessionConfigAvailableToolsToolsModelOnPermissionRequestSystemMessagePermissionHandler.ApproveAllclient.AsAIAgent(config, ownsClient: false, name:, description:),以及 AgentWorkflowBuilderCreateHandoffBuilderWithWithHandoffsWithHandoffBuild 链式调用。我为验证写的是等价简化版(工具声明部分直接做了类型转换),没有连接 Copilot 运行时,也没有消耗账号的 Copilot 额度。

这条路径本身也值得说清楚:原文的代码片段与仓库源码能对上,并且在当前包版本上仍然可编译。 这是「照着抄不会立刻报错」的最低保证,不等于整套示例的运行时行为已被我复现。

自己接一下:最小路径

如果你想先确认这条路走得通再动大工程,官方的 getting started 给了一个五行版本的起点。

前置条件:

dotnet new console -n CopilotDemo
cd CopilotDemo
dotnet add package GitHub.Copilot.SDK

Program.cs 换成:

using GitHub.Copilot;

await using var client = new CopilotClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
    Model = "auto",
    OnPermissionRequest = PermissionHandler.ApproveAll
});

var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2 + 2?" });
Console.WriteLine(response?.Data.Content);

dotnet run 之后应该看到输出 4。这一步验证的是「客户端能启动 CLI 运行时、能认证、能完成一次会话」,不涉及你自己的工具和界面。

跑通之后,按顺序加三样东西,每样都能独立验证:

  1. 加流式:设置 Streaming = true,订阅 AssistantMessageDeltaEvent 累积 DeltaContent,用 SessionIdleEvent 判断这一轮结束。预期结果是回答逐字出现,而不是等完整文本。
  2. 加一个自定义工具:用 CopilotTool.DefineTool 定义 get_weather,放进 SessionConfig.Tools。预期结果是问天气时 Copilot 会调用你的代码,并把返回值写进回答。
  3. 改成项目里的形态:把 CopilotClientMode.EmptyAvailableTools 加上,确认内置工具不再出现在桌面上。

常见的两个失败:

跑整套示例要多准备什么

Copilot 路径的认证可以完全依赖本地已登录的 GitHub 凭据,也可以显式配置 token;配置了 token 就走 token。这条路径不需要额外的 Foundry 模型部署。

但其他服务还是要有:

启动命令(仓库根目录、容器引擎已运行):

aspire start --apphost ./apphost.cs -- --provider GitHubCopilot --mode HandOff

--mode Single 可以跑单 agent 版本,作为更简单的基线。但要先看一眼 apphost.settings.json:仓库 README 明确警告,默认 AppHost 会声明一个 Foundry 资源和模型部署,也就是说一次本地运行可能创建可计费的 Azure 资源。选 Copilot provider 能避开模型部署,但资源清单和环境清理这件事仍要自己确认。

另一个我在仓库里核实到的细节:DevUI 在 Program.cs 里是刻意对所有环境开放的,AddDevUI(options => options.AllowRemoteAccess = true)app.MapDevUI() 两处都带着「便于在生产场景里检查 agent 状态」的注释。原文提到它把 DevUI 暴露到了开发环境之外,代码里的意图比这更明确——它是故意的,所以更要靠访问控制而不是环境判断来保护。

成本、数据和选型边界

这几条决定了它能不能用在真实场景,而不是 demo。

额度计费。 SDK 请求按 Copilot CLI 的同一套模型计费:每个 prompt 都算进你账号的额度。可用模型与访问权限取决于套餐和组织策略。有现成的 Copilot 访问权限是个现实的好处,但它不等于无限或免费,也不等于给一个多用户的公开应用提供了认证设计方案。多租户和服务端部署是另一套题目,SDK 文档有单独的章节。

数据留存。 上传的文档字节留在 agent 进程的内存里:示例的上传端点把文件存进一个 ConcurrentDictionary,限制 10 MB,只接受 .pdf.docx.doc.txt.md.html。UI 把返回的 URL 放进对话,MarkItDown 再从自己的容器去抓这个地址。进程重启,这些字节就没了;面试记录本身在 Cosmos 里,但界面消息列表和会话 ID 存在服务端 Blazor 线路(circuit)内存中,刷新页面会新建线路和会话。

恢复与访问控制。 示例没有实现完整的工作流恢复。仓库的架构文档把边界写得很直接:这是一个学习示例,要用在受限环境里,配合虚构输入;工具只操作传入的 ID,文档解析出来的内容是不可信输入。要处理真实简历,先做三件事——限制应用和开发工具的访问、复核每个工具的实际权限、定下数据留存策略。

什么时候这套设计合适。 如果缺的是一个进程内的 agent 运行时,你希望复用现成的 Copilot 访问权限,并且能接受额度按 prompt 计费、模型可用性受组织策略约束,那么「SDK 管运行时、Agent Framework 管编排、应用管行为」这三层切分很干净,换 provider 的成本也低。

如果需求是给大量外部用户提供稳定配额的服务,或者要求数据不出境、不能进第三方运行时,或者需要持久化的工作流恢复和精确的审计链路,那就该把模型来源和编排放在你能控制的基础设施里,而不是复用个人或组织的 Copilot 额度。

结论

把 Copilot 接进自己的 .NET 应用,代码改动可以很小,但边界必须重新声明一遍:暴露层用 CopilotClientMode.Empty 加显式工具清单决定桌面上有什么,批准层用权限处理器决定每一次调用放不放行,这两件事不要混成一件。handoff 这类由框架在运行期注入的工具和指令,需要在适配器里补齐。额度、模型可用性和数据留存都是真实约束,示例仓库自己也是这么标注的。

最省事的下一步:先用 dotnet new console 加一个 GitHub.Copilot.SDK 跑通「问一句、答一句」,确认认证和额度都没问题,再决定要不要把现有的 AIAgent 抽象指向 Copilot。

如果你也在做 AI 助手、开发工具或 .NET 工程实践,Aide Hub 会继续分享这类「先划清边界、再写集成」的落地经验。

参考


Tags


Previous

.NET 11 性能改进:从 JIT 到类库的基准数据

Next

.NET 有界 Channel 管道的完整生命周期