Yyatmita

ralph-loop-python の設計思想——TOML 1 枚で別物になるエージェントループ

yatmita で 50 枚以上の TOML を書いて使ってきた自作の自律ループランナーを、ようやく一度きちんと説明する。設計の中心は『TOML を完成品質の言語にしたこと』だった。loop / checks / review / provider / parallel / prompts——TOML の各セクションがどんな思想で設計されているかを順に書く

自分のエージェント基盤を組む#ralph-loop#python#toml#claude-code#agent-stack

英語版を Substack に書きました: One Binary, 50 TOMLs: The Design of ralph-loop第 5 回 bash → Python 編と合本)

ralph-loop という自作の Python CLI を 1 年弱、毎日のように回し続けてきた。yatmita のリポジトリには TOML 設定ファイルが 50 枚以上ある。そのほとんどがこのツールに食わせるためのものだ。

公開はしてこなかった。手元で動けば困らなかったし、説明することと使えることは違うので後回しにしていた。ただ、これだけ TOML を書いていると、「自分はこのツールで何を表現してきたんだろう」が見えてくる。今回はそれを書く。

結論から言うと、ralph-loop の設計の中心は TOML を「完成品質の言語」にしたこと だった。同じバイナリが、ある TOML では「テストが通るまでコードを書く道具」になり、別の TOML では「レシピのチェックスクリプトを通るまで JSON を吐く道具」になり、また別の TOML では「3 つの LLM に同じプロンプトを競わせて一番スコアの高い候補を採用する道具」になる。バイナリは 1 つ、TOML が違う、それだけだ。

第 5 回 で bash から Python に書き直した直後の話は書いた。今回はその続きで、TOML スキーマの各セクションを順に説明しながら「なぜそう設計したか」を残す。

ループ本体はおそろしく単純

中身を先に言う。1 イテレーションでやっているのはこれだけだ。

prompt 組み立て → provider 実行(claude -p など) → checks 実行 → review 実行 → git auto-commit → progress.md 追記

スコアが閾値を超えるか、最大イテレーション・サーキットブレーカー・予算上限のどれかに当たれば止まる。それ以上のことはしていない。

このループ自体には何の独自性もない。Geoffrey Huntley の "Ralph Wiggum as a Software Engineer" で紹介された while ループのアイデアそのものだ。差分は TOML で何を表現できるようにしたか にある。

[loop] セクション——「いつ止めるか」を最初に決める

[loop]
max_iterations = 10
score_threshold = 70
allow_regression = true
checkpoint = true

このセクションは「いつ止めるか」を 4 つのつまみで決める。max_iterations で回数上限、score_threshold で「このスコアまで上がったら成功とみなす」、allow_regression で「スコアが前回より下がったら止めるか」、checkpoint で「state ファイルに保存して --resume で復帰できるようにするか」。

このセクションが TOML の先頭にあるのは偶然ではない。過去に 2 度、ralph-loop を 8 時間暴走させてクラウドの API 課金で青ざめた。「動くまで回せ」と書いた瞬間、LLM は動くまで回す。歯止めはツール側で持っておかないと持続不可能だ。

なので、後述する [budget] と合わせて、TOML を 1 枚書くたびにまず「いつ止まるか」を自分に問い直す運用にしている。

[budget]——コストでも止める

[budget]
max_total_cost_usd = 5.0
warn_at_usd = 3.0

プロバイダの stderr からトークン使用量をパースして累積する。warn_at_usd を超えたら警告ログを出し、max_total_cost_usd を超えたらサーキットブレーカーが落ちる。

イテレーション数だけで止めると、長尺イテレーションが続いて「回数は少ないのに金額がえぐい」事態になる。回数とコストの 2 軸で歯止めを置くと、どちらかが先に当たって暴走を切れる。

[[checks.items]][review]——完成品質を表現する 2 チャンネル

ここが ralph-loop の核だ。[loop] で「いつ止めるか」を決めたら、次に「何をもって成功とするか」を書く。入り口はこの 2 つしかない

[[checks.items]]
name = "test"
command = "python -m pytest"
score = 50
 
[[checks.items]]
name = "lint"
command = "python -m ruff check ."
score = 20
 
[review]
type = "code"
model = "haiku"

[[checks.items]]shell command の exit code 0 で pass[review]LLM 審判が LGTM を返せば pass。シンプルすぎて拍子抜けするかもしれないが、これしかない。

ここで設計上の最重要原則がある。

完成品質は、LLM の編集スコープの外で人間が定義する。

LLM に完成判定を委ねた瞬間、LLM は自分で基準を作り自分で満たして 1 イテで終わる。たとえば tests/test_X.py をチェックにして、その test_X.py を LLM 自身が書ける状況にすると、LLM はテストと実装を両方書いて即終了する。これは「ループしている」ではなく「ループに見えるだけの 1 ショット」だ。

なので TOML に checks を書くときは、いつも次のどちらかにする:

(a) fixture + 判定スクリプト——fixture は人間が用意して凍結し、LLM は判定実装だけ書く

[[checks.items]]
name = "valid-pass"
command = "uv run python src/.../qualify.py tests/fixtures/valid.md"
score = 50
 
[[checks.items]]
name = "invalid-fail"
command = "! uv run python src/.../qualify.py tests/fixtures/invalid.md"
score = 50

(b) TDD prompt + custom review——prompt で「先に再現テストを書いて、それから実装しろ」と指示し、[review] type = "custom" で完成条件を LLM 審判にやらせる

[review]
type = "custom"
prompt_file = "review_prompt.md"
model = "haiku"

ヘビーな案件では (a) と (b) を組み合わせる。score を配分しておくと、「fixture は通ったが review が refuse」「review は通ったが fixture が落ちた」のような部分通過が点数で可視化される。score_threshold = 70 のように設定しておけば、満点でなくても十分な水準で打ち切れる。

[[checks.items]] from_iteration——カリキュラムでチェックを段階解放する

[[checks.items]]
name = "build"
command = "npm run build"
score = 20
from_iteration = 1
 
[[checks.items]]
name = "integration"
command = "npm run test:integration"
score = 30
from_iteration = 3

from_iteration を書くと、「3 回目以降だけこのチェックを足す」ができる。最初から全項目をフルで課すと、「初期段階で完璧を目指して詰まる」ことが多かった。序盤は build だけ、中盤で test、終盤で integration——と段階的に基準を引き上げると、LLM が型を整える時間を作れる。

学習で言うところのカリキュラム学習に近い。これは TOML で表現できる範囲を広げて初めて気づいた使い方で、最初は想定していなかった。設定言語が表現力を持つと、運用パターンが後から増える。

score 配分で fast return を作る

score_threshold は単に「合格ライン」を引いているように見えるが、checks の score 配分とセットで考えると 短絡(fast return)の道具 になる。

[loop]
score_threshold = 70
 
[[checks.items]]
name = "smoke"
command = "uv run python scripts/smoke_test.py"
score = 70           # これだけ通れば即合格
timeout = 30
 
[[checks.items]]
name = "full-suite"
command = "uv run python -m pytest"
score = 30           # 余力で足すが、smoke だけで閾値届く
timeout = 600

軽くて当たり判定の鋭い検証に高めの score を振っておくと、フル回帰スイートを毎回回さなくても合格できる。重い検証は配点を低くしておき、序盤の何度かは「smoke だけ通って合格扱い」で次のフェーズに進める。

逆に、「絶対に外せない単一条件」に 100pt 振って閾値 100 にする 書き方もある。fixture 一致以外は合格を認めない、という厳密モードで使う。

from_iteration と組み合わせれば「最初の 2 回は smoke 70pt で素通り → 3 回目以降は full-suite が加わって基準が上がる」というカリキュラムにもなる。ループを止めるのは閾値だ、と覚えておくと、配点を 手元のステアリングホイール として使い始める。

[claude] から [provider] へ——プロバイダ抽象化

最初は [claude] セクションしかなかった。Claude CLI を呼ぶことだけを考えていた。

[claude]
model = "sonnet"
extra_args = ["--dangerously-skip-permissions"]
timeout = 600

途中で「他の CLI も使いたい」と思い、[provider] という汎用セクションを追加した。

[provider]
command = "gemini"           # 内部は Antigravity CLI (agy) を呼ぶ
model = "gemini-3-pro"
args = ["--dangerously-skip-permissions"]
timeout = 900

Codex / Gemini / Shannon / OpenCode のような Claude 以外の LLM CLI を、TOML を書き換えるだけで切り替えられる。プリセットを書いておくと ralph-loop --provider codex のような CLI フラグでも切り替えられる。

特に Shannon が地味に効いた。claude -p の従量課金を避けて、tmux で起動したインタラクティブな Claude を JSONL transcript 経由で claude -p 互換出力に整形する小細工で、サブスク枠の Claude を ralph-loop から呼べるようにした。claude -p は将来課金化する可能性があるので、いずれ全面的に shannon に寄せる選択肢を残しておきたい。これも TOML の 1 行で切れる。

[parallel]——Best-of-N で「LLM の気分」を平均化する

[parallel]
candidates = 3
 
[[parallel.providers]]
command = "claude"
model = "sonnet"
 
[[parallel.providers]]
command = "codex"
model = ""
 
[[parallel.providers]]
command = "gemini"
model = "gemini-3-pro"

[parallel] を書くと、同じプロンプトを N 個の候補で並列に実行する。それぞれを git worktree で隔離して、終わったら全候補に対して同じ checks を走らせ、スコアが最も高い候補を採用 して残りは捨てる。

これは「LLM の出力にぶれがある」という事実への割り切りだ。同じプロンプトでも 3 回振れば 3 通り出てくる。1 回で当てに行くより、3 回振って一番マシなものを選ぶほうが安い。プロバイダごとに得意分野が違う仕事では、プロバイダの多様性を入れて competitive にすると、自分が信じきれていなかった候補が勝つことがある。

[[prompts]]——1 ループの中をフェーズ分割する

[[prompts]]
name = "research"
file = "prompts/research.md"
iterations = 5
 
[[prompts]]
name = "implement"
file = "prompts/implement.md"
iterations = 10
model = "opus"
score_threshold = 90

[[prompts]] の配列を書くと、1 ループ内で複数フェーズを順に走らせる。各フェーズで iterations / model / score_threshold を上書きできる。

「調査は安いモデルで広く回し、実装は高いモデルで閾値を厳しく」のような使い分けが TOML で書ける。フェーズの境目で「ここまで来たらモデルを上げる」「ここからスコア基準を厳しくする」が表現できると、1 つの長いプロンプトを書くより詰まりにくい。

retry_prompt_fileoutput_file——2 回目以降は別のプロンプトを注入する

[prompt]
file = "prompt.md"                # 1 回目はこれ
retry_prompt_file = "retry.md"    # 2 回目以降はこれ
output_file = "artifacts/draft.md"  # retry に「この場所に書け」と教える

1 イテ目と 2 イテ目以降で、LLM への要求は本来別物だ。

  • 1 イテ目: 「ゼロから書け」
  • 2 イテ目以降: 「前回の出力を読んで、足りない部分だけ埋めろ

これを 1 つのプロンプトで書こうとすると、「前回の出力があれば〜なければ〜」みたいな分岐を LLM 側に押し付けることになる。retry_prompt_file を分けると、ファイル自体を別物として書ける。

output_file を併記すると、retry プロンプトのループコンテキストに Output file: artifacts/draft.md の 1 行が自動で挿入される。retry.md 側で「output_file を読み込んで残りを足せ」と指示しておくと、LLM は前回成果物を起点に増分編集できる。

地味だが、長尺生成(記事、レポート、コード一式)で効く。ゼロから書き直させると毎回違うものを出して安定しない。前回出力に「追記してくれ」と頼むと、品質が単調増加しやすい。

gap.md パターン——retry_prompt_file × custom review の合わせ技

ここまでの仕組みを組み合わせると、「LLM 自身に残作業リストを書かせて、それを次のイテで消化させる」運用ができる。手元で最もよく使うパターンだ。

[prompt]
file = "prompt.md"
retry_prompt_file = "retry.md"
output_file = "artifacts/draft.md"
 
[review]
type = "custom"
prompt_file = "gap_check.md"
model = "haiku"
 
[[checks.items]]
name = "gap-empty"
command = "test ! -s artifacts/gap.md"
score = 100

1 イテ目の prompt.md:

artifacts/draft.md を書け。書き終わったら、まだ足りない・確信が持てない・要再検証の項目を artifacts/gap.md に箇条書きで残せ。完璧なら gap.md を空ファイルにせよ。

2 イテ目以降の retry.md:

artifacts/draft.mdartifacts/gap.md を読め。gap.md の項目を 1 つずつ潰して draft.md に反映せよ。潰した項目は gap.md から削除。新たに気付いた gap があれば追記。完了したら gap.md を空にせよ。

gap_check.md(custom review):

draft.md と gap.md を読んで、本当に gap.md が空になる水準まで draft が仕上がっているか判定。明らかに薄い箇所があれば REFUSE: <理由> と返せ。問題なければ LGTM のみ。

check は gap.md が空ファイルか だけ。これで 100pt 入る。

この構成の何が嬉しいか。LLM 自身が「何が残っているか」を毎イテ更新するので、人間が修正点を指示し続ける必要がない。LLM は前回の自分が書いた gap.md を信用して仕事を進める。custom review が「gap が本当に空か」をダブルチェックするので、LLM が早とちりして gap を消しても審判が refuse する。

このパターンは、完成品質を「人間所有の判定スクリプト」に閉じ込めにくいタスク(長尺の記事、複雑な仕様書、調査レポート)でとくに効く。fixture を作れない仕事でも、「gap が空か」という形式的判定 +「審判が refuse しない」という意味的判定の二段にできる。

ralph-loop の TOML は、ここまで組むとループの形をした 増分編集システム になる。

[git][pr]——成果物の出口

[git]
auto_commit = true
 
[pr]
enabled = true
auto_branch = true
branch_prefix = "ralph-loop"
draft = true

auto_commit = true にしておくと、各イテレーションでループが追加したファイル変更だけを stage して commit する。LLM が想定外のファイルに触っても、ループの責任範囲外のものは触らない設計にしてある(暴走時の被害を限定するため)。

[pr] を有効にすると、ループ成功時に ralph-loop/<timestamp> のような新ブランチを切ってリモートに push し、gh pr create で PR まで作る。draft で開いておけば、人間がレビューしてから手動で ready に切り替える。

ここまで自動化すると、寝ている間にループ → 朝起きたら PR 一覧、という運用ができる。歯止めを [loop][budget] で固めておけば、寝ている間にクラウド請求書を爆発させずに済む(私は 2 回やった)。

TOML を書いているのは Claude Code

ここまでで TOML を 10 種類くらい貼ったが、これらを 手で書いているわけではない

手元の Claude Code には ralph-loop-python という skill を仕込んである。「この件 ralph-loop で回したい」と話しかけると、skill が立ち上がって prompt.mdralph_loop.tomlドラフトして提案 してくる。中身を眺めて「OK」と返したらループが始まる。50 枚以上の TOML はほとんどこの skill が書いた。

skill の重要な部分は 2 つある。

(1) 実行前にユーザーへ提案する

skill 側にプロトコルが書き込まれていて、ralph-loop を起動する前に必ずこの 3 点を提示してくる:

  • タスク概要: prompt.md の要約(1〜3 行)
  • 完成品質: どの [[checks.items]] + [review] で done を判定するか。人間所有ファイル(fixture / golden / 凍結テスト等)を明記
  • 歯止め: max_iterations / max_cost / score_threshold

ユーザーが GO と言うまで ralph-loop を実行しない。claude -p は将来課金化される可能性のある運用なので、勝手に N 回呼ばせない、というのが大原則だ。

(2) 完成品質の自己点検

skill には設定前のチェックリストが書いてある:

  • この合格条件を LLM は runtime に自分で動かせないか?
  • LLM が prompt 指示で書いたファイル自体が合格基準になっていないか?
  • review プロンプトは「LLM が満たしやすい甘い基準」になっていないか?

「完成品質は人間が定義する」という原則を、設定提案の段階で skill 自身が自分に問わせている。LLM がループ用 TOML を書く以上、「LLM が自分の都合のいい基準を設定する」リスクは構造的にある。それを skill レベルで強制的に問い直させる。

二段ループとも言える。ループの中身は LLMループの設定を書くのも LLM、それを 人間がレビューして GO する。私がやっているのはレビューと GO だけだ。

この構造になってから、TOML を書くのが心理的に楽になった。「設定を間違えるかも」の負担が小さい。skill が罠を先回りで潰してから提案してくれる。

逆に言えば、TOML スキーマと skill は 1 つのプロダクトとして設計しないと意味がない。TOML だけ汎化しても、それを正しく書ける人間がいなければ毎回事故る。skill にハードルを下げる責任を持たせ、TOML に表現力を持たせる、というのが組み合わせで効く。

ralph-loop の外側——IDD と 3big への接続

TOML で書ける範囲はここまでだ。実運用では ralph-loop を 単体で使うより外側のフレームに組み込んで 動かしている。代表的な接続が 2 つある。

IDD(Issue-Driven Development)と組む

yatmita 流 IDD は、GitHub Issue / Milestone / Conventional Commits を agent harness として接収する運用だ。ralph-loop と組むと、Issue 本文が prompt の正本になり、close 条件が完成品質になる

実装はシンプルで、prompt.md の冒頭に「gh issue view <num> で本文と直近コメントを取得し、その DoD を満たすまで作業せよ」と書き、[[checks.items]] の最後に Issue の DoD チェックリストを判定するスクリプトを置く。[git] の auto_commit と [pr] の auto_branch と組み合わせると、Issue を 1 つ立てるだけで「ループ → 完成品質達成 → branch 切って PR」までが GitHub の上で完結する。

ralph-loop は 「何を完成とするか」しか知らない。IDD は 「次に何をやるべきか」を持っている。両方を持って初めて、「Issue 一つ立てたら朝には PR がある」が成立する。

3big と組む

3big-ai-nemu-kaigi は ralph-loop の [parallel] を「マンガのネーム作成」用に特化させたエンジンで、Claude Code / Codex / Gemini→Grok / OpenCode を並列に走らせ、相互レビューと匿名投票で案を決める。

ralph-loop の [parallel] 自体は「同じプロンプトを N 候補で並列 → スコア最大を採用」しか持っていない。3big はそこに (1) 候補同士に相互レビューを書かせる(2) スコア判定を投票(しかも CLI 名を伏せた匿名投票)で行う、という 2 段の上乗せをしている。

なぜそれが効くかは ハーネス多様性の話 で書いたが、要点は「LLM の素の能力差より、CLI(ReAct ハーネス)の個性差のほうが創作の独立変数として効く」。ralph-loop の [parallel] が「同質の N 候補から best を選ぶ」装置だとすれば、3big は「異質な N 候補に互いを評価させて合議で選ぶ」装置になっている。

両方とも、ralph-loop の TOML 単体では書ききれない領域だ。ralph-loop が ループの単位、IDD が タスクの単位、3big が 創作・合議の単位——それぞれ別レイヤーで設計してから接続する、という関係になっている。TOML の設計を単体で完結させない理由はここにある。外から接続される前提で、必要十分の表現力だけ持たせる

TOML を「使い分けの言語」にしてからわかったこと

最初の頃は、ralph-loop を「コードを書かせる道具」だと思っていた。bash 版がそうだったし、Python に書き直したときも同じつもりだった。

実際に毎日 TOML を書いていくと、用途が勝手に増えていった。

  • コード——テストが通るまで Edit / Bash をループ
  • コンテンツ——fixture + custom review で「分量チェック」「事実関係チェック」「トーンチェック」を点数化してループ
  • データ生成——独自スクリプトでフォーマット検証してループ(レシピの分量、JSON スキーマ、CSV 列数)
  • 調査——from_iteration で段階的に深掘りさせ、custom review で「ソースが何件以上揃ったら done」と LLM 審判
  • 比較実験——[parallel] で 3 プロバイダ並べてスコア勝負

このどれもが「同じバイナリ + 違う TOML」で動いている。ループ本体には一切手を入れていない。

道具を 1 度汎用化すると、その後の自分は「TOML を 1 枚書けば動く」という前提で物事を考え始める。新しい用途を思いついたとき、コードを書かなくていい、と最初から知っている。これは思っていた以上に大きな差だった。

設計の中心が TOML なら、TOML を読めれば全部わかる。逆に言えば、TOML スキーマの設計が雑だと、TOML を 50 枚書いた頃に全部が破綻する。歯止め・完成品質・プロバイダ・並列・チェーン・PR ——この順で TOML の意味を積んできたのが、たぶん 1 番大事な意思決定だった。