学习路径对比
比较两个章节之间新增了什么能力、为什么在这里引入,以及学习时该先盯住哪条主线。
学习跃迁
先决定你要比较哪一步升级
这页优先帮助你理解能力边界的变化,而不是先把你拖进源码细节里。
一键对比入口
先用这些最稳的比较入口,不必每次手选两章
这些按钮优先覆盖最值得反复看的相邻升级和阶段切换,适合第一次理解章节边界,也适合读到一半开始混时快速重启。
学习跃迁
Agent 循环工具使用
这是紧邻的一步升级,最适合按教程顺序学习系统是如何自然长出来的。
没有循环,就没有 agent。
加一个工具, 只加一个 handler
这是紧邻的一步升级,最适合按教程顺序学习系统是如何自然长出来的。
在不改主循环的前提下,自己加一个新工具。
Agent 循环
没有循环,就没有 agent。
工具使用
加一个工具, 只加一个 handler
1
3
1
5
跃迁诊断
这是最稳的一步升级
A 和 B 相邻,最适合看“系统刚刚多了一条什么分支、一个什么状态容器、为什么现在引入它”。
跳读辅助
从 Agent 循环 跳到 工具使用 前,先补这几张图
对比页不只是告诉你“多了什么”,还应该告诉你为了消化这次跃迁,哪些结构地图和机制展开最值得先看。
主线执行对比
先看一条请求在两章之间是怎么变的:新的分支出现在哪里,哪些结果会回流到主循环,哪些部分只是侧车或外部车道。
Agent 循环
读图方式
先看主线回流,再看左右分支
从上往下看时间顺序,中间通常是主线,左右是分支、隔离车道或恢复路径。真正重要的不是节点有多少,而是这一章新增的分叉与回流在哪里。
本章先盯住
先盯住 `messages`、`tool_use` 和 `tool_result` 如何闭环回流。
最容易混
不要把“模型会思考”和“系统能行动”混成一回事,真正让它能行动的是 loop。
学完要会
手写一个最小但真实可运行的 agent loop。
节点图例
这轮从哪里开始进入系统。
系统内部稳定推进的一步。
系统在这里决定往哪条分支走。
常见于外部执行、侧车流程或隔离车道。
这轮在这里结束或回到主循环。
分支 / 侧车
权限分支、自治扫描、后台槽位、worktree 车道常在这里展开。
主线
系统当前回合反复回到的那条路径。
分支 / 侧车
权限分支、自治扫描、后台槽位、worktree 车道常在这里展开。
虚线边框通常表示子流程或外部车道;箭头标签说明当前分叉为什么发生。
工具使用
读图方式
先看主线回流,再看左右分支
从上往下看时间顺序,中间通常是主线,左右是分支、隔离车道或恢复路径。真正重要的不是节点有多少,而是这一章新增的分叉与回流在哪里。
本章先盯住
先盯住 `ToolSpec`、`dispatch map` 和 `tool_result` 的对应关系。
最容易混
工具 schema 不是执行函数本身;一个是给模型看的说明,一个是代码里的处理器。
学完要会
在不改主循环的前提下,自己加一个新工具。
节点图例
这轮从哪里开始进入系统。
系统内部稳定推进的一步。
系统在这里决定往哪条分支走。
常见于外部执行、侧车流程或隔离车道。
这轮在这里结束或回到主循环。
分支 / 侧车
权限分支、自治扫描、后台槽位、worktree 车道常在这里展开。
主线
系统当前回合反复回到的那条路径。
分支 / 侧车
权限分支、自治扫描、后台槽位、worktree 车道常在这里展开。
虚线边框通常表示子流程或外部车道;箭头标签说明当前分叉为什么发生。
架构视图
先看模块边界和协作关系,再决定要不要往下钻实现细节。
Agent 循环
这章在系统里真正新增了什么
LoopState + tool_result 回流
第一章先建立最小闭环:用户输入进入 messages[],模型决定要不要调工具,结果再回写到同一条循环里。
真正把系统往前推的那条执行主线。
Agent 循环
新增每轮都走一次调用模型 -> 处理输出 -> 再决定是否继续。
真正需要被系统记住和回写的结构。
messages[]
新增所有用户、助手和工具结果都累积在这里。
tool_result 回流
新增真正让 agent 能行动的是工具结果会回到下一轮推理。
关键记录结构
这些不是实现细枝末节,而是开发者自己重建系统时最应该抓住的状态容器。
最小可运行会话状态。
模型本轮输出。
主回流路径
用户消息进入 messages[]
模型产出 tool_use 或文本
工具结果回写到下一轮
工具使用
这章在系统里真正新增了什么
工具规格 + 分发映射
这一章把“会调一个工具”升级成“能稳定路由很多工具”,主循环不变,工具层长出来。
真正把系统往前推的那条执行主线。
稳定主循环
主循环继续只管模型调用与结果回写。
决定怎么运行、何时放行、何时转向。
ToolSpec 目录
新增把工具能力描述给模型看。
分发映射
新增按工具名把调用路由到对应 handler。
真正需要被系统记住和回写的结构。
tool_input
新增模型传入的结构化工具参数。
关键记录结构
这些不是实现细枝末节,而是开发者自己重建系统时最应该抓住的状态容器。
schema + 描述。
工具名到函数的映射。
主回流路径
模型说要调哪个工具
dispatch map 找到 handler
handler 输出 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() |