最近のAIコーディングアシスタントは、単にコードを書くだけでなく、ファイルを読み、コマンドを実行し、必要に応じてサブエージェントを呼び出して並列に作業する、いわゆる「エージェント型」のツールに進化しています。Claude Code、Cursor Agent、Aider、そしてこの記事で扱うopencodeなどが代表例です。
筆者は普段の開発でClaude Codeをヘビーに使っているのですが、あるときから気になって仕方のないことがありました。Task ツールでサブエージェントに仕事を振ると、親の会話コンテキストがまったく汚れずに結果だけ戻ってくるのです。親子で別々のプロンプトを使っているのは分かりますが、どうやってツールを絞り込み、どうやってコンテキストを分離しているのか。Claude Codeはクローズドソースなので中は覗けません。そこで、同じ「ターミナルで動くAIエージェント」のオープンソース実装であるopencodeのソースを読んで、内部の仕組みを追いかけてみることにしました。
読み終えてみると、想像より整理された作りになっていて、いくつか意外な発見もありました。
- LLM呼び出しは Vercel AI SDK をほぼ素で使っている:独自のHTTPクライアントやトークナイザは書かず、
streamText()の上に薄いラッパを載せているだけ - コンテキストの自動圧縮すら「専用エージェント」として実装されている:compaction用のシステムプロンプトと権限設定を持った別エージェントを起動するだけで、特別なコードパスはない
- 暴走対策がコードに組み込まれている:同じツールを同じ引数で3回続けて呼ぶと自動で権限確認を発動する「Doom Loop検知」というサーキットブレーカーがある
この記事では、opencodeのソースコードからエージェントの処理フローの全体像を読み取ります。細かいAPIの引数や型の話には深入りせず、「大きな流れ」を掴むことを目標にします。AIエージェントを使っている側から、作っている側の視点に少しだけ踏み込んでみる記事です。
対象リビジョン: 本記事は sst/opencode のコミット
27190635e(2026-04-11時点)を対象に読み解いています。活発に開発されているプロジェクトなので、ファイル構成や関数名は今後変わる可能性があります。
opencodeとは
opencodeは、Terminal上で動作するオープンソースのAIコーディングエージェントです。Claude Codeと似た位置付けのCLIツールで、プロジェクト内のファイルを読み書きしたり、シェルコマンドを実行したり、タスクを分解してサブエージェントに委譲したりできます。
今回読んだのはopencodeのTypeScript実装です。プロジェクト規模はかなり大きく、packages/opencode/src/ の下に40弱のディレクトリが並んでいます。ただ、AIエージェントの中核になる部分は意外とシンプルで、いくつかの主要ディレクトリを追いかければ全体像が掴めます。
以下が、今回注目する主要なディレクトリと役割です。
| ディレクトリ | 役割 |
|---|---|
src/session/ | ユーザーとのチャットセッションを管理し、エージェントのメインループを回す |
src/agent/ | build、plan、exploreなどのエージェント種別を定義する |
src/tool/ | ファイル編集、bash実行、Web検索などのツールを実装する |
src/provider/ | OpenAI、Anthropicなど各LLMプロバイダーへの接続を管理する |
src/permission/ | ツール実行前の権限チェックを担当する |
ここから先は、「ユーザーが入力を送ってから、LLMが応答を返し、ツールが実行され、次のステップへ進む」という一連の流れを掘っていきます。
全体像:3層のループ構造
opencodeのエージェントは、大きく3層のループ構造になっています。外側から順に、Stepループ、Streamループ、LLM呼び出しです。
flowchart TD
User["ユーザー入力"] --> Step
subgraph Step["外側:Stepループ (session/prompt.ts)"]
direction TB
S1["システムプロンプト組み立て"] --> S2["ツール一覧の解決"] --> Stream
end
subgraph Stream["中層:Streamループ (session/processor.ts)"]
direction TB
M1["LLMストリーム開始"] --> M2["イベント処理<br/>text/tool-call/finish"] --> M3{"ツール呼び出し?"}
M3 -->|Yes| M4["ツール実行 → 結果を会話履歴に追加"]
M4 --> M2
M3 -->|No| M5["ステップ終了"]
end
subgraph LLM["下層:LLM呼び出し (session/llm.ts)"]
direction TB
L1["Vercel AI SDK<br/>streamText()"]
end
Stream --> LLM
LLM -.ストリーム.-> Stream
Step -->|続きがあれば次のステップへ| Step
Step --> Final["最終レスポンス"]
この3層の役割分担が、opencodeのアーキテクチャを理解するうえでの土台になります。順番に見ていきます。
外側:Stepループ
外側のループは session/prompt.ts にあり、「ユーザーの1回の入力に対して、何ステップかけて応答するか」を管理します。エージェントはLLMに1回問い合わせるだけで終わるとは限りません。ツールを呼び、その結果をもとにさらに考え、また別のツールを呼び、最終的にテキスト応答で終わる、といった複数ステップになることが普通です。この単位ごとに繰り返すのが外側のループです。
該当コードは session/prompt.ts:1413 付近にあり、processor.create() でプロセッサを作り、1ステップずつ handle.process() を呼びながら戻り値(continue / compact / stop)で次のアクションを決めています。
中層:Streamループ
中層は session/processor.ts の SessionProcessor.process()(processor.ts:533 付近)にあり、1つのステップの中でLLMのストリームを処理します。LLMはレスポンスを一気に返すのではなく、少しずつストリーミングで返してきます。その「text-deltaが届いた」「tool-callが届いた」「finish-stepが届いた」といったイベントを1つずつ捌きながら、必要ならツールを実行し、結果をメッセージに書き戻します。
ストリームの各イベントは handleEvent()(processor.ts:214 付近)で処理されていて、tool-call / tool-result / text-delta / finish-step といったケースごとに分岐しています。
下層:LLM呼び出し
下層の session/llm.ts は、実際のLLM API呼び出しを担当します。opencodeは内製のHTTPクライアントを書いているのではなく、Vercel AI SDKの streamText() を土台にしています。プロバイダーごとの差異を吸収し、ツール定義をAPIが受け取れる形式に変換し、ストリーミングレスポンスを返すのは、このSDKの役割です。opencodeの llm.ts は、その上に「システムプロンプトの組み立て」「ツールのフィルタリング」「プロンプトキャッシュ対応」といった前処理・後処理を載せています(llm.ts:84-395 付近の stream() 関数)。
「独自のエージェント用SDKを書かず、既存のSDKに乗っかる」という割り切りは、読んでいて気持ちいいポイントでした。Vercel AI SDKはツール呼び出し・ストリーミング・プロバイダー抽象化を一通り面倒見てくれるので、エージェント開発者はその上のアプリケーションロジック(ループ制御・権限・コンテキスト管理)に集中できる、という役割分担が成り立っています。
ユーザー入力からLLM応答までの流れ
3層の全体像が見えたところで、実際のリクエストがどう流れていくのかをシーケンス図で追ってみます。ユーザーが「〇〇ファイルを開いて修正して」と指示したときの動きです。
sequenceDiagram
autonumber
actor U as ユーザー
participant P as Stepループ<br/>(prompt.ts)
participant SP as Streamループ<br/>(processor.ts)
participant L as LLM呼び出し<br/>(llm.ts)
participant AI as Vercel AI SDK
participant T as ツール<br/>(bash/read/...)
U->>P: 入力メッセージ
P->>P: エージェント選択<br/>システムプロンプト組み立て
P->>SP: processor.create()
loop Stepループ(最大ステップまで)
P->>SP: handle.process(streamInput)
SP->>L: llm.stream(streamInput)
L->>AI: streamText({tools, messages, ...})
AI-->>SP: text-delta / tool-call / ...
alt tool-call が届いた
SP->>T: tool.execute(args)
T-->>SP: 実行結果
SP->>AI: ツール結果を会話履歴に追加
end
AI-->>SP: finish-step
SP-->>P: "continue" / "compact" / "stop"
end
P-->>U: 最終レスポンス
ポイントは、StepループとStreamループが入れ子になっていて、それぞれ異なる粒度でループしていることです。Streamループは1つのLLM応答(1ステップ)の中で複数のツール呼び出しを処理します。Stepループはそのステップ全体を複数回回します。ツール呼び出しが続く限り次のステップへ進み、LLMが「もう何も呼ばない」と判断したステップでループを抜けて、ユーザーに応答を返します。
エージェントは1つじゃない
ここまで「エージェント」を単数で扱ってきましたが、opencodeには複数のエージェントが定義されていて、用途によって使い分けるのが面白いところです。agent/agent.ts を見ると、以下のようなエージェントがあらかじめ組み込まれています。
| エージェント名 | モード | 用途 |
|---|---|---|
build | primary | デフォルトの実装エージェント。ファイル編集・bash実行など一通りできる |
plan | primary | プランモード。編集系ツールを禁止し、計画を立てることに集中する |
general | subagent | 汎用サブエージェント。複雑なタスクを委譲する先 |
explore | subagent | コードベースの探索に特化。grep/glob/readなど読み取り系のみ許可 |
compaction | primary (hidden) | 会話履歴が長くなったときに自動で呼ばれる要約専用エージェント |
title | primary (hidden) | セッションタイトルを自動生成する専用エージェント |
注目したいのはprimaryとsubagentという2つのモードです。primaryはユーザーが直接選んで使うエージェント、subagentはTaskツール経由で他のエージェントから呼び出されるエージェントです。
flowchart LR
User["ユーザー"] --> Build["build<br/>(primary)"]
Build -->|Task ツール| Explore["explore<br/>(subagent)"]
Build -->|Task ツール| General["general<br/>(subagent)"]
Explore -.読み取り系のみ.-> Files[("ファイル<br/>システム")]
General -.フル権限.-> Files
エージェントごとに使えるツール・権限・システムプロンプト・温度パラメータを差し替えられます(定義は agent/agent.ts:107-234 付近)。たとえばexploreエージェントは編集系ツールを一切使えず、grepやreadだけで動く軽量な「探索担当」として振る舞います。buildエージェントから Task ツール経由でexploreに仕事を振れば、親の会話コンテキストを汚さずに探索結果だけ持ち帰ってくれる、というわけです。
この設計はClaude Codeのサブエージェント機能とほぼ同じで、「親エージェントの持つツールを制限した分身を作り、結果だけ受け取る」という定番パターンがopencodeでもそのまま採用されています。対応関係を並べてみると、こんな感じです。
| 用途 | opencode | Claude Code |
|---|---|---|
| デフォルト実装エージェント | build | メインエージェント |
| 計画モード(編集禁止) | plan | Plan モード |
| コードベース探索 | explore (subagent) | Explore サブエージェント |
| 汎用タスク委譲 | general (subagent) | general-purpose サブエージェント |
| 会話履歴の自動要約 | compaction (hidden) | /compact 相当 |
| セッションタイトル生成 | title (hidden) | セッションタイトル自動生成 |
筆者が最初に疑問に思っていた「Taskツール経由でサブエージェントに仕事を振ると親コンテキストが汚れない」仕組みは、ここで種明かしされました。サブエージェントは別のシステムプロンプト・別のツールセット・別の会話履歴を持った独立したエージェントとして起動され、親エージェント側から見ると単なる1回のツール呼び出しと、その戻り値(探索結果の要約など)だけが会話履歴に残る、という構造です。親からすれば「子の思考過程は見えないけど、最後に出てきた結論だけもらえる」状態で、コンテキストが汚れないのも当然というわけです。
システムプロンプトは3層構造で組み立てられる
LLMに渡すシステムプロンプトも、単純に1つの文字列を固定で持っているわけではなく、3層構造で組み立てられます。session/llm.ts:106-131 付近を読むと、次のような順序で連結しているのが分かります。
flowchart TB
A["1. ベース<br/>エージェント固有プロンプト<br/>または プロバイダー標準プロンプト"] --> D
B["2. セッション共通<br/>環境情報 + スキル + AGENTS.md等"] --> D
C["3. ユーザー個別<br/>最後のユーザーメッセージの system フィールド"] --> D
D["結合されたシステムプロンプト"] --> E["LLMへ送信"]
ベース部分にはエージェント固有のプロンプト(exploreやcompactionなど)が入り、エージェントが指定されていなければプロバイダーの標準プロンプトが使われます。セッション共通部分は、現在のワーキングディレクトリ・OS・プロジェクトのAGENTS.mdといった環境情報が詰め込まれます。ユーザー個別部分は、呼び出し側から渡された追加指示です。
興味深いのは、プロンプトキャッシュを意識した2分割構造になっていることです。opencodeはプロバイダー(Anthropicなど)のプロンプトキャッシュ機能を活用したいので、「ヘッダー部(長時間変わらない)」と「それ以外」の2パートに明示的に分けて、ヘッダー部だけをキャッシュに載せられるようにしています。プラグインが途中でシステムプロンプトを書き換えた結果、頭の部分が一致するなら2パート構造を維持する、という気配りまでコードに現れています。
ツールはどう定義されているか
エージェントが使うツールは、src/tool/ 以下に1ファイル1ツールの形で並んでいます。bash.ts、read.ts、edit.ts、grep.ts、glob.ts、webfetch.ts、task.ts……。どのツールも共通のインターフェースを実装していて、tool/tool.ts:36-42 の Tool.Def 型を覗くと、ツールが持つべき要素が簡潔に定義されています。
export interface Def<Parameters extends z.ZodType, M extends Metadata> { id: string description: string parameters: Parameters execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>> formatValidationError?(error: z.ZodError): string}要点は4つです。
id:ツール名。LLMが呼び出すときに使う識別子description:LLMに説明するための文字列。多くのツールはbash.txtのように外部のテキストファイルからインポートしていて、長いプロンプトをソースから分離しているparameters:Zodスキーマ。引数の型と構造を定義し、LLMが投げてきたJSONを実行前にバリデートするexecute:実際のロジック。Contextにはabort(中断シグナル)、ask(権限確認)、metadata(UI向け進捗更新)などが詰まっていて、ツールはここで副作用を起こす
tool/registry.ts:130-156 にはすべてのツールが集約されていて、モデルやエージェントの種別に応じて使えるツールを動的にフィルタリングします。たとえばexploreエージェントには編集系ツールを渡さない、といった絞り込みもここで行われます。プラグインが追加したツールも同じRegistry経由で統合されるので、組み込みツールとプラグインツールに差がありません。
ツール呼び出しのライフサイクルを図にすると、こんな感じです。
sequenceDiagram
participant LLM as LLM
participant SP as Streamループ
participant Perm as Permission
participant Tool as ツール実装
participant FS as 外部リソース<br/>(ファイル/コマンド)
LLM->>SP: tool-call イベント
SP->>SP: Zodで引数バリデート
SP->>Perm: 実行してよい?<br/>(allow/deny/ask)
alt ask
Perm-->>SP: ユーザーに確認ダイアログ
end
Perm-->>SP: 許可
SP->>Tool: execute(args, ctx)
Tool->>FS: 読み書き / コマンド実行
FS-->>Tool: 結果
Tool-->>SP: ExecuteResult
SP->>LLM: tool-result を会話履歴に追加
権限チェックは src/permission/ に切り出されていて、allow / deny / ask の3段階で制御されます。ルールはワイルドカードでマッチし、エージェントごとのデフォルトとユーザー設定がマージされる仕組みです。bashツールに至ってはコマンド文字列をAST解析して、rm や cd などの危険な操作を事前に検出し、より細かい粒度で権限を問い合わせる、という凝った作りになっています。
工夫されているポイント
ここまでで大枠はひととおり掴めましたが、読んでいて「なるほど」と思ったポイントをいくつか紹介します。
Doom Loop検知
エージェントは同じツールを同じ引数で延々と呼び続けてしまう、という失敗モードを起こすことがあります。opencodeは processor.ts:305-327 にDoom Loop検知を仕込んでいて、直近3ステップで同じツール名・同じ入力が続いたら、強制的に権限確認を発動してループを停止させます。暴走するエージェントを、人の手で止めるためのサーキットブレーカーです。
普段Claude Codeを使っていて、ごく稀に「同じことを繰り返して止まらない」挙動を見たことがあった筆者としては、「エージェント実装にはこういう防衛機構を入れるものなんだ」という気付きがありました。LLMは間違えるし、エージェントは間違いを繰り返すものだ、という前提で設計されているのがよく分かります。
コンテキストの自動圧縮
会話履歴が長くなってトークン制限に近づくと、ステップ終了時に自動でcompactionエージェントが起動します(検出は processor.ts:394-397 付近)。これは内部的には「会話履歴を要約するだけの専用プライマリエージェント」で、エージェントを切り替えることで圧縮処理そのものもエージェントの実行モデルに乗せています。専用のコードパスを作らず、既存の仕組みを使い回しているのがスマートです。
「圧縮機能を実装しよう」と言われたら普通は専用の関数を書きそうなものですが、opencodeはエージェントという抽象を信頼して、それを使い回すという選択をしています。冒頭で「意外だった」と挙げたポイントの1つです。
中断処理
ユーザーがESCキーなどで処理を止めたときのために、opencodeは随所に AbortSignal を流しています。LLMのストリームも、bashで起動した子プロセスも、同じシグナルで止まります。Streamループの後片付け(processor.ts:457 付近の cleanup)では、未完了のツール呼び出しを “interrupted” 状態に統一して、UIの表示が一貫するようにしています。
リトライとプロンプトキャッシュ
LLM呼び出しは一時的なエラーで失敗することがあり、session/retry.ts のポリシーに従って自動リトライされます。またシステムプロンプトは前述のとおりキャッシュを意識した2分割構造で組み立てられ、長いベースプロンプトをプロバイダー側でキャッシュに乗せてトークンコストを減らす、という実利的な工夫も入っています。
まとめ
opencodeのソースから見えてきたのは、次のような設計でした。
- エージェントのメインロジックは3層のループ(Step / Stream / LLM)に整理されている
- LLM呼び出しはVercel AI SDK の
streamText()を土台にして、その周辺で前処理・後処理を載せている - エージェントはprimaryとsubagentの2モードがあり、Taskツール経由でサブエージェントに委譲できる
- ツールはZodスキーマ + Effectで型安全に定義され、descriptionは外部テキストから注入される
- システムプロンプトは3層構造で組み立てられ、プロンプトキャッシュを意識した2分割も行われる
- ツール実行前には権限チェックが独立したモジュールとして挟まれ、bashはAST解析で粒度を上げている
- Doom Loop検知や自動コンテキスト圧縮など、暴走や長時間セッションへの対策が組み込まれている
「LLMにツールを渡して呼ばせる」という素朴なアイデアから、実用に耐える形に仕立てるためには、ストリーミング処理・権限管理・サブエージェント・コンテキスト管理・エラーリカバリといった数多くの問題を解く必要があることが、コードから伝わってきました。そして冒頭で挙げた「Taskツール経由のサブエージェントはどうやってコンテキストを分離しているのか」という疑問も、サブエージェントは別のシステムプロンプト・別のツール・別の会話履歴を持つ独立したエージェントとして起動され、親には結果だけが戻るという形で答えが出ました。
次回予告
今回は全体フローに絞って紹介しましたが、掘り下げたいポイントが大きく3つ残っています。
- 権限システムの詳細:
src/permission/の allow/deny/ask マッチングと、bashツールの AST 解析による権限粒度の上げ方 - ツール実装の個別解説:bashツール・editツールあたりを深掘りし、「どこまで自前実装で、どこから外部ツール(Ripgrep, Tree-sitter)に任せているのか」を追う
- Effect ランタイムの使い方:opencodeは Effect を全面採用しているので、その使いこなし方そのものが1本の記事になりそう
どれから書くかは、読んでいて一番おもしろかったテーマから取り上げる予定です。AIエージェントの中身が気になった人は、ぜひopencodeのソースを眺めてみてください。全体像を持ってから読むと、思ったほど難しくないはずです。
opencodeのソースを読むとね、AIエージェントの中身は意外と整理されてて、Stepループ・Streamループ・LLM呼び出しの3層構造になってるんだよ!LLMの呼び出し自体はVercel AI SDKに任せちゃってて、opencode側はループ制御とツール管理と権限チェックに集中してるのが面白いんだ。Claude Codeを普段使ってる人は、Claude Code入門と読み比べると「あの機能はこう動いてたのか〜」って気付きがたくさんあると思うよ!気になった人は、まず tool/tool.ts の130行から読み始めてみてね!