手搓Claude Code-第二章 tool_use

手搓Claude Code-第二章 tool_use 第二章 tool_use写在前面一、先写一些工具函数二、修改agent_loop写在最后写在前面第二章shareai讲述了给agent扩展工具的流程。完整代码见https://github.com/shareAI-lab/learn-claude-code/tree/main/s02_tool_use我们的任务是1扩展四个工具手把手感受整个过程2构建沙箱了解沙箱为什么重要。3感受claude code处理并发的操作那在写之前我们需要先把os.getcwd()换成Path.cwd()用WORKDIR去接收它这么做的目的是方便后续工具把它作为全局变量使用并且Path这个包还支持许多路径拼接方便操作。现在来思考一个问题不是都说bash is all your need大模型通过bash一个工具就能实现所有操作何必再写一些工具其实后者是前者的细粒度相当于把前者拆分成了一个个小工具更具体的说是把一些模型能用bash实现的主流功能专精成一个个不需要模型思考可以直接调用的硬编码。这样做的好处是什么在这些细粒度的工具函数中我们能够控制模型安全稳定的工作并且还可以减少token输出。什么意思还记得第一章末尾吗当时只是让模型简单查看一下当前目录下的python文件模型前后就输出了五次token试想放到真实场景中更复杂的任务会产生多少费用。所以有了这些细粒度的工具我们不但能安全稳定的控制模型不超过规定的环境工作还能有效的减少token的消耗。前者就是在防沙箱逃逸。那么开始吧在做中去感受。一、先写一些工具函数先直接复制shareAI的TOOLS这里给出了这五个函数的说明我们来把他们当成本章的任务。TOOLS[{name:bash,description:Run a shell command.,input_schema:{type:object,properties:{command:{type:string}},required:[command]}},{name:read_file,description:Read file contents.,input_schema:{type:object,properties:{path:{type:string},limit:{type:integer}},required:[path]}},{name:write_file,description:Write content to a file.,input_schema:{type:object,properties:{path:{type:string},content:{type:string}},required:[path,content]}},{name:edit_file,description:Replace exact text in a file once.,input_schema:{type:object,properties:{path:{type:string},old_text:{type:string},new_text:{type:string}},required:[path,old_text,new_text]}},{name:glob,description:Find files matching a glob pattern.,input_schema:{type:object,properties:{pattern:{type:string}},required:[pattern]}},]虽然有五个工具函数但其实只控制了三个功能读、写和编辑文件虽然在模型那里这些工具是没什么差别的。试想一下当你想让模型操作一个文件是不是需要先找到文件路径然后拼接路径再打开bash最后再操作所以真正控制的功能只有三个。逻辑如下。以最复杂的编辑文件的功能edit_file为例因为可以顺带实现读和写的操作一起来实现一遍这个流程。我们先来实现找文件路径的操作需要通过通配符去寻找环境中匹配的文件。传入一个通配符返还对应的绝对路径可能有多个。defrun_glob(pattern:str)-str: 这个函数就是查找当前工作目录下通配符为pattern的文件 importglobasgtry:results[]formatching.glob(pattern,root_dirWORKDIR):if(WORKDIR/match).resolve().is_relative_to(WORKDIR):results.append(match)return\n.join(results)ifresultselse(no matches)exceptExceptionase:returnfError:{e}解释下glob方法传入一个通配符和根目录返还匹配到的目录列表。然后依次检查一遍返回绝对路径。再来实现拼接路径防逃逸保证模型始终在规定的安全环境中工作。创建一个safe_path函数。“(WORKDIR / p).resolve()”先用pathlib的“/”把根目录与传入路径拼接再通过“resolve()”自动解析抵消“…/”跳转符号、换算成操作系统真实绝对路径通过“is_relative_to”校验模型工作传入路径返回经过检查的Path绝对路径来实现工作环境的管控。defsafe_path(p:str)-Path:# 把传入的p和WORKDIR拼接一下这就是换了个包的原因有很方便的功能。path(WORKDIR/p).resolve()# 如果path不是WOKRDIR的子路径则抛出异常越界访问了。ifnotpath.is_relative_to(WORKDIR):raiseValueError(fPath escapes workspace:{p})returnpathbash工具我们在第一章实现过了现在实现编辑文件的功能。在编辑文件之前我们是不是需要先读到这个文件。创建一个工具用于读文件传入路径和限制读入行数。这里的lines是一个列表元素为要读入文件的每行的内容如果有限制行数则超过的行内容不进行存储。defrun_read(path:str,limit:int|NoneNone)-str:try:linessafe_path(path).read_text().splitlines()iflimitandlimitlen(lines):lineslines[:limit][f... ({len(lines)-limit}more lines)]return\n.join(lines)exceptExceptionase:returnfError:{e}接着编辑本质是做替换。将old_text和new_text做替换后写入那需要有一个工具函数用于写入来实现它。传入文件路径和内容拿到过滤后的path不存在则创建若已存在则覆盖。defrun_write(path:str,content:str)-str:try:file_pathsafe_path(path)# 在父文件夹下创建一个文件如果没有就创建该路径如果文件已存在则覆盖file_path.parent.mkdir(parentsTrue,exist_okTrue)file_path.write_text(content)returnfWrote{len(content)}bytes to{path}exceptExceptionase:returnfError:{e}最后实现编辑文本并且需要保证在允许的路径下进行。首先拿到safe_path过滤后的函数判断要替换的文本肯定在文本中然后去替换第一个匹配的内容再写入。defrun_edit(path:str,old_text:str,new_text:str)-str:try:file_pathsafe_path(path)textfile_path.read_text()ifold_textnotintext:returnfError: text not found in{path}# 只替换第一个匹配到的内容file_path.write_text(text.replace(old_text,new_text,1))returnfEdited{path}exceptExceptionase:returnfError:{e}现在我们已经有了所有工具试想一下原先我们只有一个工具现在变成了多个是不是需要一个表去分发这个分发表其实就是一个字典。TOOL_HANDLERS{bash:run_bash,read_file:run_read,write_file:run_write,edit_file:run_edit,glob:run_glob,}二、修改agent_loop那来修改下agent_loop的内容。我们只需要修改因tool_use而停滞的情况。先观察ToolUseBlock中需要调用的工具名然后用handler接收分发表对应的值。ifblock.typetool_use:print(f\033[33m${block.name}\033[0m)handlerTOOL_HANDLERS.get(block.name)outputhandler(**block.input)ifhandlerelsefUnknow:{block.name}print(str(output)[:200])results.append({type:tool_result,tool_use_id:block.id,content:output,})接下来的三元表达式等同于ifhandler:# handler 存在、不为空/None解包入参调用函数outputhandler(**block.input)else:# handler 不存在返回提示字符串outputfUnknow:{block.name}至此我们完成了第二章的全部内容接下来创建一个文本文件测试一下。s01将名为test_words的文件内容“我是小明”改为“我是小郭” 观察content的内容[ThinkingBlock(signaturehirasgznou,thinkingThe user wants to change 我是小明 back to 我是小郭 in the test_words file.,typethinking), ToolUseBlock(id019ea094992aa272c8185779cd8c6331,callerNone,input{command:powershell -Command (Get-Content -Path test_words -Encoding UTF8) -replace \小明\,\小郭\|Set-Content-Pathtest_words-EncodingUTF8}, namebash, typetool_use)] $ bash (no output) 观察content的内容[ThinkingBlock(signaturekgzosearyr, thinkingLet me verify the change., typethinking), ToolUseBlock(id019ea094aa397df9616b5556f1ecd2c0, callerNone, input{command: type test_words}, namebash, typetool_use)] $ bash 我是小郭来自三年二班。 已完成文件test_words中的我是小明已改为我是小郭当前内容为 我是小郭来自三年二班。 s01写在最后可以看到模型输出很少的token就实现了编辑文件的操作。但有一点需要强调输出很少token不代表agent_loop有很少轮上述例子只是巧合但token总归消耗是少的。试想一下在run_bash中我们有这样的检查这只是象征性的拦截在实际中如何保证permissiondangerous[rm -rf /,sudo,shutdown,reboot, /dev/]ifany(dincommandfordindangerous):returnError: Dangerous command blocked此外当agent尝试读写编辑一个或多个文件时如何实现并发