Learning Path Compare
Compare what capability is introduced between two chapters, why it appears there, and what you should focus on first.
Learning Jump
Choose the step you want to compare
This page is designed to explain capability shifts before it throws you into code-level detail.
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.
Learning Jump
The Agent LoopTool Use
This is the next natural step in the path. It is the best way to study how the system grows chapter by chapter.
No short chapter thesis was extracted for this chapter yet.
No short chapter thesis was extracted for this chapter yet.
This is the next natural step in the path. It is the best way to study how the system grows chapter by chapter.
Add a new tool without changing the main loop.
The Agent Loop
No short chapter thesis was extracted for this chapter yet.
Tool Use
No short chapter thesis was extracted for this chapter yet.
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 The Agent Loop to Tool Use, 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.
The Agent Loop
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.
Tool Use
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.
Architecture
Read module boundaries and collaboration first, then drop into implementation detail only if you need it.
The Agent Loop
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
Tool Use
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
Tool Comparison
Only in The Agent Loop
None
Shared
Only in Tool Use
Source Diff (Optional)
If you care about implementation detail, read the diff next. If you only care about the mechanism, the learning cards above should be enough. LOC Delta: +39 lines
| 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() |