前の3編では、それぞれ次の3つの境界を確立した。Server側のSessionとRunはネットワーク接続とは別物だ。Client RuntimeはEvent Window、復元、Projectionを担当する。Reactなど他のUI層は、すでに形成されたView Stateだけを使い、Slot経由でFeature Rendererを組み合わせる。
この回では、最小限の実装で3つをつなぐ。
Demoでは実際のLLMを実装せず、React、Redis、データベースも導入しない。目標は次の構造的な判断を検証することだ。
この5つの条件が成り立つなら、後から実モデル、データベース、React、WebSocketへ置き換えても、中核の所有関係は変わる必要はない。
Demoの構成
ディレクトリは意図的に4つの層に分けている。
依存方向は一方向のままだ。
Presentation層はEventをどう復元するかを知らない。ServerもTool Cardをどう表示するかを知らない。
ServerがSessionを先に所有し、その後に接続を所有する
Serverは各Sessionについて次を保存する。
このDemoではevents[]がCanonical Stateになる。実システムではDBのEvent Logに置き換えられる。
Eventを追加する順序は次のとおりだ。
つまり、
append durable/canonical fact
↓
publish transport frame
この順序は重要だ。
先にSSEへデータを書き、その後で非同期に永続化した場合、次のようになる。
Client 看到了 seq=12
↓
Server crash
↓
12 没有持久化
↓
Reconnect 后 Server 只能恢复到 11
クライアントは、Serverが再構築できない事実をすでに観測してしまう。
本番実装では通常、DBトランザクション、Outbox、durable streamなどを使って、より強い保証が必要になる。このDemoはメモリ上のEvent Logを使い、「事実のコミットが公開に先行する」という構造だけを検証する。
RunとSSEを完全に分離する
POST /promptは新しい作業を受け付ける役割だけだ。
POST /prompt
↓
create ActiveRun
↓
202 Accepted
実際の実行は、独立したrunAgent()で進める。
次の順でEventを生成する。
run.started
message.user
message.assistant.started
message.assistant.delta
tool.call
tool.result
message.assistant.delta
message.assistant.completed
run.completed
SSE subscriberが存在するかどうかは、Runの状態判断に一切関与しない。
したがって、
接続が切れてもrun.cancel()は呼ばれない。
ActiveRunのcancellation stateを変える明示的な操作は、次の1つだけだ。
POST /cancel
これで、ユニット4の第1편で示した中核境界が検証できる。Connection AbortとExecution Cancelは別の制御チャネルだ。
SSEはseqを復元座標として使う
Eventの構造は次のとおりだ。
seqは1つのSession内で連続して増加する。
SSE Endpointは次を受け取る。
GET /api/sessions/:id/events?after=N
まず次を送る。
all events where seq > N
その後、接続を保ったままlive eventを受け取る。
SSE frameには次も書き込む。
id: <seq>
event: session-event
data: {...}
そのためseqは次の用途にも使える。
Event identity
Ordering coordinate
Replay checkpoint
Dedup key
Gap detection coordinate
実システムでは、1つのフィールドがすべての役割を担う必要はない。ただし、連続性を証明できる座標は存在しなければならない。
ReplayとLive Attachの間にGapを生じさせない
一見妥当だが、問題のある実装は次のとおりだ。
1. query history > N
2. history response returns
3. subscribe live stream
Eventがステップ2と3の間に生成されると、次のようになる。
history ends at 20
21 produced here
live subscribe starts at 22
21は永久に失われる。
DemoのSSE Endpointは、同じNode Event Loop turnの中で次を実行する。
read current tail
→ write backlog
→ register subscriber
途中にawaitがないため、この短いコードだけでDemoに必要なreplay/attachの原子性が保証される。
本番システムがDB、メッセージシステム、複数インスタンスにまたがる場合は、より正式なcheckpointやdurable subscription mechanismが必要だ。
テストで意図的に一度切断する
テストコードはまずSSEを確立し、そのあとPromptを送る。
最初の4つのEventを受け取った時点で、意図的にAbortする。
seq 0 run.started
seq 1 message.user
seq 2 assistant.started
seq 3 assistant.delta
↓
disconnect
この時点で次を保存する。
checkpoint = 3
テストは、クライアントが接続していない状態でもServer Runが実行を続けるのを待つ。
その後、再接続する。
GET /events?after=3
実際の実行結果は次のようになる。
checkpoint: 3
replayed tail: 4,5,6,7,8
final seq: 8
projection equality: ok
tool slot dispatch: ok
run status: completed
したがって、復元フローは次の通りだ。
Client observed 0..3
↓
disconnect
↓
Server produced 4..8
↓
reconnect(after=3)
↓
receive 4..8
2つの区間をマージしてから再確認する。
次の値と一致していなければならない。
ProjectionはTransportを理解しない
ProjectionEngineの中心的な入口は1つだけだ。
Eventがどこから来たかをProjectionEngineは知らない。
history query
SSE
WebSocket
local fixture
replay test
求められるのはseqが連続していることだけだ。
たとえばAssistant Streamingでは。
Toolは次のようになる。
tool.call
↓
ToolNode(status=running)
tool.result
↓
ToolNode(status=completed)
最終的なSnapshotには、UIが必要とするものだけを含める。
これがClient RuntimeとPresentationのあいだのprotocolだ。
重要なテスト:完全なReplayと分割したFoldが一致すること
テストでは2つのProjectionを作る。
1つめはすべてのEventを一度に読み込む。
2つめは実際の切断過程を再現する。
最後にassertする。
このテストは「最終的にページを表示できる」ことより重要だ。
次のことを検証する。
Projection(history + tail)
=
Projection(history) continued with tail
将来Plan、Approval、SubAgent Nodeを追加する場合も、この性質は維持すべきだ。
Slot RegistryはProjectionに関与しない
Projection Engineが最終的に生成するのは。
message.user
message.assistant
tool-call
具体的なUI Componentは参照しない。
SlotRegistryは独立して次を宣言する。
Chat Featureが登録する。
message.user
→ UserMessage renderer
message.assistant
→ AssistantMessage renderer
tool-call
→ ToolCall renderer
ToolCall RendererはToolを受け取ると、さらにディスパッチする。
toolName = read_file
↓
tool.view
↓
read_file plugin renderer
DSHの現在の実装にも、次の構造関係がある。
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
ただし、Cordis、React Scope、Storeといった本番機能は含めていない。
Tool UIを追加してもChat Rendererを変更しない
read_file Rendererは独立したcontributionとして登録する。
一致する登録がなければ、ToolCallはgeneric fallbackを使う。
テストでは最後に次を確認する。
tool slot dispatch: ok
したがって、Featureの拡張経路は次のようになる。
New Tool
↓
register new tool.view entry
次のようなことはしない。
modify ChatView
modify ToolCall switch
modify global component map
Browser Client Runtimeが公開するのはObservableなSnapshotだけ
ブラウザのClientSessionは次を保持する。
EventSource
ProjectionEngine
Connection State
Listeners
UIが呼び出すのは次の2つだけだ。
これはDSHのObject Layer → React Bindingと同じ考え方だ。
現在のDemoでは通常のDOMを使う。
Runtime Snapshot
↓
renderSnapshot()
↓
innerHTML
Reactに置き換えた場合、最も外側に次のような処理を追加するだけでよい。
Projection、Reconnect、Slot RegistryをComponent内へ移す必要はない。
DemoでReactを直接使わなかった理由
このDemoの目的はReact APIのデモではなく、アーキテクチャ上の依存関係を検証することだ。
Reactを直接使うと、記事の大半を次の準備に費やすことになる。
Vite
JSX
package setup
hook code
CSS
読者は「ページが動いた」と確認するだけでも、Client Runtimeが本当に独立しているかは判断しにくい。
ここでは意図的にDOM Bindingを使うことで、逆に次のことを検証できる。
Projectionの出力とSlot CompositionがReactに依存しなければ、それらは本当にRuntimeとUI Compositionの層に属している。
第15편では、DSHの現在のweb-reactソースを通して、本番対応のReact Bindingがどう実装されているかを説明した。
このDemoにまだ足りない本番機能
コアモデルの検証だけを持ち、本番実装として直接使うべきではない。
少なくとも次が必要だ。
特に、現在のメモリ上のevents[]はDurable Storageではない。Process Crashで全状態を失う。
開発を続けるなら、優先順位は次のとおりにするべきだ。
1. Event Log 持久化
2. Run 状态持久化
3. durable stream / pub-sub
4. Snapshot + Tail Recovery
5. React Binding
6. Plugin Scope / Store lifecycle
Componentをさらに増やす前に。
ユニット4が形にしたモデル
4編を経て、Web Agentは次の構造にまとめられる。
この構造では、どの層も差し替えられる。
SSE ↔ WebSocket
Memory Log ↔ PostgreSQL / SQLite
DOM ↔ React
Simple Slot Registry ↔ Cordis + DSH Slots
Fake Agent ↔ Real Agent Loop
差し替えても中核の所有関係は変わらない。
設計判断
ユニット4で得出的判断は3つだ。
1つ目。Web Agentの実行ライフサイクルはネットワーク接続から独立していなければならない。ネットワーク接続はtransport resourceであり、Runは業務の実行である。
2つ目。Client RuntimeがReplay、Gap Repair、Projectionを所有すべきだ。ReactはSnapshotだけを使い、復元アルゴリズムがコンポーネントツリーに分散するのを防ぐ。
3つ目。プラグイン化したUIは安定したView Modelの上に構築する。Feature Pluginはrendererを登録するだけで、元のEventを直接解釈したり、グローバルContext経由で全Runtime Serviceを暗黙に読み込んだりしない。
至此、前の4ユニットは現代のAgent Harnessの4つの基本側面をカバーした。
Execution
State & Persistence
Composition
Web Runtime & UI
次のユニットでは、これらのモデルを整理し、Vercel AI SDK、Pi、DeepSeek Harnessが境界の選択でどう異なるかを比較する。そのうえで、独自のAgent Runtime / Harness APIを設計できる。
Demoファイル
実行するには、
cd demo/web-runtime
npm test
npm start
参考資料
[1] DeepSeek Harness Client Runtime: https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/client/runtime/src/client
[2] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[3] DeepSeek Harness UI Slots: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-slots/README.md