跳到正文
原文
GitHub Blog · AI & ML· Stephen Toub·· 28 天前精选AI 评分81

GitHub Copilot 运行时如何用 Copilot 迁移到 Rust

Migrating the GitHub Copilot runtime to Rust, using Copilot

AI 导读

GitHub 用 Copilot 智能体把 Copilot agent runtime 从 TypeScript 完全重写为超过 80 万行 Rust,AI 智能体编写了大部分代码,跨 128 个 pull request 增量合入 main。

推荐理由

一位工程师复盘用 AI 智能体把 43 万行 TypeScript 运行时迁到 Rust 的全过程,含编排、缓存与评审经验。

正文 · AI 翻译

译文尚不完整,完整内容请切换到原文。

GitHub Copilot CLI、GitHub Copilot app 和 GitHub Copilot SDK 均由 Copilot agent runtime 提供支持,这是一个可嵌入应用程序和服务的智能体运行框架。它最初用 TypeScript 编写,运行在 Node.js 和 V8 JavaScript 引擎之上,服务于如今的 GitHub Copilot cloud agent(CCA);随着 runtime 及其能力快速增长,它一直沿用这套技术栈。

如今情况已经改变。我们借助 GitHub Copilot app 和 Copilot CLI,将 runtime 完全重写为超过 80 万行生产级 Rust 代码。AI 智能体编写了其中大部分代码,涉及 128 个拉取请求,这些请求陆续合入 main 分支并逐步发布,而非等到最后一次性切换。期间不可避免地出现的少量回归问题都被及时发现并迅速修复,同时 runtime 的性能提升了好几个数量级。过去需要一整个开发团队花费一两年才能完成的项目,如今主要由一名开发者仅用几个月就完成了,而团队其他成员则在此期间持续大幅扩展 runtime 的能力和覆盖面。

为什么需要移植

Copilot agent runtime 不仅仅是 Copilot CLI 背后的引擎。它支撑着越来越多的 Microsoft、GitHub 及生态解决方案,对每一个方案而言,从架构上看,AI 支持都是围绕同一个 runtime 加上该方案所需的自定义内容所构成的外壳。这不仅包括 GitHub Copilot CLI 和 GitHub Copilot app,还包括最新版本的 VS Code、Visual Studio、CCA、Copilot Code Review(CCR)、Copilot Cowork、Copilot Studio,以及 Excel、Outlook、PowerPoint、Word……不胜枚举。

这些产品差异极大,它们中没有一个愿意或应当需要自行实现生产级智能体运行框架的全部内容。它们想要的是全部的智能、安全、可靠性和性能,并且希望这些能力共享,从而在一处修复即可惠及所有产品。上一段提到的大多数产品最初都自行实现了智能体循环,但后来都替换为 GitHub Copilot SDK,这是通往 Copilot agent runtime 的入口。这样做使它们能够专注于核心业务价值,而将细节交给 runtime 处理。考虑到行业的发展速度,以及所用智能体循环需要在激烈竞争中始终保持最佳水平,这一点尤为重要。

所以,共享 runtime 是好事。问题在于被共享的这个东西本身的特性。

如果我们看 CLI,它在逻辑上就是构建在智能体循环之上的终端 UI(TUI)。而实际上,整个技术栈都是用 TypeScript 实现的,以 Node.js 作为框架、V8 作为执行引擎,UI 则使用 Ink 和 React。对于 TUI 应用来说,这是个相当合理的选择;TypeScript 和 Node.js 易于上手,能实现非常快速的应用程序开发。而且对于控制台应用的需求而言,其在启动、响应性、吞吐量和内存消耗方面的性能影响也是合理的。遗憾的是,当你考虑到该实现还要用于其他环境、面对其他约束、需要满足快速启动和因低内存开销而实现出色服务器密度等要求时,这些性能影响就远没有那么合理了。

CLI 及其运行时的架构也加剧了这里的挑战。整个行业运转得极其迅速,在这种背景下,非常聪明的人会为了交付速度和市场覆盖而做出决策。Copilot CLI 最初是快速编写并发布的,在此过程中,TUI 和运行时相当紧密地交织在一起,而不是分离为离散的层。后来,当需要一个 SDK 来以编程方式访问该运行时时,由于各层没有清晰的分离,人们做出了一个务实的决定:将 SDK 叠加在 CLI 之上,尽管从逻辑上讲,你期望的是相反的架构。CLI 不再仅仅通过用户在命令行提供的命令来访问,而是被更新为一种可以无头运行的模式,从 stdin 读取类似的命令并将响应写入 stdout。然后可以使用 JSON-RPC 协议在外部进程与 CLI 之间编组函数调用。SDK 随后可以嵌入到任意的消费程序中,这些程序会生成一个 CLI 进程来在进程外承载代理循环,而 SDK 则通过这种 JSON-RPC 机制调用远程进程中的函数。很巧妙。能快速交付。灵活。但对于那些消费应用的性能(启动、内存、吞吐量)和可靠性来说并不理想。从 SDK 创建一个新的 CopilotClient 意味着要生成另一个进程:

const client = new CopilotClient();
await client.start(); // spawns the CLI as a subprocess
const session = await client.createSession({
    /* ... */
});

该进程需要启动并承载 Node 和 V8。这意味着要解析由 CLI 中 TypeScript 代码生成的大量 JavaScript,为其生成字节码,并可能在后续的 JIT 层级中优化热点代码。这意味着 V8 带来的所有内存开销。这意味着继承 Node 的线程模型,该模型默认将我们推向所有 CPU 密集型工作都被串行化的模式。而且这意味着仅仅为了进行函数调用就不得不进行进程外通信。这意味着每个 SDK 消费者,无论使用哪种语言,都要附带 Node.js 或包含 V8 的捆绑二进制文件。这意味着 C#、Python、Go、Java 和 Rust 的 SDK 都要为每个客户端承担一个完整的第二语言运行时,最低工作集大约 100 MB,而它们应用的其他部分根本用不到这个运行时。这意味着每个事件、每条消息以及每次抽象会话文件系统的读取和写入都要跨越进程边界。这意味着 Node 中的一次崩溃会连带会话一起崩溃。而且这意味着任何部署它的人至少需要监督、监控和调试两个进程。

相反,我们想要一个运行时:

  • 它不包含 TUI,TUI 是它自己的库,TUI 以及其他应用程序和服务可以清晰、正确地分层构建在其之上。
  • 用一种依赖极少、开销极小的语言实现。
  • 以能够被干净地嵌入进程内、而非被迫置于进程外的方式实现。
  • 用一种在性能、可扩展性和可靠性方面具有顶级特性的语言实现。
  • 用一种非常适合互操作的语言实现,从而可以被全部六种 Copilot SDK 语言版本(C#、TypeScript、Python、Rust、Go、Java)通过各自技术栈的外部函数接口(FFI)机制干净地使用。
  • 用一条提供更现代安全态势的工具链实现,具有更低的供应链风险和更强的对构造即正确代码的支持。

出于所有这些原因,再加上一些更软性的理由(例如团队经验和行业方向),我们选择了 Rust。这绝不是宣称每个大型 TypeScript 程序都应该变成 Rust。我们的需求强调通过 C ABI 进行嵌入、低启动和稳态开销,以及可预测的资源使用。Rust 让这些目标成为可能,代价是其他一些复杂问题,例如我们必须显式地表示生命周期和共享状态(后文讨论的生命周期回归凸显了其影响)。正确的目标语言因应用而异,这是理所当然的。

随后开展了两项关键的相关任务:

  1. 将 TUI 特定的代码与运行时分离,使前者严格分层在后者之上,更具体地说,严格分层在 SDK 的公共接口之上。如今,CLI 仍在若干位置直接调用运行时内部;将其完全迁移到 SDK 的接口之上是正在进行的工作。
  2. 将该运行时层 100% 移植到 Rust,从而产生一个纯原生二进制文件,对外暴露 C ABI 供所有语言前端进行进程内调用,并在仍需要进程外调用时提供基于 stdin/stdout 或基于 socket 的服务器。

本文主要介绍第二项:将运行时移植到 Rust。

The Copilot runtime architecture before and after the Rust rewrite, showing the old SDK-to-CLI process boundary and the new in-process and out-of-process hosting paths.

移植前的样子

2026 年 5 月初的初始移植计划估计运行时大约有 130,000 行 TypeScript。就范围界定而言,这一初始测量相当准确,但事实证明,它在两个关键方面也极具误导性。在移植的同时:

  1. 仍被包裹在 TUI 层中的部分正被下推到运行时层。最初在估算中被忽略的整个组件以及相当大比例的代码,后来被视为与移植相关。
  2. 贡献大量新 TypeScript 的拉取请求不断推高仓库中 TypeScript 的数量。数十名由智能体辅助的开发者每周合并数百个拉取请求。

把所有因素考虑在内,我估计大约有 430,000 行生产 TypeScript 最终经过了移植。这些同样的因素也使得很难看清一路上的进展:直到接近尾声时,生产 TypeScript 的体量看起来仍相对稳定,甚至略有增加,因为移植速度与涌入的工作量持平。

这一点更加令人困惑,因为在此期间还有独立于移植之外涌入的 Rust 代码;在移植工作早期,涌入的代码更可能以 TypeScript 为主,而在后期,涌入的代码更可能以 Rust 为主。

TypeScript fell to zero while production Rust rose to approximately 830,000 lines and Rust unit tests to approximately 469,000 lines.

在移植期间,运行时吸收了约 300,000 行生产 TypeScript,并减少了约 430,000 行,同时约 1,200,000 行生产 Rust 代码进入,约 365,000 行离开。换句话说,上图中 TypeScript 行数表面的稳定实际上掩盖了大量的 TypeScript 变动。

就地移植策略

这张图还突出了移植方式的一个重要方面:就地。

这种规模的重写有两种主要方法:

  1. 大爆炸式。 新的 Rust 运行时作为完整的替代方案来开发,准备就绪后一次性全部替换。这种大爆炸式切换有两种变体。a. 暂停一切。 在重写期间,所有人停止在 main 分支上的其他工作,重写工作在 main 中进行。b. 并行开发。 重写在功能分支中进行,同时主分支的工作继续进行,重写工作不断尝试跟进并合并主分支的变更。
  2. 原地替换。 这是逐组件移植的方式,运行时被一块一块地增量重写。这种原地替换方法也有两种变体。a. 原子替换。 每一块都从 TypeScript 原子性地切换为 Rust,剩余的 TypeScript 与新的 Rust 之间的互操作提供了连续性。随着时间推移,生产运行时中 TypeScript 越来越少,Rust 越来越多,直到有一天,不再有 TypeScript,只有 Rust。b. A/B。 移植时不是删除组件,而是将 TypeScript 和 Rust 组件都维护为可热插拔的选项,待信心趋于稳定后再删除 TypeScript。

我们选择了方案 2a,原因有多种:

  • 没有人经历工作中断。 主分支继续保持活跃。每个未直接参与移植的开发者都可以继续正常工作,只有当某个长时间未合并的拉取请求恰好触及了同时被移植的代码时才会受到影响,这种情况下他们需要 rebase,并让他们的 agent 帮助只移植他们正在进行的更改。
  • 运行时的主分支始终可发布。 每个拉取请求都用一层调用 Rust 的薄垫片替换现有的 TypeScript 实现,并在一次原子变更中删除旧代码。新代码会立即在真实环境中被检验。
  • 重写是增量的、可审查的。 每个拉取请求移植单个组件或切片,因此变更范围更小,差异更容易审查,无论是由人、agent 还是两者共同完成。
  • 大多数移植都相当小且自包含,最大限度地减少了与并发拉取请求的漂移。在某些情况下,当 TypeScript 组件过大时,可以先将它们重构为更易于移植的组件。
  • 所有现有的端到端测试,涵盖 CLI 和 SDK,在每一步都会针对新的 Rust 代码运行,给了我们信心和大量验证。如果某个拉取请求导致必需的测试失败,它就不会被合并。

我们同样避开了方案 2b 的变体,即同时维护同一组件的多个版本。过去几个月里,每周都有数百个拉取请求合并进仓库,代码库一直在快速演进。让同一份代码以两种不同的语言存在两个不同的版本,并使用两套不同的依赖库,会带来巨大的复杂性。有些组件也并非完全隔离;虽然有些组件在逻辑上是独立的,其 API 对系统其余部分来说很简单,但另一些组件则有大量牵连,要让这张依赖图按组件热切换简直是一场噩梦。最有可能从谨慎的并行切换中受益的子系统,恰恰是并行切换最困难的子系统。例如,会话编排并不是一个纯函数,你可以带着某个实验开关用 if/else 去调用它的两个不同版本。它持有可变状态,双向驱动回调,并且贯穿几乎每一个其他子系统,所以“两个都跑并对比”就意味着要维护两份逐渐分叉的副本——它们承载着对话的状态和服务——还要祈祷它们能在数百次并发编辑中保持同步。让一个组件难以移植的那种耦合,也正是让它在不冒引入更多回归风险的情况下几乎无法影子运行的那种耦合。能够以这种方式切换所带来的好处,主要是获得信心,而我们可以通过其他方式获得信心。

验证也通过增量发布来完成。如果采用一次性切换的方式,我们会把所有内容放在一个长期分支上,移植整个运行时,然后一次性切换。这意味着使用者会一次性体验到所有已移植的代码,包括所有在仓库内测试中漏掉的回归。将变更分批增量发布——这里两个组件,那里一个组件——让我们能够在已部署的构建中、在真实用户使用的情况下(大多是 Microsoft 和 GitHub 内部的一手用户)获得最后一公里的验证,同时将回归风险降到最低。在大约十四周半的移植窗口期内,main 发布了 135 个版本,包括 100 个预发布版本和 35 个稳定版本,平均每天约 1.3 个版本。每天也大约有 1.3 个移植拉取请求被打开,因此每个版本都只包含一小部分且已知的已移植组件(我们通常会尽量先通过预发布版本发布移植,但并非总能做到)。在截至某日的七天 npm 下载样本中,预发布版本仅占下载量的 10.5%,这表明在初期曝光相对有限的情况下,我们得以监控反馈渠道中有关故障的信号,并在下一个预发布版本中迅速完成修复。报告的问题更容易与已知的近期变更关联起来,也更容易定位根因并快速修复。通过这种方式,在较长时间内增量进行移植实际上是一种优势而非阻碍(也就是说,更快并不总是更好)。到 8 月 21 日,运行时已 100% 是生产 Rust:832,378 行生产 Rust 代码和 468,689 行 Rust 单元测试,此外还有 174,675 行 E2E TypeScript 测试。独立的 GitHub Copilot SDK 仓库又在 Node.js、Python、Go、C#、Rust 和 Java 中增加了约 130,000 行 E2E 测试代码。

Timeline from May 12 through August 21 showing 128 port pull request merges sized by changed lines and 135 public CLI releases; the largest port changes cluster near completion of the port.

开始动手

在全面投入之前,我们先建立了信心并验证了可行性。我们首先通过两个 pull request 搭建了 Rust 工作区、工具链、lint 规则、CI、构建流水线和编码规范,随后引入了运行时 crate 以及代码生成和互操作模式,同时移植了一组纯逻辑原语——之所以特意选择这些原语,是因为它们没有 I/O 或共享状态,并且已经有完善的测试。只有在这些落地之后,第一个主要移植 pull request 才将三个无副作用的辅助函数走完了完整流程。它们充当了发布试点,把关于仓库布局、FFI、打包、测试和评审的假设转化为约定,供后续规模大得多的移植复用。基本上,我们对整套机制进行了端到端测试。计划继续推进,按照从叶子到内部的顺序安排工作,由纯辅助函数、内容排除、shell 工具和会话文件系统操作确立翻译和测试模式。随后是带状态的子系统,而工具、hooks、模型客户端和 MCP 则建立在这些部分之上。会话编排(迄今为止运行时中耦合最紧密、也最难以自然并行的部分)将在接近尾声时进行。

Timeline of 128 landed port pull requests from May through August, progressing from small foundational components to larger orchestration and session work.
PeriodPull requestsMedian changed lines
May 1–1583,250
May 16–3129,421
Jun 1–15405,073
Jun 16–30318,253
Jul 1–15109,514
Jul 16–311428,159
Aug 1–151913,861
Aug 16–30499,445

早期的移植——小型叶子组件——进展迅速。但更大的子系统并不是以单个原子步骤推进的;例如,MCP 支持经历了七个专门的 pull request,而工具则通过一个由六部分组成的系列推进,随后还需要额外工作来迁移编排并淘汰剩余的 TypeScript。Hooks、auth、遥测、插件、设置和持久化也遵循了类似的路径。

实际上,有效的移植单元并不总是“一个组件”。它往往是一波穿过相关行为区域的工作:先迁移纯逻辑,再迁移状态所有权,然后迁移编排,接着移除回退,最后在临时互操作消失后简化 Rust。

互操作

在这次移植中,涉及互操作的主要有两层:

  1. 临时内部互操作。 每当一个函数被移植到 Rust 时,该函数都需要能够被任何调用初始 TypeScript 函数的 TypeScript 代码调用。同样,我们还需要让 Rust 函数能够调用 TypeScript 回调。这种互操作需求属于实现细节,而且变化极快。随着 Rust 内部接口面积增长,所需的 TypeScript 垫片数量也会增长,因为它们与需要从 TypeScript 调用的 Rust 方法一一对应。随着这些调用方被移植到 Rust,现有的垫片层会被删除,并放置新的层。最终我们到达运行时库的公共入口点,这些垫片也就消失了。
  2. SDK 界面。 所有 SDK 库都需要能够构建在运行时之上,并暴露其功能。在移植前的世界里,这是通过一个双向 JSON-RPC 层来实现的:SDK 将函数调用请求作为 JSON-RPC 方法调用负载发送,运行时解析请求并调用相关 API,然后通过同一传输通道发回结果,供 SDK 解析并返回。反向也存在;运行时需要能够回调 SDK 客户端,例如用于钩子通知和权限请求,这些在 SDK 客户端中会以回调形式出现,使用在该语言中被视为惯用的任何特性(例如 C# 中的委托)。

我们通过 napi-rs 项目中的 napi Rust crate 实现了 (1),该 crate 的存在是为了用 Rust 构建 Node 原生插件。你用一个函数标注 #[napi],然后 napi-rs 宏会生成 N-API 注册胶水代码,使该函数可以从 JavaScript 调用,并在生成的 index.d.ts 中为其生成 TypeScript 声明。一个同步 Rust 函数会变成普通的 JavaScript 函数,一个 async fn 会变成返回 promise 的 JavaScript 函数,而标注了 #[napi(object)] 的结构体会在另一侧变成普通对象。

流量也必须双向流动。许多已移植的组件暂时依赖于尚未移植的东西,因此 Rust 需要回调到 TypeScript,例如 Rust 中的工具实现向仍为 TypeScript 的模型层请求推理,或引发钩子,或为其想要运行的命令请求权限决策。napi-rs 通过“线程安全函数”处理这一点,它允许运行在 Tokio 工作线程上的 Rust 代码回调 Node 主线程上的 JavaScript 回调。Node 安装一次回调,Rust 持有它,并在需要反向调用时随时调用它。这些回调从构造上都是临时的:回调之所以存在,只是因为另一端仍然是 TypeScript,而当那一端被移植后,它就会被删除。

Temporary Rust N-API exports and their TypeScript call sites grew during the incremental port, then declined as callers moved to Rust and the temporary interop surface disappeared.

这个临时接缝在 8 月 3 日达到峰值,有 2,019 个内部 N-API 导出和 3,356 个 TypeScript 调用点。完成时,运行时完全是 Rust,因此没有内部互操作:0 个临时内部 N-API 导出和 0 个 TypeScript 调用点保留下来。(我前面提到,CLI 仍然对运行时有一些内部访问,我们正在努力移除;这些导出不计入这里。)

第二个互操作层,即 SDK 界面,是两者中永久性的一个。Copilot SDK 面向六种语言发布:TypeScript、Python、Go、C#、Java 和 Rust。它们都使用相同的双向 JSON-RPC 契约,最初它们都以相同方式实现:以无头模式将 Copilot CLI 作为子进程启动,并通过管道或套接字与之通信。在移植期间,这仍然是默认方式。这也意味着任何语言的 SDK 使用者都要附带或定位完整的 Node 实现,在每条事件和每条消息上付出一次进程跳转成本,并监督两个进程而不是一个。

将运行时移植到 Rust 正是让另一种选择变得可行的原因。发布的 runtime.node 是一个普通的平台共享库(.node 扩展是 Node.js 原生插件的惯例;其底层是一个 .dll、.so 或 .dylib),而现在它为同一个引擎呈现了两个入口。一个是 napi 入口,Node 进程将其作为原生插件加载;这是 CLI 的路径(目前……未来意图是它会完全走 SDK 路径)。另一个是 C ABI 入口,任何语言都可以将其加载到自己的进程中并通过 FFI 调用。同一个进程内运行时通过每种语言的原生互操作机制被选择:

SDK原生桥接进程内客户端选择
C#P/Invokenew CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForInProcess() })
Gopuregocopilot.NewClient(&copilot.ClientOptions{Connection: copilot.InProcessConnection{}})
JavaJNAnew CopilotClient(new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()))
PythoncffiCopilotClient(connection=RuntimeConnection.for_inprocess())
RustlibloadingClient::start(ClientOptions::new().with_transport(Transport::InProcess)).await?
TypeScriptkoffinew CopilotClient({ connection: RuntimeConnection.forInProcess() })

Rust 重写以及进程内与进程外托管之间的选择是两个独立的维度。完成后的 Rust 运行时同时支持两者:它既可以运行在 SDK 消费者的进程内,也可以运行在现有 JSON-RPC 服务器边界之后。目前这些进程内入口点是选择加入的,以便我们逐步建立对与消费应用程序共享进程、进而共享故障边界的信心。传输层之上的一切仍是相同的 SDK API:会话、事件、工具、权限和回调并不关心它们的 JSON-RPC 字节是跨越了管道还是函数调用。

第二个入口的有趣之处在于它的规模。它只有 19 个导出函数:四个用于服务器生命周期,四个用于会话注册和配置,八个用于连接,三个用于嵌入式宿主。在这些函数背后,共享契约目前包含 364 条调度路由:340 条可由 SDK 消费者调用,而 24 条反向运行,作为运行时到 SDK 的回调。napi 入口要大得多,需要为每一条调度路由提供函数。C ABI 入口是基于调度的:API 方法根本不会获得导出,而是作为写入连接的 JSON-RPC 字节传输,而结果、事件和服务器到客户端的请求则通过宿主提供的回调返回。添加、更改或移除 API 方法只会触及引擎的调度表,而永远不会触及 ABI。SDK 只需绑定这 19 个入口点一次,就能通过它们动态地触及整个仍在增长的 API 面。

这就引出了一个显而易见的问题:为什么在一个不再跨越进程边界的调用中仍然有 JSON-RPC?

答案是,它让进程内托管成为即插即用而非重写。每个 SDK 都已经有了一个可用的 JSON-RPC 客户端,具备分帧、请求与响应关联,以及服务器到客户端方向的处理器。将 FFI 作为该客户端底层的又一种传输方式挂载,会把字节路径从管道或套接字移动到函数调用,而其上的一切保持不变。六个 SDK 将进程内托管作为一种附加的、选择加入的传输方式获得,而现有方式保持不变。如果我们改为为每个 API 方法定义一个带类型的 C 函数,那么每个 SDK 都需要第二层绑定,每个新的 API 方法都需要再增加六个绑定,而 ABI 将成为一个我们必须进行版本管理的二进制兼容性面。

对于真正远程的运行时,无论是跨子进程边界还是通过 TCP,我们仍然需要 JSON-RPC。在进程内保持相同协议意味着只需要维护一套双向 API 和分发系统,而不是为远程连接维护 JSON-RPC、再为本地连接维护第二套逐方法的 FFI 接口。这是一个实实在在的权衡,而非显而易见的选择。我们避免了进程跳转,但每次调用仍需承担 JSON-RPC 的开销。对于以推理为主的工作负载,与模型往返相比,这种序列化通常微不足道。在高吞吐量的本地工作负载中它仍然可测量,但如今还不足以证明在六个 SDK 绑定中重复数百个方法是合理的。而且这是一个我们日后可以轻松修改的决定,如果性能需求出现的话。载荷编码是两端之间的私有细节,将 JSON 换成更紧凑的 MessagePack 之类的东西不会改变任何一个已声明的导出。以后也可以为热路径添加带类型的逐方法导出,调用同一引擎和同一处理器,而无需替换字节通道——该通道将继续作为流式传输、服务端到客户端请求以及那些很少调用的长尾方法的底层基础,为这些方法定制导出并无收益。

会话数据说明了什么

本文中的几乎每一个数字都来自两个来源之一。第一个来源是私有 github/copilot-agent-runtime 仓库的 GitHub 历史记录:pull request 及其 diff、审查评论、CI 运行等。第二个来源是 agent 会话日志。运行时(以及因此的 CLI、应用等)会为其运行的每个会话写入结构化事件日志:每行一个 JSON 对象,在会话进行时追加。这些日志可能包含提示词、命令、命令输出、文件路径,以及工具可能暴露的机密信息,因此必须作为敏感数据处理。日志位于会话运行所在的本地机器上;远程会话功能在启用时也可以上传它,受产品设置和组织策略约束。

以下是所有相关移植 pull request 的数据汇总:

指标计数
事件12,760,995
用户消息31,247
助手消息1,385,214
Hook 启动和结束事件6,438,562
工具启动1,857,409
编译命令23,096
测试命令19,485
Rebase 命令2,496
提交命令7,410
推送命令5,554
完成的压缩5,116

这 31,247 条用户角色消息并不是我亲自输入的 31,247 个提示词;它们包括技能指令、自动合并标记、跨会话消息和子 agent 流量,超出了我输入或口述的大约 2,600 条,约占十二分之一。同样,1,385,214 条助手消息包括子 agent 和面向工具的消息,而不仅仅是在对话式 UI 中展示给我的文本。语料库包含 68 种不同的事件类型和 67 个不同的工具名称;1,130,921 次工具调用(61%)来自子 agent,而非主会话线程。

这个计数并没有说明我为什么插入自己约 2,600 次。为此,我让 Copilot 为会话日志语料库中每条人工撰写的消息分配一个主要意图。

Of 2,639 human-authored messages, 31.0% focused on review, testing, and CI; 17.4% challenged technical or design decisions; and 15.0% pushed for completeness.

前三个类别占了我交互量的 63%。其中只有约 40 次是我明确发起的会话启动,因为大体流程是我先创建一个聊天来探索下一个前沿方向,然后再让该聊天会话为每个所需切片创建实际的移植会话。我的角色与其说是“分配任务然后等待”,不如说是“操作控制回路”:检查结果、质疑技术决策、执行质量关口,并在代理把中间停靠点当成终点时加以推动。尽管“活儿”是代理在干,但人类判断仍然深度参与其中。我的参与只是向上移动了……我不再负责编写语法,而是负责框定问题、定义边界、选择策略、裁决例外情况,并总体上确保一切朝着好的方向推进。

一切都关乎缓存

LLM 提供商通常对输入 token(你发送给它们的内容)收取一种费率,对输出 token(它们发送给你的内容)收取另一种费率。计费通常按 token 进行,因为 token 能较好地近似执行推理所需的计算工作量:对于每个输入 token,模型都必须读取它、将其纳入内部表示,并把它作为决定下一个 token 的计算的一部分。不过,提供商通常支持缓存这些计算的结果,这样如果提示的某个相同前缀已经被处理过,提供商就可以复用缓存中的中间计算,而不必从头重新计算。这降低了处理这些 token 的成本,而节省下来的费用可以传递给消费者。因此,输入 token 往往会公布多种费率,其中包括从缓存中读取的输入 token 的费率。

折扣非常可观!提供商通常对缓存命中按 90% 的折扣计费,例如,某提供商可能对 100 万输入 token 收费 2.00 美元,但对 100 万缓存输入读取 token 只收 0.20 美元。换句话说,你确实非常、非常希望维持良好的提示缓存,这样你的账单就能少一个数量级。

移植工作的数据表明我们在这方面做得很好。提示缓存命中率为 96.22%:缓存读取量除以所有输入侧 token 总量(缓存读取加缓存写入加全新输入)。缓存写入占 3.07%,全新输入占 0.71%。这并非偶然。GitHub Copilot 专门塑造了代理循环,以保留一段长而稳定的前缀(先是系统提示,然后是工具定义,再之后是累积的对话),因此每一轮都会追加到模型已经处理过的上下文上。上下文中最昂贵的部分只支付一次,随后每次调用再读取时,其金钱成本都要低一个数量级。这也是长时间自主会话在经济上能够成立的原因。一个耗时三百小时的移植项目,如果在数万次调用中的每一次都从头重新读取其不断增长的整个上下文,其成本将与我们看到的处于完全不同的数量级。代理框架开发者花费大量精力试图避免破坏提示缓存,而模型供应商也经常发布新功能来帮助他们做到这一点。

Prompt-cache composition across the porting sessions: 96.22% cache reads, 3.07% cache writes, and 0.71% fresh input.

Compaction 讲述了一个互补的故事。在 port 会话中,GitHub Copilot 自动压缩上下文 5,116 次(即会话填满上下文窗口后,为继续运行而进行自我总结的时刻)。那个单一的 sessions-infrastructure port pull request 在其持续多天的生命周期里压缩了 647 次,而一个小型 port 则从未压缩过一次。持续数百小时的自主工作之所以可能,正是因为 agent 能够一遍又一遍地回收其工作记忆而不丢失线索。这数千次总结中的每一次,都是一个有损交接本可能悄然使 port 偏离正轨的节点,而大多数情况下并没有。Copilot 的子 agent 也在极大程度上帮助最小化压缩。每个子 agent 都拥有自己的上下文,因此父会话可以有效地提出问题,让子 agent 去独立运行并在计算答案时消耗相当多的上下文,然后只把答案回报给父会话。父会话的上下文无需受到所有这些中间信息的影响。

上面所说的“大多数情况下并没有”在会话日志中可见。我让 Copilot 将每一次成功的压缩与其周围的工作配对,条件是两侧至少各有 20 次工具调用。这产生了约 4,000 个可比较的窗口。agent 在压缩前 20 次工具调用中所做之事的构成,与压缩后所做之事的构成在规模上相似(探索:压缩前 46.5%,压缩后 48.1%;变更:压缩前 8.4%,压缩后 6.0%;验证:压缩前 4.7%,压缩后 4.0%;失败:压缩前 1.0%,压缩后 1.5%)。如果压缩经常丢失思路线索,我们会预期压缩后一侧明显以重新定位为主,阅读激增而编辑崩塌,因为 agent 在重新发现它身处何处以及它应该做什么。然而,实际上只是朝那个方向发生了轻微的偏移。

是的,静态分析有帮助

有一个流行的说法认为,Rust 是 AI 生成代码的异常理想目标,因为 Rust 严格的编译器能捕捉到模型出错的地方。会话日志让我们得以检验这一理论,至少对于看起来像这次 port 工作的任务而言。

直接的验证命令结果捕捉到了 8,678 次 rustc 的错误代码。四个最大的诊断类别覆盖了 84%:

  • 37%:名称与导入解析,主要由 E0425 主导(“在此作用域中找不到值”)
  • 22%:缺失的方法或字段
  • 14%:类型不匹配
  • 11%:未满足的 trait 约束

每一个都是普通的接线问题:一个名称输出略有错误,一个签名没有对齐,一个字段被重命名,一个抽象未被实现。这些正是批量翻译容易且意外产生的错误,也正是编译器能非常迅速捕捉到的错误。

但请注意那份清单里没有什么:任何真正属于 Rust 特有的东西。这四类中的每一类都是最基础的静态类型检查,C#、Java 或 Go 的编译器同样能全部捕获,其中好几项还能给出更友好的诊断信息,而且速度都快得多。如果这是把智能体引向 Rust 的理由,那它实际上也是在为把它们引向任何静态类型语言辩护。一个强类型编译器,和/或一门具备出色静态分析与 lint 能力的语言,确实非常适合这类工作,智能体可以把它当作快速的反馈闭环。在更严格的结果匹配器捕获到结果的 4,478 次直接 cargo check 运行中,87.1% 返回了干净结果,这正是小步修改并不断重新编译所得到的效果。

相比之下,所有权、借用和生命周期错误加起来只占已编码诊断的 1.7%。借用检查器——那个在所有关于 Rust 难学的讨论中占据主导的东西——只是一个安静的背景存在。编译器几乎把所有报错精力都花在了无聊的机械性错误上。

智能体喜欢阅读

我们还可以检查会话事件语料库中的工具调用数据,并从中提取一些关于智能体如何分配时间的有趣观察。

工具调用次数中位数实测小时数
powershell630,4233 s2,833.9
view590,9880 s621.7
rg281,7831 s408.4
grep126,4831 s115.3
apply_patch53,7150 s17.0
edit40,5911 s24.1
read_powershell36,72890 s1,203.9
task13,080274 s2,329.0

我的第一个结论是,智能体花在收集证据上的时间远远多于修改代码。在所展示的文件读取和搜索工具与编辑工具的对比中,它们做的探索是修改的 10 倍。读取文件、搜索仓库和运行诊断命令占据主导;编辑相对来说只是一小部分工作量。AI 疯狂吐代码的流行形象几乎完全反了;在这个规模上,工作看起来更像是迭代式调查:检查当前状态、形成假设、做一次有针对性的修改、反复循环。

委派进一步放大了这种模式。子智能体主要被用来把探索分散到各个独立的问题上,而主智能体更可能负责编辑并整合答案。对这类项目而言,这是一种有用的分工:许多上下文可以并行调查,但把修改保持在协调智能体附近,可以减少冲突性改动,并保持实现策略的连贯。

shell 流量还显示,自主软件工作中有多大一部分是状态管理。只读的 Git 检查是最常见的命令模式,因为智能体在不断问,实际上就是“我在哪儿?”它们在查看哪些内容变了、一次 rebase 做了什么、另一个会话落地了什么,以及一个分支距离快速演进的 main 有多远。这种定位工作使得许多长时间运行的任务能够在同一个不断变动的代码库上操作,而不会盲目地相互覆盖。

深入 shell 工具流量后,最常见的命令族让定位与验证之间的这种平衡更加清晰:

命令族调用次数中位数实测小时数
git 检查300,5302 s608.1
git 其他89,8653 s243.1
搜索85,4822 s147.6
pnpm test13,85222 s219.1
pnpm lint9,75729 s177.0
cargo test8,437120 s364.2
git commit7,41011 s39.7
cargo fmt5,22318 s77.2
cargo check4,492120 s176.9
pnpm build3,630180 s215.6
cargo clippy2,115135 s107.4
git rebase2,4967 s9.9
cargo build566104 s20.3

模型选择

GitHub Copilot 允许在单个会话中于对话中途切换模型,也允许不同会话运行不同的模型,因此模型选择变成了逐个切片的决策。日志中出现了两种不同的模型决策。在主线程上,也就是驱动每个移植工作的那个线程,由我们选择模型和推理强度。在会话内部,当代理启动子代理或子会话去探索或处理某个有界任务时,则由编排模型来选择这些模型。

Weekly model mix across primary porting sessions, concentrated in a small number of models and shifting over the course of the port.

对于子代理,模型组合看起来略有不同,因为是由代理而非人类来优化吞吐量和成本,而不是为了最困难的判断决策。它最常启动的子代理运行在 Claude Opus 4.8、GPT-5.6 Sol、Claude Haiku 4.5 和 GPT-5.5 上,其次是 Gemini 3.1 Pro 和 Claude Opus 5。然而,至少在移植进行的时候,三个频繁使用的代理定义固定了它们的模型选择(explore 和 task 用 Claude Haiku,research 用 Claude Sonnet),因此其中相当大一部分量是由子代理的选择决定的,而不是单独选择模型。

Weekly model mix across subagents spawned during the port, with volume concentrated in Claude Opus 4.8, GPT-5.6 Sol, Claude Haiku 4.5, and GPT-5.5.

使用代理集群

GitHub Copilot 应用支持可视化活跃的拉取请求会话、查看每个会话的状态,并能轻松在它们之间切换,这使它非常适合管理移植工作固有的大量并发工作。但真正让它出彩的一点,是它让会话能够与其他会话交互。

一个会话可以创建其他会话,并且可以在其他会话运行时向它们发送消息。每个会话,无论是父会话还是子会话,都拥有自己的工作树、自己的分支和自己的代理循环;它独立于创建它的会话,而不是在该会话内部运行的东西。这与子代理不同,子代理在父级自己的工作区内运行,并将答案交回父级的上下文中。两者都是有用的构造,适用于不同的事情。

举一个会话如何创建其他会话的例子,最困难的移植之一是 session.ts 文件。这个文件有机地增长到了约 30,000 行 TypeScript。它代表了会话的骨干,实际上横跨整个运行时,几乎与每个组件都有交互,处于状态、事件、工具、模型、钩子、持久化和入口点访问的中心。因此,我把它留到了移植过程接近尾声的时候,从栈底向上、跨越所有纵向模块推进,直到它们全都终止于 session.ts。接手它的移植会话并没有一开始就一头扎进去写 Rust。它花了最初五十六分钟阅读,在创建任何东西之前进行了 122 次工具调用,构建出这个文件实际拥有什么、接缝在哪里的图景。只有在这之后,它才开始委派,逻辑上拆分该文件,并把切片委派给子会话。在整个 25 小时的运行中,它自己进行了 222 次 shell 调用、205 次文件查看和 197 次 ripgrep 搜索,还不算其子会话所做的一切。

Fifteen child sessions nested under the session that spawned them.

这就是 15 个子会话,每一个都是独立的分支,拥有自己的工作树和独立的代理,全部由顶层的父会话隐式创建。父会话在大约三小时内分七波创建了它们:第一波创建了五个,约二十分钟后第二波又创建了两个,再过二十分钟又是两个,然后在接下来的两个小时里零零散散地单个和成对创建。

每个切片都单独选择了模型:15 个中有 10 个运行在 GPT-5.6 Sol 上,5 个运行在 Claude Opus 4.8 上。全部 15 个都在 GitHub Copilot 的 autopilot 模式下启动,该模式允许会话朝着目标推进,而无需在每一步停下来等待批准。启动提示词的中位数长度约为 1,100 个字符,足以承载所有权边界和约束条件,但又足够简短,让子会话不得不自行摸索出方法。我给父代理写提示,然后由父代理(而非人类)为每个子会话撰写那些启动提示词。

除了这 15 个子会话之外,同一个父会话还使用了五个子代理:三个 explore 代理与第一波并行启动,一个 code-review,还有一个 rubber-duck。这些子代理负责探究问题,并将父代理在决定下一步行动之前其上下文中所需的答案反馈回去。子代理让父代理能够获得经过深入思考的答案,而无需消耗自己的上下文窗口去推导这些答案。

相比之下,子会话则着手进行实际的移植工作——那些会产生 diff、且需要与其他并行移植者隔离开来的工作。子会话的工作触及了仓库中 140 个不同的文件,其中 120 个文件只被一个会话触及。那 20 个存在争用的文件都是枢纽文件,比如 session.ts 本身。但每个会话都在自己的工作树中工作,因此能够不受其兄弟会话干扰地推进。当然,父代理为此付出了协调的代价。它花费了大量精力与子会话沟通,充当信息中介,轮询它们的状态 60 次,并发送了 89 条协调消息。当子会话各自宣布完成时,父代理将它们的提交拣选到自己的分支中并解决冲突。这些合并也并非特别干净,父代理花费了相当多的时间来调和这些编辑。

我们可以在时间线上看到这一点,它展示了父会话及其大部分子会话。

Timeline of the session.ts port showing one 25-hour parent session using five subagents and spawning 15 child sessions in seven waves.

注意那些大的空档。我在进行这个移植工作的同时在旅行,不得不在多个时间点合上我的笔记本电脑。(我后来改变了工作流程,加入了可以远程连接的基于云的虚拟机。)

这些并行的子会话对那台笔记本电脑造成了显著影响。有一段时间,并发移植进展得非常顺利。然后,一台机器上全部 15 个并发代理都试图进行构建和测试,我那可怜的笔记本电脑就卡死了。我给父代理写提示,让它转告其子会话,它们必须全部停止构建和测试。父代理将这个约束向外传达,它们 thankfully 终止了构建,并以最低的 CPU 活动继续工作。我随后更新了我的常驻指令,要求子代理和子会话在移植期间避免大型构建和测试运行,改为仅由父代理来完成这些工作。

后来我又推进了一步,把一个原本普通的聊天会话变成了八个独立移植会话的构建调度器。提示词简单得令人尴尬:给每个打开的会话发送一条策略,要求尽可能避免 CPU 密集型构建和测试,在必须构建时要求它向本会话请求许可,并充当门控,一次只允许一个会话获得构建权限。基本上,我把这个聊天会话变成了一个 agentic 互斥锁。这个门控维护着明确的所有者和队列,并通过这些会话原本就用于协调代码的跨会话消息机制,一次只授予一个租约。那些请求租约但被拒绝的会话,往往会在此期间做其他工作来等待,比如从待办清单里挑事情做。

One chat session acting as the build resource gate for eight porting sessions.

这个 session.ts 移植也参与了我整个运行时移植过程中见证的最酷、最令人难过、也无疑最出人意料的交互之一。正如我提到的,我们主要采用自底向上的方式进行移植,这就是为什么实际上位于所有其他组件之上的 session.ts 是最后移植的组件之一。始终位于 session.ts 之上的,只有运行时的所有入口点,也就是从 SDK 公开出来、并出现在前面讨论过的调度表中的公共函数。这样的入口点有数百个。虽然我知道其中很多会直接调用 session.ts,但我想抢先推进移植工作,所以在启动 session.ts 会话之后,我又启动了一个会话来移植所有入口点。我告诉它停在 session.ts 边界处。我盘算着可能会有一些一次性工作,以及在 rebase 时需要投入一些精力或消耗一些 token,但这会加速整体移植。然后我就去睡觉了。接着……它们找到了彼此。

我给入口点会话的启动提示词确实告诉了它,会话移植和六个组件移植正在并发进行,因为我想让它知道自己的边界,以及应该避免移植什么,以尽可能减少冲突。显然,我的提示词产生了相反的效果。仅仅四分钟多一点,在盘点完入口路径、并且大概已经对它们的重叠程度形成了判断之后,它调用了一个应用内置的 orchestrate 技能,其用途是协调跨会话工作。从那时起:

  1. 入口点会话枚举了所有活动会话,并向那些它认为存在重叠的会话发送了消息。
  2.  session.ts 会话回复了一份 2001 个字符的清单,标题是“Concrete overlap on stephentoub-port-session-to-rust”
  3. 入口点会话读取了 session.ts 会话的 worktree,以确认它刚刚被告知的内容(我猜是信任但要验证)。
  4. 入口点会话询问 session.ts 会话是否准备好协调其 760 个文件的 diff。
  5.  session.ts 会话基本上叫它别烦自己:“Not ready to commit/integrate.”
  6. 入口点会话接着又问了同样的问题三次,每次都从 session.ts 会话那里得到同样的回答。
  7. 这时,入口点会话决定不再在乎 session.ts 会话怎么想,直接伸手进入它的 worktree,抓取了另一个会话的所有更改,并合并到了自己的分支中。
  8. 然后两个会话就各自继续愉快地工作了。

我从这次交互中得到了一些启示:

  1. 明确表达意图非常重要。 启动提示词中列出了其他正在运行的会话,以便当前会话知道哪些不该碰。但我没有把"不该碰"这部分说清楚,结果非但没能阻止智能体去做某件事,反而鼓励了它去做。我需要在意图和指导上表达得更加明确。
  2. 你提供的任何能力,智能体都可能认为它适用。 orchestrate 技能随 GitHub Copilot 应用一起发布,其自我描述是用于并行运行独立的工作流。我的提示词中完全没有提到它。模型发现了自身所处的境况,将其与那段描述相匹配,然后加载了它。你所暴露的能力集合,就是你可能得到的行为集合,包括在你从未设想过的情境中。
  3. 并行会话之间需要一个裁决者。 两个会话都无法强制对方服从。当 session.ts 会话四次表示尚未准备好集成时,这一拒绝没有任何约束力,于是那个愿意单方面行动的会话就默认胜出了。在相邻代码上并行运行的会话需要一个指定的协调者,或者需要一个人类,而这两个会话两者皆无。
  4. "自主运行"需要对超出自己分支范围的决策设立例外。 我真正的意思是"不要因为设计细节来叫醒我"。它(并非不合理地)理解为吞并一个同伴也在授权范围内。再次说明,我本应在指导中表达得更明确。
  5. 这里的根本原因是我自己。 我同时以自上而下和自下而上的方式划分这项工作,两个方向在代码库中连接最密集的那个文件处交汇。我太贪心,想要快速推进。以上所有问题都源于此。

所幸,这整个交互只是一个有趣的离群案例。在整个运行时移植工作中,大多数叶子组件的移植都是简单的单会话任务。较大的子系统往往涉及多个子会话和子智能体。不过,这些部分在移植过程中的参与方式差异很大。

模型编排层的移植——即真正与提供商通信的那一层——提供了一个很好的模式示例。它的主会话运行了 42 个挂钟小时,启动了 126 个子智能体。最繁忙时,有 22 个同时工作。然而大多数时间里,只有主智能体在运行,然后时不时地会在一段时间窗口内生成大量子智能体。

The 42-hour model orchestration port generated most code in its first 12 hours, followed by a distinct validation and review phase involving 126 subagents.

那幅图中有三点很突出。第一,几乎所有代码生成都在前 12 小时内完成;之后一天的工作全是验证。第二,底行的颜色从左到右变化,从以蓝色和绿色为主(阅读、构建)变为以蓝色和橙色为主(阅读、审查);这在逻辑上说得通,但在实践中看到它还是很有趣。第三,那个例子,以及更普遍地来说这种模式,各阶段之间有非常清晰的分隔。扩展运行时的移植则是一个反例。

The 88-hour extension runtime port interleaved reading, writing, building, and reviewing throughout most of the session rather than separating them into phases.

它花了 88 小时而不是 42 小时,结构也截然不同:

  • 编写和审查显著重叠。 而在前一个例子中,工作是高度瀑布式的(先生成代码,再审查),这里审查在编写停止之前很早就开始了,两者在大部分工作期间并行推进且相互重叠。
  • 底部的行在颜色上分布得非常杂乱。 模型编排从写作转向检查时颜色会从绿色变为橙色,而这一行从始至终都是阅读、构建和审查的混合色彩。阅读调用的中间一半分散在 49 小时的时间跨度内,写作跨越 33 小时,审查跨越 27 小时,都包含在一段 88 小时的会话中。每个类别都贯穿了这次运行的绝大部分时间。
  • 空闲被推迟到了最后。 整个集群在前 56 小时内几乎连续工作。
  • 比例仍然吻合。 编写 Rust 在这里占工具调用的 2%,在那里占 1%;阅读占 44% 对 57%;审查占 23% 对 27%。这两次会话对所要做的工作内容是一致的,只是发生的时间不同。

结尾那些空白的切片也直观地呈现了在这个智能体编程时代越来越常见的一个问题:等待批准。团队中的某个人和/或某个智能体审查代码并留下反馈,然后有一段短暂的活动期,智能体处理反馈并让 CI 重新变绿,接着又是更多的等待,如此循环往复,直到最终我们得到那个令人多巴胺飙升的批准印章。

这两个例子各自代表了一种主导模式。大约四分之一的会话看起来更像模型编排那一次,而四分之三则更像扩展运行时那一次。按阶段清晰推进只是例外情况;常见的情况是智能体自始至终都在计划、编写和审查。

大规模代码审查

之前图表中可见的对审查的大量关注,很大程度上是由于我的明确提示。我创建了一个简单的提示即技能,我称之为 rust-rebase-review (此外还有我们已合并到仓库中的通用 Rust 编程技能)。由于传入变更的速度很快,其中许多相互冲突,我频繁地进行变基。通过自定义指令,我鼓励测试框架在流程中的适当节点调用这个技能,同时我自己也不时手动调用它。这个提示随时间略有演变,但大致是这个的变体:

Squash into a single commit, then rebase on the latest in origin/main, resolving all conflicts, and force push. As part of rebasing, pay extra special attention to anything that has changed, been added, been removed, and ensure that logic is all ported over to the corresponding Rust code correctly. Always do the rebasing yourself / in the main agent; do not spawn a subagent for it.

Then enter a review/fix loop where you launch a subagent per opus 5, gpt-5.6-sol, and grok 4.6.

- That subagent should do a line-by-line comparison of the old TypeScript and the new Rust, confirming behavioral equality.
- Look for anything introducing any kind of incompatibility; our goal is to move this code into Rust with as close as is possible to 100% the same semantics. If you hit anything questionable, ask me about it.
- We want to ensure we're writing as efficient and idiomatic Rust code as we can; look for opportunities to simplify, to use routines like from the memchr crate to optimize searches instead of open-coded loops, avoid unnecessary allocation, use traits for reuse and loose coupling, etc.
- Ensure that all defunct TypeScript code (e.g. code that has been fully ported, tests that are now no longer necessary because they're duplicative, unnecessary napi shims, etc.) has been deleted.
- Ensure that we've ported as much code as possible, e.g. if there's any TypeScript remaining in touched files and that TypeScript is more than just a shim, that's a red flag. If new TypeScript that's not just a super thin shim is being added, that's a red flag. Look for any callers of TypeScript shims to see whether those callers can instead be ported to Rust, pushing the boundary as far as reasonably possible. Our goal is to soon get to 100% Rust in the runtime layer.
- Validate that no E2E tests have been deleted or changed. Such changes are an indication of a porting bug.

If a review surfaces issues, validate them, and then if there are any to fix, fix them, and iterate to do another full review. Continue iterating with reviewing/fixing until all reviews come back clean. After every set of changes in response to review feedback, commit and push so that CI validation runs concurrently with subsequent reviews.

Don't bother running full test suites; that'll be handled in CI. Try to minimize CPU-consuming efforts to the bare minimum, as we'll likely have many operations happening concurrently.

在频繁变基的情况下,传入的变更很容易意外丢失。但我们发现就地原子交换有一个意想不到的好处:在添加相应 Rust 的同时删除 TypeScript,我们实际上隐式地与变基引入的对该 TypeScript 的变更产生了冲突:一个分支修改它,另一个分支删除它。这保证了我们会注意到对已移植代码的变更,而不需要为每一行传入代码去推断它是否可能触及了之前已移植的代码。

我自己的审查当然只是所执行的智能体审查的一部分。除了每次提交都运行的 CCR 之外,团队还有多个专门的代码审查机器人,各有自己的方法和提示,在每次提交时运行并提供详细反馈。所有这一切最终都会成为拉取请求上的评论,随后需要加以处理。幸运的是,处理所有这些智能体反馈也可以(大部分)由智能体来完成。

在评审中,我重点关注架构、设计、约定和方法。智能体负责做详尽的新旧对比;测试和静态分析检查可机械验证的属性;人类评审员则专注于架构、API 契约、风险,以及其他环节暴露出的可疑之处。

我选定了目标架构,决定了哪些行为至关重要,划分了工作,解决了模糊的权衡取舍,评判了证据,人工审查了高风险区域,审查了智能体对反馈的回应,并做出了最终的合并决策。智能体改变了一名工程师能够监督的代码量。但它们并没有消除对这样一名工程师的需求——他理解系统,并能为方向、护栏和发布背书。

自动化内循环

GitHub Copilot 应用是这项工作的核心。它管理着大量并发活动会话,让你可以轻松在它们之间切换,并为每个会话携带所有相关的配对上下文(关联的终端窗口、浏览器窗口、画布等)。这里最重要的功能是智能体合并:

The GitHub Copilot app's Agent Merge panel tracking review feedback, CI, conflicts, and merge readiness.

智能体合并是应用内置的一个循环(CLI 也通过 /pr auto 提供了该功能)。按定时器或响应外部刺激(比如来自 GitHub 的 CI 完成或评审评论通知),应用会查看发生了什么变化。如果留下了评审评论,它会调用智能体来决定是拒绝该评论,还是接受并处理它(并回复,注明回复的是自动化流程)。如果测试失败,它会下载日志、调查失败原因并修复 bug。如果发生冲突,它会调用智能体来合并或变基。实际上,它自动化了我们作为人类开发者都会做的循环:推动拉取请求变“绿”、获得签核,并最终合并。

智能体合并处理了每一个移植拉取请求。不过在大多数情况下,我们止步于实际的“合并”环节之前。智能体会修复所有 CI 失败、处理并回复所有评论,并确保所有冲突都已解决。在合并之前,我会抽查智能体实际做了什么,尤其是它如何回应反馈。我是否不同意它对评审者的任何回复?所应用修复的高层方向是否可取且合理?对于这些移植,我通常不勾选最后那一项。

那最后一个复选框不止一次发挥了作用。在一次合并循环中,该移植删除了一个暴露给 SDK 的函数。我们仓库的 schema 兼容性 CI 环节正好尽职尽责,失败了。智能体的回应是给仓库打上 schema-break-ok 自动化标签,这是让检查通过的逃生通道。在合并前审查拉取请求时,我问了那个显而易见的问题:“schema 破坏是什么?你给拉取请求加了 schema-break-ok 标签;为什么这没问题?”这并不没问题。该方法在 main 上存在;移植只是把它弄丢了。我称这是不可接受的回归,并告诉智能体用 Rust 完整地把它带回来。二十一秒后,豁免被移除,该方法以原生 Rust 实现恢复了。

我们在本地和全局层面都针对失败做出了响应,修复了个别案例,但随后也修复了系统,以降低这些问题再次发生的可能性。我们不断改进提供给编码和审查代理的指令,以进一步减少未来移植拉取请求中再次出现相同问题的机会。我们将会话日志转化为评估。在某些情况下,我们实际上利用所学到的经验通过调整提示词、工具描述或自动驾驶的运行方式来改进运行时本身。

一次迁移,两件事

语言重写几乎从来不只是语言重写。运行时依赖的每个库也都必须被替换,而且与我们在 Rust 代码中自行编写并拥有的部分不同,这些替换并非由我们决定能否忠实还原。有些只是同一思路换了个名字。有些需要多个 crate 才能覆盖原先一个 npm 包的功能。还有少数根本没有可接受的现成方案,只能(由代理)手写。

The CLI and runtime are currently in the same repo, sharing a package.json. Over the course of the port, we removed ~60 npm dependencies because they were only being used by runtime code that was ported to Rust. This is a lower bound for removals, because there were packages replaced for the runtime but that were still needed by the CLI. For example, zod is a TypeScript schema declaration and validation library that both the CLI and the runtime were using. With the Rust port, the runtime now uses a combination of serde, schemars, and jsonschema to satisfy the same purposes, yet zod is still in the manifest for the CLI’s sake.

There were a bunch of examples where one npm package became one crate doing the same job. js-tiktoken became tiktoken-rs with the same o200k_base encoding. ignore became the crate of the same name with the same gitignore semantics. minimatch became globset, fast-myers-diff became similar, dompurify became ammonia, and github/keytar became keyring.

In other cases, we weren’t able to replace packages with crates one-to-one. Instead one package became several crates, or several collapsed into fewer. This is where the majority of the dependency work went. Eight opentelemetry/* packages became four crates plus a hand-written tracker state machine and file exporter. Three web-content packages, mozilla/readability, linkedom, and turndown, became two crates, readability and htmd. sharp, image-size, file-type became image and imagesize. And so on. There were also five cases where we replaced an npm package entirely with a completely custom implementation.

How much unsafe?

Another question people ask about agent-written Rust is how much of it quietly opted out of safety guarantees. Rust’s safety is a property you can turn off with a keyword, so an agent that hits a borrow it can’t satisfy has an obvious escape hatch. Within the entire runtime crate, we now have 158 unsafe blocks, across only 36 files (alongside them are 26 unsafe fn declarations, 26 unsafe extern blocks, and nine unsafe impl trait implementations). Importantly, every single one of these is about interop with external components.

Why the unsafe block existsBlocksShare
C ABI boundary5132.3%
Windows API4931.0%
POSIX / libc4629.1%
SQLite C API74.4%
Dynamic library loading42.5%
Process environment10.6%
Donut chart of 158 unsafe blocks, all at external boundaries: C ABI, Windows API, POSIX and libc, SQLite, dynamic library loading, and process environment mutation.

The C ABI blocks are the front door SDK hosts come through, so they receive raw pointers and lengths from a caller the Rust compiler has no control over. The Windows and POSIX blocks are system calls: registry reads, credential handshakes, process trees, sysconf. SQLite is a C library. Dynamic library loading is dlopen, which cannot be safe by construction if for no other reason than because the symbol you resolve might not be the function you expected. The process environment unsafe block exists because Rust 2024 treats mutation of process-global environment state as unsafe in a multi-threaded process. Every one of those unsafe blocks marks a place where the guarantees Rust makes genuinely end: the thing on the other side is a C function, a syscall, a pointer from a foreign runtime, or process-global host state.

The useful property is that unsafe makes all of these places in the Rust code we own auditable. The equivalent code in the TypeScript runtime crossed exactly the same boundaries, through Node’s C++ internals and native npm packages, and nothing in our source marked where the checked world stopped. Of course, that’s not a complete inventory of every safety boundary in the delivered system: dependencies, build tools, C libraries, safe wrappers, and incorrectly specified FFI contracts can still contain or expose unsafety.

The details of where unsafe is used are interesting, but I’m more interested in where it isn’t used. It’s not used in the model clients, in the MCP layer, in the agent layer, or in the prompt layer. And not one of the known regressions from the porting effort involved an unsafe block.

The regressions

Porting code is easy. Making it correct is hard. And with a codebase as large and as complicated as the Copilot agent runtime, regressions are to be expected.

By September 14, 2026, we’d traced dozens of known port regressions, all fixed. Most were correctness bugs, with a smaller set of performance regressions. Against the ~832,000 lines of production Rust written from scratch.

Of course, not all of those regressions shipped. Some were caught just by developing in the repo. Others appeared in a pre-release but were fixed before making it to a stable release. And some reached a stable release, often after being unnoticeable enough to make it through one or more rounds of pre-release usage undetected.

Known correctness regressions grouped into five recurring failure modes: incomplete migration, state and lifetime, behavioral contract mismatches, host boundaries, and incorrect test oracles.

This isn’t zero, of course, and I’m 100% sure there are more than the ones we know about. These are the ones we noticed or that were reported, but a migration this size absolutely shipped a few more that have been quiet enough that nobody has hit them yet. As with bugs in general, I expect we’ll continue to discover a trickle of corner-case regressions as the further reaches of the stack are exercised aggressively in the wild.

The absolute number also isn’t all that important. What’s more important is the “why” all of these happened, so that we can learn from the issues and avoid repeating them in the future. Nearly all of the correctness regressions land in three large families: the new code implemented a different behavioral contract; state, ownership, or lifetime behavior changed; or some part of the migration was omitted, only partially applied, or lost in rebasing. A smaller set came from requirements at the host or interop boundary, and even from tests that confidently validated the wrong behavior. Those families become more concrete in a handful of recurring patterns:

Ambiguous semantics. Several regressions came from behavior the source language left implicit. TypeScript has one number type; Rust requires choosing among several, including whether a value can be fractional. And the agents guess wrong. Fields that were conceptually integers became f64s, so Rust serialized values such as 42.0 instead of 42: strongly typed SDKs like Go and C# couldn’t unmarshal a repo ID into an int64 and rejected a hook timestamp and task duration. In the other direction, an agent declared timeToFirstTokenMs as i64, but the streaming path emitted values such as 5446.712845, making written sessions unreadable and unresumable. A subtler case wasn’t about types: event.error || "Unknown error" became .unwrap_or("Unknown error"). JavaScript’s || replaces an empty string; Rust’s unwrap_or preserves it, so an empty subagent error remained empty. Oops.

Ambient behaviors. Another recurring source was behavior JavaScript or Node supplied invisibly. Quota code used toLocaleDateString, which inherits the host time zone; Rust needed that zone passed explicitly. But although Intl.DateTimeFormat().resolvedOptions().timeZone is typed as string, it can return undefined, which napi couldn’t convert to a Rust String, breaking model-list loading. Elsewhere, moving an environment-variable read from before an await to after it meant a host change during the wait could alter the result. A native-addon loader called process.report.getReport() only to identify the platform, but on Windows that honored _NT_SYMBOL_PATH and could spend minutes downloading PDBs before rendering the CLI. Other ambient inputs included the working directory, repository identity, PATH, and session authentication. Moving ownership into Rust required deciding when to capture each one, how to carry it, and when to refresh it.

Only porting half of a pair. Several of the regressions came from paired operations falling out of sync. A turn-cap check updated the native registry’s abort state but didn’t cancel the in-process model loop, allowing one more request to escape. Elsewhere, task completion was persisted and emitted but not projected into active session state, so Autopilot continued after completing the task.

Blocking the main thread. The CLI still drives the Rust runtime from Node’s single-threaded event loop, so synchronous work across the napi boundary freezes the UI. /chronicle reindex parsed hundreds of session files this way, blocking rendering and input for nearly a minute. Making the export async and moving the work to a blocking thread-pool thread fixed it. An audit found several more potentially blocking entry points, leading to a standing rule: napi exports that do real work must be asynchronous and, when necessary, use spawn_blocking.

Flashing windows. On Windows, spawning a child process without CREATE_NO_WINDOW briefly opens a console window. The Node.js runtime had monkey patched process spawning to add the flag, hiding the requirement from the porting agent; the Rust replacement omitted it. An audit fixed two additional spawn sites, though those were preexisting omissions rather than port regressions, and we added the rule to the copilot instructions.

Lifecycle management. The largest cluster involved lifecycle, disposal, ownership, ordering, or races. Moving state into Rust often left TypeScript holding an opaque handle to an instance stored in a native table. Unlike an object reference, that handle can outlive the instance. One hook disposed mid-request orphaned a tool_use block, wedging the conversation because the model API required a matching result. A shell canceled between “announced” and “started” leaked an orphan that kept the session active. A sandbox toggle updated one generation counter but not its native twin, leaving the shell stuck “reconfiguring.”

Overlooked features. Another group involved features the port simply missed. One port omitted SDK callbacks and deleted their end-to-end test, prompting a new rule that agents must not change E2E tests without explicit consent. A session abort kept its native half but lost the in-process cancellation needed to interrupt a turn waiting in a tool. The SDK’s ability to replace built-in tool search depended on enablement, the model-facing description and schema, and routing execution to the SDK callback. The port dropped all three (yay for consistency?), silently replacing one consumer’s natural-language search with regular-expression search.

Different libraries with different opinions. Several more regressions came from replacing JavaScript libraries or APIs with stricter Rust equivalents. At the time, the Rust MCP SDK (rmcp) responded to malformed JSON-RPC input while the TypeScript SDK didn’t. Against a server that answered errors with more malformed output, that politeness became an infinite loop that hung startup. Related libraries in different ecosystems rarely behave identically.

Ships passing in the night. A handful of regressions came from branch drift or rebasing. In a repo with hundreds of pull requests per week, a session open for days accumulates constant changes and conflicts. The port required thousands of rebases; even a very high success rate leaves failures.

And the slowpokes. The last group was functionally correct but slower. Some regressions lost existing efficiencies such as memoization, fully asynchronous waiting, or bounded log streaming. Others added migration-specific overhead at the Rust-TypeScript boundary: redundant serialization, locking, polling, unbounded native concurrency, and native-to-host crossings. These weren’t later optimization opportunities; each was a degradation introduced by the port. One read-only scan deep-copied a 260 MB event log instead of borrowing it. Under sustained event traffic, another implementation completed only a tiny fraction of the requested channel flushes while retaining an asynchronous handle per event until V8 exhausted its heap.

Dozens of regressions sounds like a lot. But in a port that generated more than 800,000 lines of code, frankly I’m surprised and pleased we didn’t encounter an order of magnitude more. We can also get a sense for whether these were broadly “felt” by looking at trends in issues in the public github/copilot-cli and github/copilot-sdk repos. Here we’ve classified issues opened from January through August as quality related when they carried a bug label or when their title used common failure terms such as “bug,” “regression,” “crash,” “hang,” “timeout,” “broken,” or “incorrect.” The levels were essentially unchanged from before the porting work as compared to during and after it:

RepositoryJan–Apr, before the rewriteMay–Aug, during / after the rewrite
github/copilot-cli22.9% (454 / 1,982)23.7% (354 / 1,496)
github/copilot-sdk36.2% (190 / 525)32.3% (135 / 418)

That’s not an availability metric or an exact count of escaped defects… as with the regressions themselves, there can be a ton of variability in what an issue represents, its scope, and so on. But it does provide a useful check: despite the extraordinary volume of product change, the product-facing issue channels didn’t show a meaningful quality concern spike during the migration.

If it compiles, it compiles

Earlier I referred to a popular meme that Rust is great for AI-generated code because of its strict compiler. There’s another related and popular Rust meme, that if the code compiles, it’s correct. Nope. Our known regression list provides a good response to that: every regression in the corpus was merged to main, which means it successfully compiled. The compiler accepted the buggy versions because, as far as the compiler was concerned, every one of them was valid Rust.

The compiler can prove that an f64 is used consistently. It cannot know that a repository ID must be serialized as an integer or that a timestamp serialized with a trailing .0 will be rejected by every strongly-typed SDK at the other end of the wire. The compiler can prevent unsynchronized data races in the code it can see, but it can’t prevent a perfectly synchronized state machine from encoding the wrong states. A queue can be protected by locks and still let two senders each conclude the other will drain it. Events can move safely between threads and still arrive in the wrong order. A synchronous napi function can be memory-safe and still block Node’s main thread for a minute. Compilation is also almost by definition unable to detect something that isn’t there. The compiler can’t object when a rebase quietly removes a guard and its test, or when a process spawner forgot the Windows flag that suppresses console window pop-ups. It also had no opinion about cloning a 250 MB event log on every read. A compiler checks whether the program you wrote is internally coherent. It cannot check whether you wrote the whole program, preserved the old contract, called things in the right order, met the host’s unwritten requirements, or did the work at an acceptable cost.

That’s in no way an argument against Rust’s compiler. As with any statically-typed language, the compiler eliminated a huge class of mechanical mistakes and gave the agents an exceptionally useful inner loop. But “if it compiles, it’s correct” is useful only as a joke.

Perf, perf, and more perf

So what did we buy with all of this? The port was deliberately behavior-preserving. It did not set out to redesign algorithms or fix bugs; in fact, I repeatedly pushed the agents away from opportunistic optimization because changing language and behavior at the same time makes it much harder to know which one broke you. A key goal of the rewrite, however, was indeed performance and scalability (in addition to reliability and other factors). When folks asked me why did I set out to rewrite the runtime in Rust, my answer was often along the lines of “I didn’t set out to move to Rust, I set out to move away from Node.js and V8”. That move has indeed significantly moved our needle on performance.

I benchmarked several scenarios for the runtime via the C# SDK before and after the port (the TypeScript, Python, Go, C#, Java, and Rust SDKs all reach the same engine through the same transport architecture). The baseline against a pre-port build of the SDK and CLI, with the TypeScript runtime hosted by Node and reached over stdio. The August 21 result uses the Rust runtime, both as an out-of-process server and loaded in-process through FFI. This is an end-to-end comparison of the delivered systems rather than an attempt to isolate the effect of the language change; other changes landed during the same period, so the numbers need to be taken with some grains of salt.

Each timed turn went to a deterministic chat completion server running on localhost, producing a fixed, small response. In other words, these numbers deliberately remove model inference and network latency. They measure the part we changed: client startup, process launch, session creation, event handling, persistence, teardown, etc.

ScenarioMay 12Aug 21 out-of-processAug 21 in-process
Client, session, one turn5.25 s1.33 s (4.0x)292 ms (18.0x)
Resume 32-turn session5.64 s1.52 s (3.7x)264 ms (21.4x)
Ten concurrent client lifecycles12.34 s4.18 s (3.0x)742 ms (16.6x)
1,000 one-turn session lifecycles132.52 s22.53 s (5.9x)20.93 s (6.3x)
From May 12 to August 21, creating a client and session, completing one turn, and tearing them down improved from 5.25 seconds to 55.3 milliseconds in-process, throughput rose from 7.55 to 120.0 sessions per second, and ten-client memory fell from 1,383 MB to 126 MB.

The “client, session, one-turn” number is the easiest one to feel. It’s creating a client, creating a session, doing a single turn, and tearing everything down. A large part of the out-of-process overhead came from starting Node, initializing V8, and loading, parsing, and generating bytecode for the JavaScript application produced from the TypeScript code before the first turn could begin. The Rust runtime removes those Node/V8 and JavaScript-loading costs.

The “1,000 one-turn session lifecycles” pressure test represents a step function change in what we can build. It uses a single shared client and measures running 100 concurrent pipelines, each creating a session, taking a complete model turn, disposing the session, and doing that ten times in sequence. The pre-port TypeScript CLI completed 7.55 of those lifecycles per second. Rust out-of-process completed 57.45. Rust in-process completed 120.0.

That is a workload-specific result; the Rust runtime is not universally “15.9x faster.” But it is exactly the workload server hosts care about: many independent sessions sharing one runtime. And wall-clock time isn’t hiding the work on another core. In a separate resource-sampling pass over the same 100-by-10 workload, the pre-port process tree consumed 312 seconds of aggregate CPU. The Rust configurations consumed about 110 seconds. That is CPU capacity the host can spend on more sessions.

Memory tells the same story, with the usual warning that memory metrics are annoyingly easy to misuse. Looking at resident private memory added during the ten-client batch, the pre-port process tree peaked 1,383 MB above baseline. Rust out-of-process peaked at only 247 MB. And Rust in-process at 126 MB, an order of magnitude less. While these numbers of course will differ from use to use and machine to machine, they get to the core goal: a service can host far more clients and sessions on the same machine before memory, process count, or CPU become the limiting resources.

The best part is this is the baseline port. Much of the implementation still consists of TypeScript-shaped algorithms faithfully rendered in Rust. We haven’t yet done the broad redesign work that the new ownership model, concurrency model, and in-process architecture enable. Getting an in-process client through creation, a complete one-turn session, and teardown in about 55 milliseconds, pushing a shared client to 120 one-turn session lifecycles per second, and cutting the measured ten-client memory delta by 91% before that optimization work begins is a very good place to start.

What the port cost

So… how much did all this cost, monetarily? Drum roll, please….

My token spend for all of the porting work was ~136.3 billion total tokens, including ~130.6 billion cached input read tokens, ~4.2 billion cached input write tokens, ~900 million fresh input tokens, and ~600 million output tokens. The monetary bill for all those tokens came to ~$120,000.

Of course, these tokens weren’t spending themselves. The cost for using a significant amount of a developer’s time guiding these agents should also be factored in. However, I wasn’t devoting all of my time just to this project. Agentic development is a lot of “hurry up and wait;” submit a prompt, let the coding agent do its thing, check in on it from time to time to possibly steer it, but in the meantime until it completes, do other things. That means developers are no longer working on just one coding task at a time; they’re overlapping that waiting to enable working on many things concurrently. During the porting window, these Rust porting PRs represented ~20% of my PRs across all repos to which I was contributing. If we wave our hands and estimate that the share of pull requests approximates the share of my time, that works out to roughly three weeks dedicated to the port.

In other words, the rough bill for the porting effort was about $120,000 in attributed token spend plus three weeks of a developer’s time.

That said, the end-to-end Rust migration was not 100% a single developer; it’s been a team effort. @stevesandersonms provided the design and implementation for napi-oop, the temporary out-of-process interoperability layer, as well as five of the six SDK FFI implementations, with @edburns providing the sixth. @roji implemented the packaging for the SDK to ship and utilize the Rust binaries correctly. @caarlos0 has been helping to split the Rust code into many small subcrates in order to help with build times that started to become problematic, while @criemen helped with improved asset caching to speed up CI and local builds. @devm33, @examon, @MRayermannMSFT, @dereklegenzoff, and others have helped with countless pull request reviews and approvals. And everyone contributing to the copilot-agent-runtime repo has been supportive and accommodating as the world has shifted beneath their feet.

Lessons learned

The experience reinforced a few lessons that apply beyond this rewrite. Here are some things we’d carry into a next time:

  • The goal needs to be clearly and fully stated. Our early instructions were too vague. “Port XYZ component to Rust” got read as only the hot paths, or only the logic, and the agents repeatedly treated I/O and orchestration as being out of scope. Once we were explicit that the end state was a native binary built from a 100% Rust codebase, with no execution environment left for TypeScript even if we wanted one, they got much better at driving autonomously to that goal.
  • End-to-end tests are absolutely, unequivocally critical. With one exception, all of the regressions that involved missing features, and many of the others, were due to lack of sufficient end-to-end tests. For any port of this nature, you must have tests that can be used to validate the correctness of the port, and those tests can’t themselves be re-written during the porting, or else you lose your oracle. Our initial plan for this porting effort called this out, and called out that we needed to significantly improve our E2E testing posture before starting on the port. We did that, but we didn’t do it enough. We’re sure that if we’d added even more E2E tests before starting the port, really focusing on ensuring most meaningful behaviors were covered by them, we would have had fewer regressions than we did along the way.
  • Protect the oracle from the agent. The agent changing an implementation cannot also be allowed to silently redefine correctness by weakening a test, updating a snapshot, raising a compatibility baseline, or applying an escape-hatch label, at least not without oversight. Keep the behavioral contract independent where possible, put sensitive guardrails behind separate ownership or approval, and layer checks with different failure modes so that one mistake isn’t enough to ship a big regression.
  • Translate first, redesign second. Preserving behavior and existing algorithms kept the number of variables moving at once manageable. Once the old implementation and the transitional scaffolding are gone, ownership, concurrency, and performance can be redesigned against a stable baseline. I veered away from this a few times, out of restlessness or difficulty saying no to a peer or a conviction that this case was different, and in hindsight I regret every one of them. Each cost more regressions, more time, or more tokens than staying the course would have.
  • Turn repeated failures into future successes. AI agents will wander off track: when they do, learn from it. When a failure mode shows up twice, it belongs in standing instructions, a reusable skill, an eval, a protected baseline, or the harness itself.
  • The developer inner loop matters more with agents in the loop, not less. For us as developers, we live and breathe in our inner loop, how quickly we can make a change, build, test, iterate. We get frustrated when the tooling part of that takes too long. And it’d be forgivable to think that doesn’t apply when agents are doing the lower-level work. It does. More so. AI agents do the thinking and writing code parts of these tasks incredibly quickly, but they still need to build and they still need to test. And the proportion of the time they build and test actually increases as they spend less time thinking and writing and more time in fast validation inner loops. Spend some time up front optimizing the inner loop, and optimizing it for multiple things happening at the same time (e.g. as if you’re working on multiple tasks in multiple worktrees at the same time). You will thank yourself later for the investment.

What’s next?

It worked. The execution runtime that was entirely TypeScript in May is now entirely Rust in August, and it shipped to real users continuously the whole way rather than landing as one terrifying cutover at the end.

I didn’t simply ask an AI agent to “port this whole codebase from TypeScript to Rust.” Even if that’s where we’re heading as an industry, we’re definitely not there yet. Instead, agents made an entire category of project feasible. A rewrite producing hundreds of thousands of lines of production Rust in a live system, done in place, in main, by one engineer supported by a team, is not a proposal that would have been accepted before agents. It would have needed a whole team and a year or two, it would have competed against every feature that team could have shipped instead, and it would have lost (and, honestly, should have lost). Agents moved the price to where the project became tenable.

The port itself is complete: the runtime’s production implementation is 100% Rust, and the temporary internal TypeScript/N-API seam is gone. There is still plenty of work we desire to do, though: further improving the build system and developer inner loop, cleaning up translated structures, redesigning around Rust’s ownership and concurrency models, and pursuing further performance wins. The port was a translation, deliberately so (prompted as such), and we kept behavior as close to 100% identical as possible, rather than opportunistically fixing additional bugs, rearchitecting components, or further improving performance and scalability (beyond what came implicitly from the rewrite). Much of the code at the micro level is idiomatic Rust, but at the macro level there’s a good deal of initially TypeScript algorithms wearing Rust’s syntax. Revisiting those decisions now that the constraints underneath them have changed is where the interesting wins will be.

I’m most excited about what the port makes possible. The SDK can be loaded directly into a host process in any of six languages, with no Node.js or V8 in the dependency chain, and no second process to supervise, which is the single most common piece of friction we’ve heard from partners adopting the SDK. A runtime instance that costs a fraction of what it used to means a host can run far more concurrent sessions before it runs out of machine. And the runtime can now go places Node.js was never going to follow it, across the spectrum of cloud to desktop to device to embedded systems. None of that is the finish line. It’s the base we now get to build the future of GitHub Copilot on top of, and after three months of watching agents rewrite the very engine that runs them, I’m looking forward to seeing just how far it goes.

Happy coding!

The post Migrating the GitHub Copilot runtime to Rust, using Copilot appeared first on The GitHub Blog.

来源:GitHub Blog · AI & ML · github.blog