コンテンツにスキップ

Agent モジュール ソースコード解析

モジュール概要

TypeScript バージョンでは、Agent は実行エンジンではなく、純粋な設定定義となりました。Agent は「ロール」の振る舞いパラメータ(名前、パーミッションルール、システムプロンプト、モデル設定、温度設定など)を記述しますが、実行ロジックは含みません。

実際の実行ループは Session モジュールの駆動によって行われます。Session は SessionPrompt.loop() を通じてメインループを起動し、各反復で SessionProcessor.process() -> LLM.stream() -> Vercel AI SDK streamText() を呼び出して LLM 呼び出しを完了し、その後 SessionProcessor がストリーミングイベント、ツール呼び出し、コンテキスト圧縮などを処理します。

この「設定と実行の分離」設計により、以下が実現されます:

  • Agent の追加や変更は実行ロジックを書かずに設定の宣言だけで可能
  • すべての Agent が同じ Session 実行ループを共有するため、ツール呼び出し、エラーハンドリング、コンテキスト管理は一度だけ実装
  • Agent のパーミッション、ツールセット、システムプロンプトはすべて宣言的設定で制御

主要ファイル

ファイルパス行数責任範囲
src/agent/agent.ts~230Agent 設定定義:Agent.Info スキーマ、組み込み Agent リスト、state() ファクトリ、generate() 動的生成
src/session/index.ts~400Session 管理:CRUD、メッセージ永続化、コスト計算、ページネーションクエリ
src/session/prompt.ts~580コアエントリポイント:loop() メインループ、ユーザーメッセージ構築、ツール解決、サブタスクディスパッチ
src/session/llm.ts~200LLM 呼び出し:LLM.stream() は Vercel AI SDK streamText() をラップし、システムプロンプトアセンブルとパラメータマージを処理
src/session/processor.ts~220メッセージプロセッサ:ストリーミングイベントディスパッチ、ツールライフサイクル、Doom Loop 検出、スナップショットトラッキング
src/session/compaction.ts~220コンテキスト圧縮:オーバーフロー検出、メッセージプルーニング、サマリー生成
src/session/overflow.ts~20オーバーフローチェック:トークン使用量がモデルのコンテキストウィンドウを超えるかをチェックする純粋関数
src/session/retry.ts~100リトライ戦略:エラー分類、指数バックオフ、retry-after ヘッダ解析
src/session/revert.ts~160メッセージの復元:スナップショット復元、メッセージ削除、差分計算
src/session/status.ts~80状態管理:idle/busy/retry の三状態切り替えを Bus 経由でブロードキャスト
src/session/message-v2.ts~600メッセージ型:すべてのメッセージ型と Part 型を定義する Zod スキーマ、永続化、ストリーミングクエリ

型システム

Agent.Info — Agent 設定スキーマ

export const Info = z
  .object({
    name: z.string(),
    description: z.string().optional(),
    mode: z.enum(["subagent", "primary", "all"]),
    native: z.boolean().optional(),
    hidden: z.boolean().optional(),
    topP: z.number().optional(),
    temperature: z.number().optional(),
    color: z.string().optional(),
    permission: PermissionNext.Ruleset,
    model: z
      .object({
        modelID: z.string(),
        providerID: z.string(),
      })
      .optional(),
    variant: z.string().optional(),
    prompt: z.string().optional(),
    options: z.record(z.string(), z.any()),
    steps: z.number().int().positive().optional(),
  })
  .meta({
    ref: "Agent",
  })

主要フィールドの説明:

  • mode: "primary" はユーザーと直接対話する主要 Agent、"subagent" は Task ツールから呼び出されるサブ Agent、"all" はカスタム Agent を指定
  • permission: PermissionNext.Ruleset、この Agent のツールパーミッションルールチェーンを定義
  • prompt: Provider のデフォルトプロンプトをオーバーライドするカスタムシステムプロンプト
  • model: オプションの固定モデルバインディング。指定がない場合、ユーザーの現在選択されているモデルが使用される

組み込み Agent リスト

Agent 名modehidden目的
buildprimaryNoフルツールパーミッションを持つデフォルト Agent、質問と plan_enter をサポート
planprimaryNoplanning モード、すべての編集ツールを無効化し plans ディレクトリへの書き込みのみ許可
exploresubagentNo読み取り専用ツールのみを持つクイックコード探索(grep、glob、read、bash、webfetch など)
generalsubagentNo並列マルチステップタスク実行用の汎用サブ Agent、todo ツールを無効化
compactionprimaryYesコンテキスト圧縮専用、すべてのツールを無効化、会話の要約を生成
titleprimaryYesSession のタイトルを生成、すべてのツールを無効化、温度は 0.5 に固定
summaryprimaryYesSession のサマリーを生成、すべてのツールを無効化

Session.Info — Session スキーマ

export const Info = z
  .object({
    id: Identifier.schema("session"),
    slug: z.string(),
    projectID: z.string(),
    directory: z.string(),
    parentID: Identifier.schema("session").optional(),
    summary: z
      .object({
        additions: z.number(),
        deletions: z.number(),
        files: z.number(),
        diffs: Snapshot.FileDiff.array().optional(),
      })
      .optional(),
    share: z.object({ url: z.string() }).optional(),
    title: z.string(),
    version: z.string(),
    time: z.object({
      created: z.number(),
      updated: z.number(),
      compacting: z.number().optional(),
      archived: z.number().optional(),
    }),
    permission: PermissionNext.Ruleset.optional(),
    revert: z
      .object({
        messageID: z.string(),
        partID: z.string().optional(),
        snapshot: z.string().optional(),
        diff: z.string().optional(),
      })
      .optional(),
  })

並行制御 — Runner メカニズム

各 SessionID は Effect の Runner.make() によって作成された Runner インスタンスにバインドされます。Runner は内部的にタスクキューを維持し、同じ Session への操作が逐次に実行されることを保証します:

const runners = new Map<string, Runner<MessageV2.WithParts>>()
const getRunner = (runners, sessionID) => {
  const existing = runners.get(sessionID)
  if (existing) return existing
  const runner = Runner.make<MessageV2.WithParts>(scope, {
    onIdle: Effect.gen(function* () {
      runners.delete(sessionID)   // アイドル時の自動クリーンアップ、リソース解放
      yield* status.set(sessionID, { type: "idle" })
    }),
    onBusy: status.set(sessionID, { type: "busy" }),
    onInterrupt: lastAssistant(sessionID),
    busy: () => { throw new Session.BusyError(sessionID) },
  })
  runners.set(sessionID, runner)
  return runner
}

主要な設計ポイント:

  • 直列化の保証: 同じ Session へのすべての操作は Runner を介してキューイングされ、並行競合状態が発生しない
  • 自動クリーンアップ: Runner はアイドル状態になると Map から削除され、メモリリークを回避
  • 割り込みコールバック: onInterrupt はキャンセル時に lastAssistant() をトリガーし、割り込み後の状態整合を保証
  • ビジー拒否: Session がビジーでキューイングをサポートしない場合、BusyError を直接スロー

競合状態 — SyncEvent イベントソース

メッセージ書き込みはデータベースを直接操作せず、SyncEvent イベントソースを通じて同期的に実行されます:

const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
  Effect.gen(function* () {
    yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
    return msg
  })

この設計により競合状態の可能性が排除されます:

  • SyncEvent.run() は同期的に実行:最初に SQLite WAL に書き込み、その後ブロードキャスト BusEvent
  • Effect のシングルスレッドコルーチンモデルが操作的原子性を保証
  • 同じ Session への操作はすでに Runner によって直列化されているため、マルチスレッド競合はない
  • Part 更新にはデルタ最適化:ストリーミングテキストは DB 書き込みなしで BusEvent のみを送信し、IO オーバーヘッドを削減

コアフロー — Session 実行ループ

完全な呼び出しチェーン

User input -> SessionPrompt.prompt()
         -> SessionPrompt.loop()
           |-- First message? -> ensureTitle() generates title asynchronously
           |-- Pending subtask? -> TaskTool executes directly
           |-- Pending compaction? -> SessionCompaction.process()
           |-- Context overflow? -> SessionCompaction.create() -> continue
           `-- Normal processing:
              |-- SessionProcessor.create()
              |-- resolveSystemPrompt() assembles system prompt
              |-- resolveTools() registers built-in + MCP tools
              `-- processor.process()
                 `-- LLM.stream()
                    `-- ai.streamText() (Vercel AI SDK)
                 `-- for await (stream.fullStream) process streaming events
                    |-- text-start / text-delta / text-end -> Text Part
                    |-- reasoning-start / reasoning-delta / reasoning-end -> Reasoning Part
                    |-- tool-input-start / tool-call / tool-result / tool-error -> Tool Part
                    |-- start-step / finish-step -> Snapshot tracking + token billing
                    `-- Error? -> SessionRetry.retryable() determines whether to retry

SessionPrompt.loop() — メインループの詳細

loop() はシステム全体の心臓部です。SessionPrompt.prompt() 内で呼び出され、キャンセル制御のために start()AbortController を登録します。主なループロジック:

  1. メッセージストリームの読み取り: MessageV2.filterCompacted(MessageV2.stream(sessionID)) がメッセージを逆順に走査し、圧縮された履歴をスキップ
  2. ループ終了条件のチェック: 最後の Assistant メッセージの finish"tool-calls" でも "unknown" でもない場合、およびその ID が最後の User メッセージ ID より大きい場合はループを終了
  3. 保留タスクの優先処理: 最後のメッセージの Parts に compaction または subtask 型の Part が含まれているかチェック
  4. コンテキストオーバーフロー検出: SessionCompaction.isOverflow() で圧縮が必要かチェック
  5. 通常処理: Assistant メッセージを作成し、ツールを解決し、processor.process() を呼び出し

SessionProcessor.process() — ストリーミングイベント処理

SessionProcessor は現在の Assistant メッセージ、ツール呼び出しマッピングテーブル、スナップショット参照を保持するステートフルオブジェクトです。process() メソッドには内部的に while(true) ループが含まれています:

async process(streamInput: LLM.StreamInput) {
  while (true) {
    const stream = await LLM.stream(streamInput)
    for await (const value of stream.fullStream) {
      abort.throwIfAborted()
      switch (value.type) {
        // Text stream -> create/append TextPart
        // Reasoning stream -> create/append ReasoningPart
        // Tool call -> create ToolPart (pending -> running -> completed/error)
        // Step tracking -> Snapshot.track() + StepStartPart/StepFinishPart
      }
      if (needsCompaction) break  // Break on context overflow
    }
    // Error handling -> retryable? continue loop : mark error and exit
    // Normal end -> return "continue" | "stop" | "compact"
  }
}

LLM.stream() — Vercel AI SDK ラッパー

LLM.stream() は Vercel AI SDK streamText() のラッパーであり、以下の責任を負います:

  1. 言語モデルの取得: Provider.getLanguage(model) が AI SDK 互換の LanguageModel を返す
  2. システムプロンプトのアセンブル: Agent プロンプト、Provider デフォルトプロンプト、ユーザーがカスタマイズしたシステムプロンプトを優先順位でマージ
  3. パラメータマージパイプライン: base -> model.options -> agent.options -> variant で、各レイヤーが前をオーバーライド
  4. ツール解決: resolveTools() がユーザーの無効化リストとパーミッションルールに基づいてツールをフィルタリング
  5. streamText() の呼び出し: メッセージフォーマット変換用の wrapLanguageModel() ミドルウェアを渡す
export async function stream(input: StreamInput) {
  const [language, cfg, provider, auth] = await Promise.all([
    Provider.getLanguage(input.model),
    Config.get(),
    Provider.getProvider(input.model.providerID),
    Auth.get(input.model.providerID),
  ])
  // ... system prompt assembly, parameter merging ...
  return streamText({
    model: wrapLanguageModel({ model: language, middleware: [...] }),
    messages: [...system.map(x => ({ role: "system", content: x })), ...input.messages],
    tools,
    abortSignal: input.abort,
    maxRetries: input.retries ?? 0,
    // ... other parameters
  })
}

Doom Loop 検出メカニズム

SessionProcessor は内部的に Doom Loop 検出を実装しており、LLM が同じツールを呼び出し続ける無限ループに入るのを防ぎます:

const DOOM_LOOP_THRESHOLD = 3

// In the tool-call event handler
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
  lastThree.length === DOOM_LOOP_THRESHOLD &&
  lastThree.every(
    (p) =>
      p.type === "tool" &&
      p.tool === value.toolName &&
      p.state.status !== "pending" &&
      JSON.stringify(p.state.input) === JSON.stringify(value.input),
  )
) {
  await PermissionNext.ask({
    permission: "doom_loop",
    patterns: [value.toolName],
    sessionID: input.assistantMessage.sessionID,
    // ...
  })
}

検出条件:最後の 3 回のツール呼び出しがすべて同じツール名で同じ入力パラメータの場合。トリガーされると PermissionNext.ask() がユーザーに確認を求めます。ユーザーはそのツールの doom loop を「常に許可」を選択できます。

サブタスク実行モデル

サブタスクは新しい Session を作成せず、代わりに現在の Session 内に新しい Assistant Message + Tool Parts を作成します:

const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input) {
  // 1. スタンドアロンの assistant メッセージを作成 (mode = task.agent)
  // 2. ツール Part を作成 (status: "running")
  // 3. 同じセッション内で taskTool.execute() を実行
  //    サブ Agent のパーミッションルールを使用
  //    現在のセッションのメッセージ履歴を渡す
})

主要な設計ポイント:

  • サブタスクは現在の Session のメッセージ履歴を再利用し、冗長な読み込みを回避
  • 主要 Agent のパーミッションではなく、サブ Agent のパーミッションルールを使用(Session レベルとマージ)
  • /command でトリガーされた場合、完了時にサマリー User Message が自動的に挿入され、主要 Agent にサブタスクの実行結果を通知
  • Tool Part ライフサイクル:pending -> running -> completed/error、他のツール呼び出しと一貫

プラグインフックシステム

Session 実行ループは複数の時点で Plugin Hook を注入し、外部拡張を可能にします:

フックポイントトリガータイミング目的
chat.system.transformシステムプロンプト構築中システムプロンプトコンテンツの変換/注入
chat.paramsLLM パラメータを渡す前temperature、maxTokens などのパラメータ変更
tool.execute.beforeツール実行前ツール入力を傍受、ログ、修正
tool.execute.afterツール実行後ツール出力の後処理、監査ログ
command.execute.beforeコマンド実行前コマンドパラメータの傍受/変更
chat.messageメッセージ構築中メッセージコンテンツの変換/拡張
shell.envシェルコマンド実行中環境変数の注入

これらのフックにより、プラグインはコアコードを修正することなく実行フローに介入できます。例えば、監査プラグインは tool.execute.before を介してすべてのツール呼び出し入力をログに記録したり、shell.env を介してシェルコマンドにプロジェクト固有の環境変数を注入できます。

コンテキスト圧縮 — 二層最適化

圧縮は二つのフェーズに分かれ、各々に細粒度の保護メカニズムがあります:

1. オーバーフロー検出 (SessionCompaction.isOverflow()):

// overflow.ts
export function isOverflow(input) {
  const reserved = input.cfg.compaction?.reserved ?? Math.min(20_000, maxOutputTokens(model))
  const usable = input.model.limit.input
    ? input - reserved
    : context - maxOutputTokens(model)
  return count >= usable
}
  • input + output + cache.read + cache.write の合計トークン数を計算
  • 使用可能空間 = model.limit.input - reserved(reserved のデフォルトは min(20000, maxOutputTokens))
  • トークン使用量が使用可能空間を上回る場合に圧縮をトリガー

2. メッセージプルーニング (SessionCompaction.prune()) — 二層保護:

第一層 — 最小閾値:

  • PRUNE_MINIMUM = 20,000 トークン。この値を下回るとプルーニングはトリガーされない
  • 短い会話での無意味なプルーニング操作を回避

第二層 — 保護バンド:

  • PRUNE_PROTECT = 40,000 トークン、最新のツール出力をプルーニングから保護
  • PRUNE_PROTECTED_TOOLS = ["skill"]、保護対象としてマークされたツール出力は決してプルーニングされない
  • 最新メッセージから逆方向に走査、最新の 2 ラウンドの会話をスキップ

プルーニング実行:

  • 閾値を超えるツール出力は compacted としてマーク(state.time.compacted = Date.now() を設定)
  • 後続の toModelMessages() 呼び出しで、プルーニングされた出力を [Old tool result content cleared] に置換
  • 各プロンプトループの終了時に非同期的に実行(Effect.forkIn(scope))、メインループをブロックしない

3. サマリー生成 (SessionCompaction.process()):

  • compaction Agent を使用(すべてのツールが無効)
  • メッセージ履歴全体 + サマリー指示を LLM に送信
  • 生成されたサマリーメッセージは summary: true としてマーク。後続の filterCompacted() 呼び出しがこれをプルーニング点として使用
  • 自動圧縮が成功した場合、会話を再開するために「Continue」メッセージが自動的に追加される

エラーハンドリングとエッジケース

LLM 呼び出しリトライ戦略

SessionRetry モジュールは完全なリトライ戦略を実装しています:

リトライ可能なエラー分類 (retryable()):

  • isRetryable === trueAPIError:429 レート制限と 500 サーバーエラーを含む
  • ContextOverflowError:リトライせず、直接圧縮をトリガー
  • FreeUsageLimitError:プロンプトメッセージを返します(リトライなし)
  • レスポンスボディに "exhausted" / "unavailable" / "rate_limit" を含むエラー

バックオフアルゴリズム (delay()):

  • まずレスポンスヘッダー retry-after-ms(ミリ秒)または retry-after(秒/HTTP 日付)を読み取る
  • ヘッダーがない場合、指数バックオフを使用:2000ms * 2^(attempt-1)、上限 30 秒
  • ヘッダーがある場合、上限は 2^31 - 1(32 ビット符号付き整数の最大値)
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000

SessionProcessor.process() の catch ブロック:

const error = MessageV2.fromError(e, { providerID: input.model.providerID })
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
  attempt++
  const delay = SessionRetry.delay(attempt, ...)
  SessionStatus.set(sessionID, { type: "retry", attempt, message: retry, next: ... })
  await SessionRetry.sleep(delay, input.abort)
  continue  // Return to top of while(true) to retry
}

ツール実行エラーハンドリング

ツール実行が失敗した場合(tool-error イベント)、ハンドラはエラータイプに応じて異なるアクションを取ります:

  • PermissionNext.RejectedError:ユーザーがパーミッションリクエストを拒否。設定に基づいて、ループを中断するかを決定
  • Question.RejectedError:ユーザーが質問の確認を拒否。同様に設定に基づいて決定
  • その他のエラー:エラー情報をログに記録し、ツールステータスを error に設定。後続イベントは継続処理

ループ終了後、pending または running 状態のすべてのツールが "Tool execution aborted" エラーでマークされます。

メッセージの復元 — 完全なメカニズム

SessionRevert モジュールは Effect Service パターンを使用し、三つの操作を提供します。revert のコアはファイルシステム状態を正確に復元することにあります:

revert() 完全フロー

  1. メッセージと Parts を走査してユーザーが指定した revert ポイントを見つける
  2. revert ポイントから開始して、後続のすべてのパッチ Parts(ファイル編集操作)を収集
  3. 現在のファイルシステム Snapshot を revert ベースラインとしてキャプチャ
  4. 逆パッチ操作を適用:snap.revert(patches) が逆順序ですべてのファイル変更を元に戻す
  5. revert メタデータ(messageID、partID、snapshot、diff)を Session.revert フィールドに書き込み
// Simplified core logic
const revert = Effect.fn("SessionRevert.revert")(function* (input) {
  // 1. Locate revert point
  // 2. Collect subsequent patches
  // 3. Capture current snapshot
  // 4. snap.revert(patches) — reverse-apply all edits
  // 5. Update session.revert metadata
})
  • revert(): 指定されたメッセージ/Part に戻し、Snapshot を介してファイルシステム状態を復元し、差分を計算
  • unrevert(): revert を取り消し、revert 前の Snapshot に復元
  • cleanup(): 次の会話の開始時(prompt() 呼び出し内)で revert 状態をクリーンアップし、revert ポイント後のすべてのメッセージを削除

この設計により、revert が元に戻可能であることが保証されます — unrevert は各ステップで Snapshot と差分を保存するため、revert 前の状態に正確に復元できます。

Provider 利用不可時のデグラデーション

Provider 認証が失敗すると、MessageV2.fromError()AuthError を生成します。SessionRetry.retryable() は認証エラーに対して undefined を返し(リトライ不可)、ループは直ちに終了します。コンテキストオーバーフローエラー(ContextOverflowError)もリトライせず、loop()isOverflow() を介して検出され、自動圧縮がトリガーされます。

正確なコスト計算

Session のコスト計算には、精密な処理が必要な細部が多数あります:

  • キャッシュ書き込みトークン:anthropic、vertex、bedrock、venice などを含む複数のソースから Provider 固有フィールドを抽出
  • キャッシュトークンのデデュプリケーション:AI SDK v6 の inputTokens にはすでにキャッシュトークンが含まれている。請求重複を避けるために差し引く必要がある
  • 200K 以上の料金設定experimentalOver200K フィールドを使用して差別化料金設定
  • 推論トークン:別途料金設定ではなく出力価格で請求
  • 精度保証:JavaScript のネイティブ浮動小数点エラーを回避するために Decimal.js を使用して正確な浮動小数点計算を実行

ツール呼び出し修復メカニズム

LLM によって返されるツール呼び出し名には大文字小文字のエラーがある場合があります。experimental_repairToolCall が自動修復を提供します:

async experimental_repairToolCall(failed) {
  const lower = failed.toolCall.toolName.toLowerCase()
  // Case repair: convert tool name to lowercase for matching
  // If still unmatched -> route to "invalid" tool
  // "invalid" tool returns a friendly error message
}

これにより、モデルが read_file の代わりに Read_File を出力したことによるツール呼び出し失敗を防ぎます—システムは自動的に大文字小文字を区別しないマッチングを試み、それが失敗した場合、「invalid」ツールが明確なエラーメッセージを提供します。

状態管理

InstanceState キャッシュ

Agent リストは Instance.state() を介した遅延ロードキャッシュを実装しています:

const state = Instance.state(async () => {
  const cfg = await Config.get()
  // ... build Agent list ...
  return result as Record<string, Agent.Info>
})

export async function get(agent: string) {
  return state().then((x) => x[agent])
}

Instance.state() は初回の呼び出しでファクトリ関数を実行し、結果をキャッシュする非同期関数を返します。後続の呼び出しは直接キャッシュされた値を返します。キャッシュは Instance が破棄されると自動的にクリーンアップされます。

Session 状態三状態

SessionStatus は Effect Service を介して各 Session の状態を管理します:

export const Info = z.union([
  z.object({ type: z.literal("idle") }),
  z.object({ type: z.literal("busy") }),
  z.object({ type: z.literal("retry"), attempt: z.number(), message: z.string(), next: z.number() }),
])

状態の変更は Bus.publish(Event.Status, ...) を介してブロードキャストされ、UI レイヤーがこれらのイベントを購読してインターフェースを更新します。

キャンセル制御と精密な割り込み

SessionPrompt.loop()start() を介して各 Session に AbortController を登録します:

function start(sessionID: string) {
  const controller = new AbortController()
  s[sessionID] = { abort: controller, callbacks: [] }
  return controller.signal
}

cancel()controller.abort() を呼び出して、すべての進行中の LLM 呼び出しとツール実行を中断します。同じ Session にキューされたリクエスト(callbacks)がある場合、それらは拒否されます。

AbortController 精密割り込みポイント:LLM.Service の stream メソッドは Effect.acquireRelease を介して AbortController を作成し、割り込み時に精密なリソースクリーンアップを実行します:

  1. 不完全なスナップショット:現在のファイルシステム状態のパッチを計算し、完成した編集を保存
  2. 不完全なテキスト:すでに受信したがまだ書き込まれていないストリーミングテキストを保存
  3. 不完全な reasoning parts:不完全な推論コンテンツをクリーンアップ
  4. ツールマーカーrunning / pending 状態のすべてのツールが error("Tool execution aborted") でマーク

この細粒度の割り込み処理により、キャンセル操作が一貫性のない状態を残さないことが保証されます — 不完全な編集はロールバックまたは保存され、ツール呼び出しは安全に終了します。

呼び出しチェーンの例

チェーン 1:ユーザーがメッセージを送信 -> LLM 呼び出し -> ツール実行 -> レスポンス描画

1. SessionPrompt.prompt({ sessionID, parts: [{ type: "text", text: "read main.ts" }] })
   |-- createUserMessage() -> write User message + TextPart to Storage
   `-- loop(sessionID)
      |-- start() -> register AbortController
      |-- MessageV2.filterCompacted(stream) -> load uncompacted message history
      |-- Agent.get("build") -> get build Agent configuration
      |-- SessionProcessor.create() -> create empty Assistant message
      |-- resolveSystemPrompt() -> [header, agentPrompt + environment + custom]
      |-- resolveTools() -> register ReadTool, BashTool, EditTool and other built-in tools + MCP tools
      `-- processor.process()
         |-- LLM.stream() -> ai.streamText() initiates streaming request
         |-- Streaming events:
         |  |-- text-delta -> write TextPart, Bus broadcast PartUpdated
         |  |-- tool-input-start -> create ToolPart (status: "pending")
         |  |-- tool-call -> update ToolPart (status: "running", input: { filePath: "main.ts" })
         |  |-- ReadTool.execute() -> read file contents
         |  `-- tool-result -> update ToolPart (status: "completed", output: file contents)
         `-- return "continue" (finish reason is not tool-calls)

2. UI layer subscribes to MessageV2.Event.PartUpdated via Bus for real-time rendering
3. loop() next iteration detects Assistant is complete -> break -> return final message

チェーン 2:コンテキスト圧縮がトリガー -> サマリー生成 -> メッセージプルーニング

1. finish-step event in processor.process():
   |-- Session.getUsage() -> calculate token usage: { input: 180000, output: 8000, ... }
   |-- SessionCompaction.isOverflow() -> 180000 + 8000 >= 190000 -> true
   `-- needsCompaction = true -> break interrupts streaming

2. process() returns "compact" -> loop() continues
3. loop() next iteration:
   |-- Detects isOverflow() -> true
   `-- SessionCompaction.create() -> write User message + CompactionPart

4. loop() iterates again:
   |-- Detects pending compaction task
   `-- SessionCompaction.process()
      |-- Agent.get("compaction") -> get compaction-dedicated Agent
      |-- Create Assistant message (mode: "compaction", summary: true)
      |-- SessionProcessor.create()
      `-- processor.process()
         |-- Historical messages + summary instructions -> LLM generates structured summary
         |  (Goal / Instructions / Discoveries / Accomplished / Relevant files)
         `-- return "continue"

5. Subsequent filterCompacted() uses the summary:true message as the pruning point
6. SessionCompaction.prune() -> clean up old tool outputs (preserving the most recent 40000 tokens)

設計上のトレードオフ

なぜ Agent はクラスではなく設定なのか?

TypeScript バージョンは Agent を Go バージョンの「インターフェース + 実装」パターンから純粋な設定オブジェクトに変更しました。理由:

  1. コード重複の排除:Go バージョンでは、Coder Agent と Task Agent は実行ループがほとんど同一で、ツールセットのみが異なっていました。TS バージョンでは、すべての Agent が SessionPrompt.loop() -> SessionProcessor.process() 実行パスを共有
  2. 宣言的コンポジション:パーミッションは PermissionNext.merge() を介して結合され、ツールは ToolRegistry.enabled() を介してフィルタリングされ、モデル/温度は設定でオーバーライドされます — サブクラス化は不要
  3. 実行時拡張性generate() メソッドは LLM を介して新しい Agent を動的に作成でき、ユーザー設定は組み込み Agent を disable したりカスタム Agent を追加したり可能

なぜ Vercel AI SDK なのか?

ai パッケージ(Vercel AI SDK)は、プロバイダ間のプロトコルの違い(Anthropic、OpenAI、Google など)を抽象化する統一的な streamText() API を提供します。OpenCode は wrapLanguageModel() ミドルウェア(ProviderTransform.message())を介して独自のメッセージ変換ロジックを注入し、ProviderTransform.providerOptions() を介してプロバイダ固有のパラメータフォーマットを処理することでこれを基盤としています。

なぜ prompt.ts は大きい(約 580 行)のか?

prompt.ts は Go バージョンの agent.gotools.gosession.go に分散していた責任を統合しています:

  • ユーザーメッセージ構築(ファイル読み取り、Agent 参照解決、ディレクトリリスト)
  • ツール解決と登録(組み込みツール + MCP ツール + パーミッションフィルタリング)
  • サブタスク直接実行
  • コマンド(/command)テンプレート展開
  • シェルコマンド実行
  • 自動タイトル生成

他のモジュールとの関係

  • Providersrc/provider/):LLM.stream()Provider.getLanguage() を介して AI SDK 互換の LanguageModel を取得。Provider.getModel() を介してモデルメタデータ(コンテキストウィンドウ、pricing など)を取得
  • Configsrc/config/):Agent リストの state()Config.get() を読み取ってユーザーカスタマイズされた Agent 設定とパーミッションオーバーライドを取得
  • Permissionsrc/permission/):resolveTools()PermissionNext.disabled() を呼び出して無効化されたツールをフィルタリング。Doom Loop 検出は PermissionNext.ask() を介してユーザー確認を要求
  • Storagesrc/storage/):すべてのメッセージ、Parts、Session は Storage.write/update/read を介して永続化
  • Bussrc/bus/):メッセージ更新、Part 更新、状態変更はすべて Bus を介してブロードキャスト。UI レイヤーがこれらのイベントを購読して描画を駆動
  • Snapshotsrc/snapshot/):SessionProcessor は各ステップ(step-start/step-finish)でファイルシステムスナップショットをトラッキングし、差分計算と revert のため
  • ToolRegistrysrc/tool/):resolveTools()ToolRegistry.tools() から組み込みツールリストを取得し、ToolRegistry.enabled() を介して Agent のツール有効設定をマージ
  • MCPsrc/mcp/):resolveTools()MCP.tools() から外部ツールを取得し、AI SDK tool() フォーマットでラップ
  • Pluginsrc/plugin/):システムプロンプト構築、ツール実行前後、チャットパラメータなど複数の段階でフックポイントを提供
  • Instancesrc/project/):Instance.state() は Agent リストキャッシュのライフサイクル管理を提供。Instance.directory/worktree は作業ディレクトリ情報を提供