【#8】 OpenClaw を読み解く — 会話と文脈を育て組み立てる規律
本記事のコード参照は OpenClaw
mainのcee2aca409(version2026.6.10)時点。行番号は更新でズレ得ます。
連載「OpenClaw を読み解く」
#07 のエージェントループは「文脈(context)」を受け取って LLM を呼びます。その文脈を作り、保存し、溢れたら圧縮するのが今回のテーマ。会話履歴を木構造で持つセッションと、トークン予算内にプロンプトを組み立てるコンテキストエンジンを src/agents/sessions/・src/context-engine/・src/trajectory/ から読みます。
会話は木である — parentId が結ぶ枝と分岐
OpenClaw のセッションは、追記専用(append-only)の JSONL ツリーです。各エントリは次を持ちます。
-
id: 8 文字の一意 ID(衝突チェックつき) -
parentId: 親エントリへのポインタ(これで DAG を成す) -
type:message/compaction/branch_summary/customなど -
timestamp: ISO 文字列
保存先は ~/.openclaw/agent/sessions/--{cwd-encoded}--/{sessionId}.jsonl。1行=1 JSON で、書き込みはファイル単位でキューイングされ順序が保証されます(src/config/sessions/transcript-append.ts:37)。
なぜ単純な配列ではなく木なのか。分岐(branching)を表現するためです。leafId ポインタが「いまどこにいるか」を指し、過去のエントリに leaf を戻せば、そこから別の枝を伸ばせます。
文脈の組み立ては leaf から root への遡上
LLM に渡す履歴は、現在の leaf から parentId を辿って root まで遡り、反転して得ます(buildSessionContext(), src/agents/sessions/session-manager.ts:383)。
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return buildCoreSessionContext(path as CoreSessionTreeEntry[]) as SessionContext;
byId マップで O(1) ルックアップ。アクティブな枝に乗っているエントリだけが文脈に入るので、別の枝(過去に分岐して捨てた会話)は混ざりません。
追記は必ず appendMessage を通す
#03 で予告した invariant がここです。メッセージ追記は appendMessage()(session-manager.ts:2333)を必ず通します。
appendMessage(message): string {
const entry: SessionMessageEntry = {
type: "message",
id: generateId(this.byId),
parentId: this.appendParentId, // 追記位置(論理親を加味した leaf)を親に
timestamp: new Date().toISOString(),
message,
};
this.appendEntry(entry); // JSONL に追記し leaf を前進
return entry.id;
}
parentId に渡すのは素の leafId ではなく appendParentId です。通常はこれが現在の leaf を指しますが、分岐や論理親(logicalParentsById)が絡む場面では「いま追記すべき親」を解決した値になります。
src/gateway/server-methods/AGENTS.md が「生 JSONL で type: "message" を直書きするな、必ず SessionManager.appendMessage(...) を使え」と強制するのはこのためです。直書きは parentId チェーンを壊し、圧縮・履歴を破壊します。
差し替えられる組み立て器 — コンテキストエンジンの契約
文脈の組み立てロジック自体がプラグイン化されています。ContextEngine インターフェイス(src/context-engine/types.ts:298)はこんな契約です。
-
bootstrap(): セッション単位の初期化 -
ingest(): メッセージ1件の取り込み -
assemble(): トークン予算内で、モデルに渡す順序付きメッセージを組み立てる -
compact(): 要約・刈り込みでトークンを削る -
afterTurn()/maintain(): ターン後の保守・トランスクリプト書き換え
assemble() の返り値が示唆に富みます。
type AssembleResult = {
messages: AgentMessage[];
estimatedTokens: number;
promptAuthority?: "assembled" | "preassembly_may_overflow";
systemPromptAddition?: string;
contextProjection?: ContextEngineProjection;
};
promptAuthority で「この組み立てが権威ある最終形か、それとも事前見積もりで溢れるかも知れないか」を返す。トークン予算はモデルのコンテキストウィンドウから予約分を引いて算出し、送信前に圧縮要否を判定します。
溢れたら畳む — /compact は過去を壊さない
/compact のCLI側ライフサイクルは src/agents/command/cli-compaction.ts(入口は runCliTurnCompactionLifecycle)が束ねます。ただし実処理は複数モジュールに分散しており、おおよそ次の流れです。
- 圧縮要否の判定 —
shouldCompactは関数ではなく真偽値フィールドで、src/agents/embedded-agent-runner/run/preemptive-compaction.tsが算出する -
prepareCompaction()(src/agents/sessions/compaction/compaction.ts)で履歴の切断点を特定 -
generateSummary()(圧縮ランナー側の依存)で刈り込む部分を LLM で要約 -
CompactionEntryを要約+firstKeptEntryId付きでトランスクリプトに追記
これらは cli-compaction.ts が一直線にこの順で呼ぶわけではなく、cli-compaction.ts がオーケストレーションし、判定・準備・要約は上記の各モジュールが担う、という分担です。
CompactionEntry の形(session-manager.ts:95)はこうです。
interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string;
firstKeptEntryId: string; // ここから先は原文を保持
tokensBefore: number;
details?: T;
fromHook?: boolean;
}
圧縮も木に追記するだけで、過去を破壊しません。「firstKeptEntryId 以前は要約で代替、以降は原文」という形で、文脈組み立て時に古い部分を要約に差し替えられます。
セッションコマンドの他の2つ: /new は TUI スコープの一意セッションキー(tui-{uuid})を作ってクライアント状態を隔離、/reset は resetSession() でランタイムメタデータをクリア(履歴は非破壊で温存)します。
もう一つの流れ — 実行を刻むトラジェクトリ
セッションが「会話の木」なら、トラジェクトリ(src/trajectory/)は「実行イベントの流れ」です。TrajectoryEvent(types.ts:13)はバージョン付き封筒で、
-
source:"runtime" | "transcript" | "export" -
type: イベント種別(tool-call, model-complete など) -
sessionId/runId/entryId/parentEntryId: 実行・セッションとの対応 -
data: イベント固有ペイロード -
ts/seq/sourceSeq: タイミングと順序
セッショントランスクリプトを補完する位置づけで、同じ sessionId で相互参照します。エクスポート時には TrajectoryBundleManifest を生成し、ランタイムイベントとトランスクリプトエントリを結びつけます(冒頭の git ログにあった "trajectory session branch bundle" のテストはこの周辺です)。
並び順という静かな規律 — キャッシュを壊さないために
地味ですが効くのが、ルート AGENTS.md のこの規約です。
Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
LLM プロバイダのプロンプトキャッシュは「前回と同じ先頭バイト列」のときに効きます。マップやセット、プラグイン一覧の順序がリクエストごとに揺れると、キャッシュが無効化されて遅く・高くなる。だからモデル/ツールペイロードに載せる前に決定論的に並べる。実装例が src/agents/prompt-cache-stability.ts:17 です。
export function normalizePromptCapabilityIds(capabilities: ReadonlyArray<string>): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const capability of capabilities) {
const value = normalizeLowercaseStringOrEmpty(normalizeStructuredPromptSection(capability));
if (!value || seen.has(value)) continue;
seen.add(value);
normalized.push(value);
}
return normalized.toSorted((left, right) => left.localeCompare(right));
}
重複排除し、空白・改行を正規化し、localeCompare で辞書順ソート。ツールも名前順に materialize され、入力順 [zeta, alpha, mu] でも [alpha, mu, zeta] で並ぶことがテストで担保されています。「キャッシュを壊さない」という運用要件が、コードの順序規律として結晶しているわけです。
まとめ — 木に追記し、順序で速さを守る
- セッションは
parentIdで繋がる JSONL の木。分岐を表現でき、追記は必ずappendMessage経由。 - 文脈は leaf から root への遡上で組み立て、アクティブな枝だけを使う。
- コンテキストエンジンは差し替え可能で、
assembleがトークン予算内の最終形を返す。 - 圧縮も木への追記(
CompactionEntry)で、過去を破壊しない。 - トラジェクトリは実行イベントの記録で、セッションを補完。
- 決定論的順序でプロンプトキャッシュを効かせる規律が、コードに浸透している。
次回予告 — 会話を越える記憶へ
#09 は、会話を越えて記憶を持つ仕組み、メモリシステムです。「同時に有効化できるメモリプラグインは1つだけ」という排他スロット、MemorySearchManager 契約、そして memory-core / lancedb / wiki / active-memory の役割分担を読み解きます。
