tk3.biz
ブログ一覧に戻る Back to Blog

dev-orchestra に自作のエージェントを繋いでみた(ローカルLLM編) Connecting My Own Agent to dev-orchestra (Local LLM Edition)

はじめに

前回は、dev-orchestra のレビュアーとロールの組み替え方を書きました。 今回は、Claude Code・Codex・Antigravity 以外の AI を、自分で書いたアダプタで繋ぐ方法です。例として、手元で動かしているローカル LLM をレビュアーに加えます。

ローカル LLM を繋ぐ話は、これまでにも 2 本書いています。

この記事では経緯は省いて、dev-orchestra 1.0.0 で一からアダプタを書く手順に絞ります。

dev-orchestra は GitHub で公開しています(MIT ライセンス)。役に立ちそうと思ったら、スターを付けてもらえるとうれしいです。

→ github.com/istb16/dev-orchestra

仕組み:アダプタは「コマンドを組み立てる」だけ

dev-orchestra は、AI を直接呼びません。CLI を起動して、プロンプトを標準入力で渡し、答えを標準出力から読むだけです。 その CLI のコマンドを組み立てるのがアダプタ(provider)です。

部品役目
dev-orchestraプロンプトを作り、アダプタが返したコマンドを起動する
アダプタ「どのコマンドを、どの引数で起動するか」を返す
CLI標準入力のプロンプトを AI に渡し、答えを標準出力に書く

つまり、標準入力で受けて標準出力で返す CLI さえあれば、どんな AI でも繋げます。

手順

1. プロンプトを受けて答えを返す CLI を用意する

ローカル LLM(llama.cpp の llama-server など)は、OpenAI 互換の API を出しています。そこにプロンプトを投げて、答えを出力するだけの CLI を書きます。標準ライブラリだけで書けます。

"""Send a prompt from stdin to a local OpenAI-compatible server and print the answer."""

import json
import os
import sys
import urllib.request

URL = os.environ.get("LOCAL_LLM_URL", "http://127.0.0.1:8080") + "/v1/chat/completions"


def main():
    # dev-orchestra writes the prompt as UTF-8, whatever the OS locale is
    sys.stdin.reconfigure(encoding="utf-8")
    prompt = sys.stdin.read()
    body = json.dumps({"messages": [{"role": "user", "content": prompt}]}).encode("utf-8")
    request = urllib.request.Request(URL, data=body, headers={"Content-Type": "application/json"})
    # Ignore proxy settings so the prompt never leaves this machine
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    with opener.open(request, timeout=1800) as response:
        answer = json.load(response)["choices"][0]["message"]["content"]
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stdout.write(answer)


if __name__ == "__main__":
    main()

これを local_llm_review.py として、好きな場所に保存しておきます。

この CLI で気をつけたのは 2 つです。

書いたこと理由
sys.stdin.reconfigure(encoding="utf-8")dev-orchestra はプロンプトを UTF-8 で書き込む。日本語の Windows は既定で cp932 で読むので、日本語のコメントを含む差分が化けたり、読み込みで落ちたりする
ProxyHandler({}) で作った opener を使うurlopen は HTTP_PROXY などの環境変数や Windows のプロキシ設定に従う。127.0.0.1 宛てでも、プロキシ経由でコードが外に出ることがある

ローカル LLM のサーバーは、127.0.0.1(localhost)だけで待ち受けるようにしておきます。0.0.0.0 で待ち受けると、同じネットワークの誰からでも使えてしまいます。

2. アダプタを書く

次にアダプタです。dev-orchestra のドキュメントにある最小の例をもとに、ローカル LLM 用に書きました。

"""Adapter for a local LLM behind an OpenAI-compatible server. Review only."""

from __future__ import annotations

import sys
from typing import Any, Dict, List, Optional, Sequence

from orchestrator.providers.base import (
    READ_ONLY_MODES,
    ModelCandidate,
    ModelResolutionError,
    Provider,
    ResolvedModel,
)

# The small CLI that sends the prompt to the local server
CLI = r"C:\path\to\local_llm_review.py"


class LocalLLMProvider(Provider):
    name = "localllm"
    display_name = "Local LLM"
    executable = sys.executable

    fallback_models = (ModelCandidate("", "default", "server default", "builtin-fallback"),)
    fallback_updated = "2026-10-12"
    option_keys = ("args",)

    def resolve_latest(self, family: str) -> ResolvedModel:
        if family in ("", "default"):
            return ResolvedModel(self.name, "default", "latest", None, "server default", "cli-default")
        raise ModelResolutionError("localllm: only the default model is supported, not %r" % family)

    def build_command(
        self,
        mode: str,
        resolved: ResolvedModel,
        cwd: str,
        extra_args: Sequence[str] = (),
        options: Optional[Dict[str, Any]] = None,
    ) -> List[str]:
        # The CLI cannot read or write files, so it can only review a diff it is given.
        if mode not in READ_ONLY_MODES:
            raise ModelResolutionError("localllm cannot implement: it has no file access")
        return [self.executable, CLI] + self.option_args(options)


def build_provider(executable: Optional[str] = None) -> LocalLLMProvider:
    return LocalLLMProvider(executable)

ポイントは 4 つです。

ポイント理由
from orchestrator.providers.base import ...このファイルはプラグインのパッケージの外から読み込まれるので、相対 import(from .base)は使えない
name = "localllm"設定の provider: に書く名前。claude・codex・agy・mock は使えない
resolve_latest は default だけ受け付ける確かめられないモデル名では推測せずに止める。dev-orchestra 全体の決まりに合わせる
読み取り専用でない実行(実装)は断るこの CLI はファイルを読み書きできない。実装役に置かれても、何も変えずに成功したふりをしないように

CLI には、手順 1 で保存したファイルの本当の場所に書き換えます。例の r"..." は、Windows のパスの \ をそのまま書くためのものです。macOS / Linux なら CLI = "/home/you/local_llm_review.py" のように書きます。

executable = sys.executable なので、dev-orchestra がインストールを確かめるときに動かすのは python --version です。CLI の側に --version を用意する必要はありません。

3. 設定ディレクトリの providers/ に置く

アダプタは、設定ディレクトリの providers/ に置きます。

OS置き場所
Windows%APPDATA%\dev-orchestra\providers\
macOS / Linux~/.config/dev-orchestra/providers/

ファイル名は localllm.py のように、識別子として使える名前にします。

プラグインの中(~/.claude/plugins/cache/...)に置かないでください。そこはプラグインを更新すると消えます。設定ディレクトリなら、設定ファイル(config.yaml)と同じ場所なので、更新しても消えません。

Windows で Microsoft Store 版の Python を使っていると、%APPDATA% に置いたファイルが Python からは見えないことがあります(以前の記事で半日はまりました)。doctor がこれを見つけると、本当の置き場所と対処のしかたを表示してくれます。

4. doctor で読み込まれたか確かめる

アダプタを置いたら、何よりも先に dev-orchestra doctor を実行します(エージェントに頼んでも同じです)。こう出ました(抜粋、パスは省略)。

Local LLM (localllm)
  Installed: yes
  Source: user module <設定ディレクトリ>\providers\localllm.py
  Model selection: supported
  Models (builtin-fallback): server default
  Read-only runs: not reported by this adapter
  Resume: NOT SUPPORTED -- localllm does not resume sessions

User providers
  Directory: <設定ディレクトリ>\providers
  Code in this directory is imported at startup, from outside the plugin.
  Imported: localllm  <- <設定ディレクトリ>\providers\localllm.py

どのファイルから読み込んだのかまで表示されます。最後に No problems found. と出れば成功です。 書き間違いがあっても dev-orchestra 全体は止まらず、doctor がそのファイルとエラーを教えてくれます。

5. レビュアーとして登録する

レビュアーに加えます。--model default を忘れずに付けます。

dev-orchestra reviewer add --provider localllm --model default --role general

私は最初 --model を付け忘れました。すると系列名が recommended-coding で登録され、レビューのときに自作のアダプタが only the default model is supported, not 'recommended-coding' と断りました。手順 2 で「確かめられない名前では止める」と書いたとおりの動きです。

6. レビューさせてみる

前回の記事で使った、バグを仕込んだサンプルで試しました。 ただし、この確認で見たいのは仕組みが通るかです。そこで、ローカル LLM の代わりに、決まった答えを返すダミーのサーバーを使いました。

ok      localllm-general   localllm server default     1 finding(s)

1 successful, 0 failed

自作のアダプタを通して、ダミーのサーバーの答えが返ってきました。確かめられたのは、アダプタの繋ぎ込みと、答えの書式の読み取りまでです。答えが dev-orchestra の決まった書式(## Finding …)になっていれば、ほかのレビュアーの指摘と同じように重複の統合やトリアージにかかります。

実際のローカル LLM のレビューの実力は、以前の記事(商用 2 つと比べて、6 件中 1 件が採用)や機密コードの記事に書いています。

7. 実装を任せようとすると、断ることを確かめる

念のため、実装役をこのアダプタにして実行してみました。

localllm cannot implement: it has no file access

CLI を起動する前に止まりました。うっかり実装役に置いても、「成功したのに何も変わっていない」という気づきにくい失敗にはなりません。

できる役・できない役

役このアダプタで理由
レビュー○差分はプロンプトで渡されるので、ファイルを読めなくても成り立つ
設計△動くが、渡されたプロンプトしか見えない。関連するファイルを自分で調べられない
実装・修正×ファイルを書けないので、アダプタで断っている

Claude Code や Codex は、関連するファイルを自分で追いかけて調べられます。この CLI は渡されたものしか見えないので、別の視点を足すレビュアーとして使うのが向いています。

注意すること

  • providers/ のファイルは、dev-orchestra を起動するたびに自分の権限で実行されます。サンドボックスではありません。自分で書いたものか、中身を読んで信頼できるものだけを置きます
  • ローカル LLM のサーバーは localhost だけで待ち受けます(手順 1 の注意)
  • CLI の側では、プロキシを通さずに 127.0.0.1 へ送ります(手順 1 の ProxyHandler({}))。LOCAL_LLM_URL を変えるときも、手元のアドレスにします
  • コードを外に出さないつもりなら、Claude Code などのクラウドの AI から dev-orchestra を呼ばないようにします。呼び出し元の AI がコードを読むからです。ターミナルから直接実行し、--only でローカル LLM に絞ります(機密コードの記事)
  • 自作のアダプタは、プリセットには自動で入りません。設定で名前を挙げたときだけ動きます
  • doctor の Read-only runs: not reported by this adapter は、このアダプタが「読み取り専用をどう守るか」を報告していないという意味です。この CLI はそもそもファイルに触れないので実害はありませんが、ファイルを触れる CLI を繋ぐときは、読み取り専用を本当に守れるかを自分で確かめる必要があります
  • アダプタが頼る部分(この記事の例で使っているもの)は、1.0.0 の互換性の約束に入っています。それでも、dev-orchestra を更新したら doctor を一度実行しておくと安心です

まとめ

  • dev-orchestra は CLI を起動するだけ。標準入力で受けて標準出力で返す CLI があれば、どんな AI でも繋げる
  • アダプタは 50 行ほど。orchestrator.providers.base から import し、resolve_latest と build_command を書く
  • 置き場所は設定ディレクトリの providers/。プラグインを更新しても消えない
  • 置いたら doctor で、どのファイルから読み込んだかを確かめる
  • 登録は reviewer add --provider localllm --model default --role general
  • ファイルに触れない CLI はレビュー専用にして、実装はアダプタで断る
  • providers/ のコードは自分の権限で実行される。信頼できるものだけを置く

細かな決まりは providers のドキュメント にあります。

GitHub: istb16/dev-orchestra(スターを付けてもらえると励みになります)


English version

Introduction

Last time, I wrote about rearranging reviewers and roles in dev-orchestra. This time, I connect an AI other than Claude Code, Codex, or Antigravity with an adapter I wrote myself. As an example, I add the local LLM I run on my own machine as a reviewer.

I’ve written two posts about connecting a local LLM before.

This post skips the backstory and focuses on the steps to write an adapter from scratch in dev-orchestra 1.0.0.

dev-orchestra is open source on GitHub (MIT license). If it looks useful, a star would mean a lot.

→ github.com/istb16/dev-orchestra

How it works: the adapter just “builds a command”

dev-orchestra doesn’t call AIs directly. It just starts a CLI, passes the prompt on stdin, and reads the answer from stdout. The adapter (provider) is what builds the command for that CLI.

PartRole
dev-orchestraBuilds the prompt and starts the command the adapter returns
AdapterReturns “which command to start, with which arguments”
CLIPasses the prompt from stdin to the AI and writes the answer to stdout

In other words, any AI can be connected as long as you have a CLI that reads stdin and writes stdout.

Steps

1. Prepare a CLI that takes a prompt and returns an answer

Local LLMs (such as llama.cpp’s llama-server) expose an OpenAI-compatible API. I wrote a CLI that just sends the prompt there and prints the answer. The standard library is enough.

"""Send a prompt from stdin to a local OpenAI-compatible server and print the answer."""

import json
import os
import sys
import urllib.request

URL = os.environ.get("LOCAL_LLM_URL", "http://127.0.0.1:8080") + "/v1/chat/completions"


def main():
    # dev-orchestra writes the prompt as UTF-8, whatever the OS locale is
    sys.stdin.reconfigure(encoding="utf-8")
    prompt = sys.stdin.read()
    body = json.dumps({"messages": [{"role": "user", "content": prompt}]}).encode("utf-8")
    request = urllib.request.Request(URL, data=body, headers={"Content-Type": "application/json"})
    # Ignore proxy settings so the prompt never leaves this machine
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    with opener.open(request, timeout=1800) as response:
        answer = json.load(response)["choices"][0]["message"]["content"]
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stdout.write(answer)


if __name__ == "__main__":
    main()

Save this as local_llm_review.py wherever you like.

There were two things I was careful about in this CLI.

What I wroteWhy
sys.stdin.reconfigure(encoding="utf-8")dev-orchestra writes the prompt as UTF-8. Japanese Windows reads cp932 by default, so diffs containing Japanese comments get garbled or crash on read
Use an opener built with ProxyHandler({})urlopen follows environment variables like HTTP_PROXY and the Windows proxy settings. Even for 127.0.0.1, your code can leave the machine through a proxy

Make the local LLM server listen only on 127.0.0.1 (localhost). If it listens on 0.0.0.0, anyone on the same network can use it.

2. Write the adapter

Next is the adapter. I based it on the minimal example in the dev-orchestra docs and wrote it for the local LLM.

"""Adapter for a local LLM behind an OpenAI-compatible server. Review only."""

from __future__ import annotations

import sys
from typing import Any, Dict, List, Optional, Sequence

from orchestrator.providers.base import (
    READ_ONLY_MODES,
    ModelCandidate,
    ModelResolutionError,
    Provider,
    ResolvedModel,
)

# The small CLI that sends the prompt to the local server
CLI = r"C:\path\to\local_llm_review.py"


class LocalLLMProvider(Provider):
    name = "localllm"
    display_name = "Local LLM"
    executable = sys.executable

    fallback_models = (ModelCandidate("", "default", "server default", "builtin-fallback"),)
    fallback_updated = "2026-10-12"
    option_keys = ("args",)

    def resolve_latest(self, family: str) -> ResolvedModel:
        if family in ("", "default"):
            return ResolvedModel(self.name, "default", "latest", None, "server default", "cli-default")
        raise ModelResolutionError("localllm: only the default model is supported, not %r" % family)

    def build_command(
        self,
        mode: str,
        resolved: ResolvedModel,
        cwd: str,
        extra_args: Sequence[str] = (),
        options: Optional[Dict[str, Any]] = None,
    ) -> List[str]:
        # The CLI cannot read or write files, so it can only review a diff it is given.
        if mode not in READ_ONLY_MODES:
            raise ModelResolutionError("localllm cannot implement: it has no file access")
        return [self.executable, CLI] + self.option_args(options)


def build_provider(executable: Optional[str] = None) -> LocalLLMProvider:
    return LocalLLMProvider(executable)

There are four key points.

PointWhy
from orchestrator.providers.base import ...This file is loaded from outside the plugin’s package, so relative imports (from .base) don’t work
name = "localllm"The name you write in provider: in the config. claude, codex, agy, and mock are not allowed
resolve_latest accepts only defaultFor a model name it can’t verify, it stops instead of guessing, in line with dev-orchestra’s rule everywhere
Refuse runs that aren’t read-only (implementation)This CLI can’t read or write files. Even if it’s put in the implementer role, it should never pretend to succeed without changing anything

Change CLI to the actual location of the file you saved in step 1. The r"..." in the example lets you write the \ in Windows paths as is. On macOS / Linux, write something like CLI = "/home/you/local_llm_review.py".

Since executable = sys.executable, what dev-orchestra runs to check the installation is python --version. The CLI doesn’t need its own --version.

3. Put it in providers/ in the config directory

The adapter goes in providers/ in the config directory.

OSLocation
Windows%APPDATA%\dev-orchestra\providers\
macOS / Linux~/.config/dev-orchestra/providers/

Give the file a name that works as an identifier, such as localllm.py.

Don’t put it inside the plugin (~/.claude/plugins/cache/...). Anything there is deleted when the plugin updates. The config directory is where the config file (config.yaml) lives, so it survives updates.

On Windows, if you use the Microsoft Store version of Python, files you put in %APPDATA% may not be visible to Python (I lost half a day to this in an earlier post (in Japanese)). If doctor detects this, it shows the real location and how to fix it.

4. Check that it loaded with doctor

Once the adapter is in place, run dev-orchestra doctor before anything else (asking the agent to do it works too). Here’s what I got (excerpt, paths omitted).

Local LLM (localllm)
  Installed: yes
  Source: user module <config dir>\providers\localllm.py
  Model selection: supported
  Models (builtin-fallback): server default
  Read-only runs: not reported by this adapter
  Resume: NOT SUPPORTED -- localllm does not resume sessions

User providers
  Directory: <config dir>\providers
  Code in this directory is imported at startup, from outside the plugin.
  Imported: localllm  <- <config dir>\providers\localllm.py

It even shows which file it was loaded from. If it ends with No problems found., you’re done. Even if you make a typo, dev-orchestra as a whole keeps working, and doctor tells you the file and the error.

5. Register it as a reviewer

Add it as a reviewer. Don’t forget --model default.

dev-orchestra reviewer add --provider localllm --model default --role general

At first I forgot --model. It got registered with the family name recommended-coding, and at review time my adapter refused with only the default model is supported, not 'recommended-coding'. That’s exactly the “stop on names it can’t verify” behavior from step 2.

6. Have it review

I tried it on the sample with planted bugs from the previous post. But what I wanted to check here was whether the plumbing works. So instead of the local LLM, I used a dummy server that returns a fixed answer.

ok      localllm-general   localllm server default     1 finding(s)

1 successful, 0 failed

The dummy server’s answer came back through my adapter. What this confirmed is the adapter wiring and the parsing of the answer format. If the answer follows dev-orchestra’s format (## Finding …), it goes through deduplication and triage just like the other reviewers’ findings.

For how well a real local LLM actually reviews, see an earlier post (in Japanese) (compared with two commercial ones, 1 of 6 findings was adopted) and the confidential-code post (in Japanese).

7. Confirm it refuses when asked to implement

Just to be safe, I set this adapter as the implementer and ran it.

localllm cannot implement: it has no file access

It stopped before starting the CLI. Even if you put it in the implementer role by mistake, you won’t get the hard-to-notice failure of “it succeeded but nothing changed.”

Roles it can and can’t take

RoleWith this adapterWhy
ReviewYesThe diff is passed in the prompt, so it works without reading files
DesignPartlyIt runs, but it only sees the prompt it’s given. It can’t look up related files on its own
Implementation / fixingNoIt can’t write files, so the adapter refuses

Claude Code and Codex can follow and investigate related files on their own. This CLI only sees what it’s given, so it works best as a reviewer that adds another perspective.

Cautions

  • Files in providers/ run with your permissions every time dev-orchestra starts. There’s no sandbox. Only put in files you wrote yourself, or ones you’ve read and trust
  • The local LLM server listens only on localhost (the note in step 1)
  • On the CLI side, send to 127.0.0.1 without going through a proxy (ProxyHandler({}) in step 1). If you change LOCAL_LLM_URL, keep it a local address too
  • If you mean to keep your code on your machine, don’t call dev-orchestra from a cloud AI like Claude Code, because the calling AI reads the code. Run it directly from the terminal and narrow it to the local LLM with --only (the confidential-code post (in Japanese))
  • Your own adapter is not added to presets automatically. It only runs when you name it in the config
  • Read-only runs: not reported by this adapter in doctor means this adapter doesn’t report how it keeps runs read-only. This CLI never touches files, so there’s no real harm, but when you connect a CLI that can touch files, you need to check for yourself that it really stays read-only
  • The parts adapters rely on (the ones used in this post’s example) are covered by the 1.0.0 compatibility promise. Still, it’s reassuring to run doctor once after updating dev-orchestra

Summary

  • dev-orchestra just starts a CLI. With a CLI that reads stdin and writes stdout, any AI can be connected
  • The adapter is about 50 lines. Import from orchestrator.providers.base and write resolve_latest and build_command
  • It goes in providers/ in the config directory, which survives plugin updates
  • Once it’s in place, use doctor to check which file it was loaded from
  • Register it with reviewer add --provider localllm --model default --role general
  • Make a CLI that can’t touch files review-only, and have the adapter refuse implementation
  • Code in providers/ runs with your permissions. Only put in what you trust

The detailed rules are in the providers docs.

GitHub: istb16/dev-orchestra (a star is always appreciated)

関連記事 Related Posts

機密コードをクラウドに出さずにAIでレビューする(ローカルLLM × dev-orchestra) 機密コードをクラウドに出さずにAIでレビューする(ローカルLLM × dev-orchestra)

社外に出せないコードを、クラウドのAIに送らずにレビューする方法をまとめました。ローカルLLMをdev-orchestraのレビュアーに登録し、機密のものはローカルだけで、公開してよいものはクラウドのAIと併用します。ローカルLLMのレビューの実力と、呼び出し方ひとつでコードが外に出てしまう落とし穴も書きます。 社外に出せないコードを、クラウドのAIに送らずにレビューする方法をまとめました。ローカルLLMをdev-orchestraのレビュアーに登録し、機密のものはローカルだけで、公開してよいものはクラウドのAIと併用します。ローカルLLMのレビューの実力と、呼び出し方ひとつでコードが外に出てしまう落とし穴も書きます。

自分が書いた1418行を3つのAIにレビューさせたら、実バグが11件出た 自分が書いた1418行を3つのAIにレビューさせたら、実バグが11件出た

Claude・Codex・ローカルQwenの3枚に、自分で書いて自分で動かしていたスクリプトをレビューさせました。3件は自力では気づけないもので、1件は「レビュー結果を集計するスクリプト自体」のバグでした。一方で、3枚とも見逃した欠陥もあります。 Claude・Codex・ローカルQwenの3枚に、自分で書いて自分で動かしていたスクリプトをレビューさせました。3件は自力では気づけないもので、1件は「レビュー結果を集計するスクリプト自体」のバグでした。一方で、3枚とも見逃した欠陥もあります。

ローカルLLMをdev-orchestraのコードレビュアーに追加してみた ローカルLLMをdev-orchestraのコードレビュアーに追加してみた

自宅PCで動かしているQwenを、複数モデルで独立レビューするdev-orchestraの3枚目のレビュアーとして組み込みました。プロバイダがAPIではなくCLIをラップする設計だったので、薄いCLIを1枚挟んで繋いだ話です。その後の公式対応と、Store版Pythonのパス罠についても追記しました。 自宅PCで動かしているQwenを、複数モデルで独立レビューするdev-orchestraの3枚目のレビュアーとして組み込みました。プロバイダがAPIではなくCLIをラップする設計だったので、薄いCLIを1枚挟んで繋いだ話です。その後の公式対応と、Store版Pythonのパス罠についても追記しました。