news 2026/9/6 2:43:50

Code Agent 解剖(20):从零扩展——给 agent 加一个新工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Code Agent 解剖(20):从零扩展——给 agent 加一个新工具

从上一篇的遗留问题出发

前四个 Part 一直在"解剖":看代码、理设计、读原理。这一篇开始转向"动手"——用 MyCodeAgent 作为起点,扩展出自己的东西。

先回答一个问题:agent 的"工具"到底是什么?

从模型的视角看,工具就是 Function Calling 里的一个 function 定义:一个名字、一段描述、一组参数。模型选了这个工具,框架负责执行,把结果作为 observation 塞回对话历史。

从框架的视角看,工具是一个实现了特定接口的 Python 类:有参数定义,有run()方法,run()返回一个标准格式的结果对象。

理解了这两个视角,添加新工具就是一件有章可循的事。


结论先说

给 MyCodeAgent 添加一个新工具,需要四步:

步骤做什么涉及文件
1. 继承 Tool 基类定义参数、实现run()tools/builtin/your_tool.py
2. 注册到 Registry让框架"知道"这个工具存在runtime/host.pyapp/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=TrueToolResult创建后不能修改,这防止了在工具执行管道中被意外改动。


二、实战:写一个 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_mscontext.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则放心使用。


小结

步骤要点
继承 Toolrun()返回ToolResultget_parameters()定义参数 schema
沙箱保护target.relative_to(project_root)是每个涉及文件系统工具的必要检查
注册registry.register_tool(YourTool(project_root=...))
Promptdescription是模型选工具的唯一依据,要写清楚"什么时候用"
测试至少覆盖:成功路径、参数缺失、沙箱逃逸

下一篇讲接入新的 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 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 2:36:07

冰雪传奇点卡版:公平点卡复古冰雪,热血打金尽在忆往游戏

冰雪传奇点卡版是传奇怀旧圈口碑出众的冰雪版本,主打纯点卡公平机制,全民打金,不滚服,也是少有的每日开启双区的冰雪版本,深受散人与打金玩家喜爱。游戏由安徽游昕联合忆往游戏联合运营,还原复古冰雪的核心…

作者头像 李华
网站建设 2026/9/6 2:32:54

2026年9月西安 AI 搜索优化是什么?功能与价值解读

西安 AI 搜索优化是什么?2026年9月功能与价值解读如果用户搜索“西安 AI 搜索优化是什么”,通常想了解的并不是传统网页排名,而是企业信息如何被大模型理解、引用,并在西安本地服务、品牌比较和消费决策中获得准确呈现。简单来说&…

作者头像 李华
网站建设 2026/9/6 2:27:38

Git 学习笔记:从命令到原理1

Git 基础操作与核心内部结构:从工作区到 Commit ID 摘要 本文整理 Git 的基础使用流程,并补充 .git 文件夹与 Commit ID 的作用。重点是理解文件如何从工作区进入暂存区,再通过一次提交形成可追踪的项目快照。 关键词:Git、版本…

作者头像 李华
网站建设 2026/9/6 2:27:16

GitHub日榜爆款项目拆解:数据资产化、企业开源与自动化升级

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华