Learn Claude Code

学習パス比較

2つの章のあいだで何の能力が増えるのか、なぜそこで導入されるのか、学習時にどこへ注目すべきかを比べる。

学習ジャンプ

まず比べたい一歩を選ぶ

このページは、先に能力境界の変化を理解させ、そのあとで必要なら実装詳細へ入る構成です。

ワンクリック比較

毎回2章を手で選ぶ前に、まず安定した比較入口を使う

ここには最も見返す価値の高い隣接アップグレードと段階切り替えを置いてあります。初回読みにも、途中で境界が混ざった時の立て直しにも向いています。

学習ジャンプ

エージェントループツール使用

これは隣り合う一歩です。システムが章ごとに自然に育つ流れを学ぶのに最も向いています。

A から持ち帰るもの

loop がなければ agent は生まれません。

B で増えるもの

ツールを足すなら、ハンドラーを1つ足すだけ

進み方

これは隣り合う一歩です。システムが章ごとに自然に育つ流れを学ぶのに最も向いています。

B を読み終えた後の到達点

主ループを変えずに新しいツールを追加できるようになる。

エージェントループ

loop がなければ agent は生まれません。

130 LOC1 toolsコアループ

ツール使用

ツールを足すなら、ハンドラーを1つ足すだけ

169 LOC4 toolsコアループ
章の距離

1

Bで増えるツール

3

共通ツール

1

新しい実装面

5

ジャンプ診断

これは最も安定した1段階の比較です

A と B は隣接しているため、何が新しい分岐で、何が新しい状態容器で、なぜ今入るのかを最も素直に見られます。

より安定した読み方

まず実行フロー、その後アーキテクチャ図を見て、最後に必要ならソース diff へ進みます。

このジャンプ前に最も先に補いたい bridge doc

飛び読み補助

エージェントループ から ツール使用 へ飛ぶ前に、この橋渡し資料を読む

比較ページは「何が増えたか」だけでなく、そのジャンプを理解する前に何を補うべきかも示すべきです。

主線実行の比較

1つの要求が2つの章の間でどう変わるかを先に見ます。どこで新しい分岐が生まれ、何が主ループへ戻り、何が側車レーンに残るのかを比較します。

エージェントループ

読み方

まず主線の回流を見て、その後で左右の分岐を見る

上から下へ時間順に読みます。中央は主線、左右は分岐・隔離レーン・回復経路です。大事なのはノード数ではなく、この章で新しく増えた分岐と回流がどこかです。

まず注目

まず `messages`、`tool_use`、`tool_result` がどう閉ループを作るかを見る。

混同しやすい点

モデルが考えられることと、システムが行動できることを混同しない。行動を成立させるのは loop です。

学習ゴール

最小でも実際に動く agent loop を自力で書けるようになる。

ノード凡例

入口

このターンがどこから入るかを示します。

主処理

システム内部で安定して進む一段です。

分岐判断

ここでどの分岐へ進むかを決めます。

子過程 / 外部レーン

外部実行、サイドカー、隔離レーンなどでよく現れます。

回流 / 終了

このターンが終わるか、主ループへ戻る場所です。

分岐 / サイドレーン

権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。

主線

システムがこのターン中に繰り返し戻る経路です。

分岐 / サイドレーン

権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。

はいいいえユーザー入力モデル呼び出しtool_use?Bash 実行結果を追加出力

破線の枠は子過程や外部レーンを示すことが多く、矢印ラベルはなぜ分岐したかを示します。

ツール使用

読み方

まず主線の回流を見て、その後で左右の分岐を見る

上から下へ時間順に読みます。中央は主線、左右は分岐・隔離レーン・回復経路です。大事なのはノード数ではなく、この章で新しく増えた分岐と回流がどこかです。

まず注目

`ToolSpec`、dispatch map、`tool_result` の対応関係を先に見る。

混同しやすい点

schema は実行関数そのものではありません。片方はモデル向けの説明、もう片方は実装側の handler です。

学習ゴール

主ループを変えずに新しいツールを追加できるようになる。

ノード凡例

入口

このターンがどこから入るかを示します。

主処理

システム内部で安定して進む一段です。

分岐判断

ここでどの分岐へ進むかを決めます。

子過程 / 外部レーン

外部実行、サイドカー、隔離レーンなどでよく現れます。

回流 / 終了

このターンが終わるか、主ループへ戻る場所です。

分岐 / サイドレーン

権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。

主線

システムがこのターン中に繰り返し戻る経路です。

分岐 / サイドレーン

権限分岐、自治スキャン、バックグラウンドスロット、worktree レーンはここで展開されます。

はいいいえユーザー入力モデル呼び出しtool_use?ツール分配bash / read / write / edit結果を追加出力

破線の枠は子過程や外部レーンを示すことが多く、矢印ラベルはなぜ分岐したかを示します。

アーキテクチャ

まずモジュール境界と協調関係を見て、そのあと必要なら実装詳細へ入ります。

エージェントループ

この章でシステムに何が増えたか

LoopState + tool_result の戻し込み

最初の章では最小の閉ループを作ります。ユーザー入力が `messages[]` に入り、モデルが tool を呼ぶか判断し、その結果が同じループへ戻ります。

主線実行

実際にシステムを前へ進める主線です。

Agent ループ

新規

各ターンでモデルを呼び、出力を処理し、その後で続けるかどうかを決めます。

状態レコード

システムが記憶し、回写すべき構造です。

messages[]

新規

ユーザー、assistant、tool result の履歴がここへ積み上がります。

tool_result write-back

新規

agent が本当に動き出すのは、tool result が次の推論へ戻るときです。

主要レコード

これらは実装の枝葉ではなく、自分で再構築するときに掴むべき状態容器です。

LoopState新規

最小で実行可能なセッション状態です。

Assistant 内容新規

現在のターンでモデルが出した内容です。

主回流経路

1

ユーザーメッセージが `messages[]` に入る

2

モデルが `tool_use` またはテキストを出す

3

tool result が次のターンへ書き戻される

ツール使用

この章でシステムに何が増えたか

ツール仕様 + ディスパッチマップ

この章では 1 回の tool 呼び出しを、主ループを変えずに複数 tool を安定して扱える routing 層へ引き上げます。

主線実行

実際にシステムを前へ進める主線です。

安定した主ループ

主ループは引き続き、モデル呼び出しと結果の回写だけを担当します。

制御面

どう動かし、いつ通し、いつ向きを変えるかを決めます。

ToolSpec カタログ

新規

tool の能力をモデルへ説明します。

ディスパッチマップ

新規

tool 名で対応する handler へルーティングします。

状態レコード

システムが記憶し、回写すべき構造です。

tool_input

新規

モデルが出した構造化された tool 引数です。

主要レコード

これらは実装の枝葉ではなく、自分で再構築するときに掴むべき状態容器です。

ToolSpec新規

schema と説明です。

ディスパッチ項目新規

tool 名から関数への対応表です。

主回流経路

1

モデルが使う tool を選ぶ

2

dispatch map が handler を解決する

3

handler が `tool_result` を返す

ツール比較

のみ エージェントループ

なし

共通

bash

のみ ツール使用

read_filewrite_fileedit_file

ソース差分(任意)

実装の展開まで追いたい場合だけ diff を見れば十分です。機構だけ知りたいなら、その前の学習カードで足ります。 コード量差分: +39

s01 (s01_agent_loop.py) -> s02 (s02_tool_use.py)
11#!/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.
33"""
4-s01_agent_loop.py - The Agent Loop
4+s02_tool_use.py - Tool dispatch + message normalization
55
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.
79
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."
1611"""
1712
1813import os
1914import subprocess
20-from dataclasses import dataclass
15+from pathlib import Path
2116
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-
3317from anthropic import Anthropic
3418from dotenv import load_dotenv
3519
3620load_dotenv(override=True)
3721
3822if os.getenv("ANTHROPIC_BASE_URL"):
3923 os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
4024
25+WORKDIR = Path.cwd()
4126client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
4227MODEL = os.environ["MODEL_ID"]
4328
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."
4830
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-}]
5831
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
5937
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
6638
67-
6839def run_bash(command: str) -> str:
6940 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):
7142 return "Error: Dangerous command blocked"
7243 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)"
8148 except subprocess.TimeoutExpired:
8249 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:
8460 return f"Error: {e}"
8561
86- output = (result.stdout + result.stderr).strip()
87- return output[:50000] if output else "(no output)"
8862
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}"
8971
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()
9972
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}"
10083
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
11684
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"}
11789
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+}
12797
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+]
131108
132- results = execute_tool_calls(response.content)
133- if not results:
134- state.transition_reason = None
135- return False
136109
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.
141112
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)
142133
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"))
146141
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+ ]})
147154
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+
148193if __name__ == "__main__":
149194 history = []
150195 while True:
151196 try:
152- query = input("\033[36ms01 >> \033[0m")
197+ query = input("\033[36ms02 >> \033[0m")
153198 except (EOFError, KeyboardInterrupt):
154199 break
155200 if query.strip().lower() in ("q", "exit", ""):
156201 break
157-
158202 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)
165209 print()