VS Code + Cline で ”Devin 風”開発を行う方法⑦ - マルチエージェントループ実行編 #3【LangGraph 版】

前回の記事では、Kanban 内部でエージェントループを動作させた結果、推論の停止やセッションごとの動作のばらつきなど、長時間運用するうえでいくつかの問題があることを確認しました。

そこで今回は、これまで Kanban 内部に持たせていたループ制御を外部へ移し「LangGraph」というフレームワークを使用してエージェントループを構築してみます。

LangGraph は、複数の処理をノードとして定義し、処理結果に応じた分岐やループ、状態管理などをプログラム側で制御できるフレームワークです。

これまでの構成では、Worker や Reviewer の実行だけでなく、次にどの処理へ進むかという制御にも Kanban 内のワークフローを使用していました。今回からは、LLM による推論が必要な部分と、タスクの遷移や再試行などの制御処理を分離していきます。

まずは第一段階として、ローカル LLM のみを使用し、

Worker → Reviewer → 判定 → 必要に応じて Worker へ戻る

という基本的なエージェントループを LangGraph 上で動作させるところまで実装します。

将来的には、ローカル LLM で解決できない場合にクラウド LLM へ処理を引き継ぐ仕組みなども追加する予定ですが、本記事ではその前段階となる外部ループの構築についてご紹介します。

今回の記事を作成するにあたり、以下の講座で勉強させていただきました。「LangChain」と呼ばれる、LLM を利用したアプリケーションを構築するためのフレームワークと、LangGraph や RAG を使用して、ローカルに保存されている PDF 資料を参照して ChatGPT のように答えてくれるアプリケーションを作成されています。

Udemy

LangChain入門: PythonによるAIアプリ開発

この記事で使用している実行環境は以下のようになっています。

  • VS Code (Windows 版)・・・バージョン 1.134.0
  • Node.js (Windows 版)・・・バージョン 24.15.0
  • Cline・・・バージョン 3.0.48
  • Kanban・・・バージョン 0.1.70
  • Python・・・バージョン 3.12.13
  • LangGraph・・・バージョン 1.2.10

※本記事でご紹介している内容は、確立された手法ではなく著者の独自の解釈に基づいています。その点をご理解のうえご参照ください。

本記事でご紹介したサンプルコードは記事末尾からダウンロードできます。

動作環境について
Miniconda の環境作成とパッケージのインストール

LangGraph は Python や TypeScript を使用して開発できますが、今回は「Miniconda」と呼ばれる Python の環境管理ツールを使用しています。Miniconda のインストール方法はこちらの記事をご参照ください。

まず Anaconda Prompt と呼ばれるターミナル画面を起動します。普通のターミナル画面でも問題ありませんが、専用のターミナルはプロンプトに現在の環境を表示してくれるのでわかり易いかもしれません。

VS Code や普通のターミナル画面で実行したい場合は、Miniconda が「C:\miniconda3」にインストールされていると仮定すると

C:\miniconda3\shell\condabin\conda-hook.ps1
conda activate base

などと入力すると同じ表示になります。

少しわき道にそれましたが、Anaconda Prompt に以下のように入力して新しい環境を作成して有効化します。ここでは例として「langgraphv1.2」という環境を作成しています。途中で「Proceed ([y]/n)?」と表示されたら「y」と入力して継続します。

conda update -n base -c defaults conda
conda create -n langgraphv1.2 python=3.12
conda activate langgraphv1.2

環境が作成できたら、以下のパッケージをインストールします。

python -m pip install langchain
python -m pip install langgraph
python -m pip install langchain-ollama

追加で以下のパッケージもインストールします。

# SQLite パッケージ
pip install -U langgraph-checkpoint-sqlite

# watchdoc パッケージ
python -m pip install watchdog

# pydantic パッケージ (型チェックを厳密に行う)
python -m pip install pydantic

# python-dotenv パッケージ
python -m pip install python-dotenv

以上でパッケージのインストールは終了です。次に動作テストを行います。

LangGraph (LangChain) の動作テスト

Ollama を使用してローカル環境の LLM で動作確認を行います。VS Code で任意のフォルダを開いて、「lang_graph.py」などの任意の Python ファイルを作成します。画面の下に表示されている「インタープリターの選択」をクリックします。

上部に表示されたリストから、先ほど作成した環境 (例では langgraphv1.2) を選択します。

作成した Python ファイルに以下のコードを追加して保存します。

from langchain_ollama import ChatOllama

model = ChatOllama(
    model="gpt-oss:20b",
)

response = model.invoke("PythonでHello Worldを書いてください")
print(response.content)

エディタ画面の右上にある実行ボタンを押すとコードが実行されます。ターミナル画面に実行結果が表示されたら正常に動作しています。(Ollama が別の PC 上で動作している場合は、以下のように「base_url」項目で IP アドレスを指定できます。)

以上で環境設定は終了です。

LangGraph について

こちらのチュートリアルを参考にして基本的なしくみを理解します。

LangGraph は以下の要素によって構成されています。

  • 「ノード」と呼ばれる、LLM やツールの呼び出しなどの処理を行う関数
  • 「エッジ」と呼ばれる、ノード間の処理の流れを定義する接続
  • 条件に応じて次に実行するノードを切り替える「コンディショナル・エッジ」
  • ノード間でデータを受け渡す「State (状態)」

これだけでは良く分からないので、実際に何かサンプルを作成して動作を確認します。ここでは現在の時刻を返すツールを使用して、ローカル LLM に時間を聞くサンプルを作成してみます。以下のような構造を作成します。

まずは使用するローカル LLM モデルを定義します。新しいファイル (quick_start.py 等) を作成して以下のコードを記入します。(同じ PC 内で Ollama が起動している場合は「base_url」項目は不要です。)

from langchain_ollama import ChatOllama

model = ChatOllama(
    model="gpt-oss:20b",
    base_url="http://192.168.11.50:11434",
    temperature=0,
)

次に順番が前後しますが、先にツールノード関数を作成します。

from langchain_core.tools import tool
from datetime import datetime

@tool
def get_current_time() -> str:
    """現在の日時を取得します。"""
    return datetime.now().isoformat()

先程定義したモデルとツールを紐付けます。

tools = [get_current_time]
model_with_tools = model.bind_tools(tools)

ローカル LLM を呼び出す関数 (ノード) を定義します。モデルの呼び出しは「invoke」関数で行います。

from langgraph.graph import START, END, MessagesState, StateGraph

def call_model(state: MessagesState):
    response = model_with_tools.invoke(
        state["messages"]
    )

    return {
        "messages": [response]
    }

コンディショナル・エッジ用の判定関数を追加します。LLM がツールの呼び出しが必要と判定した場合に「tool_calls」が真になります。

def route(state: MessagesState):
    last_message = state["messages"][-1]

    if last_message.tool_calls:
        return "tools"

    return END

パーツが揃ったのでグラフを組み立てます。コンディショナル・エッジの最初の引数は接続元のノード名になっています。2 番目の引数は判定する関数、最後の引数はそれぞれの遷移先の定義になっています。判定する関数が返す値により分岐先が切り替わります。

from langgraph.prebuilt import ToolNode

def create_graph():
    builder = StateGraph(MessagesState)
    builder.add_node("agent", call_model)
    builder.add_node("tools", ToolNode(tools))
    builder.add_edge(START,"agent")
    builder.add_conditional_edges(
        "agent",
        route,
        {
            "tools": "tools",
            END: END,
        },
    )
    builder.add_edge("tools","agent")

    return builder.compile()

最後にグラフを実行する処理を追加します。

from langchain_core.messages import HumanMessage

def main():
    graph = create_graph()
    result = graph.invoke(
        {
            "messages": [
                HumanMessage(
                    content="現在の時刻を教えてください。"
                )
            ]
        }
    )
    print(result["messages"][-1].content)

if __name__ == "__main__":
    main()

実際に実行してみると、現在の時刻が表示されています。

本当にツールが呼ばれているか確認するために、VS Code に設定ファイルを追加してデバッグを行います。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Python: quick_start",
      "type": "debugpy",
      "request": "launch",
      "program": "${workspaceFolder}/quick_start.py",
      "cwd": "${workspaceFolder}",
      "console": "integratedTerminal",
      "args": [],
      "justMyCode": true
    }
  ]
}

ブレークポイントを設定してデバッグを行ってみると、特に問題なくツールが呼び出されているようです。

エージェントループの外部化

Kanban 内に構築したエージェントループを LangGraph で実行する方法を考えます。

それぞれのタスクを LangGraph のノードに置き換えれば実現できそうな気がしますが、その方法では上手くいかないようです。

先ほどの LangGraph のノードはローカル LLM が回答を返すまで同期的に待ってくれましたが、今回は Kanban タスクを起動する必要があります。タスクの起動コマンドはタスクが起動すると制御を戻すので、推論 (タスク) が終了するまで同期的に待つ方法がありません。Kanban タスクが終了するまで LangGraph を止めておく必要があります。

そこで LangGraph の「Interrupt / Resume」という機能を導入します。イメージ的には以下のようになっています。

具体的には、LangGraph の「interrupt」関数を実行すると、その時点の実行状態 (チェックポイント) をデータベースやメモリーに保存して停止します。その後 resume (正確には invoke 文) を実行するとデータベースやメモリーから保存時に指定した「スレッド ID」をキーにして以前の実行状態を読み込んで続きの処理を再開します。

説明だけではよく分からないので、何かサンプルを作成します。それほど長いコードではないので一気にご紹介します。

このコードのポイントは以下のようになっています。

  • グラフ作成時にチェックポイントを保存するインスタンスを指定します。(38 行目) このサンプルでは SQLite データベースにチェックポイントを保存しています。
  • wait_task ノードの 17 行目にある「interrupt」関数が呼ばれたら実行状態を保存して停止します。
  • 63 行目の「invoke」関数が呼ばれたらデータベースに保存されている停止状態のデータを読み込んで処理を再開します。(invoke 関数の第 1 引数に Command 関数の処理結果を渡す必要があります。)
  • interrupt 機能を使用するには、invoke 関数の第 2 引数にスレッド ID を持つ json データを渡す必要があります。
from __future__ import annotations

import argparse
from typing import TypedDict
from langgraph.graph import START, END, StateGraph
from langgraph.types import Command, interrupt
from contextlib import ExitStack
from langgraph.checkpoint.sqlite import SqliteSaver

class State(TypedDict):
    data: str

def task_start(state: State):
    print("タスクを開始します。")

def wait_task(state: State):
    interrupt("waiting")

def task_check(state: State):
    print("タスクをチェックします。")

def create_graph():
    graph = StateGraph(State)
    graph.add_node("task_start", task_start)
    graph.add_node("wait_task", wait_task)
    graph.add_node("task_check", task_check)
    graph.add_edge(START, "task_start")
    graph.add_edge("task_start", "wait_task")
    graph.add_edge("wait_task", "task_check")
    graph.add_edge("task_check", END)

    stack = ExitStack()
    db_path = "langgraph.sqlite"
    checkpointer = stack.enter_context(
        SqliteSaver.from_conn_string(str(db_path))
    )

    return graph.compile(checkpointer=checkpointer), stack

def main() -> None:
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("start")
    sub.add_parser("resume")
    args = parser.parse_args()

    thread_id = "thread-12345"
    config = {"configurable": {"thread_id": thread_id}}
    graph, stack = create_graph()

    if args.command == "start":
        initial_state: State = {
            "data": "1234",
        }
        with stack:
            graph.invoke(initial_state, config=config)
            snapshot = graph.get_state(config)
        if snapshot.interrupts:
            print("\nグラフは正常に中断されました。")
    else:
        resume_state = {"status": "completed"}
        with stack:
            graph.invoke(Command(resume=resume_state), config=config)

if __name__ == "__main__":
    main()

実際に実行してみると、意図したとおりに動作しているようです。

Kanban タスクの終了を検知する方法

ここまでで、LangGraph の処理を interrupt 関数で一旦停止し、後から同じスレッド ID を指定して処理を再開できることが確認できました。

しかし、実際のエージェントループでは、人がターミナルから resume コマンドを実行するわけにはいきません。Kanban 上で実行している Worker や Reviewer の状態を外部から監視し、処理が終了したタイミングで自動的に LangGraph を再開する仕組みが必要になります。

そこで、LangGraph とは別に監視用のループを動作させ、実行中の Kanban タスクの状態を定期的に確認する方法を考えます。

監視ループは一定間隔で Kanban のタスクリストを取得するようにします。Kanban には 4 つのレーンがあり、推論中は基本的に「In Progress」レーンで動作しています。

タスクが終了したら、「Review」レーンに移動します。推論途中でもコミットなどの処理が必要になると一時的に Review レーンに移動します。オートコミット機能を有効にしておけば自動でコミット作業を行い、再び「In Progress」レーンに戻ります。また、タスクが停止してしまった場合も「Review」レーンに移動されます。

これらの状態は「kanban task list」コマンドを実行すると取得できます。「tasks」配列に格納されている各タスク情報の「column」の値から現在の状態が取得できます。

====================
Time: 2026-08-20 16:45:46
Command: npx kanban task list
Output:
{
  "ok": true,
  "workspacePath": "D:\\MAMP\\htdocs\\cline\\YelpCampCleanArch",
  "column": null,
  "tasks": [
    {
      "id": "42642",
      "prompt": "/worker-workflow",
      "column": "in_progress",
      "baseRef": "refactor/login",
      "startInPlanMode": false,
      "autoReviewEnabled": true,
      "autoReviewMode": "commit",
      "createdAt": 1787211934478,
      "updatedAt": 1787211945546,
      "session": null
    },
    {
      "id": "bff90",
      "prompt": "/reviewer-workflow",
      "column": "review",
      "baseRef": "refactor/login",
      "startInPlanMode": false,
      "autoReviewEnabled": true,
      "autoReviewMode": "commit",
      "createdAt": 1787211786322,
      "updatedAt": 1787211921532,
      "session": {
        "state": "awaiting_review",
        "agentId": "cline",
        "pid": null,
        "startedAt": 1787211794702,
        "updatedAt": 1787211920731,
        "lastOutputAt": 1787211920731,
        "reviewReason": "exit",
        "exitCode": null
      }
    }
  ],
  "dependencies": [],
  "count": 2
}

これらの情報とループのステータス情報 (status.json) ファイルなどから、以下のような状態になったら停止したとみなします。

  • タスクが「Review」カラムにあり、修正されたファイルがない。(正常または異常終了)
  • タスク ID がタスクリストにない。(タスクが削除または Done レーンに移動)

タスクが「Review」カラムにあり、修正されたファイルがあった場合は以下の処理を行います。

  1. 修正されたファイルをコミットする。
  2. ステータス情報ファイル内にあるループカウンターに初期値 (3) をセットする。
  3. 監視ループから呼び出される度にカウンター値を減算する。
  4. カウンター値が 0 になったら終了とみなす。
  5. 上記の処理中に「In Progress」カラムに移動したら追跡をやめる。

また、タスクが「In Progress」カラムにあっても以下の状態の場合は終了とみなします。

  • 「WORKER」タスクで、ステータスが「WORKER_TASK_DONE」になっている。
  • 「REVIEWER」タスクで、ステータスが「REVIEWER_TASK_DONE」になっている。

この判定で、作成したファイルの上書きや 2 重実行を抑制します。

監視ループから LangGraph を再開する

interrupt のサンプルでも書きましたが、LangGraph を再開するには「スレッド ID」が必要になります。そこで監視ループにスレッド ID を渡す仕組みを簡単にご説明いたします。

まず、LangGraph でタスク作成時に「タスク ID」を取得できますので、以下のような json ファイルを作成します。ファイル名は作成したタスク ID にしておきます。

kanban/
└── python/
   └── lang_graph/
      └── data/
         └── requests/
            └── 2ae34.json

ファイルの内容は以下のような管理情報になっています。この中で最も重要なのが「thread_id」です。最終的に監視ループがこの ID を使用して LangGraph を再開します。

{
    "thread_id":  "thread-a3d92f6e1e56",
    "task_id":  "2ae34",
    "task_type":  "WORKER",
    "baseref":  "refactor/login",
    "loop_status":  "LOOP_STARTED",
    "workspace_path":  "D:\\MAMP\\htdocs\\cline\\YelpCampCleanArch",
    "attempt":  0,
    "status":  "submitted",
    "additional_instruction":  "",
    "created_at":  "2026-08-20T07:59:40.508287+00:00",
    "review_attempt":  0
}

次に、監視ループがこれらのファイルが保存されているフォルダ名を実際に監視を行うスクリプトに渡します。

監視スクリプトではフォルダ内のすべてのファイルに対して、 Kanban タスクの状態を調べて以下のような json 形式の文字列を監視ループに返送します。

{
    "Success":true,
    "Output":[
        {
            "filename":"D:\\MAMP\\htdocs\\cline\\YelpCampCleanArch\\kanban\\python\\lang_graph\\data\\requests\\95ea0.json",
            "id":"2ae34",
            "status":"finished",
            "loopStatus":"WORKER_STARTED"
        }
    ],
    "ExitCode":0
}

監視ループでは「status」が「finished」になったタスクの管理ファイルから「スレッド ID」を取り出して LangGraph を再開します。

再開された LangGraph では監視ループから渡された管理情報を元に、タスクが停止したと判定した場合は、現在のタスクを削除して新しいタスクを作成します。成功した場合は WORKER タスクの場合は REVIEWER タスクを生成します。REVIEWER タスクの場合は、レビューが NG の場合はプランや実装計画を作り直してから、再度 WORKER タスクを生成します。OK の場合は次の実装タスクに移行します。

Reviewer タスクが終了すると、実行結果の成否にかかわらず処理結果が「kanban/history」フォルダ内に記録されます。また、Worker と Reviewer が交互に実行され、最終的に実装タスクが完了していることが Git 画面より確認できます。

まとめ

今回は、これまで Kanban 内部で行っていたエージェントループの制御を LangGraph へ移し、Worker と Reviewer を外部から制御する仕組みを構築しました。

Kanban タスクは起動後すぐに制御を返すため、LangGraph の Interrupt / Resume を利用して一旦処理を停止し、別の監視ループからタスクの終了を検知して処理を再開する構成としています。

これにより、LLM による推論を担当する Kanban と、タスクの遷移や再試行を担当する LangGraph を分離できました。

現時点ではローカル LLM のみで Worker / Reviewer のループを実行していますが、今後はローカル LLM で解決できなかった場合にクラウド LLM へ処理を引き継ぐ仕組みなども追加していく予定です。

まだまだ不安定ですが、今回作成したスクリプトは こちら からダウンロードできます。よろしければご覧ください。

実際に実行するには、ターミナル画面で以下の監視ループを起動しておく必要があります。

python .\kanban\python\lang_graph\watcher.py

また、git の初期化と Lint などを行うパッケージのインストールも必要です。

git init
cd app
composer install

あとは Kanban Agent のプロンプト欄に「/loop-start refactor/login」などと入力するか、直接ターミナル画面に以下のように入力するとループが起動します。

python .\kanban\python\lang_graph\main.py start --baseref refactor/login

今回はここまでとさせていただきます。