learn-claude-code/s02_tool_use at main · shareAI-lab/learn-claude-code

前文需要完成一个文件读写,通过bash工具

需要让模型自己思考:

  • 是否使用cat指令
  • cat哪一个文件
  • ……

因此对于常用的功能,我们最好通过系统层面的封装

避免token的消耗,同时也能够减少出错概率

我们可以引入一个TOOL_HANDLERS,作为字典映射到每一个工具的具体代码

  1#!/usr/bin/env python3
  2
  3from dotenv import load_dotenv
  4from anthropic import Anthropic
  5import os, subprocess
  6from pathlib import Path
  7
  8load_dotenv(override=True)
  9
 10client = Anthropic(
 11    base_url=os.getenv("ANTHROPIC_BASE_URL"),
 12    api_key=os.getenv("ANTHROPIC_API_KEY"),
 13)
 14
 15MODEL = os.getenv("MODEL_ID", "claude-3-5-sonnet-latest")
 16
 17TOOLS = [
 18    {
 19        "name": "bash",
 20        "description": "Run a shell command.",
 21        "input_schema": {
 22            "type": "object",
 23            "properties": {
 24                "command": {"type": "string"}
 25            },
 26            "required": ["command"],
 27        },
 28    },
 29    {
 30        "name": "read_file",
 31        "description": "Read the content of a file.",
 32        "input_schema": {
 33            "type": "object",
 34            "properties": {
 35                "path": {"type": "string"},
 36                "limit": {"type": "integer"} # 可选参数,限制读取的字节数,默认读取整个文件
 37            },
 38            "required": ["path"],
 39        },
 40    },
 41    {
 42        "name": "write_file",
 43        "description": "Write content to a file.",
 44        "input_schema": {
 45            "type": "object",
 46            "properties": {
 47                "path": {"type": "string"},
 48                "content": {"type": "string"}
 49            },
 50            "required": ["path", "content"],
 51        },
 52    },
 53    {
 54        "name": "edit_file",
 55        "description": "Replace exact text in a file once.",
 56        "input_schema": {
 57            "type": "object",
 58            "properties": {
 59                "path": {"type": "string"},
 60                "old_text": {"type": "string"},
 61                "new_text": {"type": "string"}
 62            },
 63            "required": ["path", "old_text", "new_text"],
 64        },
 65    },
 66    {
 67        "name": "glob",
 68        "description": "Find files matching a glob pattern.",
 69        "input_schema": {
 70            "type": "object",
 71            "properties": {
 72                "pattern": {"type": "string"}
 73            },
 74            "required": ["pattern"],
 75        }
 76    }
 77]
 78
 79WORKDIR = Path.cwd()
 80# ── Tool execution ────────────────────────────────────────
 81def run_bash(command: str) -> str:
 82    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
 83    if any(d in command for d in dangerous):
 84        return "Error: Dangerous command blocked"
 85    try:
 86        r = subprocess.run(command, shell=True, cwd=WORKDIR,
 87                           capture_output=True, text=True,
 88                           encoding="utf-8", errors="replace", timeout=120)
 89        out = (r.stdout + r.stderr).strip()
 90        return out[:50000] if out else "(no output)"
 91    except subprocess.TimeoutExpired:
 92        return "Error: Timeout (120s)"
 93    except (FileNotFoundError, OSError) as e:
 94        return f"Error: {e}"
 95
 96# 路径安全限制:确保路径必须在WORKDIR之内,不能逃逸
 97def safe_path(p: str) -> Path:
 98    path = (WORKDIR / p).resolve() # 绝对路径
 99    if not path.is_relative_to(WORKDIR):
100        raise ValueError(f"Path escapes workspace: {p}")
101    return path
102
103
104def run_read(path: str, limit: int | None = None) -> str:
105    try:
106        lines = safe_path(path).read_text().splitlines()
107        if limit and limit < len(lines):
108            lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
109        return "\n".join(lines)
110    except Exception as e:
111        return f"Error: {e}"
112    
113def run_write(path: str, content: str) -> str:
114    try:
115        path = safe_path(path)
116        path.parent.mkdir(parents=True, exist_ok=True)
117        path.write_text(content)
118        return f"Wrote {len(content)} bytes to {path}"
119    except Exception as e:
120        return f"Error: {e}"
121    
122def run_edit(path: str, old_text: str, new_text: str) -> str:
123    try:
124        path = safe_path(path)
125        text = path.read_text()
126        if old_text not in text:
127            return f"Error: text not found in {path}"
128        path.write_text(text.replace(old_text, new_text, 1))
129        return f"Edited {path}"
130    except Exception as e:
131        return f"Error: {e}"
132
133
134def run_glob(pattern: str) -> str:
135    import glob as g
136    try:
137        results = []
138        for match in g.glob(pattern, root_dir=WORKDIR):
139            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
140                results.append(match)
141        return "\n".join(results) if results else "(no matches)"
142    except Exception as e:
143        return f"Error: {e}"
144    
145
146
147TOOL_HANDLERS = {
148    "bash": run_bash,
149    "read_file": run_read,
150    "write_file": run_write,
151    "edit_file": run_edit,
152    "glob": run_glob
153}
154
155
156SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
157
158
159
160def agent_loop(history):
161    while True:
162        
163        response = client.messages.create(
164            model=MODEL,
165            max_tokens=1024,
166            messages=history,
167            tools=TOOLS,
168            system=SYSTEM,        
169        )
170
171        # 保存模型记录
172        history.append({"role": "assistant", "content": response.content})
173        print(f"Model response: {response.content}")
174        # 如果不再需要工具调用
175        if response.stop_reason != "tool_use":
176            return 
177        
178        tool_results = []
179        for block in response.content:
180            if block.type == 'tool_use':
181                print(f"Tool call: {block.name} with input {block.input}")
182                handler = TOOL_HANDLERS[block.name] # 转化对应函数
183                output = handler(**block.input) if handler else f"Unknow: {block.name}"
184                print(str(output)[:200])
185                tool_results.append({
186                    "type": "tool_result",
187                    "tool_use_id": block.id, # 对应同block的工具调用id
188                    "content": output
189                })                 
190
191        # 将工具调用结果添加到历史记录中,供模型后续生成使用
192        history.append({"role": "user", "content": tool_results})        
193
194
195
196
197if __name__ == "__main__":
198
199    history = []
200    
201    # 这里是多轮对话的循环
202    while True:
203        query = input("Enter your query: ")
204
205        if query.lower() in ["exit", "quit"]:
206            print("Exiting the agent loop.")
207            break
208
209        history.append({"role": "user", "content": query})
210        agent_loop(history) # Agent内部循环,完成工具调用和响应生成
211
212        final_output = history[-1]["content"]
213        print(final_output)
214
215    print("Goodbye!")

当任务比较复杂的时候,模型会一次性输出多个toolblock

如果模型足够强,这当然是并发的,因此可以获取所有toolblock去做

1Read both README.md and requirements.txt, then create a summary file

这里会先两个tool块获取两个文件信息

然后思考一下,第二次循环做写的工具调用

Claude Code

定义

工具的定义上我们是定义与实现分开的

但实际上CC会对每个工具通过buildTool()实例化一个对象

包含 schema、验证、权限、执行

Validation

工具调用前需要经过严格的五步验证

  • Zod Schema(类似json schema):参数类型、结构检查
  • 工具级别的参数验证:确保参数符合工具输入要求
  • PreToolUse hooks:工具执行前设计一些hooks进行工作
  • 权限:是否涉及了不允许的内容
  • 执行

isConcurrencySafe()

代码中还是串行的,CC实际上是按照batch做并行

有专门的方法isConcurrencySafe()检验一个工具调用是否是并发安全的

并发安全和只读是不一样的。有的方法是非只读的,但是由于操作不同文件,因此两者之间是并发安全的

1[read A, read B, glob *.py, bash "rm x", read C]
2  → batch1(并发): [read A, read B, glob *.py]
3  → batch2(串行): [bash "rm x"]
4  → batch3(并发): [read C]

并发安全的连续块编入同一个 batch

batch 内真正并发执行(有并发上限)

遇到非并发安全就开新batch

batch之间串行

Result持久化

当一个工具的输出足够长时,为了节省token

设计了一个maxResultSizeChars

如果超过上限,自动截断,将完整内容保存到文件中

只提供给模型上限内的文本 + 完整内容的文件路径

对于Read操作,为了防止重复创建文件

这个上限直接设置成无限即可