DeepSeek、拼装可能なエージェントアーキテクチャ「DeepSeek Harness」を公開
本文の状態
日本語全文を表示中
詳細モードで約53分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
Tencent Engineering
DeepSeek が発表した初の Agent 製品「DeepSeek Harness」は、Cordis というプラグイン基盤を採用し、すべての機能をプラグインとして扱うアーキテクチャを確立した。
AI深層分析を開く2026年8月14日 22:06
AI深層分析
キーポイント
Cordis ベースのプラグインアーキテクチャ
DeepSeek は「すべてはプラグイン」という方針のもと、Shigma のオープンソースプロジェクト Cordis をベースに、モデル接続から UI までを統一されたプラグインシステムで管理する。
可逆副作用とライフサイクルの完全制御
Cordis はコンポーネントのアンロード時にリソース解放や状態変更を完全に元に戻す「時間可組合性」を実現し、プロセス再起動なしで安全な環境管理を可能にする。
既存フレームワークとの明確な差別化
従来の DI 容器や Pi の拡張機能とは異なり、依存関係の自動管理、ホットリロード(HMR)、そして副作用の可逆性を備えた独自の設計を採用している。
コード実行モードと設定の分離
安全性を懸念される隔離方案を選定した Code Mode や、Preset のスコープ継承チェーンを二層構造に分割するなどの具体的な工学的判断が示されている。
Fiber を介した依存関係の自動解決
プラグインは依存サービスが揃うまで PENDING 状態で待機し、条件を満たすと自動的に ACTIVE 状態へ遷移する。
重要な引用
"everything is a plugin"
「時間可組合性:卸载组件时,组件对共享环境所做的修改必须被完整、安全地逆转」
「有没有一种 programming model,能让动态本身具备类似进程那样的生命周期隔离?」
Cordis 中每一次上下文变更都通过唯一原语 ctx.effect 完成——提供服务、实例化组件、所有修改上下文的操作都归约成一次 ctx.effect 调用
編集コメントを表示
編集コメント
DeepSeek が既存のオープンソースプロジェクトを深く改修し、自社の製品哲学である「すべてはプラグイン」を実現した点は、技術選定の自由度とシステム堅牢性の両立を目指す開発者にとって示唆に富む。特に、動的な環境下での副作用制御という難問に対し、プロセス隔離の概念を応用して解決を図った手法は、今後の Agent 開発の標準的なアプローチの一つとなり得る。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
DeepSeek が初の Agent 製品「Harness」を発表
DeepSeek が初の Agent 製品「Harness」を発表しました。
著者:chino(腾讯 WXG 微信小店 フロントエンドエンジニア)
DeepSeek が初の Agent 製品「Harness」を発表しました。今回の焦点はランタイムアーキテクチャにあります。公式が掲げるキーワードは"everything is a plugin"(すべてをプラグイン化する)。モデルの接続、ツールの実行、会話履歴の記録、ループ処理そのもの、さらには UI まで、すべてがプラグイン機構を通じて管理されます。
本稿では、Agent 分野ですでに業界共通の常識となっている部分——ツール呼び出しの検出、実行、結果のフィードバック、ループ処理など——については省略します。現在、ほぼすべてのフレームワークがこの一連の流れを同じように実装しており、改めて解説する価値はありません。代わりに、DeepSeek Harness が他社と明確に差別化されている設計のポイントに焦点を当てます。
具体的には、プラグインランタイム「Cordis」がどのようにライフサイクルを管理するか、Preset のスコープ継承チェーンがなぜ 2 層構造になっているのか、Code Mode においてあえて"安全"とは言い難い隔離方式を選んだ理由、そしてツール登録における遮蔽アルゴリズムの実装方法です。さらに、Codex のいくつかの具体的なエンジニアリング判断との比較も交えつつ、異なる点のみを解説します。
一、なぜ Cordis を採用したのか
「すべてをプラグイン化する」という方針の基盤となっているのが、Shigma がオープンソース化したプロジェクト「Cordis」です。これは"Meta-Framework for Modern JavaScript Applications"(モダンな JavaScript アプリケーションのためのメタフレームワーク)と位置づけられており、依存関係の注入、スコープごとのサービス管理、ライフサイクルのクリーンアップといった機能を担う汎用的なプラグインフレームワークです。
Agent や LLM とは直接の関係はなく、DeepSeek Harness の登場以前から既に存在していました。
Cordis はオープンソースコミュニティで実際に使われており、その代表例がクロスプラットフォームチャットボットフレームワークの Koishi です。QQ、Discord、Telegram、WeChat といった主要なプラットフォームをすべてサポートしています。チャットボットの運用では「数十個のプラグインを組み合わせて使う」「ホットスワップに対応する」「設定を随時変更できる」という要件が自然に発生しますが、これはエージェントの運用環境とほぼ同じです。唯一の違いは LLM を使わない点だけです。
DeepSeek Harness では Cordis のソースコード全体を自社のリポジトリに取り込み(vendor)、パッケージ名を @deepseek-ai/cordis として再定義しています。さらに、自社で開発したすべてのパッケージに対して、これを peer dependency として設定しました。これは単なる「サードパーティライブラリの利用」を超え、製品全体が Cordis を基盤に構築されていることを意味します。
まず、代表的なプラグインアーキテクチャを三つ比較してみましょう。従来の DI(依存性注入)コンテナ、軽量なフックベースの手法、そして Cordis です。それぞれに明確なトレードオフがあり、並べて検討する価値があります。
従来の DI コンテナには三つの課題があります。「主流」のアプローチは DI コンテナとライフサイクル注釈を組み合わせたものですが、「バインドしたサービスは誰がアンバインドし、クリーンアップするのか?」という問いに対する答えがありません。従来の DI はこれを放置してメモリリークを招くか、あるいは開発者が手動でライフサイクルフックを実装するよう要求します。例えば LLM プロバイダをホットスワップした場合、それに依存する ToolRegistry も自動的に再起動すべきですが、従来の DI では「依存関係に駆動されたリロード」を実現できません。設定についても同様です。従来の DI は起動時に設定を読み込むだけで、変更には対応しません。一方、Cordis の cordis.yml 設定ファイルでは一行が一つのプラグインインスタンスを定義しており、設定の変更は即座に HMR(ホットモジュールリロード)として反映されます。
Pi もエージェント界の注目製品ですが、その Extension(拡張機能)のアプローチは異なります。Pi は「自己拡張可能なコーディングエージェント」を自認しており、その拡張機能は TypeScript で記述され、フックポイントにロジックを注入する仕組みです。しかし、依存性注入もプラグイン間の依存管理(ロード順序が優先度となる)も備えておらず、アンインストールもできません。
これに対し、Cordis のプラグインは「依存関係の宣言」「ライフサイクル管理」「可逆的な副作用」を持つコンポーネントとして設計されています。依存性注入に対応し、反応性の coeffects を持ち、プラグイン間で依存関係を宣言可能で、アンインストール時には副作用を完全に元に戻すことができます。
この「可逆的な副作用」という点が、本稿の核心です。Cordis の設計論文ではこれを理論的レベルまで昇華させ、以下のような核心的な問いを立てています。
動的な処理に、プロセスやコンテナと同様のライフサイクル分離を持たせるプログラミングモデルは存在するか?
プロセスやコンテナの利点は、停止(kill)して再起動すれば状態がクリアされることです。その代償として、キャッシュ、接続、未完了の計算などが失われます。プラグインシステムでは、再起動を伴わずに同じクリーンアップを実現したいと考えています。論文はこの課題に対し、二つの定義を提示しています。
時間的結合性:コンポーネントをアンインストールする際、共有環境に対して行ったすべての変更が完全に安全に元に戻される必要があります。これには、コンポーネントの実行中に発生したリソース割り当て、イベント登録、状態変更のすべてを追跡することが求められます。
空間的結合性:依存関係の変化に応じて、関連するコンポーネントが自動的に有効化または無効化されます。
本稿で最も重視されているのは「時間的結合性」です。残りの要素——Fiber、effect、システム境界、preset、Code Mode——はすべて、「アンインストール=完全な元に戻し」をどのように工学的に実現するかという問いに対する答えです。
Cordis のエコシステムには、プロジェクトと共にベンダーされたいくつかの連携パッケージも含まれています。宣言型設定ファイル(cordis.yml など)を解析する設定ファイルローダ、設定をサブツリーとしてマウントする include プラグイン(preset 機構はこの機能をリネームしたものです)、プラグインのホットスワップをサポートする HMR モジュール、そしてログとタイマーという二つの基盤プラグインです。これらを組み合わせて初めて、「すべてがプラグインである」という概念を真に理解できます。
Fiber と effect:可逆的な副作用の基盤
Cordis において、プラグインの型定義は以下のようなユニオン型となります:
Plugin は Plugin.Function、Plugin.Constructor、Plugin.Object のいずれかです。これらは「裸の関数」「クラス」「apply メソッドを持つオブジェクト」という 3 つの形式で記述されますが、最終的にはすべて統一されたコールバックとして解析されます。
プラグインをマウントするには ctx.plugin(plugin, config) を呼び出します。内部ではまず、すでに存在する Runtime の記録(コールバック関数のアイデンティティをキーとして使用)を検索します。もし該当する記録があればそれを再利用し、なければ新規作成します。その後、Fiber が生成され、その Runtime の fibers リストに追加されます。
Fiber はプラグインインスタンスのライフサイクル状態マシンです。6 つの状態(PENDING、LOADING、ACTIVE、FAILED、DISPOSED、UNLOADING)を持ちます。例えば、プラグインが inject: ['tools', 'shell'] と宣言している場合、対応する Fiber は PENDING 状態で待機します。依存チェーン上にこれらのサービスがすべて現れるまで待機し、その後に初めてプラグインのコードを実行して ACTIVE 状態に移行します。
この待ち合わせ機構は Cordis が独自に実装した依存解析機能です。プラグイン作者が「相手が準備されるのを待つ」ためのポーリングロジックを手動で記述する必要はありません。これは空間的結合可能性(spatial composability)に対応しており、依存関係が満たされると自動的にコンポーネントが活性化します。
プラグインのアンロードが安全かどうかは、effect 機構によって決定されます。プラグインが登録するあらゆるもの——イベントリスナー、サービス、タイマーなど——は ctx.effect() を通じて登録されます。登録時に即座に実行され、返される取り消し関数(disposer)が disposables リストに追加されます。
論文 5.1.1 では、Cordis におけるすべてのコンテキスト変更は唯一の原語である ctx.effect を通じて行われると指摘されています。サービス提供やコンポーネントのインスタンス化、コンテキストへのあらゆる修正操作は、すべて単一の ctx.effect 呼び出しに帰約されます。
使い方は以下のようになります:
ctx.effect(() => {
// 副作用の実行:リスナー登録、タイマー起動、サービス提供など
return () => { /* 逆処理:上記操作の取り消し */ }
})コールバックは、disposer 関数、Promise、あるいは(非同期)イテレータを返すことができます。イテレータの場合は yield で逐段に disposer を返します。つまり、副作用を行うたびに、その逆処理を行う関数を一つずつ引き渡す仕組みです。
ここで言う「副作用」とは、Context を介して共有環境に対して行われるあらゆる変更を指します。以下の表では、日常的な操作がすべてこの定義に含まれることが示されています:
- 類別:例
- イベント登録:ctx.on("foo", handler) —— 共有イベントバスにリスナーを登録
- サービス提供:ctx.provide(name, service) —— コンテキスト上にサービスを追加
- サブコンポーネントのマウント:ctx.plugin(plugin) —— 子プラグイン自体が「親レベルの副作用」となる
- コンテキスト拡張:ctx.extend() / ctx.intercept() / ctx.isolate()
- 外部リソース:タイマー、ファイル監視、HTTP サーバー、サブプロセス、DB 接続など
- 状態変更:config の変更、レジストリの更新、store の書き換え
Koishi では ctx.command(...) によるコマンド登録や ctx.router.get(...) による HTTP ルート登録が、DSH(DeepSeek Shell)ではツールの登録、HMR リスナーの設置、スケジューラの起動などが該当します。これらすべてが副作用です。
「どのように逆処理するか」は実装上非常にシンプルです。各副作用には独自の取り消し関数が付随しており、実行時にはそれらを LIFO(Last In, First Out)のスタックに積みます。アンロード時にはこのスタック全体を一度に実行することで、すべての副作用を安全に取り除きます。
登録された順序の逆順に、登録解除関数を一つずつ実行します。これはスタックから要素を取り出す操作に似ています。
子ファイバーの削除関数自体も、parent.fiber.effect(...) を通じて親ファイバーに登録されます。ソースコード上では、Fiber コンストラクタの末尾にある以下の行が該当します:
this.dispose = parent.fiber.effect(() => { /* プラグイン登録 */
return async () => { /* プラグイン解除 */ }
})つまり、プラグインとは親ファイバー上の「エフェクト(副作用)」そのものです。プラグインの起動ロジックが副作用であり、アンロード(削除)ロジックはその逆操作となります。
子コンポーネントの逆操作は、親コンテキストの累積器に先頭へ追加(prepend)され、再帰的な構造を形成します(論文では「twisted composition」と呼ばれ、𝜕²Γ と表記されます)。したがって、あるプラグインをアンロードすると、その下に紐付いている子プラグインもスタック順に自動的にすべて削除されるため、「誰が誰に依存しているか」を管理する専用のクリーンアップテーブルを別途用意する必要はありません。
このメカニズムには、信頼性を高めるための 2 つの重要な仕組みがあります。
1つ目は冪等性(いどうせい)です。dispose() を初めて呼び出すと armed フラグが false に設定され、その後再度呼び出されても即座に返却されます。これにより、逆操作は最大で 1 回しか実行されません。「一度も発生しなかったエフェクトに対して逆操作を適用すると、状態の復元ができなくなる」というリスクを防ぎます。
2つ目は中断機能です。コールバックがイテレータ(反復子)を返す場合、各ステップの実行前にガード条件をチェックします。条件が失われた瞬間に即座に停止し、すでに累積された逆操作のみを実行します。これにより、ホットリロードやアンロード中の失敗といった状況でも、安全に処理を中断できます。
イベントリスナーも同様に可逆的なメカニズムに従います。ctx.on(...) は unsubscribe 関数を返し、リスナーはファイバーのフックテーブルに登録されます。ファイバーがアンロードされる際に、これらは一括で削除されます。
_unload() がすべての撤销(解除)関数を実行した後、もし依存関係が再び満たされていると検知されれば、即座に _reload() を呼び出して再構築します。「あるサービスの実装を差し替える」という行為は、ランタイムレベルでは自動的に「アンロード→ロード」のサイクルとして処理されるのです。
プラグインの実装例(UI 画面に時計を追加するケース)は以下のようになります:
会话日志プラグインは、イベントリスナーの登録とタイマーの起動を行い、アンロード時には自動的にクリーンアップする仕組みです。
export const name = 'session-logger'
export function apply(ctx: Context) {
// effect コールバックは登録時に即座に実行される
ctx.effect(() => {
// 副作用 1:イベントバスを購読し、イベントをログに追加する
const off = ctx.on('tool/result', (event) => appendToLog(event))
// 副作用 2:タイマーを開始し、定期的にバッファをディスクへフラッシュする
const timer = setInterval(flushBuffer, 1000)
// 戻り値は取り消し関数:アンロード時には登録の逆順で実行される
return () => {
clearInterval(timer) // 後から登録されたものは先に削除
off() // 次にリスナーを解除する
}
})
}このプラグインをマウントすると、ctx.effect のコールバックは即座に実行されます。リスナーが取り付けられ、タイマーが起動し、返された取り消し関数は disposables リストに追加されます。アンロード時(プラグインの削除、セッション終了、HMR による再読み込みなど)には、disposables.splice(0).reverse() が逆順で個別に実行され、まず clearInterval が呼び出され、次に off() が実行されます。
取り消しロジックは登録ロジックのすぐ隣に記述されるため、開発者は「どこを解除するか」を別箇所で管理する必要はありません。
この書き方は非常に React のそれに似ています。useEffect の形状もほぼ同じです:
useEffect(() => { subscribe(); return () => unsubscribe() }, deps)
—— コールバック内で副作用を実行し、クリーンアップ関数を返すことで、コンポーネントのアンロード時に自動的に呼び出されます。
両者に共通する核心的な約束事は、「副作用とその逆処理をセットで記述し、破棄はフレームワークがトリガーする」という点です。違いも明確です。React の effect はレンダリング後に実行され、依存関係の変化時にはまずクリーンアップされてから再構築されます。一方、Cordis の effect は登録時に即座に実行され、アンロード時には一元的に取り消しが行われます。また、クリーンの順序には明確な LIFO(後入れ先出し)の保証があり、「後から登録されたものが先に削除される」ため、子プラグインは親プラグインの逆順でカスケード的に解除されます。
なぜ「逆処理」にこれほど多くのページを割く必要があるのか。論文では「自進化する Agent Harness」が 2 つの主要な動機の一つとして挙げられており、その記述は非常に鋭いです:
将来の harness は、継続的なサービスリクエストを受けながら、自身のコンポーネントに対する変更を生成・デプロイしていくことになる……時間的結合性(time-composability)が失われ、自己修正のたびに完全な再起動を余儀なくされ、プロセス内に蓄積されたすべての状態が失われる。さらに悪化すれば、欠陥のある自己修正によって、回復に唯一頼れるプロセスさえも破綻する恐れがある。
これが「自進化」の前提条件です。Agent 自身がプラグインやツールを記述し、実行時に自らに組み込みます。不良なコンポーネントが導入された場合でもロールバック可能であり、かつそのロールバック機構自体(ホストプロセス)を新コンポーネントによって破壊されてはなりません。
通常のプラグインシステムではこれが実現できません。なぜなら、ホストのプロセスを再起動すれば、現在のプロセスに蓄積されていたすべての状態が失われてしまうからです。
既存の生態系と比較してみましょう。VSCode の拡張機能ホストでは単一の拡張機能をアンロードできず、無効化やアンロードにはホスト全体の再起動が必要です。Koishi コミュニティでも、設定を変更した後にデーモンをリロードするのはごく一般的な操作です。
Cordis の「逆処理」メカニズムにより、「アンロード」は「ロード」と対称的な一等操作となります。サービスは中断されず、他のプラグインへの影響もありません。
この仕組みは、2 つの一般的なエンジニアリング課題も同時に解決します。
- 失敗時の原子性:プラグインの初期化中に例外が発生した場合(設定検証の失敗や依存関係の欠如など)、fiber は FAILED 状態に入り、dispose が実行されます。すでに適用された効果の一部は逆順で削除され、部分的な初期化状態が残留することはありません。
- カスケードクリーンアップ:開発者はアンロード用のコードを記述する必要がありません。
論文の 5.3 セクションにおける重要な主張は以下の通りです。
コンテキストの追跡と自動逆复合(リバーシブル・コンポジション)の仕組みにより、経験が浅い開発者でも、プラグインの副作用を体系的に整理できます。アンロードパスを手動で記述する必要はなく、「正しさ」は個々の開発者の自己責任から、抽象層が一括して保証するものへと変わります。
具体的には、プラグインをアンロードする際、その子プラグインが逆順でカスケード(連鎖)的にアンロードされ、タイマーやリスナー、HTTP サーバー、データベース接続などが解放されます。これにより、「各開発者が deactivate 関数を正しく記述すること」に依存していた清理の正しさが、「フレームワーク構造による保証」へと移行します。
ここで VSCode モデルには弱点があります。同モデルでは activate と deactivate のフックが分離しており、「副作用の生成と清理が分断されている」ため、関心の局所性が損なわれ、完全な清理を検証することが困難です。
この逆転(リバーシブル)メカニズムの主な役割は以下の 4 点に集約されます。
① 開発中の HMR(コード変更で再起動不要、エラー発生時はトランザクションロールバック可能)
② 本番環境でのプラグインの動的なロード/アンロード(サービス停止なし)
③ 自己進化型 Agent(自身を修正し、障害時には自己修復)
④ 失敗の原子性とカスケード清理(不良プラグインが残留して他に影響せず、開発者はアンロードコードを書く必要がない)
この仕組みは、「手動での事後処理」だったアンロードを、「フレームワーク保証による構造的な操作」へと変革しました。これが「すべてをプラグインとする」「プラグインのホットスワップが可能」という理念が成立する前提条件です。
三、四条の制約とシステムの境界:効果(エフェクト)が確実に実行される仕組み
ここまで読んで、「もし開発者が副作用を effect の外側で記述したらどうなるのか?例えば globalThis.foo に直接代入した場合、このメカニズムは機能するのか?」と疑問を持つ方もいるかもしれません。論文はこの問いに正面から回答しています。
フレームワークの対応は二つの側面から行われます。一つは API レベルでの制約で、コンテキストの変更は effect を通じてのみ行うことを強制します。もう一つはシステム境界の明確化で、境界外で行われる操作は追跡対象外と定義されています。
フレームワークは効果(エフェクト)の使用について、以下の 4 つの側面から制約を設けます。
第一に、API エントリーポイントが唯一つであること。イベント ctx.on、サービス提供 ctx.provide、プラグインの登録 ctx.plugin、サービスの設定 ctx.use など、フレームワークの機能を利用する手段はすべて ctx 経由のみです。また、ctx 上のあらゆる操作内部では ctx.effect がラップされています。API デザイン上、effect を迂回してフレームワーク機能を利用する経路は存在しません。
第二に、Context は Proxy です。ソースコードには単一の行で定義されています:const self = new Proxy(this, ReflectService.handler)。ctx の属性に対する get/set 操作はすべてプロキシを介して処理されます。これが反応性 coeffect(「アクセス=通知」)とインターセプションのメカニズムの源泉です。したがって、ctx.foo = x という直接代入も拦截され、記録されます。
第三に、ライフサイクル状態マシンです。各 fiber には状態マシンが備わっており、ctx.effect() の第一行は this.assertActive() です。すでにアンロードされた fiber で effect を作成しようとすると、即座に CordisError INACTIVE_EFFECT がスローされます。アンロード後に副作用を登録しようとしてもフレームワークは拒否するため、時間的な観点から此类のリークが排除されます。
第四に、トランザクション型 HMR です。もし開発者が逆転ロジックを誤記し、新コードの import に失敗した場合(文法エラーなど)、Algorithm 10 の backup/restore メカニズムにより、全体が旧バージョンへロールバックされます。システムは部分的なロード状態に留まることはありません。これがメカニズムレベルでの最後の保障です。
境界外で行われる操作については、フレームワークは明確に追跡しない方針です。論文 §6.1 では環境を二つに分けています。「境界内(inside)」とは、システムが独占的に変更でき、かつ元状へ回復可能な領域で、その操作記録は Γ に保存され、recover 可能です。「境界外(outside)」では、この二つの条件のいずれかが欠けているため、操作は idΓ として扱われ、追跡も回復も行われません。注意すべきは、この境界が「場所」によって定義される点です。媒体の種類とは無関係です。
例えば、プライベートパスの scratch ファイルや、本システムのみが書き込むメモリ領域は「境界内」に分類されます。一方、公開ファイルや他のプロセスも書き込んでいるデータは「境界外」となります。グローバル変数の直接変更、monkey-patch、公共ファイルへの書き込みはいずれも境界外の操作です。フレームワークはこれらの操作を追跡せず、回復できるとも主張しません。これは設計上、明確に宣言された境界なのです。
さらに細分化すると、取得と放出の 2 つの段階があります。取得段階(open/malloc/fork)はシステムの境界内にあり可逆ですが、放出段階(write/send でデータがシステム外へ出る)は境界の外にあり不可逆です。この場合、回復には「 withholding(放出を猶予し、状態が確定するまで待つ)」か「compensation(補償動作:作成したファイルを削除したり、受け取った資金を返却したりする)」のいずれかが必要になります。補償も LIFO(後入れ先出し)で組み合わされますが、元となる理論はもう保証されません。
では、境界外の資源を工程上どう扱うか?論文 §6.1 では「coeffect を用いて外部位置を具体化し、境界を移動させる」という手段を示しています。これにより、特定位置へのアクセスを一連の操作に限定できます。各操作には逆処理が用意されているため、本来 idΓ として振る舞っていた操作も追跡可能になり、回復が可能になります。工程上ではこれが「サービス(service)」という抽象化です。開発者は ctx.database や ctx.assets を通じてこれらの資源にアクセスしますが、データベース接続やファイルハンドル、子プロセスといった低レベルの要素には直接触れません。資源のライフサイクルは各サービスの effect 内部に閉じ込められ、サービス提供者が正しい逆処理を実装する責任を負います。消費者側は高レベルなインターフェースのみを扱えばよいです。副作用はプラグインコードのあちこちに散在せず、サービス実装内部に集約されます。また、作者としての義務も多数のプラグイン作者から少数のサービス実装者に収束します。
最後に残る問題は、「逆処理が正しく書かれているか、どう判定するか」です。論文 §3.3.2 では「物理的な完全な復元」を要求していません。なぜならそれは不可能だからです(free 命令はヒープのレイアウトを元に戻せず、生成された名前も再生されません)。代わりに、「回復」は観察者による区別がつかない状態として定義されます。
「2 つの状態が関連しているとは、いかなる観察者もそれらを区別できないことを意味します。表現ではなく振る舞いを比較するのです……等価関係は、coeffect がそれぞれ持つ等価性によって構成されます。」
つまり、「逆処理」の意味は観察レベルでの復元です。回復前と回復後の状態において、いかなる観察者も違いを認識できないことが、逆処理の正しさの基準となります。ヒープレイアウトが完全に元に戻っていなくても、coeffect の操作で差異を検出できなければ、回復は成功したとみなされます。
この章で示された保証事項は、以下のように階層化して整理できます:
- 层次:谁保证 / 内容
- 路径强制:框架(API 设计) / 用框架功能只能走 ctx,ctx 内操作全是 effect 封装 → 必然被追踪
- 时间强制:框架(状态机) / 已卸载 fiber 上建 effect 直接抛 INACTIVE_EFFECT
- 属性拦截:框架(Proxy) / ctx 属性读写被代理拦截、可记录
- 模块级事务:框架(HMR backup/restore) / import 失败整体回滚,不进半重载态
- 逆的正确性:作者义务 / 运行时不验证 witness(g(δ)=γ),论文明说
- 边界外行为:无保证 / 全局变量 / 公共文件 / 发射出去的数据 = idΓ
- 边界移动:coeffect / 服务物化 / 把外部位置封装成"可逆的服务",访问受限、逆由服务提供
まとめると、このフレームワークは「ctx.effect を通じてコンテキストを変更する」ことを唯一のパスとし、「逆処理が正しいか」については作者の義務に委ねます。同時にシステムの境界を明確に宣言します。工程上の着地点は「サービスの具体化」です。副作用はサービス実装内部に集中し、責任は「すべてのプラグイン作者」から「少数のサービス実装者」へと収束します。
論文には非常に率直な一文もあります。「逆処理を提供するコールバックはあるが、それが伴う効果を本当に復元できているかは、コンポーネント作者の義務(obligation)であり、ランタイムが検証する性質(property)ではない」と。ランタイムは「逆処理が呼び出されること」を構造的に保証しますが、「逆処理の内容が正しいか」までは保証しません。
ついでに一つの観察を:この設計は TypeScript/JavaScript で最も直接的に実装できます。多くのコンパイル言語の型システムはクラスベースであり、型レイアウトはコンパイル時に固定されます。そのため、新しいコンパイル結果を動的にロードして上書きすることができず、メモリ上に読み込まれた型情報も通常アンロードできません。
一方、TS/JS はプロトタイプシステムです。型構造を動的に調整できます。Agent が新しいツールを作成すれば、それを直接プロトタイプに紐付けるだけで、すべてのサブコンポーネントが即座に感知して呼び出せます。取り消す際はプロトタイプチェーンから切断するだけなので、システム状態は瞬時に回復します。
Proxy は「論理的には極めて解離され、構文的には完全に透明な」動的ルーティングを提供します。コードで ctx.tool と記述した瞬間に、Proxy が動的アドレス指定と権限チェックを完了させます。静的言語ではこれを実現するのは困難です。Module Augmentation により、プラグインはグローバルな Context の型定義を動的に変更できます。Agent が生成した新しいツールに対して、型ヒントとセキュリティチェックがシステム全体へ瞬時に同期され、「動的進化」と「型安全性」の両立を実現します。
さらに JS にはプログラムによるモジュール登録表があり、モジュールをアンロードして関連オブジェクトをガベージコレクションに任せることが可能です。Swift のような言語では、読み込まれた型メタデータを完全にクリーンアップすることはほぼ不可能です。
四、ctx. の解析方法:Proxy と Fiber チェイン一つで
プラグイン同士がどのようにして互いの提供能力を取得するかは、別途説明する価値があります。これが「すべてがプラグイン」という概念を実装できるもう一つの柱だからです。
ルートコンテキストは Proxy で包まれています:new Proxy(this, ReflectService.handler)。ctx.tools、ctx.llm、ctx.session といったプロパティにアクセスする際、これは Proxy の get トラップを経由します。既に存在する自身のプロパティは原生の Reflect.get で処理され、見知らぬ属性名が現れた場合は、まず登録済みのアクセサがあるか確認します。なければ、現在の Fiber を上向きに探索します。
let fiber = ctx.fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return impl.value
if (prop in fiber.inject) throw new Error('service not active')
if (!fiber.runtime) throw new Error('unknown service')
fiber = fiber.parent.fiber
}親の Fiber をたどり、そのサービスを提供するレイヤーを見つけるか、本当に誰も提供していないことが確定するまで続けます。
実際にサービスを「公開」するのは Service という基底クラスです。コンストラクタで self.ctx.reflect.provide(name, self, check) を呼び出します。provide() 自体も ctx.fiber.effect(...) に包まれています。現在の Fiber の store に {name, value, fiber, check} のレコードを追加し、そのサービスに依存して待機している方々をウェイクアップさせます。返される取り消し関数は、このレコードを store から削除し、再度通知を行います。サービスの削除は、プラグインのアンロードと全く同じエフェクトの取り消しメカニズムを経由し、特別なパスは用意されていません。システム内に専用の「サービス登録センター」クラスはありません。ctx. という書き方の背後にあるのは、Proxy のインターセプトと Fiber チェーンに沿った検索です。誰がどのレイヤーで何を登録したかが、直接誰が見えるかを決定します。
この設計による直接的な帰結は、スコープの隔離が本質的に保証される点です。子 Fiber は親 Fiber で登録されたサービスを見ることができますが、逆は成立しません。特定のプラグインのサービスを特定の範囲内(例えば、ある preset に固有のファイルシステム実装など)でのみ有効にしたい場合、追加的な可視性制御ロジックは不要です。そのプラグインを対応する階層の Fiber 下に配置するだけで、検索チェーンが自動的にその範囲に制限されます。
この仕組みの価値は、実際のユースケースでこそ明確になります。
DeepSeek Harness は「llm」サービスインターフェース(packages/llm/llm)を定義しています。これは「ストリーミング呼び出しをどう行うか」「リトライをどう処理するか」という点だけを規定し、どのモデルベンダーを使うかは問いません。システムにはこのインターフェースを実装する 2 つのプラグインが同時に登録されています。
1 つは dsh-llm-deepseek で、DeepSeek 自社のモデルに特化して設計されています。もう 1 つは dsh-llm-pi-ai で、内部でサードパーティの npm ライブラリ @earendil-works/pi-ai をラップし、複数のモデルベンダー間のプロトコル変換を担当します。
なお、この「pi」は外部で比較対象として言及される Pi agent harness とは無関係です。単に名前が被っているだけです。
公式には約 40 のモデルベンダーが利用可能ですが、その大半はこの dsh-llm-pi-ai プラグインによってサポートされています。2 つのプラグインは互いの存在を知らず、どちらをインストールするか、あるいは切り替えるかによって、上位レイヤーにある agent loop の呼び出しコードは一切変更する必要がありません。なぜなら、実際に呼ばれるのはインターフェースそのものだからです。
公式ドキュメントではこの構成を「設計検証の双子(design verification twins)」と呼んでいます。2 つの完全に独立した実装が同じ定義を満たすことで、このインターフェースが十分に汎用的であることを裏付けています。
五、Agent Loop において特筆すべき設計ポイント
Loop の全体像は、現在の多くのフレームワークと同様です。モデルへの入力・出力、ツール呼び出しの検出、実行、結果のフィードバックといった基本フローについては繰り返しません。ここでは特に注目すべき具体的な実装に焦点を当てます。
第一に、ループの終了条件は多様なソースから得られます。「新しいツール呼び出しがない」こと以外にも、ツールの実行結果自体が concludesTurn: true というフラグを含めることができます。このフラグによって、「このラウンドで停止する」という意志をツール側が宣言し、モデルが「よし、これでいい」と言うのを待つ必要はありません。
第二に、max-tokens(最大トークン数)の状態は「粘着性」を持ちます。
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
あるステップで出力上限に達して切り捨てられた場合、その後のステップが正常に完了したとしても、ラウンド全体の終了理由として「正常終了」という情報は上書きされません。上位層(統計処理やログ表示)は、「このラウンドは一度も切り捨てられたことがある」というシグナルを正しく認識し続けます。
第三に、ラウンドが実際に閉じられる直前に、agent/turn-stopping というイベントがブロードキャストされます。これはプラグインがフックして処理を介入できるフックポイントです。プラグインは agent.steer(...) を呼び出して新たなメッセージを追加すれば、ループは継続します。何も操作しなければ、デフォルトとしてラウンドは終了します。
「いつ本当に終わるか」という判断が Loop 内部から外部へ移されました。どのプラグインも最終段階で「まだ続く、何か付け加えたい」と決定できます。Loop 自体は、「まだ続く」理由がいくつあるかを事前に知る必要はありません。
第四に、ツールスケジューリングでは、実際の呼び出しを行うたびに、各ツールに対して並列実行が可能かどうかを問い合わせます。この判断は静的なホワイトリストに依存しません。
executionMode(exec): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
return tool.isConcurrencySafe(exec.arguments) === true
? { kind: 'parallel' }
: { kind: 'exclusive' }
}
同じツールでも、引数の組み合わせによって異なる回答が返されます。例えば、「ファイルの読み込み」は常に並列実行が可能ですが、「ファイルへの書き込み」は、対象パスが重複しない場合にのみ安全です。
スケジューラはグループ化して実行する際、次の呼び出しを開始する直前に再度モードを判定します。並列グループの中に、その時点で互斥と判断された呼び出しが含まれていた場合、そのグループはそこで打ち切られ、互斥な呼び出しが強制的に並列入力されることはありません。
結果は、モデルが最初に提示した呼び出し順に従って提出されます。スケジューリングの完了順序とは無関係です。
第五,工具执行前后的检查环节是独立且可插拔的。它们各自单独注册,不会直接写在工具执行的函数内部。
tools/pre-execute 是一个瀑布流式的事件触发点,任何插件都能在此拦截请求或要求审批。只有通过这一关后,系统才会进入内置的防护检查(例如:若同一工具被连续调用相同参数过多次数,会触发提醒),随后才真正执行工具调用。执行完成后,还会通过 tools/post-execute 让插件对结果进行二次处理。
这四个阶段顺序固定、彼此独立,构成了插件挂载的标准接口。若要新增一种审批策略或防护规则,无需改动底层的执行链路,只需注册一个新的事件监听器即可。
六、Preset 的两层作用域链:真实继承与影子路由表
Preset 的挂载机制解决了一个具体问题:如何让同一份配置被多个会话复用,同时避免每次都要重新解析配置文件。
一份预设本质上是一个目录,其核心是 agent.cordis.yml 文件。该文件按顺序列出了需要挂载的插件及其参数。挂载这份文件时,系统复用了 Cordis 生态中的 Include 插件,并将其重命名为 PresetTree:
const handle = agentCtx.plugin(PresetTree, { path: pathToFileURL(preset.path).href })
await handle.await()PresetTree 重写了两个关键方法:在解析裸模块名时,它会查找系统自身的 baseUrl,预设目录不参与此过程;同时,写回配置被直接禁用,防止预设源文件被意外修改。挂载完成后,系统还会核对两件事:是否存在插件配置停留在不可用状态,以及是否有服务在挂载过程中“泄漏”到了预设作用域之外。任何一项不满足条件,都会触发整体回滚。
关于作用域链的构造方式值得细看。从全局到某个预设的过程是一次真实的上下文派生:createScope() 内部调用 ctx.plugin(scope) 获取一个 Fiber 对象,随后通过 fiber.ctx.extend({[kScope]: key}) 在 Cordis 的上下文树中真实创建一个节点。
而从预设到具体某个会话的继承,则采用另一种方式:
this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))bindScopeParent 会在一张 WeakMap 中记录一条“逻辑上级”关系,而不会在 Cordis 的 Fiber 树中开启任何新节点。
如此拆分的动机非常直接:一份预设的“标准挂载实例”只需构建一次(通过 ensureStanding()),后续无论是写作、编码还是调研等不同类型的会话,都可以复用同一份实例。系统仅需往这张 WeakMap 中追加一条绑定即可,无需为每个新会话重新运行插件加载流程。
子 Agent 若要继承父 Agent 当前挂载的预设,调用的 composeFrom(childCtx, parentCtx) 也仅仅是这张表上的一次查找与绑定操作,属于同步行为,不会重新触发任何插件的挂载流程。因此,子 Agent 拿到的是与父 Agent 完全相同的插件实例,包括同一批已注册的工具和提示词片段。
系统判定当前会话生效的是哪个预设时,采用倒序扫描事件日志的逻辑:寻找最新一条 agent-preset/selected 记录。
セッションイベントの末尾から順にループ処理を行い、agent-preset/selected 型のイベントが見つかったら即座にそのデータを返します。該当するイベントが存在しない場合は、セッション作成時に設定されたデフォルト値を返す仕組みです。
理論上、セッション実行中にプリセットを変更することはアーキテクチャ上で許容されており、検索ロジックもこのケースを前提として設計されていますが、実際に運用で利用するかは製品側の判断に委ねられています。
プリセットの検出メカニズムについても触れておきます。スキャン対象となるルートディレクトリでは、ディレクトリ名が特定の命名規則に合致し、かつ内部に agent.cordis.yml ファイルを持つサブディレクトリのみが対象となります。各 YAML ファイルの解析可否と構造の妥当性を個別に検証し、破損した項目は検出してもスキャン自体を中断しません。
複数のルートディレクトリを統合する際は、登録順で先に現れた ID が優先され、後続のルートにある同名プリセットは無視されます。この仕様により、「ユーザー定義のプリセットが公式プリセットを上書きする(あるいはその逆)」といった柔軟な運用が可能になります。
七、Code Mode の隔離方案:worker_threads
モデルにコードを書かせて複数のツール呼び出しをオーケストレーションするというアプローチ自体は新しいものではありませんが、DeepSeek Harness が採用した隔離手法の選定と実装には注目すべき点があります。
この環境では node:worker_threads を使用しています。これは一般的な V8 分離環境や vm2 とは異なる、標準的な Node.js のワーカースレッドです。同プロセス内で動作する node:vm ベースのサンドボックスは、プロトタイプチェーンを介してメイン環境へのエスケープが可能な点や、外部から実行を完全に停止できない点など、信頼性に欠けると判断されました。独立したワーカースレッドを採用することで、コード実行ごとに新たなスレッドを起動するコストが発生しますが、その代わりに真に独立した V8 ヒープと、外部から強制終了可能な実行環境を獲得しています。
モデルが生成したコードは、まず stripTypeScriptTypes 関数によって型注釈が除去されます。この際、前後に包装文字列を追加して位置情報を保持し、除去後のコードでも元の行番号や列番号が一致するように調整します。
const stripped = stripTypeScriptTypes(prefix + program + suffix)
除去されたコードは動的に構築された非同期関数内に格納されます。
const AsyncFunction = (async () => {}).constructor
const fn = new AsyncFunction(...bindingNames, ...errorClassNames, 'console', code)
const value = await fn(...bindings, ...errorClasses, consoleShim)
各ツールの名前空間、事前に定義されたエラークラス、そして置換用の console オブジェクトは、この関数の引数として渡されます。これにより、モデルが生成したコード内ではトップレベルの await 文や return 文を直接使用することが可能になります。
ワーカースレッド自体にはリソース制限が設けられており、resourceLimits.maxOldGenerationSizeMb でヒープメモリの上限が管理されています。また、実行時間の制御には二重のタイムアウト機構が用意されています。一つはイベントループの利用状況をポーリングして CPU 使用時間を計測する方式、もう一つは単純な setTimeout を用いた総実行時間の制限です。いずれかの条件でタイムアウトが発生すると、即座に処理を強制中断します。
コード内でのツール呼び出しは、関数を直接参照するのではなく、メッセージチャネルを通じて行われます:
worker から host への呼び出しは、{ type: 'call', id, global: 'tools', name, args } の形式で行われます。
host から worker への返信は、{ type: 'reply', id, ok: true, value } の形式です。
宿主側が呼び出しを受け取ると、それは通常のツール呼び出しとして処理され、第五章で解説した pre-execute(事前実行)、防護、dispatch(振り分け)、post-execute(事後実行)というパイプラインを通過します。モデルから直接行われる構造化されたツール呼び出しと同じ実行コアを利用しますが、入口が異なるだけです。
通信の両端では、受信するメッセージの各フィールドを手動で厳格に検証しています。宿主側では、バインド関数の検索に Object.hasOwn() を用いて自有属性を正確に判定し、コードに暴露される tools 命名空間の構築には Object.create(null) で初期化してから、一つずつ Object.defineProperty でマウントします。
これらの対策はすべて、同じ目的のために実施されています。つまり、コード内に __proto__ や constructor といったフィールドを仕込まれ、プロトタイプチェーンを迂回して予期せぬ動作を引き起こされるのを防ぐためです。コードのコメントには「worker を敵対する他者として扱う」と明記されており、この信頼性の仮定は明確に宣言されています。
Codex のアプローチとの決定的な違いは隔離層にあります。独立した V8 isolate を用意し、ツールをグローバルな tools オブジェクト上に配置します。公式の説明では「新しい V8 分離環境で JavaScript を実行する」とされています。
両者の解決策は同じ問題、つまりコードによる複数のツール呼び出しのオーケストレーションと往復通信の削減に向かっていますが、隔離技術の選定方針が異なります。背景にあるのは、「毎回新しい isolate を作成するオーバーヘッド」と「毎回新しいワーカースレッドを開くオーバーヘッド」のどちらがコスト的に有利かというトレードオフと、それぞれのエコシステムで利用可能な既存の分離原語の違いです。
八、ツール登録における階層遮蔽アルゴリズム
ツールを異なるスコープ間で「近接して上書きする」仕組みは、プラグイン設計において見過ごされがちですが極めて重要な要素であり、これが preset によるツールの真の置換可否を決定づけます。
各スコープは独自の ToolLayer を保持しており、内部では名前をキーとしたテーブルが管理されています。「現在のエージェントがどのツールを見られるか」を決定するのは view() というステップです。まずグローバル層のツールを読み込んでベースとし、次に祖先となるスコープを順にたどり、同名のエントリを上書きしていきます。この際、現在のスコープに近い祖先ほど優先度が高くなります。この処理は継承された部分のみを対象とします。
その後、制限ルール(例えば特定の preset でツールの使用が明確に禁止されている場合など)がフィルタリングされます。最後に、現在のスコープで直接登録されたツールが、それまでに継承された同名のエントリをすべて上書きします。たとえ制限ルールでその名前が禁止されていたとしても例外です。
この順序は極めて重要です。自分で登録したツールは、継承や制限ルールよりも優先されます。
登録プロセス自体にも厳格な検証が施されています:
register(definition: ToolDefinition): () => void {
if (definition.name === RUN_CODE_NAME) {
throw new Error(tool name "${RUN_CODE_NAME}" is reserved for the Code Mode transport)
}
assertSupportedJsonSchema(definition.output.schema)
return this.layers.effect(this.ctx, layer => layer.tools.insert(definition.name, definition))
}
run_code という名前はハードコードで予約されており、どのプラグインもこれを登録したり上書きしたりできません。その理由はコードコメントに書かれています。あらゆるエージェントが自分自身に対してコードモードを選択する可能性があります。デフォルトのデプロイでは一見すると空き状態に見えるこの名前ですが、あるプリセットがマウントされた瞬間、Code Mode の呼び出し入口と衝突する恐れがあります。そのため、無条件で予約されています。
登録動作自体も effect() を通じて行われます。register() から返される取り消し関数を、現在の Fiber の disposables リストに挿入します。プラグインに登録されたツールは、そのプラグインがアンロードされると自動的にレイヤーから削除されます。「アンロード時にツールを削除する」といった追加のクリーンアップコードを書く必要はありません。これは第二章で説明した効果の取り消しメカニズムと同じ経路です。ツールの登録も、「登録=副作用」の数あるケースの一つに過ぎません。
ツールの実行自体も、このスケジューラの並行処理契約を流用しています。Code Mode でモデルコードがツールを呼び出す際、その呼び出しはホスト側の同じ TOOL_RUNTIME_SCHEDULER の待機キューに入ります。モデルが直接行う構造化されたツール呼び出しと、並行制御や事前チェックのセットを共有します。これが、前章で述べた「Code Mode と通常のツール呼び出しは実行コアを共用しており、入口が違うだけである」という理由です。
九、Codex と比較した工程上の決断の違い
核心ループの置換可能性。Codex のツールインターフェース、承認ポリシー、サンドボックス戦略はすべて設定可能ですが、「サンプリング-実行-フィードバック」という循環を駆動するコードはコアに固定されており、独立したユニットとして置き換えることはできません。DeepSeek Harness では、このループの具体的な実装(ReactLoopAgent)がファクトリとして登録されています。理論的には、全く異なる別のループ実装で置き換え可能です。現時点ではシステムに一つの実装しか紐付いておらず、ファクトリは一度だけ登録することを許可しています。
サンドボックスの位置づけ。Codex は Linux における bwrap/Landlock や macOS における Seatbelt を、コア実行パスに直接コンパイルして組み込んでおり、インフラストラクチャの一部です。一方 DeepSeek Harness では、サンドボックスを能力プラグインとして実装しています。Linux 上では独立したネイティブ Node 拡張を通じて Landlock を呼び出し、前述のサービス登録経路を踏みます。理論的には他の隔離実装に置き換え可能で、それを呼び出すツールコードに変更は不要です。
コード規模の分割粒度。Codex の Rust コードは約百個の crate に分けられていますが、DeepSeek Harness の TypeScript コードは二百以上のパッケージに分かれています。どちらもモノリスアーキテクチャではありませんが、Codex で分割されたモジュールは最終的に置き換え不可能な核心ループのために機能します。一方 DeepSeek Harness で分割されたモジュール(循環自体を含む)は、理論上すべて同じ置換可能な階層にあり、どのモジュールも特別視されることはありません。
コードオーケストレーションツールの呼び出しにおける隔離技術の選定について、両方のアプローチは独立して同じ結論に至りました。ツール呼び出しは単一の構造化リクエストだけでなく、コードによるオーケストレーションもサポートすべきです。実装時の隔離手法には違いがあり、Codex は独立した V8 isolate を採用しましたが、DeepSeek Harness は worker_threads を選択しました。この差異が裏付けるトレードオフについては第七章で詳しく解説しています。
ツールを外部に公開する粒度の違いについても触れておきます。Codex のツールインターフェースでは、DIRECT/DEFERRED/CODE_MODE/Hidden といった公開方式は、ツール側が静的なマークとして宣言します。一方 DeepSeek Harness では、あるスコープ下でツールが見えるかどうかもしくは上書きされるかは、ランタイム時にスコープチェーンをたどってリアルタイムに計算されます。つまり、同じツールでもセッションによって可視性が異なり、これはどの preset がマウントされているかに依存します。
参考:プロセス起動からツール呼び出しまでの一連のフロー
プロセスが起動すると、まず使用するプロファイル(Profile)を確認し、それに合わせて必要なプラグインパッケージをロードします。これを Cordis のローダーが順次マウントし、各プラグインは 1 つの Fiber として扱われます。セットアップ完了後、host レイヤーではモデルへの接続、認証情報の管理、サンドボックスといったランタイム基盤が準備されます。
新しいセッションが始まると、エージェント登録表から具体的なインスタンスが割り当てられ、これは「グローバル - プリセット - セッション」というスコープチェーンの下に配置されます。利用可能なツールやプロンプトは、このチェーン上の位置と、第八章で解説する遮蔽ルールによって決定されます。
セッションの運用を担うのは Agent Loop です。現在のコンテキストを読み取り、モデルへリクエストを送り、ツール呼び出しを検知して、統一されたツールパイプラインに渡します。ここでは事前チェック、実行、事後処理の一連の流れが処理されます。
プロセス中のあらゆる事象、つまりモデルからの出力やツールの結果は、すべてイベントとしてセッションログに追加されます。削除も上書きもされません。このログは、永続化の源であり、次のリクエストにおけるコンテキストの提供元であり、Web 画面での表示ソースでもあります。これら 3 つの用途は、同一のログから投影されるものです。Web 画面自体も、このアーキテクチャに属する一連のプラグインとして機能します。
この記事では、Cordis の Fiber ライフサイクルと効果の取り消しスタック、ctx. の Proxy 解析、Agent Loop 内のいくつかの詳細設計、Preset の 2 層スコープチェーン、Code Mode における隔離技術の選定、そしてツール登録における階層的遮蔽アルゴリズムなど、具体的な仕組みについて多くを語りました。これらを組み合わせることで解決されるのは、エージェント製品を開発する際や、深く活用する際に繰り返し直面するいくつかの課題です。
まず一つ目の課題は、機能追加や変更に伴うリスク管理です。一般的な手法では、コアループの外側にプラグインを追加し、コア自体はほとんど改修しません。一度改修すると影響範囲が広がり、回帰テストも再実行が必要になるためです。DeepSeek Harness では、このリスク管理の粒度をプラグインレベルまで落とし込みました。各プラグインに登録されたすべての要素は effect として登録され、アンロード時には登録時の逆順で元通りに取り消されます。機能の追加や置き換えによる影響範囲は、そのプラグイン自身の境界内に限定され、検証範囲も同様に縮小されます。
二つ目の課題は、同じエージェントサービスで異なるシナリオに対応しつつ、毎回環境を再構築する必要がないようにすることです。Preset の 2 層スコープチェーンがこれを解決します。一つのプリセットのプラグインタリーは一度だけマウントされ、ライティング、コーディング、リサーチなど用途の異なるセッション間で、すでに稼働している同一インスタンスを共有できます。論理的な親子関係テーブルによって、負荷分散と再利用が行われます。
3 つ目の課題は、多段階タスクにおけるツール呼び出しの往復コストです。Code Mode では、こうしたタスクのオーケストレーションロジックをモデル自身がコードとして記述することで、「発言して待つ」という往復回数を大幅に削減します。この点において DeepSeek Harness と Codex はほぼ同時に同じ結論に至りましたが、その分離アプローチは異なります。
4 つ目の課題はツールの可視性と権限です。多くのシナリオでは、ツールが「グローバルに利用可能」か「グローバルに利用不可」かの二択しかありません。しかし、階層化された遮蔽アルゴリズムを採用することで、可視性はランタイム時にスコープチェーンに基づいてリアルタイム計算されるものになります。これにより、同じツールでも異なるプリセットやセッションによって全く異なる可視性を示すことが可能になり、ツールコード内で多数の条件分岐を書く必要がなくなります。
これらの設計はすべて、より大きな洞察へとつながっています。つまり、エージェント製品の競争力はモデル性能だけでなく、ランタイム層の設計次第です。ランタイムをどう組織し、拡張コストはいくらになるか、コンポーネントを交換する際の代償はどの程度かといった工学的判断も、ユーザーがそのエージェントを使いこなせるかどうかを左右します。DeepSeek Harness はこの洞察に焦点を当て、Cordis という既存のプラグインフレームワークと「可逆的な副作用」という設計哲学を用いて、これを具体的なコード構造へと落とし込みました。その効果は、このアーキテクチャが実際の運用でどれほど持続可能か、より複雑なシナリオにも耐えられるかどうかにかかっています。
P.S. この記事で紹介したサンプルプラグインを実装する際、UI がクラッシュしてしまいました。HMR の安定性にはまだ改善の余地があるようです。
原文を表示
原创 腾讯程序员 2026-08-14 17:20 广东
image
DeepSeek 发布了第一款 Agent 产品
image
作者:chino,腾讯WXG 微信小店前端开发工程师
DeepSeek 发布了第一款 Agent 产品,DeepSeek Harness。这次的重点放在了运行时架构上,官方给的关键词是"everything is a plugin",模型接入、工具执行、会话记录、循环本身、界面,全部走插件机制。
这篇文章跳过 agent 领域已经成为共识的那部分——检测工具调用、执行、回填、循环,这套流程眼下几乎每个框架都长一个样,不值得再讲一遍。重点放在 DeepSeek Harness 真正跟别人不一样的几处设计:Cordis 这套插件运行时具体怎么管生命周期,preset 的 scope 继承链为什么拆成两层,Code Mode 为什么选了一个听起来不太"安全"的隔离方案,工具注册的遮蔽算法怎么写。顺带会跟 Codex 的几个具体工程决策做对比,只挑不一样的地方讲。
一、为什么是 Cordis
"everything is a plugin" 的基础是 Shigma 的开源项目 Cordis。定位是"Meta-Framework for Modern JavaScript Applications",一个通用的插件框架,处理依赖注入、作用域服务、生命周期清理这几件事,与 agent、与 LLM 都没有直接关系,在 DeepSeek Harness 出现之前就已经存在。
Cordis 在开源社区有实际的使用者。最典型的是 Koishi,一个跨平台聊天机器人框架(QQ / Discord / Telegram / 微信都接)。聊天机器人场景天然是"几十个插件拼在一起、热插拔、随时改配置",与 agent 的场景几乎相同,只是没有 LLM。DeepSeek Harness 把整个框架的源码 vendor 进自己的仓库,改了个 scope 叫 @deepseek-ai/cordis,然后把公司自己写的每一个包,都设成对它的 peer dependency。这比"使用一个第三方库"更进一步:整个产品都构建在 cordis 之上。
先对比几种常见的插件形态:传统 DI 容器、轻量钩子方案、Cordis。这三种方案各有取舍,值得摆在一起看。
传统 DI 容器的三个缺口。 "主流"方案是一个 DI 容器加生命周期注解。你 bind 了一个 service,谁来负责 unbind 和 cleanup?传统 DI 要么不管(泄漏),要么要求你手写 lifecycle hook。LLM provider 热替换了,依赖它的 ToolRegistry 应该自动重启,传统 DI 做不到这种"依赖驱动的重载"。配置上,传统 DI 的配置在启动时读一次;而 cordis.yml 里一行就是一个插件实例,改配置直接触发 HMR。
Pi也是Agent界的明星产品了,但Pi 的 Extension 走的是另一条路。 Pi 的自我定位是 "self extensible coding agent",它的插件叫 Extension(扩展),基于 TS,在扩展点上通过钩子塞逻辑:没有依赖注入,没有插件间依赖管理(加载顺序即优先级),也不能卸载。Cordis 的插件是带依赖声明、生命周期、可逆副作用的"组件":有依赖注入,有反应性 coeffects,插件间可以声明依赖,卸载时能完整逆转副作用。
这最后一点——"可逆副作用"——是整篇文章的地基。Cordis 的设计论文把这件事上升到理论层面,核心问题问得很直接:
有没有一种 programming model,能让动态本身具备类似进程那样的生命周期隔离?
进程和容器的好处在于:kill 掉再启动,状态就清空了。代价是重启会丢掉进程内的 cache、connection、partial computation。插件系统希望不用重启,也能完成同样的清理。论文为此给了两个定义:
时间可组合性:卸载组件时,组件对共享环境所做的修改必须被完整、安全地逆转——这要求追踪组件执行的每一次资源分配、事件注册和状态变更。
空间可组合性:依赖变化时,相关组件自动激活或停用。
时间可组合性是这篇文章的主线。剩下的事情——Fiber、effect、系统边界、preset、Code Mode——都在回答同一个问题:怎么在工程上实现"卸载 = 完整逆转"。
Cordis 生态还带了几个配套包,随项目一起 vendor 进来:一个配置文件加载器(负责解析 cordis.yml 这类声明式配置),一个 include 插件(负责把一份配置当成子树挂载进来,preset 机制用的就是它改名后的版本),一个 HMR 模块(支持插件热替换),外加日志和定时器这两个基础设施插件。这几块拼起来,才能真正理解"everything is a plugin"。
二、Fiber 与 effect:可逆副作用的地基
Cordis 里,插件的类型定义是一个联合类型:
type Plugin<T> = Plugin.Function<T> | Plugin.Constructor<T> | Plugin.Object<T>裸函数、类、带 apply 方法的对象,三种写法最终都被解析成一个统一的回调。挂载一个插件调用 ctx.plugin(plugin, config),内部先找有没有已经存在的 Runtime 记录(按回调函数身份做 key,同一个插件多次挂载共享同一条 Runtime),没有就新建,再造一个 Fiber,塞进这条 Runtime 的 fibers 列表里。
Fiber 是插件实例的生命周期状态机,六个状态:PENDING、LOADING、ACTIVE、FAILED、DISPOSED、UNLOADING。插件声明了 inject: ['tools', 'shell'],对应的 Fiber 会停在 PENDING,直到这两个服务都在依赖链上出现,才真正跑这个插件的代码,进入 ACTIVE。这套等待机制是 Cordis 自己实现的依赖解析,插件作者不需要手写"等对方准备好"的轮询逻辑。这对应空间可组合性:依赖出现后,组件自动激活。
卸载一个插件是否安全,由 effect 机制决定。插件注册的任何东西——事件监听、服务、定时器——都通过 ctx.effect() 登记。登记时立刻执行一次,返回的撤销函数被推进一个 disposables 列表。论文 5.1.1 指出:Cordis 中每一次上下文变更都通过唯一原语 ctx.effect 完成——提供服务、实例化组件、所有修改上下文的操作都归约成一次 ctx.effect 调用。用法长这样:
ctx.effect(() => {
// 做副作用:注册监听、开定时器、提供服务……
return () => { /* 逆:撤销上面的操作 */ } // 返回 disposer
})callback 可以返回一个 disposer 函数、一个 Promise,或者一个(异步)迭代器,逐段 yield disposer——每做一步副作用就交一个撤销函数。
在这里,"副作用"的定义是:任何通过 Context 对共享环境进行的修改。这张表把日常操作都覆盖了:
类别
例子
事件注册
ctx.on("foo", handler) —— 往共享事件总线挂监听器
提供服务
ctx.provide(name, service) —— 往上下文上"放"一个服务
挂载子组件
ctx.plugin(plugin) —— 子插件本身就是一个"父级上的副作用"
上下文扩展
ctx.extend() / ctx.intercept() / ctx.isolate()
外部资源
定时器、文件监听、HTTP server、子进程、DB 连接等
状态变更
改 config、改注册表、改 store
Koishi 里 ctx.command(...) 注册命令、ctx.router.get(...) 注册 HTTP 路由;DSH 里注册工具、挂 HMR 监听、起调度器——全是副作用。"怎么逆转",实现上很直接:每个副作用都带着自己的撤销函数,运行时把它们按 LIFO 叠起来,卸载时整体执行一遍:
disposables.splice(0).reverse().forEach(dispose => dispose())按注册的相反顺序逐个撤销,类似退栈。子 Fiber 的销毁函数本身也是通过 parent.fiber.effect(...) 挂在父 Fiber 上注册的——在源码里就是 Fiber 构造函数末尾这一行:
this.dispose = parent.fiber.effect(() => { /* 注册插件 */
return async () => { /* 注销插件 */ }
})也就是说,一个插件本身 = 父 fiber 上的一个 effect:插件的启动逻辑就是副作用,卸载逻辑就是逆。子组件的逆会被 prepend 到父上下文的累加器上,形成递归结构(论文里叫 twisted composition,记作 𝜕²Γ)。所以卸载一个插件,会连带把它下面挂的子插件一起按栈的顺序拆干净,不需要单独维护一张"谁依赖谁"的清理表。
这个机制还有两个细节保证可靠性。一是幂等:dispose() 首次调用把 armed 标志置 false,之后再次调用直接返回——每个逆最多执行一次。"执行两次会在从未产生过该效应的状态上应用逆,无从还原。"二是中断:如果 callback 返回的是迭代器,每一步之前会查 guard,一旦失效立即停止,只执行已累积的逆——热重载、卸载中途失败都能安全停住。事件监听器也遵循同样的可逆机制:ctx.on(...) 返回 unsubscribe 函数,监听器登记在 fiber 的 hook 表里,fiber 卸载时统一移除。_unload() 跑完所有撤销函数后,如果发现依赖又重新满足了,会立刻调 _reload() 重新拉起来——"换掉某个服务的实现"在运行时层面就是一次自动的卸载再加载。
一个插件的写法大致是这样(在UI界面上添加一个时钟):
// 会话日志插件:注册一个事件监听 + 一个定时器,卸载时自动清理
export const name = 'session-logger'
export function apply(ctx: Context) {
// effect 回调在注册时立即执行
ctx.effect(() => {
// 副作用 1:订阅事件总线,把事件追加进日志
const off = ctx.on('tool/result', (event) => appendToLog(event))
// 副作用 2:开一个定时器,定期把缓冲刷到磁盘
const timer = setInterval(flushBuffer, 1000)
// 返回撤销函数:卸载时按注册的相反顺序执行
return () => {
clearInterval(timer) // 后注册,先撤销
off() // 再退订监听
}
})
}挂载这个插件时,ctx.effect 的回调立即执行:监听器挂上、定时器启动,返回的撤销函数被推进 disposables 列表。卸载时(插件被移除、会话结束、HMR 重载),disposables.splice(0).reverse() 按逆序逐个执行——先 clearInterval,再 off()。撤销逻辑写在注册逻辑旁边,作者不需要在别处维护一份"哪里注销"的清单。
这个写法真的很像 React。useEffect 的形状几乎一样:useEffect(() => { subscribe(); return () => unsubscribe() }, deps)——回调里做副作用,返回清理函数,组件卸载时自动调用。两边共享同一个核心约定:副作用和它的逆写在一起,销毁由框架触发。差异也清楚:React 的 effect 在渲染后执行,依赖变化时先清理再重建;Cordis 的 effect 在注册时立即执行,卸载时统一撤销,清理顺序有明确的 LIFO 保证(后注册的先撤销,子插件随父插件逆序级联)。
那"逆转"为什么值得花这么大篇幅?论文把"自进化 Agent Harness"列为两大动机之一,原文非常尖锐:
未来的 harness 会在持续服务请求的同时生成并部署对自己组件的修改……没有时间可组合性,每次自我修改都迫使完整重启,丢弃所有进程内累积状态;更糟的是,一个有缺陷的自我修改可能废掉唯一能用来恢复的那个进程。
这是"自进化"的前提:Agent 自己写插件/工具,运行时装进自己。装了坏的能回滚,而且回滚机制本身(宿主进程)不能被新组件弄坏。普通插件系统做不到这一点,因为重启宿主意味着丢失当前进程的所有状态。对照一下现有生态:VSCode 的扩展宿主无法卸载单个扩展,禁用/卸载必须重启整个宿主;Koishi 社区里,改完配置 reload daemon 是很常见的操作。Cordis 的逆转让"卸载"成为与"加载"对称的一等操作——服务不中断,其他插件不受影响。
这套机制顺带解决了两个常见工程问题。一是失败原子性:插件初始化中途抛错(配置校验失败、依赖缺失),fiber 进入 FAILED 状态并 dispose——已经应用的一半效应被逆序清掉,不会残留部分初始化状态。二是级联清理,作者不用写卸载代码。论文 5.3 的关键论断是:
因为通过 context 的效应被自动追踪、逆自动复合,即使是没经验的作者,也能为一个插件的 context 介导效应获得有序清理,而不用写卸载路径——正确性从依赖每个作者的自觉,变成由抽象层一次性承担。
卸载一个插件 → 逆序级联卸载它的子插件、释放它的定时器/监听器/HTTP server/DB 连接。清理正确性从"每个作者写对 deactivate"变成"框架结构保证"。VSCode 模型在这里有一个短板:它的 deactivate 钩子和 activate 分离,"效应清理与创建分离,违背关注点局部性,完整清理难以验证"。
逆转机制的关键作用可以归纳为四点:① 开发期 HMR(改代码不重启、出错可事务回滚);② 生产期运行时插件装卸(服务零中断);③ 自进化 Agent(改自己、坏了能自救);④ 失败原子性与级联清理(坏插件不残留影响、作者不用写卸载代码)。它把"卸载"从手动善后变成框架保证的结构性操作,这是"一切皆插件、插件可热插拔"能成立的前提。
三、四条约束与系统边界:框架怎么保证 effect 被执行
看到这里可能有人会问:如果作者把副作用写在 effect 外面,比如直接给 globalThis.foo 赋值,这套机制还有效吗?论文正面回答了这个问题。框架的处理分两边:一边在 API 层面约束,上下文修改只能通过 effect 完成;一边明确声明系统边界,边界外的操作不受追踪。
框架从四个层面约束 effect 的用法。
第一,API 入口唯一。想用框架的任何能力——事件 ctx.on、服务 ctx.provide、挂插件 ctx.plugin、配服务 ctx.use——都只有 ctx 这一个入口,而 ctx 上的每个操作内部都是 ctx.effect 的封装。API 设计上不存在绕过 effect 使用框架功能的路径。
第二,Context 是 Proxy。源码里就是一行:const self = new Proxy<this>(this, ReflectService.handler)。对 ctx 的属性 get/set 全部走代理,这也是反应性 coeffect("访问即通知")和 interception 的机制来源。所以 ctx.foo = x 这种直接赋值也会被拦截并记录。
第三,生命周期状态机。每个 fiber 有状态机,ctx.effect() 第一行是 this.assertActive(),在已卸载的 fiber 上创建 effect 会直接抛 CordisError INACTIVE_EFFECT。卸载之后再注册副作用会被框架拒绝,从时间上消除了这类泄漏。
第四,事务性 HMR。即使作者的逆写得有问题,新代码 import 失败(比如语法错误)时,Algorithm 10 的 backup/restore 会让整个重载回滚到旧版本,系统不会停留在部分加载的状态。这是机制层面的最后一道保障。
边界外的操作,框架明确不追踪。 论文 §6.1 把环境分成两半:边界内(inside)是系统能独占修改、且能恢复原状的位置,操作记录在 Γ,可以 recover;边界外(outside)二者缺一,操作表现为 idΓ,既不追踪也不恢复。注意边界按位置划分,与介质无关:私有路径的 scratch 文件、只有本系统写的内存属于边界内;公共文件、别的进程也在写的东西属于边界外。直接改全局变量、monkey-patch、写公共文件,都是边界外的操作。框架不会追踪这些操作,也不会声称能恢复它们。这是设计上明确声明的边界。
更细的一层是获取/发射两阶段:获取阶段(open/malloc/fork)在边界内、可逆;发射阶段(write/send 的数据离开系统)在边界外、不可逆——恢复只能靠 withholding(暂缓发射,直到状态确定)或 compensation(补偿动作:删除创建的文件、退还收到的款项)。补偿也按 LIFO 组合,但元理论不再保证。
工程上怎么处理边界外的资源?论文 §6.1 给了一个手段:coeffect 通过物化外部位置来移动边界——把所有对该位置的访问限制在一组操作内,其中每个操作都提供逆,于是原本表现为 idΓ 的操作变得可追踪、可恢复。在工程上,这就是服务(service)抽象:开发者通过 ctx.database、ctx.assets 访问这些资源,不直接接触数据库连接、文件句柄、子进程。资源生命周期被关进服务自己的 effect 里,服务提供者负责写对的逆,消费者只面对高层接口。副作用从插件代码各处集中到服务实现内部,作者义务集中到少数服务实现者身上。
还有最后一个问题:逆写得对不对,怎么判定?论文 §3.3.2 不要求"物理复原",因为那做不到(free 不会还原堆布局,生成式名字不会再生)。恢复保证读作观察等价:
两个状态相关,当没有任何观察者能区分它们。比较行为而非表示……等价关系由 coeffect 各自携带的等价组装而成。
"逆转"的含义是观察层面的还原:恢复前后的状态,没有任何观察者能区分。这就是逆的正确性标准。堆布局没有还原也没关系,只要没有任何 coeffect 操作能察觉差异,就算恢复成功。
这一章的保证可以分层整理成一张表:
层次
谁保证
内容
路径强制
框架(API 设计)
用框架功能只能走 ctx,ctx 内操作全是 effect 封装 → 必然被追踪
时间强制
框架(状态机)
已卸载 fiber 上建 effect 直接抛 INACTIVE_EFFECT
属性拦截
框架(Proxy)
ctx 属性读写被代理拦截、可记录
模块级事务
框架(HMR backup/restore)
import 失败整体回滚,不进半重载态
逆的正确性
作者义务
运行时不验证 witness(g(δ)=γ),论文明说
边界外行为
无保证
全局变量 / 公共文件 / 发射出去的数据 = idΓ
边界移动
coeffect / 服务物化
把外部位置封装成"可逆的服务",访问受限、逆由服务提供
总结一下。框架把"必须通过 ctx.effect 修改上下文"变成唯一路径,把"逆是否正确"留给作者义务,并明确声明系统边界。工程上的落点是服务物化:副作用集中在服务实现内部,义务从"每个插件作者"收敛到"少数服务实现者"。论文还有一句很诚实的话:"回调提供了逆,但该逆是否真的恢复了伴随它的效应,是组件作者的责任(obligation),而不是运行时验证的性质(property)"。运行时保证"逆会被调用"(结构性),不保证"逆写得对"(语义性)。
顺带一个观察:这套设计在 TS/JS 里落地最直接。 大多数编译语言的类型系统基于类,类型布局在编译期固定,无法通过动态加载新的编译结果来覆盖;加载进内存的类型信息通常也无法卸载。TS/JS 是原型系统,类型结构可以动态调整:Agent 造一个新工具,直接挂在原型上,所有子组件立即感知并调用;撤销时从原型链切断,系统状态立即恢复。Proxy 提供"逻辑上极度解耦、语法上完全透明"的动态路由——代码写下 ctx.tool 时,Proxy 完成动态寻址与权限检查,静态语言很难实现这一点。Module Augmentation 让插件能动态修改全局 Context 的类型定义,agent 生成的新工具,类型提示和安全检查瞬时同步给整个系统,实现"动态演化"与"类型安全"并存。另外,JS 有程序化的模块注册表,可以卸载模块并让垃圾回收回收相关对象;在像 Swift 这种语言里,彻底清理已加载的类型元数据几乎不可能。
四、ctx.<service> 怎么解析:一个 Proxy 加一条 Fiber 链
插件之间怎么互相拿到对方提供的能力,值得单独说一下,这是"everything is a plugin"能落地的另一半。
根上下文被包了一层 Proxy:new Proxy(this, ReflectService.handler)。访问 ctx.tools、ctx.llm、ctx.session 这类属性时,走的是 Proxy 的 get 陷阱。自身已有的属性直接走原生的 Reflect.get,遇到没见过的属性名,先查一下有没有注册过的访问器,没有就沿着当前 Fiber 往上找:
let fiber = ctx.fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return impl.value
if (prop in fiber.inject) throw new Error('service not active')
if (!fiber.runtime) throw new Error('unknown service')
fiber = fiber.parent.fiber
}一路往父 Fiber 走,直到找到提供这个服务的那一层,或者确认真的没人提供,抛错。
真正把一个服务"发布"出去的地方是 Service 这个基类,构造时调用 self.ctx.reflect.provide(name, self, check)。provide() 本身也包在 ctx.fiber.effect(...) 里:往当前 Fiber 的 store 里塞一条 {name, value, fiber, check} 记录,再唤醒等着这个服务的依赖方。返回的撤销函数负责把这条记录从 store 里摘掉,并重新通知一遍。一个服务被移除,走的是跟插件卸载完全一样的那套 effect 撤销机制,没有另开一条特殊路径。系统里没有一个专门的"服务注册中心"类。ctx.<name> 这种写法背后就是一次 Proxy 拦截加一次沿 Fiber 链的查找。谁在哪一层注册了什么,直接决定了谁能看到什么。
这套设计带来一个直接后果:作用域隔离是天然的。子 Fiber 能看到父 Fiber 注册的服务,反过来不行。想让某个插件的服务只在特定范围内生效(比如某个 preset 专属的文件系统实现),不需要额外的可见性控制逻辑,把这个插件挂在对应层级的 Fiber 下面,查找链条自然会把它限制在那个范围里。
这套机制的价值在一个真实场景里能看清楚。DeepSeek Harness 定义了一份"llm"服务接口(packages/llm/llm),只规定"怎么发一次流式调用、怎么处理重试",不关心具体是哪家模型。系统里同时挂着两个实现这份接口的插件:dsh-llm-deepseek,专门适配自家模型;dsh-llm-pi-ai,内部包了一个第三方 npm 库 @earendil-works/pi-ai,专门做多家模型厂商的协议转换(这个"pi"和外界拿来对比的那个 Pi agent harness 是两个不相关的东西,撞了名字)。官方近 40 家可选模型厂商,大部分由后面这个插件支持。两个插件互不知道对方存在,装哪个、换哪个,上层 agent loop 的调用方式不用改一行,因为它调的是接口本身。官方文档管这种搭配叫"设计验证双胞胎":两套完全独立的实现都能满足同一份定义,从侧面证明这份接口写得足够通用。
五、Agent Loop 里真正值得说的几处设计
Loop 的整体形态跟眼下大多数框架差不多,模型输入输出、检测工具调用、执行、回填,不重复讲。挑几处具体做法值得展开。
第一,轮次的结束条件有多种来源。除了"没有新的工具调用",工具执行结果本身可以带一个 concludesTurn: true 标记。工具的执行逻辑用这个标记声明"这一轮该停了",不需要等模型再说一句"好了"。
第二,max-tokens 状态是粘性的:
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd一旦某个 step 因为撑满输出上限被截断,即便后续的 step 正常完成,轮次最终报告的结束原因也不会被覆盖为"正常完成"。上层(统计、日志展示)看到的"这轮被截断过"的信号,不会因为后续步骤正常完成而被覆盖。
第三,轮次真正关闭前会广播一个 agent/turn-stopping 事件,这是一个可以被插件截住的钩子。插件调用 agent.steer(...) 塞一条新消息,轮次会继续跑下去;什么都不做,默认就真的结束。"什么时候真正收尾"这件事从 loop 内部移到了外部,任何插件都能在最后一刻决定"还没完,我还要说点什么",loop 本身不需要预先知道有多少种"还没完"的理由。
第四,工具调度这块,每次实际调用前都会向工具询问能否并行,判断不依赖静态白名单:
executionMode(exec): ToolExecutionMode {
const tool = this.resolveExecution(exec.name, exec.agent)
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
return tool.isConcurrencySafe(exec.arguments) === true
? { kind: 'parallel' }
: { kind: 'exclusive' }
}同一个工具,不同的参数组合能给出不同的答案(读文件永远并行安全,写文件只有目标路径不冲突才安全)。调度器组队执行时,会在启动下一个调用之前重新判定一次模式。并行组里如果混进了一个当下判定为互斥的调用,会就此截断这一组,不会把互斥调用硬塞进并行批次。结果按模型原始给出的调用顺序提交,与调度完成顺序无关。
第五,工具执行前后的检查是独立的、可插拔的阶段,各自单独注册,不写在工具执行函数内部。tools/pre-execute 是一个 waterfall 事件,任何插件都能在这里拦截或者要求审批;通过后才进入内置的防护检查(比如同一个工具带同样参数被连续调用太多次会被提醒);然后才是真正调用工具体;跑完还有 tools/post-execute 让插件对结果做二次处理。这四段是固定顺序、彼此独立注册的插件挂载点。新增一种审批策略或者防护规则,不需要改动执行链路本身,挂一个新的事件监听就行。
六、Preset 的两层 Scope 链:真实继承与影子路由表
Preset 的挂载机制解决了一个具体问题:怎么让同一份配置被多个会话复用,又不用每次都重新解析一遍配置文件。
一份预设是一个目录,核心是一份 agent.cordis.yml,按顺序列出要挂载的插件和参数。挂载这份文件用的是一个复用自 cordis 生态的 Include 插件,改名叫 PresetTree:
const handle = agentCtx.plugin(PresetTree, { path: pathToFileURL(preset.path).href })
await handle.await()PresetTree 重写了两个方法:解析裸模块名时,查找基于系统自己的 baseUrl,预设目录不参与解析;写回配置被直接禁用,预设源文件不会被意外修改。挂载完成后还会核对两件事:有没有哪一行插件配置停在不可用的状态,有没有哪个服务在挂载过程中"泄漏"到预设作用域之外。任何一项不满足,都会整体回滚。
scope 链的构造方式值得细看。从全局到某个预设,是一次真实的上下文派生:createScope() 内部调 ctx.plugin(scope) 拿到一个 Fiber,再 fiber.ctx.extend({[kScope]: key}),这一步在 Cordis 的上下文树里真实存在一个节点。从预设到具体某个会话,则使用另一种方式:
this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))bindScopeParent 往一张 WeakMap 里记一条"逻辑上级"关系,不在 Cordis 的 Fiber 树里新开任何节点。
这么拆开的动机很直接:一份预设的"标准挂载实例"只建一次(ensureStanding()),后续任意多个会话——写作的、编码的、调研的——都可以复用同一份实例,靠的就是往这张 WeakMap 里追加一条绑定,不用每接一个新会话就重新跑一遍插件加载。子 agent 要继承父 agent 当前挂的预设,调用的 composeFrom(childCtx, parentCtx) 也只是这张表上的一次查找加绑定,是同步操作,不会重新触发任何插件的挂载流程。子 agent 拿到的是跟父 agent 完全同一份插件实例,包括同一批已经注册好的工具和提示词片段。
会话当前生效的是哪个预设,判定逻辑是倒着扫事件日志,找最新一条 agent-preset/selected:
for (let index = session.events.length - 1; index >= 0; index -= 1) {
const event = session.events[index]
if (event?.type === 'agent-preset/selected') return event.data.agentPreset
}
return session.header.agentPreset完全没有这类事件才回退到会话创建时的默认值。理论上,一个会话中途换预设是架构允许的操作,查找逻辑天然支持这种情况,实际用不用是产品层面的决定。
预设的发现机制也值得一提。扫描一个预设根目录时,只认目录名匹配特定命名规则、且内部有 agent.cordis.yml 的子目录。逐个校验 YAML 是否能解析、结构是否合法,损坏的条目标记出来,不会中断整个扫描。多个根目录合并时,按顺序先出现的 id 优先,后面根目录里同名的预设被忽略——这给了"用户自定义预设覆盖官方预设"(或反过来)的空间,取决于根目录的注册顺序。
七、Code Mode 的隔离方案:worker_threads
让模型写代码去编排多个工具调用,这个思路眼下不算新鲜,Codex 也做了一套。值得细看的是隔离方案的选型和落地方式。
DeepSeek Harness 跑这段代码用的是 node:worker_threads,一个普通的 Node 工作线程,与常见的 V8 隔离环境或者 vm2 是不同类型的方案。选这条路有一个明确考量:node:vm 那类基于同进程的沙箱,原型链可以逃逸到主环境,热循环也无法从外部真正打断,被认为不可靠。换成独立的工作线程,代价是每次跑代码都要开一个新线程,换来的是一个真正独立的 V8 堆,以及一个可以从外部强制终止的执行环境。
模型写的代码,先过一遍 stripTypeScriptTypes 把类型标注剥掉,剥的时候用一层包装前后缀撑住位置:
const stripped = stripTypeScriptTypes(prefix + program + suffix)保证剥完之后代码原本的行列号不变,报错定位对得上。剥完的代码被塞进一个动态构造的异步函数:
const AsyncFunction = (async () => {}).constructor
const fn = new AsyncFunction(...bindingNames, ...errorClassNames, 'console', code)
const value = await fn(...bindings, ...errorClasses, consoleShim)每个工具命名空间、每个约定好的错误类、还有一个替换用的 console,都变成这个函数的形参和实参。模型写的代码里可以直接用顶层 await,也能直接 return 一个值出来。
工作线程本身有资源上限,resourceLimits.maxOldGenerationSizeMb 限制堆内存。另外有两道独立的超时:一道拿事件循环利用率轮询,计算这段代码占了多少 CPU 时间;一道是一个简单的 setTimeout,兜底总耗时。任意一道触发都会强制中断。
代码里调用工具,通过消息通道完成,不直接引用函数:
{ type: 'call', id, global: 'tools', name, args } // worker → host
{ type: 'reply', id, ok: true, value } // host → worker宿主收到调用请求后,把它当成一次正常的工具调用,推进第五章讲的那条 pre-execute/防护/dispatch/post-execute 流水线,与模型直接发起的结构化工具调用共用同一套执行内核,只是入口不同。通道两端都手动校验每一条收到的消息字段。宿主侧查找绑定函数用 Object.hasOwn() 精确判断自有属性,构造暴露给代码的 tools 命名空间时,用 Object.create(null) 起手,再逐个 Object.defineProperty 挂载。这些做法都是为了同一个目的:防止代码里塞进 __proto__ 或 constructor 这类字段,绕过原型链产生意料之外的行为。代码注释里直接写"把 worker 当成敌对的另一方来处理",这个信任假设是明确声明的。
Codex 那套做法的核心差异在隔离层:用一个独立的 V8 isolate,工具挂在一个全局的 tools 对象上,官方描述是"在一个全新的 V8 隔离环境里执行这段 JavaScript"。两边解决的是同一个问题——用代码编排多个工具调用、减少往返——隔离技术选型不同,背后权衡的是"每次新建一个 isolate 的开销"和"每次开一个工作线程的开销"哪个更划算,以及各自生态里现成的隔离原语是什么。
八、工具注册的分层遮蔽算法
工具怎么在不同作用域之间"就近覆盖",是插件化设计里容易被忽略但很关键的一块,决定了 preset 能否真正替换工具。
每个作用域维护一份自己的 ToolLayer,内部是一份按名字索引的表。决定"当前 agent 能看到哪些工具"的,是 view() 这一步:先加载全局层的工具,作为基础;再让每一层祖先 scope 依次覆盖同名条目,越靠近当前 scope 的祖先优先级越高——这一步只处理继承来的那部分。限制规则(比如某个 preset 明确禁掉了某个工具)在这之后筛一遍。最后,当前 scope 自己直接注册的工具,覆盖前面所有继承来的同名条目,即使限制规则禁用了这个名字。这一步的顺序很关键:自己注册的工具优先于继承和限制规则。
注册这一步本身也有硬校验:
register(definition: ToolDefinition): () => void {
if (definition.name === RUN_CODE_NAME) {
throw new Error(tool name "${RUN_CODE_NAME}" is reserved for the Code Mode transport)
}
assertSupportedJsonSchema(definition.output.schema)
return this.layers.effect(this.ctx, layer => layer.tools.insert(definition.name, definition))
}run_code 这个名字被硬编码保留,任何插件都不能注册或者覆盖它。理由写在代码注释里:任何 agent 都可能给自己选一个代码模式,一个在默认部署下看似空闲的名字,一旦某个 preset 挂载进来,随时可能跟 Code Mode 的调用入口产生冲突,所以无条件预留。
注册动作本身也走 effect():调用 register() 拿到的撤销函数,插进当前 Fiber 的 disposables 列表。一个插件注册的工具,会随着这个插件被卸载自动从层里摘掉,不需要额外写一段"卸载时记得把工具删掉"的清理代码——这跟第二章讲的效果撤销机制是同一条路径,工具注册只是众多"注册即副作用"里的一种。
工具执行本身也复用了这套调度器的并发契约。Code Mode 里模型代码调用工具时,调用会进入宿主侧同一个 TOOL_RUNTIME_SCHEDULER 的待处理队列,与模型直接发起的结构化工具调用共享同一份并发控制、同一套前置检查——这也是上一章说 Code Mode 与普通工具调用"共用同一套执行内核,只是入口不同"的原因。
九、跟 Codex 比,几个工程决策的差异
核心循环的可替换性。 Codex 的工具接口、审批策略、沙箱策略都能配置,但驱动"采样-执行-反馈"这条循环的代码固定在核心中,无法作为独立单元替换。DeepSeek Harness 里这条循环的具体实现(ReactLoopAgent)注册为一个工厂,理论上可以被另一套完全不同的循环实现替换掉。目前只有这一个实现挂在系统里,工厂只允许注册一次。
沙箱的位置。 Codex 把 Linux 下的 bwrap/Landlock、macOS 下的 Seatbelt,直接编译进核心执行路径,属于基础设施。DeepSeek Harness 把沙箱做成一层能力插件,Linux 上通过一个独立的原生 Node 扩展调用 Landlock,走的是前面几章讲的同一条服务注册路径,理论上可以换成别的隔离实现,调用它的工具代码不需要改动。
代码规模的拆分粒度。 Codex 的 Rust 代码分成大约上百个 crate;DeepSeek Harness 的 TypeScript 代码分成两百多个包。两边都不是单体架构,但 Codex 拆出来的模块最终都服务于同一个不可替换的核心循环;DeepSeek Harness 拆出来的模块(包括循环本身)理论上都处在同一个可替换层级上,没有哪一个模块的地位特殊。
代码编排工具调用的隔离选型。 两边独立得出了同一个功能判断:工具调用除了单个结构化请求之外,还应该支持用代码编排。落地时隔离技术不同:Codex 选了独立的 V8 isolate,DeepSeek Harness 选了 worker_threads。第七章已经展开过这个差异背后的取舍。
工具暴露方式的粒度。 Codex 的工具接口里,暴露方式(DIRECT/DEFERRED/CODE_MODE/Hidden)是工具自己声明的一个静态标记。DeepSeek Harness 里,一个工具在某个 scope 下可见还是被覆盖,是运行时按 scope 链实时计算出来的,同一个工具在不同会话里的可见性可以完全不同,取决于挂载了哪些 preset。
附:从进程启动到一次工具调用,整条链路走一遍
进程启动,先看用的是哪个启动档(Profile),决定整个进程要装哪些插件包,交给 Cordis 的加载器逐个挂载,每个插件对应一个 Fiber。装完,host 这一层准备好模型接入、凭据管理、沙箱这些运行时基础设施。
一个会话开始,agent 注册表分配一个具体实例,这个实例挂在"全局-预设-会话"这条 scope 链下面。能看到哪些工具、哪些提示词,取决于它在这条链上的位置,以及第八章讲的那套遮蔽规则。
Agent Loop 接管这个会话的运转:读取当前上下文,请求模型,检测工具调用,交给统一的工具流水线走一遍前置检查、执行、后置处理。
每一步发生的事,不管是模型的输出还是工具的结果,都作为一条事件追加进这个会话的日志里,不删除、不覆盖。这份日志同时是持久化的来源、下一次请求的上下文来源、网页界面的展示来源,三条用途都从同一份日志投影出来。网页界面本身也是挂在这套体系里的一组插件。
这篇文章讲了不少具体机制:Cordis 的 Fiber 生命周期和 effect 撤销栈,ctx.<service> 的 Proxy 解析,agent loop 里几处细节设计,preset 的两层 scope 链,Code Mode 的隔离选型,工具注册的分层遮蔽算法。这些机制拼在一起,解决的是做 agent 产品、或者深度使用 agent 时会反复遇到的几个具体问题。
第一个问题是加能力和改能力的风险。常见的做法是在一个核心循环外面挂插件,核心本身很少改动,因为改动一次要担心影响面,回归测试也要重新跑。DeepSeek Harness 把风险控制粒度做到了插件级:一个插件注册的所有东西都通过 effect 登记,卸载时按注册的反顺序原样撤销。新增或者替换一个能力,影响范围被框定在这个插件自己的边界里,验证范围也缩小到这个边界。
第二个问题是,想让同一套 agent 服务不同场景,又不想每次都重新搭一遍环境。preset 的两层 scope 链解决的是这个:一份预设的插件树只挂载一次,写作、编码、调研几种不同用途的会话共享同一份已经跑起来的实例,靠一张逻辑父子表分流复用。
第三个问题是多步骤任务里,一次一个工具调用的来回成本。Code Mode 把这类任务的编排逻辑交给模型自己写代码解决,减少了"说一句、等一下"的往返次数。这块 DeepSeek Harness 和 Codex 几乎同时给出了同一个判断,只是隔离方案选得不一样。
第四个问题是工具的可见性和权限,在不同场景下常常只有两种状态:一个工具要么全局可用,要么全局不可用。分层遮蔽算法把可见性变成运行时按 scope 链实时计算的结果,同一个工具在不同预设、不同会话里可以有完全不同的可见性,工具代码里不需要写一堆条件判断。
这几处设计共同指向一个更大的判断:agent 产品的竞争力,除了模型效果,还取决于运行时这一层。运行时怎么组织、扩展成本有多高、换一个组件要付出多大代价,这些工程决策同样会影响一个 agent 产品用起来顺不顺手。DeepSeek Harness 把重点放在这一层,用 Cordis 这套现成的插件框架,以及"可逆副作用"这套设计哲学,把这一判断落实到具体的代码结构上。效果怎么样,还得看这套架构在真实使用里能撑多久,能不能撑住更复杂的场景。
P.S. 在写这篇文章中提到的那个示例插件的时候还把UI给搞崩了,看来HMR的稳定性还有待提升啊
![image](https://wechat2rss.bestblogs.dev/img-proxy/?k=07ccd113&u=https%3A%2F%2Fmmbiz.qpic.cn%2Fsz_mmbiz_png%2FKVER9adz9057kibs7a1M5GHqBebn0Zs1iaHMWic5wUPtQ8IJKCHvqviactURZCNLgicLAKhicuK0b1NqPicLsLDMprRKDGcoBsdto9lj5WE9OxhzY8%2F64
関連記事
News to Guide
ニュースの次に確認する
発表内容を、現在の料金や仕様と照らし合わせられる関連ガイドです。
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み