Appendix B

付録B: 最小エージェントを Python で書く

ここまで 10 章をかけて、「LLM + 道具 + ハーネス」の三角形と、それを回す think → act → observe のリズムを言葉にしてきた。

しかし、本当に腑に落ちるのは 自分で書いたとき だ。 この付録では、Anthropic の Claude API を使って、70〜100 行程度の Python で最小のエージェントを組み上げる

仕組みは本書のとおりだ。LLM に道具のカタログを見せる。LLM が道具を呼ぶ命令を返す。ハーネスが本当に道具を動かす。結果を続きとして LLM に渡す。これを繰り返す ── それだけ。

書き終わったとき、あなたの手元には 本書の骨格そのもの が動いている、はずだ。

B.1 何を作るか

作るのは、こういうエージェントだ。

第1〜4章で見たループの 最小実装 だ。これを段階的に組み上げる。

補足

本書のスコープに合わせ、ここでは Anthropic の Claude API(tool use) を使う。OpenAI / Gemini も API 形は似ているので、終わったあとで好きな API に書き換えられるはずだ。コードは Python 3.10+ を想定する。

B.2 段階 0 ── 準備

依存はひとつだけだ。

pip install anthropic
export ANTHROPIC_API_KEY=sk-ant-...

最低限のインポートと、モデル名を決めておく。本書では Claude Opus 4.5 を基準にする(API 識別子は claude-opus-4-5)。

import os
import subprocess
import anthropic

MODEL = "claude-opus-4-5"  # Claude Opus 4.5。適宜書き換え
client = anthropic.Anthropic()

ここから 5 段階で組み上げる。

B.3 段階 1 ── 補完だけのチャット(道具なし)

まずは「LLM に話しかけて、返事をもらう」だけのコードを書く。 これは本書でいう チャット に相当し、まだ エージェントではない

def chat(user_text: str) -> str:
    resp = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        messages=[{"role": "user", "content": user_text}],
    )
    # 返答は content の中のテキストブロックを連結
    return "".join(b.text for b in resp.content if b.type == "text")

if __name__ == "__main__":
    print(chat("Python で fibonacci(10) を計算する関数を書いて"))

これを動かすと、Claude が コードのテキストを返してくる だけだ。 ファイルは書かれない。シェルも動かない。本書の言葉でいえば、これが ガラスの向こうの専門家 ── まだ世界に手を出していない。

B.4 段階 2 ── 道具を 1 つ定義する(read_file)

ここから、ハーネスを組み始める。 最初の道具は ファイルを読む だけのものにする。

LLM に道具を見せるには、JSON Schema で道具のカタログを定義する (第5章で扱った function calling の話だ)。

TOOLS = [
    {
        "name": "read_file",
        "description": "指定したパスのテキストファイルを読み、中身を返す。",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "読みたいファイルのパス"},
            },
            "required": ["path"],
        },
    }
]

道具の 本体(実装) は普通の Python 関数だ。

def run_tool(name: str, args: dict) -> str:
    if name == "read_file":
        with open(args["path"], "r", encoding="utf-8") as f:
            return f.read()
    return f"unknown tool: {name}"

LLM 呼び出しのときに tools=TOOLS を渡せば、LLM はカタログを見て「read_file を呼んでね」とテキストで返してくれるようになる。

resp = client.messages.create(
    model=MODEL,
    max_tokens=1024,
    tools=TOOLS,
    messages=[{"role": "user", "content": "README.md の冒頭3行を要約して"}],
)
print(resp.stop_reason)  # "tool_use" になるはず
print(resp.content)      # tool_use ブロックが入っている

stop_reasontool_use のとき、LLM は 道具を呼ぼうとしている。 だが、まだ私たちは呼んでいない。次の段階で 本当に呼ぶ ループを書く。

B.5 段階 3 ── tool_use を処理してループを回す

ここが本書の核 ── think → act → observe を回す本体 だ。

def run_agent(user_text: str, max_turns: int = 10) -> str:
    messages = [{"role": "user", "content": user_text}]

    for turn in range(max_turns):
        resp = client.messages.create(
            model=MODEL,
            max_tokens=2048,
            tools=TOOLS,
            messages=messages,
        )
        # アシスタントの応答を履歴に積む(次ターンで参照される)
        messages.append({"role": "assistant", "content": resp.content})

        # ① 道具を呼ばずに終わった → ループ脱出("自然停止")
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")

        # ② tool_use ブロックを取り出して、ハーネス側で実行
        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                print(f"  -> {block.name}({block.input})")
                output = run_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })

        # ③ tool_result をユーザーメッセージとして返す("観測の続き"を LLM に渡す)
        messages.append({"role": "user", "content": tool_results})

    return "[max_turns 到達] ループを打ち切りました"

このたった 1 つの関数に、本書で扱ったループの すべての要素 が入っている。

たった 20 行ほどのループに、本書の第1〜4章でずっと扱ってきた骨格が そのまま 詰まっている。 ハーネス = この for ループ + tools リスト + run_tool の合計、と言ってしまってもよい。

B.6 段階 4 ── 道具を 3 つに増やす

道具は 1 つでは不便なので、write_filerun_command を足す。 ここで重要なのは、道具を増やすために LLM のコードはまったく触らない ことだ。 追加するのは「カタログ(JSON Schema)」と「本体(Python 関数)」だけ。

TOOLS = [
    {
        "name": "read_file",
        "description": "指定したパスのテキストファイルを読む。",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": "指定したパスにテキストを書き込む(上書き)。",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "run_command",
        "description": "シェルコマンドを実行し、標準出力と終了コードを返す。",
        "input_schema": {
            "type": "object",
            "properties": {"cmd": {"type": "string"}},
            "required": ["cmd"],
        },
    },
]

def run_tool(name: str, args: dict) -> str:
    if name == "read_file":
        with open(args["path"], "r", encoding="utf-8") as f:
            return f.read()
    if name == "write_file":
        with open(args["path"], "w", encoding="utf-8") as f:
            f.write(args["content"])
        return f"wrote {len(args['content'])} chars to {args['path']}"
    if name == "run_command":
        r = subprocess.run(args["cmd"], shell=True, capture_output=True, text=True, timeout=60)
        return f"exit={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
    return f"unknown tool: {name}"

これで、第6章で扱った「良い道具のカタログ」のミニ版ができた。 description は丁寧に書く ── これは本書で繰り返した 道具の品質はカタログの言葉で決まる という原則の、実演でもある。

落とし穴

ここで run_commandshell=True でなんでも実行できてしまう。本物のエージェントなら、本書の第10章で扱った HITL(実行前のユーザー承認)コマンドのホワイトリスト をここに挟む。学習用と割り切るとしても、自分の本番マシンで動かす前に一度立ち止まってほしい。

B.7 段階 5 ── つなげて動かす

最終形を main で呼ぶ。

if __name__ == "__main__":
    task = (
        "カレントディレクトリの README.md を読んで、その内容を1段落に要約し、"
        "summary.txt というファイルに書き出してください。"
    )
    print(run_agent(task, max_turns=8))

動かすと、こんな順序で動く(標準出力に -> tool(...) が出るはず)。

  -> read_file({'path': 'README.md'})
  -> write_file({'path': 'summary.txt', 'content': '...'})
要約を summary.txt に書き出しました。

LLM は自分で「まず README を読み、内容を吟味し、summary.txt に書く」と 計画して 動いた。 私たちは「何をするか」を一行で渡しただけだ。 あとは LLM + 道具 + ハーネス が、本書で見てきたとおりにループを回して、止まった。

B.8 完成コードの全体像

ここまでをひと続きにすると、こうなる(80 行弱)。

import subprocess
import anthropic

MODEL = "claude-opus-4-5"
client = anthropic.Anthropic()

TOOLS = [
    {
        "name": "read_file",
        "description": "指定したパスのテキストファイルを読む。",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": "指定したパスにテキストを書き込む(上書き)。",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "run_command",
        "description": "シェルコマンドを実行し、標準出力と終了コードを返す。",
        "input_schema": {
            "type": "object",
            "properties": {"cmd": {"type": "string"}},
            "required": ["cmd"],
        },
    },
]

def run_tool(name, args):
    if name == "read_file":
        with open(args["path"], "r", encoding="utf-8") as f:
            return f.read()
    if name == "write_file":
        with open(args["path"], "w", encoding="utf-8") as f:
            f.write(args["content"])
        return f"wrote {len(args['content'])} chars to {args['path']}"
    if name == "run_command":
        r = subprocess.run(args["cmd"], shell=True, capture_output=True, text=True, timeout=60)
        return f"exit={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
    return f"unknown tool: {name}"

def run_agent(user_text, max_turns=10):
    messages = [{"role": "user", "content": user_text}]
    for _ in range(max_turns):
        resp = client.messages.create(
            model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                print(f"  -> {block.name}({block.input})")
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": run_tool(block.name, block.input),
                })
        messages.append({"role": "user", "content": results})
    return "[max_turns 到達]"

if __name__ == "__main__":
    print(run_agent(
        "README.md を読んで、内容を1段落に要約して summary.txt に保存して",
        max_turns=8,
    ))

80 行弱。これが、Claude Code や Cursor が内側でやっていることの だ。

B.9 この最小実装と、本物の Claude Code との距離

「これでもう Claude Code が書けたのでは?」と一瞬思うかもしれないが、当然ながら本物はもっと複雑だ。 ただ、その複雑さは 本書で扱った観点 にすべて分類できる。

不足している要素本書のどの章で扱ったか
道具の 承認 UI、危険コマンドの ホワイトリスト第10章(HITL)
大きなファイル をどうコンテキストに収めるか第7章(コンテキストウィンドウ)
CLAUDE.md 相当の永続メモリ・プロジェクト設定第8章(メモリ階層と圧縮)
Plan mode / TodoWrite / サブエージェント第9章(計画とサブエージェント)
MCP で外部ツールを動的に追加終章(軸③ プロトコル)
三大病(早すぎる終了 / 無限ループ / 幻のツール呼び)対策第10章

逆に言えば、この 80 行に上記の各章で扱った設計判断を肉付けしていけば、Claude Code 級のエージェントの輪郭は組み上がる。 (実際に組み上げきれるかは別問題だが、骨格は同じだ)

80 行のコードを目の前に置くと、「LLM + 道具 + ハーネス」も「think → act → observe」も「停止条件」も、もう抽象論ではなくなる。 for turn in range(max_turns): の中身に、本書の全章がコメントとして書き込める。これが、自分で書くという行為の効能だ。

B.10 拡張のヒント

完成形に対して、本書の各章を踏みながら段階的に拡張できる。 手を動かして腑に落とす ために、いくつか宿題を置いておく。

  • 第5章: read_file の description を 1 行から 5 行に増やしてみて、エージェントの呼び方の質が変わるか観察する
  • 第6章: write_file の前に diff を表示してユーザー承認 を取るバージョンを書く。これが Cursor の Accept ボタンの原型
  • 第7章: read_file が巨大ファイルを読んだとき、先頭 N 行に切り詰める バージョンを書く
  • 第8章: スクリプトと同じディレクトリの AGENTS.md をシステムプロンプトに自動で混ぜるようにする
  • 第9章: run_agent「計画専用エージェント」と「実行専用エージェント」 に分け、計画ステップでは write/run を呼べないようにする
  • 第10章: 同じ tool_use連続 3 回呼ばれたら警告して止める ガードを足す(無限ループ対策)

これを順に試すと、本書のどの章が どの行に対応する か、コードの中で見える化されるはずだ。

B.11 仕上げ ── 「動く」が、もう不思議ではない

このコードを書く前と書いた後で、Claude Code や Cursor を起動したときの見え方は きっと変わっている

以前は「画面の中で何かよく分からない動き方をしている奇妙な存在」だったものが、今は「for ループの中で tools.create() を呼んで tool_use を捌いている、見慣れた構造」として見える。

私は学生のころ、ネットワークの教科書を読んでも TCP/IP が何ひとつ腑に落ちなかった。 腑に落ちたのは、socket() bind() listen() を生で叩いて、Python で数十行のサーバーを書いた瞬間だ。

エージェントも同じだと思う。 本書の 10 章をいくら読んでも、抽象的な「ループ」が体に入ってくる感じはしないかもしれない。 けれど、80 行のループを自分で書いて、自分の手で tool_use をディスパッチした瞬間、エージェントは急に身近な存在に変わる。

これが、この付録を最後に置いた理由だ。 腑に落とすための最後の一押しを、ぜひ自分の指先でやってほしい。

この最小エージェントを起点に、本書の全章を 改めて読み返してみる とよい。 読書時間は前回と同じはずなのに、各章の言っていることが コードのどこに刺さるか で見えてくる ── 一冊が二重に読める、最後のおまけ体験になるはずだ。