从上一篇的遗留问题出发
前四个 Part 一直在"解剖":看代码、理设计、读原理。这一篇开始转向"动手"——用 MyCodeAgent 作为起点,扩展出自己的东西。
先回答一个问题:agent 的"工具"到底是什么?
从模型的视角看,工具就是 Function Calling 里的一个 function 定义:一个名字、一段描述、一组参数。模型选了这个工具,框架负责执行,把结果作为 observation 塞回对话历史。
从框架的视角看,工具是一个实现了特定接口的 Python 类:有参数定义,有run()方法,run()返回一个标准格式的结果对象。
理解了这两个视角,添加新工具就是一件有章可循的事。
结论先说
给 MyCodeAgent 添加一个新工具,需要四步:
| 步骤 | 做什么 | 涉及文件 |
|---|---|---|
| 1. 继承 Tool 基类 | 定义参数、实现run() | tools/builtin/your_tool.py |
| 2. 注册到 Registry | 让框架"知道"这个工具存在 | runtime/host.py或app/bootstrap.py |
| 3. 写 Prompt | 告诉模型什么时候用、怎么用 | prompts/tools_prompts/your_tool_prompt.py |
| 4. 写测试 | 验证协议合规、验证逻辑正确 | tests/test_your_tool.py |
一、Tool 基类:一切从这里开始
打开tools/base.py,你会看到整个工具系统的基础结构。
# tools/base.pyclassTool(ABC):def__init__(self,name,description,project_root=None,working_dir=None):self.name=name self.description=description self._project_root=Path(project_root).resolve()ifproject_rootelseNoneself._working_dir=...@abstractmethoddefrun(self,parameters:Dict[str,Any])->ToolResult:pass@abstractmethoddefget_parameters(self)->List[ToolParameter]:pass两个抽象方法必须实现:
get_parameters():告诉框架这个工具接受哪些参数run():工具的实际逻辑,返回ToolResult
ToolResult也在base.py里,它是一个不可变的数据类,封装了标准响应信封:
@dataclass(frozen=True)classToolResult:status:ToolStatus# success / partial / errortext:str# 给模型看的文字摘要data:Dict[str,Any]# 核心载荷error_code:...# 仅 error 时有值stats:Dict[str,Any]# 耗时等统计context:Dict[str,Any]# cwd、params_input 等注意frozen=True:ToolResult创建后不能修改,这防止了在工具执行管道中被意外改动。
二、实战:写一个 WordCount 工具
用一个具体例子把整条路走通。我们要写的工具:统计一个文件里的行数、单词数、字符数。
第一步:实现工具类
# tools/builtin/word_count.pyimporttimefrompathlibimportPathfromtypingimportAny,Dict,List,Optionalfrom..baseimportTool,ToolParameter,ToolResult,ErrorCodeclassWordCountTool(Tool):"""统计文件的行数、单词数、字符数。"""def__init__(self,name:str="WordCount",project_root:Optional[Path]=None,working_dir:Optional[Path]=None,):ifproject_rootisNone:raiseValueError("project_root must be provided by the framework")super().__init__(name=name,description="Count lines, words, and characters in a file.",project_root=project_root,working_dir=working_dirorproject_root,)defget_parameters(self)->List[ToolParameter]:return[ToolParameter(name="path",type="string",description="Path to the file (relative to project root).",required=True,),]defrun(self,parameters:Dict[str,Any])->ToolResult:start_time=time.monotonic()params_input=dict(parameters)path_str=parameters.get("path")# 参数校验ifnotpath_str:returnself.error_result(error_code=ErrorCode.INVALID_PARAM,message="Parameter 'path' is required.",params_input=params_input,)# 沙箱:确保路径在 project_root 内target=(self._project_root/path_str).resolve()try:target.relative_to(self._project_root)exceptValueError:returnself.error_result(error_code=ErrorCode.ACCESS_DENIED,message=f"Path '{path_str}' is outside project root.",params_input=params_input,)ifnottarget.exists():returnself.error_result(error_code=ErrorCode.NOT_FOUND,message=f"File '{path_str}' does not exist.",params_input=params_input,)iftarget.is_dir():returnself.error_result(error_code=ErrorCode.IS_DIRECTORY,message=f"Path '{path_str}' is a directory, not a file.",params_input=params_input,)# 核心逻辑content=target.read_text(encoding="utf-8",errors="replace")line_count=len(content.splitlines())word_count=len(content.split())char_count=len(content)elapsed_ms=int((time.monotonic()-start_time)*1000)rel_path=str(target.relative_to(self._project_root))returnself.success_result(data={"lines":line_count,"words":word_count,"characters":char_count,},text=(f"'{rel_path}':{line_count}lines, "f"{word_count}words,{char_count}characters."),params_input=params_input,time_ms=elapsed_ms,path_resolved=rel_path,)几个值得注意的细节:
沙箱检查:target.relative_to(self._project_root)如果抛ValueError,说明路径逃出了项目根目录。这一行是所有涉及文件系统的工具的必要保护。
参数校验在前:先验参数,再做任何 IO。这样模型传了坏参数时,能立刻拿到清晰的错误信息,而不是在 IO 层收到一个莫名其妙的异常。
success_result()辅助方法:基类已经提供了success_result()、partial_result()、error_result()三个辅助方法,不需要手动构造ToolResult。它们会自动组装stats.time_ms和context.cwd等固定字段。
三、注册:让框架"看到"这个工具
工具类写完了,但框架还不知道它的存在。需要在runtime/host.py的工具注册区加上它:
# runtime/host.py — 在内置工具注册区加入以下两行fromtools.builtin.word_countimportWordCountTool# 在 _build_tool_registry() 或 __init__ 里:registry.register_tool(WordCountTool(project_root=self._project_root,working_dir=self._working_dir,))注册后,工具会出现在registry.get_openai_tools()返回的列表里,模型在下一次请求时就能看到这个工具的 schema。
四、Prompt:告诉模型何时用、怎么用
工具能被执行,但模型不一定知道什么时候该用它。写一个 Prompt 文件:
# prompts/tools_prompts/word_count_prompt.pyword_count_prompt="""Count lines, words, and characters in a file. Use this tool when you need to: - Know the size of a file before deciding whether to read it in full - Get a quick overview of a file's content volume Parameters: - path (required): Relative path to the file Returns: - lines: Number of lines - words: Number of words - characters: Number of characters Example: WordCount(path="src/main.py") → "src/main.py: 312 lines, 1847 words, 14203 characters." """然后在工具类的description参数里引用它:
fromprompts.tools_prompts.word_count_promptimportword_count_promptsuper().__init__(name=name,description=word_count_prompt,# 这个 description 会被放进 Function Calling schema...)这个 description 就是模型决定"要不要用这个工具"的唯一依据。写得清晰,模型就能在合适的时候选中它;写得模糊,模型要么不会用,要么用错场景。
五、测试:验证协议合规
新工具至少要写两类测试:
# tests/test_word_count_tool.pyfrompathlibimportPathimportpytestfromtools.builtin.word_countimportWordCountToolfromtools.baseimportToolStatus,ErrorCode@pytest.fixturedeftool(tmp_path):returnWordCountTool(project_root=tmp_path)deftest_success(tool,tmp_path):(tmp_path/"hello.txt").write_text("hello world\nfoo bar baz\n")result=tool.run({"path":"hello.txt"})assertresult.status==ToolStatus.SUCCESSassertresult.data["lines"]==2assertresult.data["words"]==5assert"stats"inresult.__dataclass_fields__assertresult.stats["time_ms"]>=0deftest_not_found(tool):result=tool.run({"path":"nonexistent.txt"})assertresult.status==ToolStatus.ERRORassertresult.error_code==ErrorCode.NOT_FOUNDdeftest_sandbox_escape(tool):result=tool.run({"path":"../../../etc/passwd"})assertresult.status==ToolStatus.ERRORassertresult.error_code==ErrorCode.ACCESS_DENIEDdeftest_missing_param(tool):result=tool.run({})assertresult.status==ToolStatus.ERRORassertresult.error_code==ErrorCode.INVALID_PARAM沙箱逃逸测试(../../../etc/passwd)是必测项。一个工具如果能被模型用来读项目外的文件,那就是一个安全漏洞。
设计亮点
1. 框架注入,工具不猜路径
project_root由框架在注册时传入,工具自己不决定"从哪里开始"。这保证了所有路径操作都在一个可控的范围内,也让工具在测试时可以用tmp_path隔离。
2. ToolResult 是不可变类型
frozen=True的 dataclass 让工具不能在run()返回后再修改结果。管道里的任何一步(乐观锁注入、字节预算截断等)都会产生新对象,而不是在原对象上修改,减少了数据竞争的可能性。
3. 三种状态,不只是成功/失败
status=partial是给"结果可用但有折扣"的情况准备的,比如读了一个大文件只返回前 500 行,或者用了编码回退。模型看到partial会知道结果可能不完整,可以追问或调整策略;看到success则放心使用。
小结
| 步骤 | 要点 |
|---|---|
| 继承 Tool | run()返回ToolResult,get_parameters()定义参数 schema |
| 沙箱保护 | target.relative_to(project_root)是每个涉及文件系统工具的必要检查 |
| 注册 | registry.register_tool(YourTool(project_root=...)) |
| Prompt | description是模型选工具的唯一依据,要写清楚"什么时候用" |
| 测试 | 至少覆盖:成功路径、参数缺失、沙箱逃逸 |
下一篇讲接入新的 LLM provider——工具系统是 agent 的手,LLM 是 agent 的大脑,它们是同样重要的扩展点。
关于本系列的源码
本系列所有分析均基于开源项目 MyCodeAgent。
源码里已经按照本系列文章的讲解顺序,在关键位置加入了配套注释——读文章时可以对照代码,也可以直接克隆下来自己跑、改、扩展,基于它开发你自己的 agent。
gitclone https://github.com/chendongqi/MyCodeAgentcdMyCodeAgentcp.env.example .env# 填入你的 LLM API keyuvsyncuv run python main.py欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页