學習路徑對比
比較兩個章節之間新增了什麼能力、為什麼在這裡引入,以及學習時該先盯住哪條主線。
學習躍遷
先決定你要比較哪一步升級
這頁優先幫助你理解能力邊界的變化,而不是先把你拖進原始碼細節裡。
One-Click Compare
Start with these safe comparison moves instead of selecting two chapters every time
These presets cover the most useful adjacent upgrades and stage boundaries. They work both for a first pass and for resetting when chapter boundaries start to blur.
學習躍遷
Agent 迴圈工具使用
這是緊鄰的一步升級,最適合按教程順序學習系統是如何自然長出來的。
沒有迴圈,就沒有 agent。
加一個工具, 只加一個 handler
這是緊鄰的一步升級,最適合按教程順序學習系統是如何自然長出來的。
Add a new tool without changing the main loop.
Agent 迴圈
沒有迴圈,就沒有 agent。
工具使用
加一個工具, 只加一個 handler
1
3
1
5
Jump Diagnosis
This is the safest upgrade step
A and B are adjacent, so this is the cleanest way to see the exact new branch, state container, and reason for introducing it now.
Safer Reading Move
Read the execution flow first, then the architecture view, and only then decide whether you need the source diff.
Bridge docs most worth reading before this jump
Jump Reading Support
Before jumping from Agent 迴圈 to 工具使用, read these bridge docs
A good comparison page should not only show what was added. It should also point you to the best bridge docs for understanding the jump.
Mainline Flow Comparison
Compare how one request evolves between the two chapters: where the new branch appears, what writes back into the loop, and what remains a side lane.
Agent 迴圈
How to Read
Read the mainline first, then inspect the side branches
Read top to bottom for time order. The center usually carries the mainline, while the sides hold branches, isolated lanes, or recovery paths. The key question is not how many nodes exist, but where this chapter introduces a new split and write-back.
Focus First
Focus first on how `messages`, `tool_use`, and `tool_result` close the loop.
Easy to Confuse
Do not confuse model reasoning with system action. The loop is what turns thought into work.
Build Goal
Be able to write a minimal but real agent loop by hand.
Node Legend
Where the current turn enters the system.
A stable internal processing step.
Where the system chooses a branch.
Often used for external execution, sidecars, or isolated lanes.
Where the turn ends or writes back into the loop.
Branch / Side Lane
Permission branches, autonomy scans, background slots, and worktree lanes often expand here.
Mainline
The path the system keeps returning to during the turn.
Branch / Side Lane
Permission branches, autonomy scans, background slots, and worktree lanes often expand here.
Dashed borders usually indicate a subprocess or external lane; arrow labels explain why a branch was taken.
工具使用
How to Read
Read the mainline first, then inspect the side branches
Read top to bottom for time order. The center usually carries the mainline, while the sides hold branches, isolated lanes, or recovery paths. The key question is not how many nodes exist, but where this chapter introduces a new split and write-back.
Focus First
Focus on the relationship between `ToolSpec`, the dispatch map, and `tool_result`.
Easy to Confuse
A tool schema is not the handler itself. One describes the tool to the model; the other executes it.
Build Goal
Add a new tool without changing the main loop.
Node Legend
Where the current turn enters the system.
A stable internal processing step.
Where the system chooses a branch.
Often used for external execution, sidecars, or isolated lanes.
Where the turn ends or writes back into the loop.
Branch / Side Lane
Permission branches, autonomy scans, background slots, and worktree lanes often expand here.
Mainline
The path the system keeps returning to during the turn.
Branch / Side Lane
Permission branches, autonomy scans, background slots, and worktree lanes often expand here.
Dashed borders usually indicate a subprocess or external lane; arrow labels explain why a branch was taken.
架構檢視
先看模組邊界和協作關係,再決定要不要往下鑽實現細節。
Agent 迴圈
What This Chapter Actually Adds
LoopState + tool_result feedback
The first chapter establishes the smallest closed loop: user input enters messages[], the model decides whether to call a tool, and the result flows back into the same loop.
The path that actually pushes the system forward.
Agent Loop
NEWEach turn calls the model, handles the output, then decides whether to continue.
The structures the system must remember and write back.
messages[]
NEWUser, assistant, and tool result history accumulates here.
tool_result write-back
NEWThe agent becomes real when tool results return into the next reasoning step.
Key Records
These are the state containers worth holding onto when you rebuild the system yourself.
The smallest runnable session state.
The model output for the current turn.
Primary Handoff Path
User message enters messages[]
Model emits tool_use or text
Tool result writes back into the next turn
工具使用
What This Chapter Actually Adds
Tool specs + dispatch map
This chapter upgrades one tool call into a stable multi-tool routing layer while keeping the main loop unchanged.
The path that actually pushes the system forward.
Stable Main Loop
The main loop still only owns model calls and write-back.
Decides how execution is controlled, gated, and redirected.
ToolSpec Catalog
NEWDescribes tool capabilities to the model.
Dispatch Map
NEWRoutes a tool call to the correct handler by name.
The structures the system must remember and write back.
tool_input
NEWStructured tool arguments emitted by the model.
Key Records
These are the state containers worth holding onto when you rebuild the system yourself.
Schema plus description.
Mapping from tool name to function.
Primary Handoff Path
The model selects a tool
The dispatch map resolves the handler
The handler returns a tool_result
工具對比
僅在 Agent 迴圈
無
共有
僅在 工具使用
原始碼差異(選看)
如果你在意實現展開,可以再看原始碼 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() |