从上一篇的遗留问题出发
前四个 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,你会看到整个工具系统的基础结构。
python
# tools/base.py
class Tool(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() if project_root else None
self._working_dir = ...
@abstractmethod
def run(self, parameters: Dict[str, Any]) -> ToolResult:
pass
@abstractmethod
def get_parameters(self) -> List[ToolParameter]:
pass
两个抽象方法必须实现:
get_parameters():告诉框架这个工具接受哪些参数run():工具的实际逻辑,返回ToolResult
ToolResult 也在 base.py 里,它是一个不可变的数据类,封装了标准响应信封:
python
@dataclass(frozen=True)
class ToolResult:
status: ToolStatus # success / partial / error
text: str # 给模型看的文字摘要
data: Dict[str, Any] # 核心载荷
error_code: ... # 仅 error 时有值
stats: Dict[str, Any] # 耗时等统计
context: Dict[str, Any] # cwd、params_input 等
注意 frozen=True:ToolResult 创建后不能修改,这防止了在工具执行管道中被意外改动。
二、实战:写一个 WordCount 工具
用一个具体例子把整条路走通。我们要写的工具:统计一个文件里的行数、单词数、字符数。
第一步:实现工具类
python
# tools/builtin/word_count.py
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..base import Tool, ToolParameter, ToolResult, ErrorCode
class WordCountTool(Tool):
"""统计文件的行数、单词数、字符数。"""
def __init__(
self,
name: str = "WordCount",
project_root: Optional[Path] = None,
working_dir: Optional[Path] = None,
):
if project_root is None:
raise ValueError("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_dir or project_root,
)
def get_parameters(self) -> List[ToolParameter]:
return [
ToolParameter(
name="path",
type="string",
description="Path to the file (relative to project root).",
required=True,
),
]
def run(self, parameters: Dict[str, Any]) -> ToolResult:
start_time = time.monotonic()
params_input = dict(parameters)
path_str = parameters.get("path")
# 参数校验
if not path_str:
return self.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)
except ValueError:
return self.error_result(
error_code=ErrorCode.ACCESS_DENIED,
message=f"Path '{path_str}' is outside project root.",
params_input=params_input,
)
if not target.exists():
return self.error_result(
error_code=ErrorCode.NOT_FOUND,
message=f"File '{path_str}' does not exist.",
params_input=params_input,
)
if target.is_dir():
return self.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))
return self.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 的工具注册区加上它:
python
# runtime/host.py --- 在内置工具注册区加入以下两行
from tools.builtin.word_count import WordCountTool
# 在 _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 文件:
python
# prompts/tools_prompts/word_count_prompt.py
word_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 参数里引用它:
python
from prompts.tools_prompts.word_count_prompt import word_count_prompt
super().__init__(
name=name,
description=word_count_prompt, # 这个 description 会被放进 Function Calling schema
...
)
这个 description 就是模型决定"要不要用这个工具"的唯一依据。写得清晰,模型就能在合适的时候选中它;写得模糊,模型要么不会用,要么用错场景。
五、测试:验证协议合规
新工具至少要写两类测试:
python
# tests/test_word_count_tool.py
from pathlib import Path
import pytest
from tools.builtin.word_count import WordCountTool
from tools.base import ToolStatus, ErrorCode
@pytest.fixture
def tool(tmp_path):
return WordCountTool(project_root=tmp_path)
def test_success(tool, tmp_path):
(tmp_path / "hello.txt").write_text("hello world\nfoo bar baz\n")
result = tool.run({"path": "hello.txt"})
assert result.status == ToolStatus.SUCCESS
assert result.data["lines"] == 2
assert result.data["words"] == 5
assert "stats" in result.__dataclass_fields__
assert result.stats["time_ms"] >= 0
def test_not_found(tool):
result = tool.run({"path": "nonexistent.txt"})
assert result.status == ToolStatus.ERROR
assert result.error_code == ErrorCode.NOT_FOUND
def test_sandbox_escape(tool):
result = tool.run({"path": "../../../etc/passwd"})
assert result.status == ToolStatus.ERROR
assert result.error_code == ErrorCode.ACCESS_DENIED
def test_missing_param(tool):
result = tool.run({})
assert result.status == ToolStatus.ERROR
assert result.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。
bash
git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env # 填入你的 LLM API key
uv sync
uv run python main.py
欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页