学習パス比較
2つの章のあいだで何の能力が増えるのか、なぜそこで導入されるのか、学習時にどこへ注目すべきかを比べる。
学習ジャンプ
まず比べたい一歩を選ぶ
このページは、先に能力境界の変化を理解させ、そのあとで必要なら実装詳細へ入る構成です。
ワンクリック比較
毎回2章を手で選ぶ前に、まず安定した比較入口を使う
ここには最も見返す価値の高い隣接アップグレードと段階切り替えを置いてあります。初回読みにも、途中で境界が混ざった時の立て直しにも向いています。
学習ジャンプ
エージェントループツール使用
これは隣り合う一歩です。システムが章ごとに自然に育つ流れを学ぶのに最も向いています。
loop がなければ agent は生まれません。
ツールを足すなら、ハンドラーを1つ足すだけ
これは隣り合う一歩です。システムが章ごとに自然に育つ流れを学ぶのに最も向いています。
主ループを変えずに新しいツールを追加できるようになる。
エージェントループ
loop がなければ agent は生まれません。
ツール使用
ツールを足すなら、ハンドラーを1つ足すだけ
1
3
1
5
ジャンプ診断
これは最も安定した1段階の比較です
A と B は隣接しているため、何が新しい分岐で、何が新しい状態容器で、なぜ今入るのかを最も素直に見られます。
より安定した読み方
まず実行フロー、その後アーキテクチャ図を見て、最後に必要ならソース diff へ進みます。
このジャンプ前に最も先に補いたい bridge doc
飛び読み補助
エージェントループ から ツール使用 へ飛ぶ前に、この橋渡し資料を読む
比較ページは「何が増えたか」だけでなく、そのジャンプを理解する前に何を補うべきかも示すべきです。
主線実行の比較
1つの要求が2つの章の間でどう変わるかを先に見ます。どこで新しい分岐が生まれ、何が主ループへ戻り、何が側車レーンに残るのかを比較します。
エージェントループ
読み方
まず主線の回流を見て、その後で左右の分岐を見る
上から下へ時間順に読みます。中央は主線、左右は分岐・隔離レーン・回復経路です。大事なのはノード数ではなく、この章で新しく増えた分岐と回流がどこかです。
まず注目
まず `messages`、`tool_use`、`tool_result` がどう閉ループを作るかを見る。
混同しやすい点
モデルが考えられることと、システムが行動できることを混同しない。行動を成立させるのは loop です。
学習ゴール
最小でも実際に動く agent loop を自力で書けるようになる。
ノード凡例
このターンがどこから入るかを示します。
システム内部で安定して進む一段です。
ここでどの分岐へ進むかを決めます。
外部実行、サイドカー、隔離レーンなどでよく現れます。
このターンが終わるか、主ループへ戻る場所です。
分岐 / サイドレーン
権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。
主線
システムがこのターン中に繰り返し戻る経路です。
分岐 / サイドレーン
権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。
破線の枠は子過程や外部レーンを示すことが多く、矢印ラベルはなぜ分岐したかを示します。
ツール使用
読み方
まず主線の回流を見て、その後で左右の分岐を見る
上から下へ時間順に読みます。中央は主線、左右は分岐・隔離レーン・回復経路です。大事なのはノード数ではなく、この章で新しく増えた分岐と回流がどこかです。
まず注目
`ToolSpec`、dispatch map、`tool_result` の対応関係を先に見る。
混同しやすい点
schema は実行関数そのものではありません。片方はモデル向けの説明、もう片方は実装側の handler です。
学習ゴール
主ループを変えずに新しいツールを追加できるようになる。
ノード凡例
このターンがどこから入るかを示します。
システム内部で安定して進む一段です。
ここでどの分岐へ進むかを決めます。
外部実行、サイドカー、隔離レーンなどでよく現れます。
このターンが終わるか、主ループへ戻る場所です。
分岐 / サイドレーン
権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。
主線
システムがこのターン中に繰り返し戻る経路です。
分岐 / サイドレーン
権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。
破線の枠は子過程や外部レーンを示すことが多く、矢印ラベルはなぜ分岐したかを示します。
アーキテクチャ
まずモジュール境界と協調関係を見て、そのあと必要なら実装詳細へ入ります。
エージェントループ
この章でシステムに何が増えたか
LoopState + tool_result の戻し込み
最初の章では最小の閉ループを作ります。ユーザー入力が `messages[]` に入り、モデルが tool を呼ぶか判断し、その結果が同じループへ戻ります。
実際にシステムを前へ進める主線です。
Agent ループ
新規各ターンでモデルを呼び、出力を処理し、その後で続けるかどうかを決めます。
システムが記憶し、回写すべき構造です。
messages[]
新規ユーザー、assistant、tool result の履歴がここへ積み上がります。
tool_result write-back
新規agent が本当に動き出すのは、tool result が次の推論へ戻るときです。
主要レコード
これらは実装の枝葉ではなく、自分で再構築するときに掴むべき状態容器です。
最小で実行可能なセッション状態です。
現在のターンでモデルが出した内容です。
主回流経路
ユーザーメッセージが `messages[]` に入る
モデルが `tool_use` またはテキストを出す
tool result が次のターンへ書き戻される
ツール使用
この章でシステムに何が増えたか
ツール仕様 + ディスパッチマップ
この章では 1 回の tool 呼び出しを、主ループを変えずに複数 tool を安定して扱える routing 層へ引き上げます。
実際にシステムを前へ進める主線です。
安定した主ループ
主ループは引き続き、モデル呼び出しと結果の回写だけを担当します。
どう動かし、いつ通し、いつ向きを変えるかを決めます。
ToolSpec カタログ
新規tool の能力をモデルへ説明します。
ディスパッチマップ
新規tool 名で対応する handler へルーティングします。
システムが記憶し、回写すべき構造です。
tool_input
新規モデルが出した構造化された tool 引数です。
主要レコード
これらは実装の枝葉ではなく、自分で再構築するときに掴むべき状態容器です。
schema と説明です。
tool 名から関数への対応表です。
主回流経路
モデルが使う tool を選ぶ
dispatch map が handler を解決する
handler が `tool_result` を返す
ツール比較
のみ エージェントループ
なし
共通
のみ ツール使用
ソース差分(任意)
実装の展開まで追いたい場合だけ diff を見れば十分です。機構だけ知りたいなら、その前の学習カードで足ります。 コード量差分: +39 行
| 1 | 1 | #!/usr/bin/env python3 | |
| 2 | - | # Harness: the loop -- keep feeding real tool results back into the model. | |
| 2 | + | # Harness: tool dispatch -- expanding what the model can reach. | |
| 3 | 3 | """ | |
| 4 | - | s01_agent_loop.py - The Agent Loop | |
| 4 | + | s02_tool_use.py - Tool dispatch + message normalization | |
| 5 | 5 | ||
| 6 | - | This file teaches the smallest useful coding-agent pattern: | |
| 6 | + | The agent loop from s01 didn't change. We added tools to the dispatch map, | |
| 7 | + | and a normalize_messages() function that cleans up the message list before | |
| 8 | + | each API call. | |
| 7 | 9 | ||
| 8 | - | user message | |
| 9 | - | -> model reply | |
| 10 | - | -> if tool_use: execute tools | |
| 11 | - | -> write tool_result back to messages | |
| 12 | - | -> continue | |
| 13 | - | ||
| 14 | - | It intentionally keeps the loop small, but still makes the loop state explicit | |
| 15 | - | so later chapters can grow from the same structure. | |
| 10 | + | Key insight: "The loop didn't change at all. I just added tools." | |
| 16 | 11 | """ | |
| 17 | 12 | ||
| 18 | 13 | import os | |
| 19 | 14 | import subprocess | |
| 20 | - | from dataclasses import dataclass | |
| 15 | + | from pathlib import Path | |
| 21 | 16 | ||
| 22 | - | try: | |
| 23 | - | import readline | |
| 24 | - | # #143 UTF-8 backspace fix for macOS libedit | |
| 25 | - | readline.parse_and_bind('set bind-tty-special-chars off') | |
| 26 | - | readline.parse_and_bind('set input-meta on') | |
| 27 | - | readline.parse_and_bind('set output-meta on') | |
| 28 | - | readline.parse_and_bind('set convert-meta off') | |
| 29 | - | readline.parse_and_bind('set enable-meta-keybindings on') | |
| 30 | - | except ImportError: | |
| 31 | - | pass | |
| 32 | - | ||
| 33 | 17 | from anthropic import Anthropic | |
| 34 | 18 | from dotenv import load_dotenv | |
| 35 | 19 | ||
| 36 | 20 | load_dotenv(override=True) | |
| 37 | 21 | ||
| 38 | 22 | if os.getenv("ANTHROPIC_BASE_URL"): | |
| 39 | 23 | os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) | |
| 40 | 24 | ||
| 25 | + | WORKDIR = Path.cwd() | |
| 41 | 26 | client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) | |
| 42 | 27 | MODEL = os.environ["MODEL_ID"] | |
| 43 | 28 | ||
| 44 | - | SYSTEM = ( | |
| 45 | - | f"You are a coding agent at {os.getcwd()}. " | |
| 46 | - | "Use bash to inspect and change the workspace. Act first, then report clearly." | |
| 47 | - | ) | |
| 29 | + | SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain." | |
| 48 | 30 | ||
| 49 | - | TOOLS = [{ | |
| 50 | - | "name": "bash", | |
| 51 | - | "description": "Run a shell command in the current workspace.", | |
| 52 | - | "input_schema": { | |
| 53 | - | "type": "object", | |
| 54 | - | "properties": {"command": {"type": "string"}}, | |
| 55 | - | "required": ["command"], | |
| 56 | - | }, | |
| 57 | - | }] | |
| 58 | 31 | ||
| 32 | + | def safe_path(p: str) -> Path: | |
| 33 | + | path = (WORKDIR / p).resolve() | |
| 34 | + | if not path.is_relative_to(WORKDIR): | |
| 35 | + | raise ValueError(f"Path escapes workspace: {p}") | |
| 36 | + | return path | |
| 59 | 37 | ||
| 60 | - | @dataclass | |
| 61 | - | class LoopState: | |
| 62 | - | # The minimal loop state: history, loop count, and why we continue. | |
| 63 | - | messages: list | |
| 64 | - | turn_count: int = 1 | |
| 65 | - | transition_reason: str | None = None | |
| 66 | 38 | ||
| 67 | - | ||
| 68 | 39 | def run_bash(command: str) -> str: | |
| 69 | 40 | dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] | |
| 70 | - | if any(item in command for item in dangerous): | |
| 41 | + | if any(d in command for d in dangerous): | |
| 71 | 42 | return "Error: Dangerous command blocked" | |
| 72 | 43 | try: | |
| 73 | - | result = subprocess.run( | |
| 74 | - | command, | |
| 75 | - | shell=True, | |
| 76 | - | cwd=os.getcwd(), | |
| 77 | - | capture_output=True, | |
| 78 | - | text=True, | |
| 79 | - | timeout=120, | |
| 80 | - | ) | |
| 44 | + | r = subprocess.run(command, shell=True, cwd=WORKDIR, | |
| 45 | + | capture_output=True, text=True, timeout=120) | |
| 46 | + | out = (r.stdout + r.stderr).strip() | |
| 47 | + | return out[:50000] if out else "(no output)" | |
| 81 | 48 | except subprocess.TimeoutExpired: | |
| 82 | 49 | return "Error: Timeout (120s)" | |
| 83 | - | except (FileNotFoundError, OSError) as e: | |
| 50 | + | ||
| 51 | + | ||
| 52 | + | def run_read(path: str, limit: int = None) -> str: | |
| 53 | + | try: | |
| 54 | + | text = safe_path(path).read_text() | |
| 55 | + | lines = text.splitlines() | |
| 56 | + | if limit and limit < len(lines): | |
| 57 | + | lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] | |
| 58 | + | return "\n".join(lines)[:50000] | |
| 59 | + | except Exception as e: | |
| 84 | 60 | return f"Error: {e}" | |
| 85 | 61 | ||
| 86 | - | output = (result.stdout + result.stderr).strip() | |
| 87 | - | return output[:50000] if output else "(no output)" | |
| 88 | 62 | ||
| 63 | + | def run_write(path: str, content: str) -> str: | |
| 64 | + | try: | |
| 65 | + | fp = safe_path(path) | |
| 66 | + | fp.parent.mkdir(parents=True, exist_ok=True) | |
| 67 | + | fp.write_text(content) | |
| 68 | + | return f"Wrote {len(content)} bytes to {path}" | |
| 69 | + | except Exception as e: | |
| 70 | + | return f"Error: {e}" | |
| 89 | 71 | ||
| 90 | - | def extract_text(content) -> str: | |
| 91 | - | if not isinstance(content, list): | |
| 92 | - | return "" | |
| 93 | - | texts = [] | |
| 94 | - | for block in content: | |
| 95 | - | text = getattr(block, "text", None) | |
| 96 | - | if text: | |
| 97 | - | texts.append(text) | |
| 98 | - | return "\n".join(texts).strip() | |
| 99 | 72 | ||
| 73 | + | def run_edit(path: str, old_text: str, new_text: str) -> str: | |
| 74 | + | try: | |
| 75 | + | fp = safe_path(path) | |
| 76 | + | content = fp.read_text() | |
| 77 | + | if old_text not in content: | |
| 78 | + | return f"Error: Text not found in {path}" | |
| 79 | + | fp.write_text(content.replace(old_text, new_text, 1)) | |
| 80 | + | return f"Edited {path}" | |
| 81 | + | except Exception as e: | |
| 82 | + | return f"Error: {e}" | |
| 100 | 83 | ||
| 101 | - | def execute_tool_calls(response_content) -> list[dict]: | |
| 102 | - | results = [] | |
| 103 | - | for block in response_content: | |
| 104 | - | if block.type != "tool_use": | |
| 105 | - | continue | |
| 106 | - | command = block.input["command"] | |
| 107 | - | print(f"\033[33m$ {command}\033[0m") | |
| 108 | - | output = run_bash(command) | |
| 109 | - | print(output[:200]) | |
| 110 | - | results.append({ | |
| 111 | - | "type": "tool_result", | |
| 112 | - | "tool_use_id": block.id, | |
| 113 | - | "content": output, | |
| 114 | - | }) | |
| 115 | - | return results | |
| 116 | 84 | ||
| 85 | + | # -- Concurrency safety classification -- | |
| 86 | + | # Read-only tools can safely run in parallel; mutating tools must be serialized. | |
| 87 | + | CONCURRENCY_SAFE = {"read_file"} | |
| 88 | + | CONCURRENCY_UNSAFE = {"write_file", "edit_file"} | |
| 117 | 89 | ||
| 118 | - | def run_one_turn(state: LoopState) -> bool: | |
| 119 | - | response = client.messages.create( | |
| 120 | - | model=MODEL, | |
| 121 | - | system=SYSTEM, | |
| 122 | - | messages=state.messages, | |
| 123 | - | tools=TOOLS, | |
| 124 | - | max_tokens=8000, | |
| 125 | - | ) | |
| 126 | - | state.messages.append({"role": "assistant", "content": response.content}) | |
| 90 | + | # -- The dispatch map: {tool_name: handler} -- | |
| 91 | + | TOOL_HANDLERS = { | |
| 92 | + | "bash": lambda **kw: run_bash(kw["command"]), | |
| 93 | + | "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), | |
| 94 | + | "write_file": lambda **kw: run_write(kw["path"], kw["content"]), | |
| 95 | + | "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), | |
| 96 | + | } | |
| 127 | 97 | ||
| 128 | - | if response.stop_reason != "tool_use": | |
| 129 | - | state.transition_reason = None | |
| 130 | - | return False | |
| 98 | + | TOOLS = [ | |
| 99 | + | {"name": "bash", "description": "Run a shell command.", | |
| 100 | + | "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, | |
| 101 | + | {"name": "read_file", "description": "Read file contents.", | |
| 102 | + | "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, | |
| 103 | + | {"name": "write_file", "description": "Write content to file.", | |
| 104 | + | "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, | |
| 105 | + | {"name": "edit_file", "description": "Replace exact text in file.", | |
| 106 | + | "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, | |
| 107 | + | ] | |
| 131 | 108 | ||
| 132 | - | results = execute_tool_calls(response.content) | |
| 133 | - | if not results: | |
| 134 | - | state.transition_reason = None | |
| 135 | - | return False | |
| 136 | 109 | ||
| 137 | - | state.messages.append({"role": "user", "content": results}) | |
| 138 | - | state.turn_count += 1 | |
| 139 | - | state.transition_reason = "tool_result" | |
| 140 | - | return True | |
| 110 | + | def normalize_messages(messages: list) -> list: | |
| 111 | + | """Clean up messages before sending to the API. | |
| 141 | 112 | ||
| 113 | + | Three jobs: | |
| 114 | + | 1. Strip internal metadata fields the API doesn't understand | |
| 115 | + | 2. Ensure every tool_use has a matching tool_result (insert placeholder if missing) | |
| 116 | + | 3. Merge consecutive same-role messages (API requires strict alternation) | |
| 117 | + | """ | |
| 118 | + | cleaned = [] | |
| 119 | + | for msg in messages: | |
| 120 | + | clean = {"role": msg["role"]} | |
| 121 | + | if isinstance(msg.get("content"), str): | |
| 122 | + | clean["content"] = msg["content"] | |
| 123 | + | elif isinstance(msg.get("content"), list): | |
| 124 | + | clean["content"] = [ | |
| 125 | + | {k: v for k, v in block.items() | |
| 126 | + | if not k.startswith("_")} | |
| 127 | + | for block in msg["content"] | |
| 128 | + | if isinstance(block, dict) | |
| 129 | + | ] | |
| 130 | + | else: | |
| 131 | + | clean["content"] = msg.get("content", "") | |
| 132 | + | cleaned.append(clean) | |
| 142 | 133 | ||
| 143 | - | def agent_loop(state: LoopState) -> None: | |
| 144 | - | while run_one_turn(state): | |
| 145 | - | pass | |
| 134 | + | # Collect existing tool_result IDs | |
| 135 | + | existing_results = set() | |
| 136 | + | for msg in cleaned: | |
| 137 | + | if isinstance(msg.get("content"), list): | |
| 138 | + | for block in msg["content"]: | |
| 139 | + | if isinstance(block, dict) and block.get("type") == "tool_result": | |
| 140 | + | existing_results.add(block.get("tool_use_id")) | |
| 146 | 141 | ||
| 142 | + | # Find orphaned tool_use blocks and insert placeholder results | |
| 143 | + | for msg in cleaned: | |
| 144 | + | if msg["role"] != "assistant" or not isinstance(msg.get("content"), list): | |
| 145 | + | continue | |
| 146 | + | for block in msg["content"]: | |
| 147 | + | if not isinstance(block, dict): | |
| 148 | + | continue | |
| 149 | + | if block.get("type") == "tool_use" and block.get("id") not in existing_results: | |
| 150 | + | cleaned.append({"role": "user", "content": [ | |
| 151 | + | {"type": "tool_result", "tool_use_id": block["id"], | |
| 152 | + | "content": "(cancelled)"} | |
| 153 | + | ]}) | |
| 147 | 154 | ||
| 155 | + | # Merge consecutive same-role messages | |
| 156 | + | if not cleaned: | |
| 157 | + | return cleaned | |
| 158 | + | merged = [cleaned[0]] | |
| 159 | + | for msg in cleaned[1:]: | |
| 160 | + | if msg["role"] == merged[-1]["role"]: | |
| 161 | + | prev = merged[-1] | |
| 162 | + | prev_c = prev["content"] if isinstance(prev["content"], list) \ | |
| 163 | + | else [{"type": "text", "text": str(prev["content"])}] | |
| 164 | + | curr_c = msg["content"] if isinstance(msg["content"], list) \ | |
| 165 | + | else [{"type": "text", "text": str(msg["content"])}] | |
| 166 | + | prev["content"] = prev_c + curr_c | |
| 167 | + | else: | |
| 168 | + | merged.append(msg) | |
| 169 | + | return merged | |
| 170 | + | ||
| 171 | + | ||
| 172 | + | def agent_loop(messages: list): | |
| 173 | + | while True: | |
| 174 | + | response = client.messages.create( | |
| 175 | + | model=MODEL, system=SYSTEM, | |
| 176 | + | messages=normalize_messages(messages), | |
| 177 | + | tools=TOOLS, max_tokens=8000, | |
| 178 | + | ) | |
| 179 | + | messages.append({"role": "assistant", "content": response.content}) | |
| 180 | + | if response.stop_reason != "tool_use": | |
| 181 | + | return | |
| 182 | + | results = [] | |
| 183 | + | for block in response.content: | |
| 184 | + | if block.type == "tool_use": | |
| 185 | + | handler = TOOL_HANDLERS.get(block.name) | |
| 186 | + | output = handler(**block.input) if handler else f"Unknown tool: {block.name}" | |
| 187 | + | print(f"> {block.name}:") | |
| 188 | + | print(output[:200]) | |
| 189 | + | results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) | |
| 190 | + | messages.append({"role": "user", "content": results}) | |
| 191 | + | ||
| 192 | + | ||
| 148 | 193 | if __name__ == "__main__": | |
| 149 | 194 | history = [] | |
| 150 | 195 | while True: | |
| 151 | 196 | try: | |
| 152 | - | query = input("\033[36ms01 >> \033[0m") | |
| 197 | + | query = input("\033[36ms02 >> \033[0m") | |
| 153 | 198 | except (EOFError, KeyboardInterrupt): | |
| 154 | 199 | break | |
| 155 | 200 | if query.strip().lower() in ("q", "exit", ""): | |
| 156 | 201 | break | |
| 157 | - | ||
| 158 | 202 | history.append({"role": "user", "content": query}) | |
| 159 | - | state = LoopState(messages=history) | |
| 160 | - | agent_loop(state) | |
| 161 | - | ||
| 162 | - | final_text = extract_text(history[-1]["content"]) | |
| 163 | - | if final_text: | |
| 164 | - | print(final_text) | |
| 203 | + | agent_loop(history) | |
| 204 | + | response_content = history[-1]["content"] | |
| 205 | + | if isinstance(response_content, list): | |
| 206 | + | for block in response_content: | |
| 207 | + | if hasattr(block, "text"): | |
| 208 | + | print(block.text) | |
| 165 | 209 | print() |