第 3 課:エージェントループとセッション:すべてが記録されている
一言でいうと:DSH はエージェントの作業プロセス全体を、追記のみ・変更不可のセッションログとして記録します——モデルに見えるものは、すべてログに記録される。復旧・fork・リプレイ・テレメトリ・UI はすべてこの 1 つのログから派生するため、タスクが途中であっても、損失なく続行・分岐・振り返りができます。
1. ユーザーストーリー:タスクが途中まで進んだとき、どうやって損失なく続けるか?
次のような場面を想像してください:エージェントに古いリポジトリを Vue 2 から Vue 3 へ移行させているとします。すでに 3 時間動き続けています——数十のファイルを変更し、何度もテストを実行し、モデルと数百ステップにわたってやり取りしてきました。そこで、血圧が上がるような出来事が 3 つ起こります:
- パソコンが突然再起動し、プロセスが消えてしまった;
- あるいは気が変わった:より保守的な移行ルートを試したいが、これまでの進捗は捨てたくない;
- あるいはリプレイしたい:昨日、一体どのステップであの設定ファイルが壊れたのか?
普通のツールでは、この 3 つは「やり直し」か「記憶を頼りに探す」に等しいものです。DSH では、これらはすべて損失なく、正確で、記録に基づいて行えます:
| やりたいこと | DSH のやり方 | 結果 |
|---|---|---|
| クラッシュ後に続行 | resumeSessionId で永続化セッションを読み込む | ターン番号と派生履歴は読み込まれたログから続き、中断がなかったかのようになる |
| 別のルートを試す | セッションを fork する | 安定したチェックポイントから並行ブランチがコピーされ、元のセッションは無傷のまま |
| 履歴を振り返る | セッションログを読む | すべてのモデルリクエスト、ツール呼び出し、結果に生の記録がある |
この課ではただ 1 つのことを明らかにします:なぜこれらすべてが可能なのか。答えは一つの言葉に隠れています——実行の再構築可能性です。
2. コアアイデア:セッションは append-only のイベントストリームであり、権威あるログ
セッション = 真実の源、メッセージ履歴 = 派生
まず、dsh-session パッケージが自分自身をどう定義しているかを見てみましょう(出典:packages/core/session/README.zh.md):
イベントソーシングによるセッションログとインメモリストレージ。Session はエージェントの全対話履歴の追記のみの真実の源であり、LLM のメッセージ履歴はこれから派生する。
この文を分解してみます:
- 追記のみ(append-only):イベントはログの末尾に追記することしかできず、書き込まれた記録を後から修正・削除することは誰にもできません;
- 真実の源(source of truth):セッションログが唯一の権威です。モデルが見るメッセージ履歴も、UI に表示される transcript も、このログから派生したコピーであり、別の状態ではありません;
- これから派生する:「ログ」「モデル履歴」「UI 状態」の 3 つのデータを同時に管理する必要はありません——データは 1 つだけで、残りはすべて射影(プロジェクション)です。
モデルに見える ⟺ 記録済み
アーキテクチャドキュメントはこの原則を一つの式として記しています(出典:docs/architecture.zh.md ·「セッションログ」):
セッションログは権威ある根拠である。
deriveMessages()がモデル履歴を射影し、生のassistant/chunkイベントがリプレイと UI の忠実性を保証する。fork・復旧・transcript(テキスト記録)のレンダリング・テレメトリ・永続化はすべてこのイベントストリームから派生する。モデルに見える ⟺ 記録済み:
step/startで入ってくるメッセージと、畳み込まれたrequest/headerで、すべてのリクエストを再構築できる。
「モデルに見える ⟺ 記録済み」はこの課の心臓部です:モデルが見られるものは、必ずログに記録されている。ログにないものは、モデルにも見えない。 「モデルがこっそり使ったのに記録されていない」というブラックホールは存在せず、「ログには記録されたがモデルには見えない」という幽霊も存在しません。だからこそ、永続化・復旧・fork・リプレイ・テレメトリ・UI という、一見まったく無関係に見える 6 つのサブシステムが、すべて同じイベントストリームからデータを取得し、互いに帳尻が合わなくなることがないのです。
💡 たとえ話:これは「日記をつける」のではなく「全過程を録画する」ことです。日記は事後に記憶を頼りに書くため、漏れたり改ざんされたりします。録画は出来事が起きた瞬間の生の記録であり、すべてのフレームが本物です。
3. 3 層構造:セッション → ターン → ステップ
セッションログはごちゃ混ぜではなく、明確な 3 層構造を持っています:
会话 → 轮次 → 步骤:事件全都追加进日志,从任意检查点都能重建
- セッション(Session):一続きの対話履歴全体で、1 つの append-only イベントログに対応し、グローバルに一意な
SessionIdを持ちます; - ターン(Turn):1 つのメッセージを受け取ることから始まり、その応答が終わるまでです。ターンは
turn/startとturn/endの 2 つのイベントに挟まれ、turn/endには終了理由——正常終了、abortedによるキャンセル、errorによる失敗——が忠実に記録され、クラッシュ復旧ではinterruptedが合成されます; - ステップ(Step):1 回のモデルリクエスト + それに伴うツール。ステップは
step/startとstep/endに挟まれます。成功したモデル呼び出しはすべてassistant/messageを残します——その呼び出しが空の内容を返した場合や、max-tokensで切り詰められた場合でも、ログは同様に記録します(空の内容は派生するメッセージ履歴に入らないだけで、永続化されたイベントと使用量はどちらも残っています)。
agent-lifecycle ドキュメントは役割分担を一文で言い切っています(出典:docs/agent-lifecycle.zh.md):
永続的なリプレイの事実は
session/eventに保存され、リアルタイムの制御と状態はagent/*に保存される。
つまり:ログは「何が起きたか」を管理し、イベントは「今どうなっているか」を管理する。 前者は正確にリプレイでき、後者はリアルタイムの駆動(例えば running / idle 状態や受信箱キュー)を担当します。
1 つのターンには往々にして複数のステップが含まれます:モデルが「ファイルを読みたい」と言う → ツールが実行される → 結果が返る → モデルが「ファイルを変更したい」と言う → ツールが実行される → ……モデルがタスクの完了と判断するまで続き、そこで初めてターンが閉じます。閉じたターンだけが、安定した fork / チェックポイントの境界となります。
4. 同じログからすべてを取り戻す:復旧・fork・検索
復旧:resumeSessionId で続きから実行
DSH のエージェントループドライバー(dsh-agent-loop)は、セッションに入る 2 つの経路を提供します:
- 作成:
ctx.agents.create(...)——新しいsessionIdでゼロから始める; - 復旧:
ctx.agents.resume({ resumeSessionId, ... })——すでに存在する永続化セッションを読み込んで続きを実行する。
agent-loop ドキュメントは復旧を次のように説明しています(出典:packages/core/agent-loop/README.zh.md):
ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }):ctx.sessionPersistenceを通じて永続化セッションを読み込み、同じ id でエージェントを登録し、履歴を再構築する……ターン番号と派生履歴は読み込まれたログから続く。この操作にはセッション永続化バックエンドが必要であり、永続化がない場合、resumeは明確なエラーで拒否される。
3 つの要点:復旧は最初からのリプレイではなく、番号を引き継いで続きを実行することです(ターン番号と派生履歴は読み込まれたログから続きます)。復旧にはセッションが実際に永続化されていることが必要です。永続化バックエンドがそもそも設定されていなければ、成功したふりをせずに明確なエラーを返します——サポートしないことを選んでも、「同じに見えて実はコンテキストが失われた」偽の復旧は渡しません。
fork:コピーの境界
「別のルートを試したい」?ctx.sessions.fork(source, boundary?, childSessionId?) を使います。そのセマンティクスは次の通りです(出典:packages/core/session/README.zh.md):
ライブセッションオブジェクトまたは id を解決し、
boundaryイベント序数(そのイベントを含む)までのシードを選び(デフォルトは現在の最後のイベント)、選択されたプレフィックスの終端にオープンなターンがないことを要求し、系譜メタデータを持つライブな子セッションを作成する。
- コピーの境界:デフォルトは現在の最後のイベントですが、
boundaryを明示的に指定することもできます。ただし、選択されたプレフィックスの終端は閉じたターンでなければなりません——ターンの途中で fork することはできません; - 系譜メタデータ:子セッションには
parentSessionなどの情報が記録され、どれがコピーかは調べれば一目瞭然です; - 元のセッションは無傷:fork は「ログを読む + 新しいセッションを派生する」だけで、元のセッションには一切触れません。
検索:session-query 全文検索
ログは日々大きくなっていきます。どうやってその中から正確にものを探すのでしょうか?DSH には専用のセッション検索機能ファミリー(session-query)があります。これは「認可されたライブおよび永続セッションログの検索を、コンパクションから独立して提供する」ものです——あるコンテキストが後からコンパクションで置き換えられても、元のログは残っており、同じように検索できます。
searchSessions():セッション横断の全文検索で、最もマッチ度の高いイベントでグループ化して返します;searchEvents():単一セッション内でイベントを検索します;- SQLite プロバイダーは全文検索(FTS)でインデックスを実装しています;
- セキュリティの細部:クエリ語句はリテラルとして解釈され、実行可能な検索構文として扱われることは決してありません——検索はコードとしてではなく、データとして扱います。
🎁 3 つの共通点:復旧・fork・検索はすべてログを読むだけです。ログが権威的で、完全で、追記のみだからこそ、この 3 つの操作はそれぞれ独立に成り立ち、互いに衝突しません。
要点の振り返り
この課では次の 5 つの文を覚えておけば十分です:
- 実行の再構築可能性は DSH の約束です:モデルに見えるすべての内容は権威あるセッションログに記録されます——モデルに見える ⟺ 記録済み。
- セッション = append-only イベントストリーム:イベントは追記のみで書き換え不可。モデルのメッセージ履歴と UI の transcript はログから派生した射影であり、ログが唯一の真実の源です。
- 3 層構造:セッション → ターン → ステップ。ターンは 1 つのメッセージを受け取って閉じます。ステップ = 1 回のモデルリクエスト + それに伴うツール。
- 復旧と fork:
resumeSessionIdは永続化ログから番号を引き継いで続きを実行します。fork(source, boundary)は閉じたターンの安定した境界で並行セッションをコピーし、元のセッションは無傷のままです。 - 全文検索:session-query がログを検索可能にし(
searchSessions/searchEvents)、コンパクションから独立しています——置き換えられた古いコンテキストも見つけられます。
🚀 次課の予告:モデルには「記憶」があるだけでは不十分です。手を動かすことができなければなりません。次の課では「ツールと実行」を扱います——エージェントがどうやって「関数を 1 回呼び出す」ことを、権限があり、サンドボックスがあり、記録のある真のアクションに変えるのか。
セルフテスト · ループとセッション
回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。
