しばらく Agent を書いていると、必ずこうした疑問にぶつかるはずだ。モデルは2ターン目で前のターンのツール呼び出し結果を「覚えて」いるのに、データベースを覗いても「会話履歴」を保存しているテーブルがどこにも見当たらない。DeepSeek Harness が示す答えは非常に直感に反している——会話履歴はそもそも個別に保存されない。それは追記専用のイベントログから派生した投影にすぎない。この記事では DeepSeek Harness(以下 dsh)の可観測性の基盤を分解して見ていく:セッション(Session)がいかにして唯一の真の情報源となるか、ターン(turn)とステップ(step)がログ上でどのように実行境界を引くか、軌跡のリプレイがなぜ「同じイベント群を再派生させるだけ」で実現できるのか。これは上級 Agent 開発者向けの深水領域の内容であり、読み終えれば dsh がなぜ「リプレイは再実行ではなく再投影だ」と言い切れるのか、そしてこの設計が可観測性を「事後的なログ出力」から「アーキテクチャレベルの不変条件」へとどう変えたのかが理解できるだろう。本段落は全文の第 1/2 部分であり、まずログ・投影・イベントドメインという3つの土台を固める。第 2 段落ではリプレイ工学と実践的なデバッグに入っていく。

セッションとは追記専用ログである:SessionEvent、単調な seq、epoch ミリ秒の time という三種の神器

まず一つの問いを投げかけよう。モデルが見ているあの会話履歴は、いったいどこに保存されているのか? ほとんどのフレームワークの答えは「メモリ上の messages 配列に保存し、ついでにディスクにも書き出す」だ。dsh の答えはまったく異なる——専用の場所には存在せず、セッションログから派生するのだ。この一言が、設計全体を理解するためのマスターキーである。

dsh において、Session とは本質的に型付きの SessionEvent からなる追記専用ログ(append-only log)である。「追記専用」という言葉の重みに注意してほしい。ログの各行は一度書き込まれたら、決して変更・削除・並べ替えされない。あなたは「過去のメッセージを更新する」ことはできず、「ここで変化が起きた」ことを表現する新しいイベントを追記するしかない。これは Git の commit チェーンや Kafka のパーティションログと同じ哲学だ——不変の追記操作によって、完全な追跡可能性と決定論的リプレイを手に入れる

ログ内の各イベントは、安定した「三種の神器」を携えている:

  • type:イベントの型タグ。例えば turn/startassistant/messagetool/result。これがそのイベントがどの意味ドメインに属するかを決め、後の判別可能な共用体の絞り込みの鍵にもなる。
  • seq:単調増加するシーケンス番号。その値の決め方は非常にギーク——seq = log.length。つまり、イベントが append されると、その seq はログ内でのそのイベントのインデックス位置になる。最初のイベントの seq は 0、2番目は 1、以下同様で、途中に空洞は決して生じない。これにより極めて強力な保証が得られる:seq は歴史におけるイベントの絶対座標である。任意の2つのイベントの前後関係は、タイムスタンプに依存せず、seq の大小で直接比較できる。
  • timeepoch ミリ秒単位のタイムスタンプ。例えば素材ログの 1755000000000 は Unix ミリ秒時間であり、人間が読むため、並べ替え表示のため、性能分析(例えばある step の所要時間の計算)のために使われる。しかし注意してほしい、time は因果順序の判定責任を負わない——因果順序は seq が保証し、time は「壁時計時間」の参考価値のみを担う。これは非常に成熟したエンジニアリング上のトレードオフである:タイムスタンプはクロックの巻き戻しや精度の問題で曖昧さを生む可能性があるが、seq はそうならない。

なぜ「三種の神器」をこのように設計するのか?それは、それぞれが直交する三つの問いに答えるからだ:type は「これは何か」に答えseq は「歴史の中で正確にどこに位置するか」に答えtime は「おおよそいつ発生したか」に答える。この三つの次元を分離することで、単一のフィールドに過剰な責務を負わせることを避け、リプレイ、デバッグ、ブレークポイントからの再開それぞれにクリーンな手がかりを持たせられる。

位置づけの観点では、このログは agent の完全なインタラクション履歴の唯一の真実の源(single source of truth)である。これは宣伝文句ではなく、アーキテクチャ層で繰り返し検証される制約である:LLM のメッセージ履歴はログから派生し、決して別途保存されない。リプレイとは、同じイベント群から履歴を再び派生させることである。言い換えれば、ログが源であり、メッセージはビューである。UI 上で見る会話、API で送信する messages 配列、リプレイヤーで再現される各ステップは、本質的に同一のログの異なる投影である。

もう一つ見落とされがちな細部がある:ログ内の各イベントの data は必ずJSON へロスレスにシリアライズ可能でなければならず、この制約は Session.append によって源流で強制される。なぜ append の層で食い止めるのか?ログは永続化され、プロセス間で転送され、ロードされてリプレイされるため、Map、Date、クラスインスタンス、循環参照といったものを許してしまうと、リプレイ時に「デシリアライズ後に意味が変わった」という災難に直面することになる。検証を書き込み入口へ前倒しすれば、コストは最小で利益は最大——書き込み時に厳格であればこそ、読み取り時に奔放になれる

以下の表は三種の神器の責務を対比して明確に示したもので、独自の Agent イベントシステムを実装する際に照らし合わせて使うことを勧める:

フィールド型/値中核的責務省略可否エンジニアリング上の落とし穴
type文字列リテラル(判別可能ユニオンの判別子)イベントの意味を識別し、投影とルーティングを決定する省略不可新しい type を追加したら必ず SessionEventMap も同期して拡張すること、さもなければ絞り込みが無効になる
seq整数、append 前の log.length に等しい歴史における絶対座標と因果順序を与える省略不可タイムスタンプを seq の代わりに並べ替えに使わないこと、クロックの巻き戻しが順序を破壊する
timeepoch ミリ秒の整数壁時計時間の参照、表示と所要時間統計に用いる省略不可ビジネス上の因果判断に使わないこと、マシン間で比較できない
dataJSON へロスレスにシリアライズ可能なオブジェクトイベントのペイロードを担い、投影の原料となる省略不可Date/Map を詰め込むとリプレイ時に歪みが生じる、append 段階で直接拒否すべき

これら三点セットを深く理解すると、dsh のセッションログが実は座標系を備え、時間を参照でき、型を識別できるイベントストリームであることがわかる。純粋なテキストログのように構造を失うこともなく、データベーステーブルのように状態の可変性を持ち込むこともない。これこそが「唯一の真実の源」を名乗れる根拠である。

なぜ LLM メッセージ履歴を別途保存しないのか:ログからの派生 + リプレイ可能な transcript の SDK 消費スタイル

従来のやり方では、多くの Agent フレームワークが独立した messages[] 配列を維持し、その配列と元の実行ログをそれぞれ別に保存していた。そこから問題が生じる:二つのデータが乖離するのだ。結合ロジックのバグを修正しても、ログには古い動作が記録され、messages 配列は新しいロジックを使っている。当時モデルが実際に何を見たのかを遡って確認しようとしても、ログに記録されているのは「我々がモデルに見たと思っていたもの」である。この種の「二重書き込みの不整合」は可観測性の最大の敵である。

dsh のアプローチは、二つ目の書き込み口を根本から排除することである:LLM メッセージ履歴は別途保存せず、ログから派生させる。いわゆる「派生(derive)」とは、明確で決定的な投影規則のセットによって、イベントログを Message[] にマッピングすることを指す。規則が決定的である以上、同じイベント群からいつでもどのマシンでも派生されるメッセージ履歴は必ず完全に一致する——これが「リプレイ可能」の数学的基盤である。

ここには非常に重要なセマンティクスがある:リプレイ = 同じイベント群から履歴を再派生させること。モデルを再実行するのでも、ネットワークリクエストを再生するのでもなく、ログを再び投影関数に投入するのだ。これはつまり、軌跡のリプレイが純粋関数的であることを意味する:入力はイベント列、出力はメッセージ列であり、副作用もランダム性もなく、外部状態に依存しない。これにより、従来のフレームワークにはできない多くのことが可能になる:

  • 決定的デバッグ:エラー発生時にその部分のログを切り出せば、誰がリプレイしても同じメッセージを確認でき、「自分の環境では再現しない」ということが起きない。
  • ゼロコストの分岐:ある step で異なる投影ロジックを試したい場合(例えば tool の結果を別の形式に変えるなど)、同じログに新しい投影器を適用するだけでよく、履歴データを一切変更する必要がない。
  • マルチビューの共存:同じログから「モデルに見せる messages」「レビュアーに見せる transcript」「UI に見せる timeline」を派生できる。それらは互いに干渉しない。すべて単なる投影だからである。

では SDK 利用者として、「リプレイ可能な transcript データ」を取得したい場合、どのように消費すべきか?公式が示すスタイルは明確である:リプレイ可能な transcript データを必要とする SDK ユーザーは session/event イベントストリームを消費すべきである。これはあるスナップショットファイルを読めということでも、deriveMessages を呼んで最終結果を取得せよということでもなく、イベントストリーム自体を購読せよということである。理由は単純で、イベントストリームだけがすべての生情報を保持しているからだ——メッセージに投影されない境界イベントやストリーミング分片も含めて。派生後の messages だけを取得してしまうと、turn/step の境界を失い、assistant/chunk のトークンレベルの忠実性を失い、tool/call の生パラメータを失う。完全なリプレイを行うには、ソースイベントストリームを保持する必要がある。

以下はそのまま貼り付けて実行できるサンプルです。Node.js で JSONL 形式のセッションログイベントストリームを消費し、境界イベントを取得しつつ派生プロジェクションも行います。ここでは JSONL の標準的なパッケージング行レイアウト——各行が完全なイベントオブジェクト——を使用します。

// ファイルパス:examples/consume_session_events.mjs
// session/event イベントストリーム(JSONL 行レイアウト)を消費し、以下を実演する
// 1)イベントごとにストリーム処理する方法;2)派生 messages とリプレイに必要な境界データを同時に取得する方法。
// 実行:node examples/consume_session_events.mjs examples/session.jsonl

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

/**
 * イベントストリームを消費する:これは SDK が推奨する「リプレイ可能な transcript」の姿勢である。
 * 注意:派生後のメッセージだけを保持するのではなく、すべてのイベントを保持する。
 * @param {string} path JSONL セッションログのパス
 */
async function consumeSessionStream(path) {
  const rl = createInterface({
    input: createReadStream(path, { encoding: "utf-8" }),
    crlfDelay: Infinity,
  });

  const events = [];      // 生イベント、リプレイとデバッグの原料
  const boundaries = [];  // turn/step 境界、メッセージには投影されないがリプレイに必須
  const messages = [];    // 派生されたモデル可視履歴

  for await (const line of rl) {
    if (!line.trim()) continue;
    const ev = JSON.parse(line);
    events.push(ev);

    // 境界イベント:実行構造を記録するが、messages には入れない
    if (ev.type === "turn/start" || ev.type === "turn/end") {
      boundaries.push({ kind: ev.type, turn: ev.data.turn, seq: ev.seq });
    } else if (ev.type === "step/start" || ev.type === "step/end") {
      boundaries.push({ kind: ev.type, turn: ev.data.turn, step: ev.data.step, seq: ev.seq });
    }

    // surface イベント:投影ルールに従ってモデル可視メッセージを生成する
    const d = ev.data;
    if (ev.type === "user/message") {
      messages.push({ role: "user", content: d.content });
    } else if (ev.type === "assistant/message") {
      // 空内容の assistant/message はプロバイダ transcript に入れない
      const content = d.message.content;
      if (!Array.isArray(content) || content.length > 0) {
        messages.push({ role: "assistant", content });
      }
    } else if (ev.type === "tool/result") {
      messages.push({
        role: "user", // ツール結果は user ロールでモデルに戻る
        content: [{ type: "tool-result", toolName: d.message.toolName, content: d.message.content }],
      });
    }
    // assistant/chunk、tool/call、turn/*、step/* はどれもメッセージに投影されない
  }

  console.log("イベント総数:", events.length);
  console.log("境界イベント数:", boundaries.length);
  console.log("派生メッセージ数:", messages.length);
  console.log("末尾イベント seq:", events.at(-1)?.seq, "time:", events.at(-1)?.time);
  return { events, boundaries, messages };
}

await consumeSessionStream(process.argv[2]);

このコードには強調すべき設計ポイントが2つあります。第一に、events 配列はリプレイの基盤であるという点です。これは seq と time を保持しているため、任意の断片に対して再投影を行うことができます。第二に、boundaries は別途収集されるという点です。これは turn/step の境界が「メッセージとして投影されないがリプレイには必須」であるためです。もしリプレイヤーで「モデルがどの step でツール結果を受け取ったか」を復元したいなら、これらの境界イベントを保持しなければなりません。

switch (event.type) による直接的な絞り込み:判別可能な共用体と event.data の無損失 JSON シリアライズ制約

イベントが「型付け」されているかどうかで、違いは甚大です。dsh のイベントは type に基づいて真の判別可能な共用体(discriminated union)を構成しています。これはつまり、TypeScript で switch (event.type) を書くとき、コンパイラがevent.data の型を直接絞り込むことができ、as による型アサーションを一切書く必要がないということです。

直感的な例を挙げましょう。あなたが次のように書いたとき

switch (event.type) {
  case "step/start":
    // 此处 event.data 被收窄为 { turn: number; step: number }
    console.log(event.data.turn, event.data.step);
    break;
  case "tool/call":
    // 此处 event.data 被收窄为 { turn; step; callId; name; arguments }
    // arguments 是模型产出的原始 JSON 字符串,不是解析后的对象
    console.log(event.data.name, event.data.arguments);
    break;
  case "tool/result":
    // 此处 event.data 被收窄为 { turn; step; message; error?; meta? }
    console.log(event.data.message.toolName, event.data.error ?? "ok");
    break;
}

case 分岐内では、event.data.turnevent.data.arguments に直接アクセスしてもすべて型安全であり、IDE が自動補完し、フィールド名を間違えれば即座に赤く表示されます。これは「ドキュメント上の取り決め」ではなく、型システムによって強制される契約です。なぜこれが可能なのか? それは各イベントタイプの data の形状が SessionEventMap によって正確にマッピングされており、type フィールドがこの共用体の判別子(discriminant)となっているからです。

ここでよくある誤解を正しておきたい:「絞り込める」ことは「実行時に安全」であることを意味しない。型アサーションが解決するのはコンパイル時の問題だが、ログは手書きでき、改ざんでき、外部システムから注入できる。だからこそ、さらに低い層の防衛線がある:すべての event.data はロスレスで JSON にシリアライズできなければならず、Session.append がそのことを源流で強制する。いわゆる「ロスレス」とは、JSON.parse(JSON.stringify(data)) の後で意味が変わらないことを指す。何がロスレスを壊すのか?

  • Date オブジェクト:シリアライズ後は文字列になり、デシリアライズして戻すと Date ではなく string になり、型がドリフトする。
  • Map / Set:JSON.stringify でそのまま {} になり、データが静かに失われる。最も危険。
  • クラスインスタンス:メソッドが失われ、列挙可能な own プロパティだけが残り、プロトタイプチェーンが断裂する。
  • undefined と関数:JSON にはこれらの値が存在せず、静かに破棄されるか null になる。
  • 循環参照:JSON.stringify がそのままエラーを投げる。
  • NaN / Infinity:null にシリアライズされ、意味の汚染を生む。
  • BigInt:JSON.stringify がエラーを投げ、シリアライズできない。

この制約を append の入口に置くのは、典型的な「システム境界で最も厳格な検証を行う」という考え方だ。書き込み時にエラーを投げる方を選び、再生できないイベントをログに混入させてはならない——なぜなら、1 つの壊れたイベントがタイムライン全体の再生能力を汚染するからだ。この組み合わせ技は一文でまとめられる:可識別ユニオンが書き込み側の型の正しさを保証し、ロスレス JSON 制約がストレージ側の意味の欠落を防ぎ、両者が合わさって初めて読み取り側(派生と再生)は「ログの中の一つ一つが再構築可能である」と安心して大胆に仮定できる。

もう一つ実践的なテクニックを補足する:業務で本当に複雑な構造(例えば Date)を持ち運ぶ必要がある場合、標準的なやり方は書き込み前に明示的に JSON 化可能な形式へ変換することだ。例えば Date オブジェクトではなく epoch ミリ秒数を保存し、投影段階で必要に応じて復元する。これは dsh が time に epoch ミリ秒を保存し、時刻オブジェクトを保存しないのと完全に同じ哲学だ。

「モデルに見えるものはすでに記録されている」不変量:モデル可視の入力を追加する = セッションイベントを追加する

これは設計全体の中で最も硬核であり、最も見落とされやすい項目だ:モデルに見えるものはすでに記録されている。噛み砕いて言えば——モデルリクエストに到達するすべてはログから再構築できなければならず、実行時の不変量(invariant)がそのことを断言する

「不変量」とは何か。それは、いかなる時点、いかなる状態においても真でなければならない断言である。ここでの断言とは、モデルリクエストに入る内容はすべて、ログに対応するイベントが見つかるということだ。もしある日、コードに「モデルにこっそり差し込むシステムプロンプト」を追加したのに、それに対応するセッションイベントを新設しなかったなら、この不変量は破られる——実行時の断言が直接エラーを出し、次回のリプレイ時に「モデルがその時見ていたものとログに記録されたものが違う」と初めて気づく羽目にはならない。

この不変量から導かれる帰結は極めて重要であり、必ず覚えておいてほしい:モデルから見える入力を一つ追加するなら、セッションイベントを一つ追加する必要がある。具体的な操作は二段階に分かれる:

  1. SessionEventMap を拡張する:この種の新しい入力のために新しいイベント型とその data の形状を定義し、識別可能なユニオンの一員にする。
  2. ログからレンダリングする:deriveMessages の投影ルールがこの新しいイベントをどう扱うかを知るようにする。そうして初めて、それは派生履歴に現れる。

なぜ両方の段階を必ず行う必要があるのか。第一段階だけを行えば、イベントは記録されるが投影されず、モデルには見えない——記録とビューが乖離する。第二段階だけを行えば、投影には根拠なく内容が増えるがログには対応するイベントがない——リプレイ時にこの内容は失われ、不変量に違反する。二段階を合わせて初めて、「ログがモデルの見たものを再構築できる」という定義を満たす。

この設計は、いくつかの非常に実用的な利益をもたらす:

  • 監査可能:モデルが見た一文字一文字に出所がある(具体的な seq まで遡れる)。ハルシネーションや権限逸脱が起きた際、どのイベントが原因でその入力になったのかを正確に特定できる。
  • リプレイ可能:「見えるものは再構築できる」ため、ログさえ与えられればモデルの入力を 100% 再現でき、追加のバイパス記録は不要である。
  • 進化可能:新しい入力を加えたい?まずイベント型を追加し、次に投影を変更する。変更の方向は「ログ優先」へと強制され、アーキテクチャのレベルで二重書き込みの不整合を防ぐ。

逆に言えば、これはコードレビューに極めて優れたチェックリストを提供する。「モデルに何かを追加する」PR をレビューするとき、三つの質問をするだけでよい:SessionEventMap は拡張されたか?投影ルールは変更されたか?不変量の断言は通るか?三つとも yes なら、その変更はアーキテクチャ上自己整合的である。このように規範を実行時の不変量として固化する手法は、dsh の可観測性が普通の「ログを打つ」フレームワークと一線を画す分水嶺である——それは「記録すべき」を人の自覚から、システムによる強制へと変えた。

deriveMessages() 投影表を行ごとに分解:user/message、assistant/message、tool/result はどれが入りどれが入らないか

「ログが真の源、メッセージは投影」を理解したなら、次に最も重要なのは投影ルールをはっきりさせることだ。dsh でこの役割を担う関数は Session.deriveMessages() と呼ばれ、イベントログをモデルが見る Message[] へと投影する。ルールは実に素朴だが、一行一行にこだわりがある。

まず一つの概念を明確にします:surface イベントだけがメッセージに投影される。いわゆる surface とは、「対話の表面コンテンツを構成する」イベントを指します。dsh には三種類の surface イベントがあり、それらはマーク用フィールド surfaceOp を持ち、派生 surface にどのように参加するかを示します。投影テーブルを一行ずつ分解していきましょう:

イベントタイプ投影先重要な説明surfaceOp の有無
user/message一つの user メッセージ正確な content を保持し、オプションの envelope はログ表示用メタデータとしてのみ機能するあり
assistant/message一つの assistant メッセージ提供元、モデル、およびオプションのリプレイ状態を含むあり
assistant/chunkスキップリプレイ/UI データに属し、組み立て後のメッセージが権威となるなし
tool/resulttool-result ブロックを持つ一つの user メッセージツール結果は user ロールとしてモデルに戻るあり
turn/*、step/*スキップ構造情報であり、メッセージに投影されないなし

一行ずつ詳しく読み解きます:

  1. user/message → 一つの user メッセージ。これは正確な content を保持します。ここに一つの細部があります:user/message にはオプションの envelope が存在する可能性がありますが、envelope はログ表示用メタデータとしてのみ機能し、モデル可視コンテンツには入りません。つまり、表示レイヤーはメッセージに「ソースチャネル」「信頼度」などの封筒情報を追加できますが、モデルが見るのは依然としてクリーンな content です。この区別は非常に実用的です——これにより「監査向けのメタデータ」と「モデル向けの本文」が分離されます。
  2. assistant/message → 一つの assistant メッセージ。提供元(provider)、モデル(model)、およびオプションのリプレイ状態を含みます。これらの付加情報により、リプレイ時に「このメッセージがどの provider のどのモデルによって生成されたか」を復元でき、マルチモデル軌跡の比較を行うための貴重な材料となります。
  3. assistant/chunk → スキップ。なぜスキップするのか?それは生のストリーミング断片であり、リプレイ/UI データに属し、組み立て後のメッセージが権威となるからです。この点は極めて重要です:モデルが見る、真に権威あるものは、組み立て済みの assistant/message であり、chunk は token レベルのリプレイ忠実度のための生素材にすぎません。投影時に chunk も含めてしまうと、重複が生じます。素材内のログがこの関係を表しています——assistant/message(seq 5)は sourceEventSeqs: [3, 4] を通じて、それを構成する二つの chunk(seq 3、4)を正確に指し示しています。
  4. tool/result → tool-result ブロックを持つ一つの user メッセージ。ロールが user であり、tool ではないことに注意してください。これは LLM メッセージプロトコルの一般的な約束事です:ツール実行結果は user ロールとしてモデルに還流されます。dsh はこれに従っています。素材内のあの tool/result の message は role: "tool"toolName: "bash"content: "runoob"isError: false を保持し、投影後は一つの user メッセージ内の tool-result ブロックになります。
  5. turn/*、step/* → スキップ。これらは構造情報であり、実行境界を画定するために用いられ、メッセージに投影されません。これにより、先にイベントストリームを消費する際に boundaries を別途収集する必要がある理由が説明されます——それらはリプレイには重要ですが、「モデルが何を見るか」には重要ではありません。

もう一つ、投影ルール表の外側にあるものの同じく重要な詳細があります:tool/call もメッセージとして投影されないということです。素材の中の tool/call(seq 6)が確かに派生履歴に現れていないことがわかります。なぜでしょうか?モデルが発起したツール呼び出しは、その意味がすでに assistant/message 内の tool_use ブロックによって担われているからです(seq 5 の assistant/message を見ると、content 内にすでに type: "tool_use", id: "call_1", name: "bash" が含まれています)。tool/call イベントは実行側の記帳であり、callId を後続の result と関連付けるためのものであって、再びモデルのビューに入るものではありません。この区別により、「モデルが何を呼び出すと言ったか」と「システムが実際に何を呼び出したか」が二つの照合可能な手がかりとなります。

さらに、先に埋めておいたルールを重ねます:内容が空の assistant/message もスキップされる。したがって完全なスキップリストは:assistant/chunk、turn/*、step/*、tool/call、そして「空内容の assistant/message」です。以下のすぐに実行できる Python サンプルは、投影ルールと境界の出力を一緒にまとめており、素材ログと照らし合わせて見るのに便利です:

# 文件路径:examples/derive_and_boundaries.py
# 解析 JSONL 会话日志:一遍打印执行边界,一遍重建模型可见历史。
# 这是 Session.deriveMessages() 的简化教学模型;真实实现是缓存的且返回冻结消息。
import json
import sys


def derive_messages(events):
    """只投影 surface 事件,模拟 deriveMessages 的投影规则。

    - user/message      -> user 消息
    - assistant/message -> assistant 消息(空内容跳过)
    - tool/result       -> 携带 tool-result 块的 user 消息
    - assistant/chunk / tool/call / turn/* / step/* 不投影
    """
    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            content = d["message"]["content"]
            if not content:          # 空内容不进提供方 transcript
                continue
            messages.append({"role": "assistant", "content": content})
        elif t == "tool/result":
            messages.append({
                "role": "tool",
                "name": d["message"]["toolName"],
                "content": d["message"]["content"],
            })
    return messages


def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # 第一遍:打印执行边界,理解 turn 与 step 的嵌套关系。
    for ev in events:
        d = ev["data"]
        t = ev["type"]
        if t == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif t == "turn/end":
            print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
        elif t == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif t == "step/end":
            print(f"  [step/end] turn={d['turn']} step={d['step']}")
        elif t == "assistant/chunk":
            print(f"  chunk: {d['chunk']['type']}")
        elif t == "tool/call":
            print(f"  tool/call: {d['name']} args={d['arguments']}")

    # 第二遍:重建模型可见的派生历史。
    print("\n模型可见的派生消息:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")


if __name__ == "__main__":
    main(sys.argv[1])

素材にあるあのログをこのスクリプトに食わせると、投影後の履歴では chunk の text-deltatool-call-delta が消え、tool/call も消えて、user メッセージ、assistant メッセージ、tool 結果の三つだけが残ることがわかる。これこそが「組み立て後のメッセージこそが権威」ということを直接示している。

キャッシュと凍結:surface ノードは初回出現時に一度投影され、書き換え時に再構築され、型上は履歴を変更できない

投影ロジックの説明は済んだが、まだ避けて通れないエンジニアリング上の問題がある:性能と安全性だ。ログはどんどん長くなる。もし誰かが「モデルは何を見ているか」と問うたびに、すべてのイベントを最初から再投影していたら、数千のイベントを持つセッションでは、履歴取得のたびに O(n) になってしまう。dsh の deriveMessages はどう処理しているのか?その答えは次の二文に隠されている:それはキャッシュされる;それは凍結されたメッセージ配列を返す。

まずキャッシュを見る。各 surface ノードは初回出現時に一度投影され、surface の書き換え時に再構築される。どう理解すればよいか?派生履歴は本質的に、一連の surface ノード(user/message、assistant/message、tool/result の数種類)からなる順序付きシーケンスだ。初めて派生結果が必要になったとき、システムはログを走査し、これらの surface ノードを一つずつメッセージへ投影し、その結果をキャッシュする。その後ふたたび deriveMessages を呼ぶと、直接キャッシュにヒットし、再計算は不要だ。

では「surface の書き換え」とは何か?ログは追記専用なのだから、履歴は変更されるべきではないのでは?鍵はここにある——ログは不変だが、表面(surface)は後続イベントによって「書き換え」可能なビューである。たとえば一部のシナリオでは、後続イベントが「以前のあのメッセージのある属性が修正/補足された」ことを表すことがある。このときキャッシュは全量再計算ではなく、影響を受ける部分だけを再構築する。これにより増分更新のコストは O(n) からほぼ O(変更量) へと下がる。これは典型的な「不変ログ + 可変ビュー + キャッシュ」の三層アーキテクチャだ:最下層の不変性が真のソースの信頼性を保証し、中間層のビューが進化を許容し、キャッシュ層が性能を保証する。

次に「凍結」を見る。deriveMessages が返すのは凍結されたメッセージ配列だ。これは、呼び出し側が受け取った後に変更できないことを意味する——push もできなければ、ある要素のフィールドも変更できない。なぜそうするのか?なぜなら「投影を通じて記録済みの履歴を変更することは、型上表現できない」からだ。この一文は非常に精妙で、何度も噛みしめる価値がある:

  • 派生履歴はログの投影であり、その権威性はこの配列自体ではなくログに由来する。
  • もしこの配列を変更できてしまうと、「モデルが見る履歴」と「ログに記録された履歴」が分岐し、唯一の真源という地位は一瞬で崩壊する。
  • したがって設計上、直接型レベルで変更を禁止する——配列は frozen であり、書き込み操作は実行時にエラーを投げ(strict モードでは)、型上も可変インターフェースを公開しない。

この制約の意義は、「やってはいけないこと」を「できないこと」に変える点にある。ドキュメントに「派生履歴を変更しないでください」と十回書くよりも、型と実行時のレベルでそれを直接不可能にするほうがよい。これはまさに dsh の可観測性設計の全体的な気質である。人の自律性に頼るのではなく、仕組みによって正しさを保証する。Agent フレームワークを書く人にとって、これはそのまま参考にできるパターンだ——不変の真実源、キャッシュ投影、凍結出力、この三点セットで速くかつ安定する。

空内容 assistant/message の二重の身分:usage と提供方モデルを記録するが、提供方 transcript には入らない

設計の細やかさを特に示す境界ケースがある:内容が空の assistant/message だ。それは存在しつつ、「存在しない」。この二重の身分は個別に取り上げて語る価値がある。

まず「存在する」側面を見よう。assistant/message イベントは、成功した提供方呼び出しのたびに記録される。これには空内容を返した呼び出しや max-tokens で終了した呼び出しも含まれる。つまり、モデルが max-tokens の上限に達して切り詰められ、空内容の応答を返した場合でも、dsh は依然として assistant/message イベントを一件記録する。なぜか?usage、提供方、モデル情報を保存する必要があるからだ。素材内の assistant/message には usage: { inputTokens: 12, outputTokens: 4 } が付いており、これらの課金とクォータの情報は必ず永続化しなければならない。そうでなければコスト計算も token 予算もできない。たとえ内容が空でも、この呼び出しの「勘定」を落とすわけにはいかない。

次に「存在しない」側面を見よう:内容のない assistant ターンは提供方 transcript に入ってはならない。つまり、派生メッセージ履歴を作る際、この空内容の assistant/message はスキップされ、空の assistant メッセージとして次のモデルリクエストに現れることはない。なぜか?ほとんどの提供方の API は空内容の assistant メッセージを受け付けないか、その挙動が一貫していないためだ。それを押し込めば、軽ければ token の無駄、重ければエラーになる。さらに重要なのは意味論的な正しさである——切り詰められて出力のないターンを、モデルが「自分が空っぽの一言を言った」とみなすべきではない。

この入りと出の取捨は、dsh の中核的な区別を完璧に示している:「ログに記録すること」と「モデルビューに入ること」は別の事柄だ。ログは完全性と監査可能性に責任を持つ(usage は残す)。投影はモデル契約とクリーンさに責任を持つ(空メッセージはフィルタする)。素材内のあの総括は極めて的確だ:「空内容は派生履歴に入らないが、その永続イベントは依然として用量を保持する。」

もう一つ関連する細部がある:この assistant/message は sourceEventSeqs を通じて、対応する assistant/chunk イベントを正確に列挙する。明示的な空リストも含めて。「明示的な空リスト」という四文字は非常に重要だ——それは、ある assistant/message に対応する chunk が一切ない場合(たとえば組み立て済みのメッセージを直接返した場合)でも、システムが空配列を書いて「確かに対応する chunk がない」ことを表現し、このフィールドを省略しないことを意味する。なぜ明示にこだわるのか?「ない」と「フィールド欠落」をリプレイ時に区別する必要があるからだ。フィールドを省略すると読み手は「データが失われたのではないか」と疑うが、明示的な空配列は「今回は確かに元の分片がない」ことを明確に表現する。これは可観測性設計において「未知」と「空」を厳密に区別するもう一つの例証である。

この部分のルールを一枚の対照リストにまとめます:

シナリオassistant/message イベントに書き込むか派生履歴に入るかusage を保持するか
正常に内容を返すはいはいはい
max-tokens で切り詰められ内容なしはいいいえはい
プロバイダが空の内容を返すはいいいえはい
tool/result でエラー該当なし(tool/result の error フィールドで処理)はい(isError 付き)該当なし

この「空メッセージの二重の身分」というルールは、実際のシナリオでのはまりどころを避けるのに役立ちます。token コスト分析を行う際、派生後の messages だけを見ていると、切り詰められた空の呼び出しを見落としてしまいます。ログ層に戻って初めて、完全な usage 統計を取得できます。改めてあの言葉を裏付けます——完全な統計とリプレイを行うなら、session/event イベントストリームを消費すべきで、派生結果だけを見てはいけません。

イベント三領域の選定ガイド:セッションイベント、Agent イベント、能力イベントそれぞれの永続性とインターセプトのタイミング

最後の基盤は、イベント領域の区分です。公式ドキュメントではイベントを三領域に分けており、それぞれに用途があります。正しいイベント領域を選ぶことは、ほとんどの変更における最初の決定です。この言葉は拡張を行う開発者にとって特に重要です——どの領域にイベントを追加するかが、その永続性、インターセプト可能性、作用範囲を直接決定します。

まず対照表を出します。これは本节で最も保存しておくべきものです:

イベント領域代表イベント特性いつ使うか
セッションイベントturn/start、step/start、user/message、assistant/*、tool/call、tool/resultログに追記されブロードキャストされる、永続的な事実ある事実が再読み込み後も存在し続ける必要があるとき
Agent イベントagent/pre-step、agent/request、agent/status、agent/turn-stoppingアクティブな Agent を伴い、リアルタイムの制御と状態進行中の作業を観察またはインターセプトするとき
能力イベントtools/*、fs/*、llm/streamインポート循環なしに seam へポリシーを付加する能力 seam にポリシーとアダプタを掛けるとき

ドメインごとに分解:

  1. セッションイベント(Session イベント)。代表例は turn/start、step/start、user/message、assistant/*、tool/call、tool/result。これらの核心的な特徴はログに追記されブロードキャストされる、永続的な事実であること。判断基準は明確で、ある事実が再読み込み後もなお存在しなければならないなら、それはセッションイベントとしてモデリングすべきである。例えば「ユーザーが何を送ったか」「モデルが何を返したか」「ツールが何を返したか」は、いずれも必ず永続化され、必ずリプレイ可能でなければならない永続的な事実である。これらはリプレイと監査のあらゆる原料の供給源を担っている。
  2. Agent イベント。代表例は agent/pre-step、agent/request、agent/status、agent/turn-stopping。これらの特徴はアクティブな Agent を伴い、リアルタイムの制御と状態に用いられること。使用シーンは進行中の作業を観察またはインターセプトすることである。例えば agent/pre-step はモデルが何を見るかを決定する——リスナーは取得済みのメッセージを書き換えることも、それらを直接拒否することもできる。この種のイベントが関心を向けるのは「今この瞬間に何が起きているか、自分が介入すべきか」であり、「歴史的に何が起きたか」ではない。素材が特に言及しているタイミングの詳細に注意:最初の取得が拒否されたり空に書き換えられたりした場合でも、ステップを含まない永続的なターンが閉じられるため、ログにはこの試行が記録される。つまり、たとえ入力を1件インターセプトし、何も実行しなかったとしても、システムはステップなしの永続的なターン記録を残す——「試行した」こと自体もまた、痕跡を残さなければならない事実である
  3. 能力イベント。代表例は tools/*、fs/*、llm/stream。これらの特徴はインポート循環なしに seam へポリシーを付加すること。使用シーンは能力 seam にポリシーとアダプターを掛けることである。この種のイベントの価値は「seam」——能力の接ぎ目——にある。ツール呼び出しに権限チェックの層を加えたい、ファイルシステムにサンドボックスの層を加えたい、LLM のストリーミング出力に監査の層を加えたいなら、いずれもセッションイベントを変更するのではなく、能力イベントを通じてマウントすべきである。これらの設計目標はモジュール間のインポート循環を避けることであり、ポリシーがプラグイン方式で能力の境界に attach できるようにすることである。

ここで、つまずきやすいメカニズムの差異をもう一つ強調しておく:waterfall と serial イベントの違いである。素材は明確に指摘している——agent/pre-step、agent/request、llm/stream および3つの tools/* イベントは waterfallであり、リスナーはnext() を呼び出さなければ委譲できない。一方、agent/turn-stopping は serial イベントであり、next() を持たない

  • waterfall(ウォーターフォール):複数のリスナーが順番に一本のチェーンとして連結され、各リスナーは処理を終えた後に next() を呼び出して制御を次に渡す。これにより各リスナーに「書き換え、短絡、バイパス」の権限が与えられる——next() を呼ばなければ後続の処理を遮断することになる。戦略インターセプトのシナリオ(例えば pre-step でメッセージを書き換えるなど)に適している。
  • serial(シリアル):リスナーは順番に実行されるが、next() の委譲セマンティクスはなく、「チェーンの遮断」という概念も存在しない。通知型のシナリオ(例えば turn-stopping 時の状態クリーンアップなど)に適している。

「どのドメインを選ぶか」と「どのディスパッチ方式を使うか」を組み合わせることで、拡張開発の意思決定ツリーが構成される:永続的な記録を残したい → セッションイベント;実行中の処理をリアルタイムでインターセプトしたい → Agent イベント(しかも多くは waterfall、next() を忘れずに);能力の境界に戦略をマウントしたい → 能力イベント(同様に next() に注意)。waterfall の落とし穴を踏んだことのある人なら誰でも知っているように、next() を忘れると後続のリスナーがサイレントに実行されなくなり、「なぜ自分のプラグインが効かないのか」という症状が現れるが、ログには一切の異常も出ない——これは初心者に最もよくある「ゴーストバグ」である。

振り返ってフロー図を一通り辿ると、理解がより完全になる。公式のシーケンス図では完全なフローが次のように描かれている:turn/start → agent/pre-step → step/start → llm/stream → ツール → step/end → turn/end。このうち turn/start、step/start、step/end、turn/end はセッションイベント(永続境界)、agent/pre-step は Agent イベント(リアルタイムインターセプトポイント)、llm/stream は能力イベント(戦略マウントポイント)である。三つのドメインが同一のタイムライン上でそれぞれの役割を果たし、全体の可観測性の骨格を共に支えている。

ここまでで、第 1/2 段の三つの基盤が敷き終わった:セッションは追記専用ログであり、seq と time が座標系を構成する;メッセージ履歴は投影であり、単独では保存されず、リプレイ即ち再投影である;イベントは type によって識別可能なユニオンを構成し、data は append の時点で無損失 JSON が強制される;「モデルに見えるものは既に記録されている」は違反できない実行時不変量である;deriveMessages はルールテーブルに従って投影し、キャッシュを持ち、凍結された配列を返す;空内容の assistant/message は usage を保存するが transcript には入らない;三ドメインの選定が、あなたの変更が永続化できるか、インターセプトできるかを決定する。続く第 2 段では、これらのメカニズムを実際のエンジニアリングに持ち込む——一份の JSONL ログから出発して、軌跡のリプレイを実践し、派生ビューと元のイベントの差異を比較し、リプレイ時に最も厄介な境界ケース(例えば sourceEventSeqs の欠落、surface の書き換えによるキャッシュ無効化など)を処理する。今すぐ手を動かしたいなら、上の Python スクリプトを保存して、自分のセッションログを一份見つけて実行してみるとよい。まず「同一のログ、二つのビュー」とは一体どのような体験なのかを感じてみよう。

前回は「モデルが見ている会話履歴は一体どこに存在するのか」という問いから出発し、Session という追記専用イベントログが唯一の真実の源であること、deriveMessages() がどのようにログをモデル可視の Message[] に射影するか、そしてイベント三領域の基本的な役割分担を分解しました。このパートではさらに深掘りしていきます:seam 上でのイベント伝播セマンティクス、ターンとステップの境界定義、公式シーケンス図の完全な流れ、そして turn/end、tool/call、tool/result といった重要イベントの具体的なフィールド、最後に実行可能な JSONL リプレイスクリプトと可観測性エンジニアリングの実践に落とし込みます。

waterfall と serial:agent/pre-step、agent/request、llm/stream と tools/* は必ず next() を呼ぶ必要がある

ターンライフサイクルを理解するには、まずイベントが seam(ケイパビリティ接縫)上でどのように伝播するかを理解する必要があります。DeepSeek Harness のイベントは「ブロードキャストしたら終わり」ではありません。その一部は waterfall イベントです——これらはリスナーチェーンに沿って順に下流へ伝わり、各リスナーは明示的に next() を呼び出さなければ制御権を次のリスナーに委譲できず、そうでなければチェーンはその場で切断されます。公式ドキュメントによると、委譲のために next() を呼ぶ必要がある waterfall イベントは以下の通りです:

  • agent/pre-step:モデルリクエスト前の最後の書き換え関門であり、モデルが最終的に何を見るかを決定します。
  • agent/request:リクエスト送信前のインターセプトポイントであり、ポリシーの付加やリクエストの書き換えに使用します。
  • llm/stream:ストリーミングレスポンス経路上の中間环节であり、ストリーミング出力の観測や書き換えに使用します。
  • tools/\*:三つのケイパビリティイベント(tools 領域の一連のイベント)であり、ツール seam にポリシーとアダプターを掛けるために使用します。

これに対して serial イベントがあります:next() を持たず、委譲チェーンを構成しません。典型的な代表は agent/turn-stopping です——ターンがまもなく停止する際に発火し、状態の収尾と観測に使用され、フローをインターセプトしたり書き換えたりするものではありません。この区別は非常にハマりやすいポイントです:もし agent/turn-stopping を waterfall と誤って next() を呼び出すと、実行時に直接エラーが発生します。逆に、agent/pre-step で next() を呼び忘れると、チェーンは静かに切断され、後続のリスナーは実行されなくなり、モデルは事前に「凍結」されたリクエストを受け取る可能性があり、調査が非常に困難になります。

イベントドメイン伝播セマンティクスnext() が必要か典型的な用途
agent/pre-stepAgent イベントwaterfall必ず呼び出す取得済み入力を書き換えまたは拒否し、モデルに可視な内容を決定する
agent/requestAgent イベントwaterfall必ず呼び出すリクエスト送出前にポリシーを付加し、リクエスト形態を観測する
llm/stream能力イベントwaterfall必ず呼び出すストリーミング経路の観測と適応
tools/*能力イベントwaterfall必ず呼び出すツール seam にポリシーとアダプタを接続する
agent/turn-stoppingAgent イベントserialnext() はないターン停止前の状態収束と観測

エンジニアリング的な書き方の一つは、「必ず next() を呼び出す」という事実を型システムや lint ルールに取り込むことです。waterfall リスナーのシグネチャでは next が必須パラメータであるため、呼び忘れは TypeScript では多くの場合型チェックを通過してしまいます(単に使っていないだけだからです)が、実行時にはバグになります。実践的な方法は、すべての waterfall リスナーに統一的な約束を設けることです:あらゆる早期リターンは、明示的な「短絡判断」に対応していなければならず、「委譲を忘れた」ものであってはなりません。以下の TypeScript は、waterfall と serial を同じ seam 上で登録する際の違いを示しており、そのまま接続テンプレートとして利用できます:

// 注册一个 waterfall 监听器:必须调用 next() 才能委托下去
agent.on("agent/pre-step", async (event, next) => {
  const claimed = event.data.messages;

  // 决策一:直接拒绝本次领取,短路整条链
  if (shouldReject(claimed)) {
    return { rejected: true };
  }

  // 决策二:改写已领取的消息,再委托给下一个监听器
  const rewritten = claimed.map((m) => rewrite(m));
  return next({ ...event, data: { ...event.data, messages: rewritten } });
  // 注意:这里若忘了 return next(...),链会静默截断
});

// 注册一个 serial 监听器:没有 next(),不要试图委托
agent.on("agent/turn-stopping", (event) => {
  recordTurnStopping(event.data.turn, Date.now());
  // 这里没有 next 参数,也不应该出现 next()
});

ターンとステップの境界定義:1回のモデルリクエストとそのツール呼び出しで、ターンは1回のモデルループを囲む

伝播セマンティクスを明確にすれば、境界は自然に決まる。公式ドキュメントが与える定義は非常に抑制的である:

  • 1つのステップ(step)は、1回のモデルリクエストと、そのリクエストが呼び出したツールである。
  • 1つのターン(turn)は0個以上のステップを含む:最初の入力を取得する前に開かれ、もはや負うべき作業がなくなった時点で閉じられる。

最も誤解されやすい点は「ターンは1回のモデルループ実行を囲むのであって、セッションログ全体を囲むのではない」ということである。つまり、turn は「1回のセッション」の同義語ではなく、セッションログ上の1つの実行境界にすぎない;1回のセッションは多くの turn を持ちうるし、1つの turn は0個の step しか持たないこともある(例えば入力が pre-step 段階で拒否された場合)。ログがこれら2層の境界を両方記録するのは、リプレイ時に「どのモデルリクエストがどのコンテキストで発生したか」を正確に再現できるようにするためである。

なぜ step の定義で「それと呼び出すツールを加えたもの」を強調するのか?なぜなら step は「1回の LLM 呼び出し」ではなく、「1回のモデルリクエストとそれが引き起こすツール往復」という閉ループだからである。モデルリクエストが tool_use を返し、システムがツールを実行し、tool/result を user ロールとして戻し、この一連の動作が全体として同じ step に属する;次のモデルリクエストで新たな step が開かれる。これが、ログにおいて step/start と step/end の間に assistant/message もあれば tool/call と tool/result もある理由を説明する。

示意图
セッションログ上における turn と step のネスト境界:turn/start がターンを開き、その内部に0個以上の step を含み、各 step は1回のモデルリクエストとそのツール呼び出しで構成され、最後に turn/end で閉じられる。

定義をログイベントに落とし込むと、境界は一対一に対応するイベントペアの集合になる。以下の表はエンジニアリングで最も頻繁に扱うフィールドの早見表である:

イベント運搬するデータ説明
turn/start{ turn }loop がキュー内の入力を引き受けるか pre-step を実行する前にターンを開く
turn/end{ turn, reason }TurnEndReason でターンを閉じる(completed / aborted / blocked / error / max-tokens / interrupted)
step/start{ turn, step }あるターン内の1つのステップを開く
step/end{ turn, step }そのステップを閉じる

注意:turn/start は { turn } のみを持ち、turn/end になって初めて { turn, reason } を持つ。この非対称性は意図的なものだ。ターン開始時には結末をあらかじめ決めず、閉じる瞬間になって初めて、システムはそれが完了・中止・ブロック・エラー・max-tokens による打ち切り・割り込みのいずれによって終了したのかを知る。これはつまり、リプレイ時に turn/start だけを見て対になる turn/end がなければ、その実行は「正常に終わらなかった」と判定するしかないということでもある。

公式シーケンス図を一通り辿る:turn/start → agent/pre-step → step/start → llm/stream → ツール → step/end → turn/end

公式シーケンス図は、一回の完全なフローをこのチェーンとして描いている:

turn/start → agent/pre-step → step/start → llm/stream → ツール → step/end → turn/end

順に読んでいこう。turn/start は、loop がキューされた入力を引き取るか pre-step を実行する前にターンを開く。これが実行境界全体の起点だ。続いて agent/pre-step に入る。これはモデルリクエスト前の最後の意思決定点であり、引き取り済みのメッセージを書き換えることも、そのまま拒否することもできる。意思決定が通ると、step/start がそのターン内の最初のステップを開き、llm/stream がストリーミング応答を担う(token レベルの断片は assistant/chunk に落ちる)。モデルがツール呼び出しを起こした場合は「ツール」の区間に入る——tool/call がリクエストを記録し、ツールが実行され、tool/result がモデルから見える結果を記録する。ツールの往復を消費し終えると、step/end が現在のステップを閉じる。モデルに後続の動作があれば、さらに次の step/start を開く。もはや未処理の作業がなくなると、turn/end がターンを閉じる。

ここには入力側の非常に重要な細部がある:入力は同じ一つの inbox を通ってドライバーに到達する。 各入口が個別にキューを通るのではなく、統一的に一つの inbox に入る。そのうち「一部のメッセージはドライバーを即座に起床させる」、例えば直接のユーザープロンプトである。一方で「注入されたコンテキストは、別のメッセージがそれを起床させるまで inbox に留まる」。この区別は可観測性に大きな影響を与える。ログで見る user/message イベントは、直接のプロンプト由来かもしれないし、注入コンテキスト、steering、リアルタイム受信箱イベント由来かもしれず、それらは同一の識別子付き値型 UserMessage を共有している。リプレイ時に content だけを見ると、あるメッセージが「ユーザーが自ら語ったもの」なのか「システムが注入したもの」なのかを誤判定してしまう。幸い、各 user/message には識別子が付いており、surfaceOp と組み合わせて初めて、それが派生 surface に入る方式を復元できる。

このシーケンスチェーンを可観測性の視点に翻訳すると、三種類の観測点に分解できる:

  1. 境界観測:turn/start、step/start、step/end、turn/end。実行構造と所要時間を復元するために使う。
  2. 内容観測:user/message、assistant/chunk、assistant/message、tool/call、tool/result。モデルが見たものと産出したものを復元するために使う。
  3. 制御観測:agent/pre-step、agent/request、agent/status、agent/turn-stopping。進行中の作業を観察または遮断するために使う。

これら三種類の観測点は、それぞれセッションイベント、Agent イベント、能力イベントの三領域に対応する。セッションイベントは永続的な事実であり、ログに追記されブロードキャストされるため、「再読み込み後も依然として存在する」ことに適する。Agent イベントはアクティブな Agent を伴い、リアルタイム制御と状態に適する。能力イベントは、インポート循環なしに seam へポリシーを付加するために用いられる。正しいイベント領域を選ぶことは、ほとんどの変更における最初の決定である。

agent/pre-step がモデルの見るものを決める:書き換え、拒否、そして「空のターンでもログに残す」

agent/pre-step はライフサイクル全体の中で最も畏敬に値する一环である。なぜなら、それはモデルが最終的に何を見るかを決めるからだ。リスナーはここで二種類の動作を行う:すでに取得されたメッセージを書き換えるか、それらを直接拒否するかである。書き換えとは、モデルが見る前にコンテキストを挿入、削除、置換できることを意味する。拒否とは、今回の取得が発生せず、モデルがこのバッチの入力を一切受け取らないことを意味する。

公式ドキュメントには、見落とされがちだが極めて重要なルールが一つある:最初の取得が拒否されたり、空に書き換えられたりした場合でも、ステップを含まない永続的なターンが閉じられるため、ログにはこの試行が記録される。 言い換えれば、「何も起こらなかった」ことも痕跡を残す。これは、セッションログを唯一の真実の源とする設計哲学に直接呼応するものであり、「モデルに可視であることはすでに記録済みである」という不変条件の延長でもある。モデルリクエストに到達したすべてのものはログから再構築できなければならず、逆に言えば、拒否された試行も完全な対話履歴の一部であり、跡形もなく消えてはならない。

このルールのトラブルシューティングにおける価値は極めて高い。あるシナリオを想像してみよう:ユーザーが「README の typo を直して」と言ったが、あなたのポリシー層が pre-step で権限やガードレールのために入力を拒否した。もし「空のターンをログに残す」がなければ、ログには何も表示されず、ドライバーがメッセージを受け取っていないと誤解するだろう。しかしこのルールがあれば、ログには turn/start と turn/end のペアが現れ、その間に step/start は一切なく、reason はおそらく blocked の档位に落ちる。これが「ステップを含まない永続的なターン」の意義である:それは「拒否された試行」を、ブラックホールに消えるのではなく、クエリ可能な事実に変える。

pre-step と対をなすのは、前述した二種類のイベントセマンティクスである:agent/pre-step、agent/request、llm/stream、そして三つの tools/* イベントはすべて waterfall であり、リスナーは next() を呼び出さなければ委譲できない。agent/turn-stopping は serial イベントであり、next() を持たない。したがって、堅牢な pre-step リスナーは三つの出口を明示的に表現すべきである:委譲(return next(...))、書き換え後の委譲、拒否によるショートサーキット。書き換えを空にすることと直接拒否することの違いについては、可観測性の観点から見ると、両方とも「ステップを含まないターン」を生成するが、セマンティクスは異なる——一方は「ポリシーフィルタ後に内容がない」であり、もう一方は「明確な拒否」である。reason とあなたの業務ログを組み合わせて判断する必要がある。

turn/end の TurnEndReason 全档位:completed / aborted / blocked / error / max-tokens / interrupted

turn/end は { turn, reason } を伴い、reason は離散的な TurnEndReason です。これを一つずつ分解していけば、ほぼターンの状態遷移の全体像をカバーできます:

reason の区分意味典型的な原因調査のヒント
completed正常完了未処理の作業がなくなり、モデルループが自然に収束健全なパスであり、ベースライン比較に利用可能
aborted中止された外部からのキャンセルまたは能動的な停止「誰がキャンセルを発起したか」を区別し、呼び出し元のログと照合する
blockedブロックされたポリシーやガードレールによる遮断、pre-step の拒否によく見られるステップを含まない空のターンを伴うことが多い
errorエラー発生実行過程で例外がスローされたerror と上流ツールのログを組み合わせて特定する必要がある
max-tokens打ち切られたmax-tokens の上限に到達使用量を保存するため、内容のない assistant/message が残る
interrupted中断された実行が外部から割り込まれたaborted との区別:中断はよりランタイムの割り込みに近い

turn/start の { turn } というデータ形態と合わせて見ると、ターンの状態遷移は非常にはっきりします:turn/start は「N 番目のターンが始まった」ことだけを宣言し、いかなる予期される結末も伴いません;turn/end になって初めて reason によって確定します。したがってログでステートマシンを再構築する場合、正しいやり方は per-turn の状態マッピングを維持し、turn/start に遭遇したら「進行中」に設定し、turn/end に遭遇したら reason に従って終端状態にし、start だけで end がない宙ぶらりんのターンに遭遇したら個別にアラートを出すことです。

そのうち max-tokens の区分には非常に具体的なフィールドレベルの詳細があり、素材は明確に述べています:max-tokens による打ち切りで内容のないステップであっても、使用量・プロバイダ・モデルを保存するために assistant/message を一件記録するが、内容のない assistant ターンはプロバイダの transcript に入れてはならない。 つまり、この assistant/message イベントは実在する永続イベントであり、その役割は usage、provider、model といったメタデータを保持することです;しかしそれがモデル履歴に投影される際にはスキップされます——内容が空の assistant/message もスキップされます。これは典型的な「永続性と投影性の分離」設計です:ログはすべての事実を記録する責務を負い、派生履歴はモデルに見せるべきものだけを見せる責務を負います。

tool/call と tool/result のフィールド詳細:callId、生の JSON 文字列 arguments、sourceEventSeqs による正確なトレースバック

ツールの往復は Agent 実行の中で最も問題が起きやすい箇所であるため、フィールド設計はかなり抑制的かつトレースバック可能になっています。まず tool/call を見てみましょう。これは { turn, step, callId, name, arguments } を保持します。ここで最も重要な点は——arguments はモデルが生成した生の JSON 文字列であり、すでにオブジェクトにパースされた構造ではないということです。これは非常に重要です。モデルが生成する JSON は不正な形であったり、末尾にカンマが付いていたり、ストリーミングの断片化によって分割されたりする可能性があります。生の文字列を保持するということは、トラブルシューティング時にモデルが「実際に吐き出したもの」を見ることができるということであり、パース失敗後の空白ではありません。パースはあなたの仕事であり、ログの仕事ではありません。

tool/call 内の callId はリクエストと結果を結ぶ鍵です。tool/result は { turn, step, message, error?, meta? } を保持し、そのうち message は「一度完了したツール呼び出しのモデル可視結果」であり、error? と meta? はオプションフィールドで、それぞれエラー情報と付加メタデータを担います。callId によって「どの呼び出しがこの結果を生成したか」を正確に対応付けることができ、特に同じ step 内で複数のツール呼び出しを並行して発行する場合、callId がなければ対応関係を復元できません。

次に sourceEventSeqs を見てみましょう。assistant/message イベントは sourceEventSeqs を通じて対応する assistant/chunk イベントを正確に列挙し、明示的な空リストを含みます。この「明示的な空リスト」という詳細は個別に強調する価値があります。これは「この assistant/message には対応するストリーミング断片が存在しない」ということが明示的に記録された事実であり、「書かれていないから不明」ではないということを意味します。可観測性において、明示的な空値と欠損値はまったく異なるセマンティクスです——前者は「既知の空」、後者は「未知」です。リプレイチェーンがこの二つの状況を区別できて初めて、ある組み立てメッセージが本来ストリーミング由来であるべきかどうかを正しく判断できます。

イベント主要フィールドフィールドのセマンティクスなぜ重要か
tool/callcallId今回のツール呼び出しの識別子tool/result と正確にペアリングし、並行呼び出しの復元を支える
tool/callargumentsモデルが生成した生の JSON 文字列不正/未パースの形態を保持し、デバッグを容易にする
tool/resultmessageモデル可視のツール結果ツール結果は user ロールとしてモデルに戻る
tool/resulterror? / meta?オプションのエラーとメタデータ失敗結果と付加コンテキストを区別する
assistant/messagesourceEventSeqs対応する assistant/chunk のシーケンス番号リスト組み立てメッセージの由来断片を正確にトレースバック、明示的な空リストを含む

もう一つ、投影レイヤーの細かい点があります。tool/result は「tool-result ブロックを持つ 1 つの user メッセージ」に投影されます。つまり、ツール結果は user ロールとしてモデルに戻ります。一方、assistant/chunk は投影時にスキップされます。これは再生/UI データに属し、組み立て後の assistant/message が正となるためです。tool/call もメッセージには投影されません。これは構造化された事実に属します。これらの「どのイベントが投影され、どれが投影されないか」を理解することが、リプレイヤーを書く前提となります。

JSONL セッションログを実際に解析する:surfaceOp マーカーと投影されない境界イベント

以下では、実際の形態のセッションログを用いて、これまでのルールをつなげて説明します。素材として、「runoob リポジトリの typo を修正する」タスクからの JSONL(正規化されたパッケージ行レイアウト)のごく一部が与えられています。各行は 1 つの SessionEvent であり、type、seq、time、data を含みます:

{"type":"turn/start","seq":0,"time":1755000000000,"data":{"turn":1}}
{"type":"step/start","seq":1,"time":1755000000010,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":2,"time":1755000000020,"data":{"role":"user","content":[{"type":"text","text":"Fix the typo in the runoob README."}]},"surfaceOp":"append","sourceEventSeqs":[0]}
{"type":"assistant/chunk","seq":3,"time":1755000000030,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","text":"I'll "}}}
{"type":"assistant/chunk","seq":4,"time":1755000000040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","name":"bash","arguments":"{\"command\":\"grep runoob README.md\"}"}}}
{"type":"assistant/message","seq":5,"time":1755000000050,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I'll search"},{"type":"tool_use","id":"call_1","name":"bash","input":{"command":"grep runoob README.md"}}]},"usage":{"inputTokens":12,"outputTokens":4}},"surfaceOp":"append","sourceEventSeqs":[3,4]}
{"type":"tool/call","seq":6,"time":1755000000060,"data":{"turn":1,"step":1,"callId":"call_1","name":"bash","arguments":"{\"command\":\"grep runoob README.md\"}"}}
{"type":"tool/result","seq":7,"time":1755000000070,"data":{"turn":1,"step":1,"message":{"role":"tool","toolName":"bash","content":"runoob","isError":false}},"surfaceOp":"append","sourceEventSeqs":[6]}
{"type":"step/end","seq":8,"time":1755000000080,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":9,"time":1755000000090,"data":{"turn":1,"reason":{"kind":"completed"}}}

このログには、一つずつ確認する価値のあるいくつかの規則が隠れている。第一に、seq は単調増加である。素材では seq = log.length と明示されており、つまり番号自体がログ長であり、自然に連続し、自然に一意である。time は epoch ミリ秒である。第二に、user/message、assistant/message、tool/result の3種類の surface イベントには surfaceOp マークが付く。これはそれらがどのように派生 surface に追加されるかを示している(ここではすべて "append")。第三に、turn/start、step/start などの境界イベントは surfaceOp を持たず、モデルメッセージにも投影されない——それらは構造情報であり、内容ではない。

次に sourceEventSeqs の使い方を見る。user/message(seq 2)の sourceEventSeqs は [0] で、turn/start を指す。assistant/message(seq 5)の sourceEventSeqs は [3, 4] で、ちょうど2つの assistant/chunk に対応する。tool/result(seq 7)の sourceEventSeqs は [6] で、tool/call を指す。この遡及チェーンにより、「組み立て結果」と「元のチャンク/呼び出し」の対応関係が完全に追跡可能になる。

続いて、このログを Python で再生し、会話を再構築して turn/step の境界を示す。これは Session.deriveMessages() の簡略化された教育用モデルである。実際の実装はキャッシュされる——各 surface ノードは初回出現時に一度だけ投影され、surface の書き換え時に再構築される——そして凍結されたメッセージ配列を返し、投影を通じて記録済みの履歴を変更することは型上表現できない:

# ファイルパス:examples/parse_session_log.py
# JSONL セッションログを解析し、モデルから見える会話を再構築して turn/step 境界を示す。
import json
import sys

def derive_messages(events):
    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            if d["message"].get("content"):
                messages.append({"role": "assistant", "content": d["message"]["content"]})
        elif t == "tool/result":
            messages.append({"role": "tool", "name": d["message"]["toolName"], "content": d["message"]["content"]})
        # turn/*, step/*, assistant/chunk, tool/call はメッセージに投影されない
    return messages

def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # 1回目:実行境界を出力し、turn と step のネスト関係を理解する。
    for ev in events:
        d = ev["data"]
        if ev["type"] == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif ev["type"] == "turn/end":
            print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
        elif ev["type"] == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "step/end":
            print(f"  [step/end] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "assistant/chunk":
            print(f"  chunk: {d['chunk']['type']}")
        elif ev["type"] == "tool/call":
            print(f"  tool/call: {d['name']} args={d['arguments']}")

    # 2回目:モデルから見える派生履歴を再構築する。
    print("\nモデルから見える派生メッセージ:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")

if __name__ == "__main__":
    main(sys.argv[1])

これを examples/parse_session_log.py として保存し、session.jsonl を用意して、そのまま実行します:

python examples/parse_session_log.py session.jsonl

ここで説明すべき教育的な簡略化が 2 点あります。第一に、実際の実装はキャッシュされており、凍結されたメッセージを返します:surface ノードが初めて出現したときに一度だけ投影し、書き換え時に再構築します。返される Message[] は型レベルで変更不可であり、投影を通じて記録済みの履歴を書き換えることを防ぎます。第二に、サンプルでは assistant/message に対して追加で「内容が空ならスキップする」という判定を入れています。これは素材が、内容が空の assistant/message はスキップされると明示しているためです——ただしそのイベント自体はログに残り、usage、provider、model を保存するために使われます。実装で無思考に append してしまうと、空の内容をモデル履歴に流し込むことになり、これこそが max-tokens シナリオで最もよくある投影バグです。

もう一つ、エンジニアリング上の落とし穴を注意喚起しておきます:このログでは tool/result の message.role は "tool" ですが、投影ルールでは tool/result は「tool-result ブロックを持つ 1 つの user メッセージ」として投影されるべきです——ツール結果は user ロールとしてモデルに戻ります。教育用スクリプトでは出力を見やすくするために元の role を保持していますが、実際の実装では提供側が要求するロール形態で組み立てなければならず、そうでなければ次のリクエストがロール不一致で拒否されます。このような「ログ形態」と「投影形態」が一致しない箇所は、リプレイヤーを書くときに最も事故りやすいポイントです。

2026 年 9 月の最新プラクティス:セッションログを可観測性データプレーンとして消費する

2026 年 9 月になると、Agent 可観測性をめぐる明らかなトレンドは、ログを「デバッグの付属品」としてではなく、ファーストクラスのデータプレーンとして消費することです。DeepSeek Harness の設計はまさにこの方向に合致しています——session/event イベントストリーム自体がリプレイ可能な transcript データソースです。公式のガイダンスは、リプレイ可能な transcript データを必要とする SDK ユーザーは session/event イベントストリームを消費すべきというものです。素材に既にある仕組みと組み合わせることで、完全な実践チェーンを構築できます:

  1. 収集層:session/event イベントストリームを購読し、永続化時に type、seq、time、data の元の形態を保持し、特に sourceEventSeqs と surfaceOp を捨てないようにします。この 2 つは、一方が「この内容の出所」を、もう一方が「それがどのように派生 surface に入るか」を担います。
  2. 再構築層:deriveMessages と一致する投影ルールでモデル可視の履歴を再構築し、「リプレイで見えるものがモデルがその時見たもの」であることを保証します。モデル可視すなわち記録済みであるため、この再構築チェーンは原理的に完全です——モデルリクエストに到達したものはすべてログから再構築できます。
  3. 遡及層:callId で tool/call と tool/result をペアにし、sourceEventSeqs で assistant/message から具体的な assistant/chunk へ逆引きします。「なぜモデルがそう言ったのか」を調査するとき、チェーンをたどって token レベルの断片まで直接ドリルダウンします。
  4. アラート層:turn/end の reason 分布を監視し、特に error、max-tokens、blocked、および turn/start だけがあって turn/end がないハングしたターンに注目します。
  5. 拡張層:モデル可視の入力を新たに追加するなら、必ずセッションイベントも新たに追加しなければなりません——SessionEventMap を拡張し、ログからレンダリングします。この制約により、可観測面が機能イテレーションによって新しい入力を取りこぼすことがなくなります。

なぜ「新しいモデル可視入力を追加するたびに新しいセッションイベントを追加する必要がある」のかがそれほど重要なのでしょうか?それは、可観測性を「事後的な計装の追加」から「構造による強制」へと変えるからです。もし新しい入力がログを迂回した場合、それを再構築できず、リプレイが実際の実行と乖離し、ランタイム不変条件に直接違反します。イベントは type に基づく真の判別可能な共用体であるため、switch (event.type) は型アサーションなしで event.data を直接絞り込めます。また、すべての event.data はロスレスで JSON にシリアライズ可能でなければならず、Session.append がその時点でこれを強制します。つまり、あなたの可観測性パイプラインが受け取るのは常に構造化され、シリアライズ可能なイベントであり、「フォーマットの不一致」のために大量のフォールトトレラントな泥沼コードを書く必要はありません。

このチェーンを日常的なツールにする際には、少なくとも3つのクエリ機能を公開することをお勧めします:turn ごとに全イベントを取得する(1回のループを復元)、callId ごとにツールの往復を取得する(1回のツール呼び出しを復元)、sourceEventSeqs ごとにソースを逆引きする(1つのメッセージの来歴を復元)。これら3つを組み合わせることで、「トラジェクトリ・リプレイ」の最小実用セットとなります。

まとめとベストプラクティス

全文を実行可能なチェックリストに圧縮します:

  • ログこそが真のソース:Session は型付き SessionEvent からなる追記専用ログであり、agent の完全なインタラクション履歴の唯一の真のソースです。LLM メッセージ履歴はログから派生し、単独で保存されることはありません。リプレイとは、同じイベント群から履歴を再び派生させることです。
  • 不変条件を守る:「モデル可視即記録済み」——モデルリクエストに到達するすべてはログから再構築可能でなければならず、ランタイム不変条件によってアサートされます。新しいモデル可視入力を追加する場合は、SessionEventMap を拡張し、新しいセッションイベントを追加します。
  • 適切なイベントドメインを選ぶ:セッションイベント(turn/start、step/start、user/message、assistant/*、tool/call、tool/result)は永続的な事実です。Agent イベント(agent/pre-step、agent/request、agent/status、agent/turn-stopping)はリアルタイム制御に使用します。ケイパビリティイベント(tools/*、fs/*、llm/stream)は seam にポリシーとアダプタを接続するために使用します。
  • 伝播セマンティクスを区別する:agent/pre-step、agent/request、llm/stream と3つの tools/* は waterfall であり、委譲するには next() を呼び出す必要があります。agent/turn-stopping は serial であり、next() はありません。
  • 境界を明確にする:step は1回のモデルリクエストとそれが呼び出したツールです。turn は0個以上の step を含み、最初の入力を受け取る前に開かれ、これ以上負う作業がなくなった時点で閉じられ、セッション全体ではなく1回のモデルループのみを囲みます。
  • 空のターンを重視する:最初の受け取りが拒否されたり空に書き換えられた場合でも、ステップを含まない永続的なターンが閉じられ、ログにこの試行が記録されます——「何も起こらなかった」を「起こらなかった」と見なさないでください。
  • turn/end の reason に注目する:completed / aborted / blocked / error / max-tokens / interrupted の6段階が、turn/start の { turn } と組み合わさって完全なターン状態遷移を構成します。宙に浮いたターンには個別にアラートを出してください。
  • 元の形態を保持する:tool/call の arguments はモデルが生成した生の JSON 文字列です。tool/result は error?/meta? を運びます。assistant/message は sourceEventSeqs で対応する chunk を正確に列挙し、明示的な空リストも含みます。
  • 投影ルールは厳密に:user/message → user メッセージ。assistant/message → assistant メッセージ(内容が空ならスキップ)。tool/result → tool-result ブロック付きの user メッセージ。assistant/chunk、tool/call と turn/*、step/* はモデルメッセージに投影されません。
  • リプレイはキャッシュと凍結を:deriveMessages はキャッシュされ、surface ノードは初回出現時に一度投影され、書き換え時に再構築されます。凍結された配列を返すことで、「投影を通じて記録済み履歴を変更する」ことを型レベルで表現不能にします。
  • session/event を消費する:リプレイ可能な transcript データを必要とする SDK ユーザーは session/event イベントストリームを消費すべきであり、少なくとも turn ごと、callId ごと、sourceEventSeqs ごとの3つのクエリ機能を提供すべきです。