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,你会看到整个工具系统的基础结构。

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=TrueToolResult 创建后不能修改,这防止了在工具执行管道中被意外改动。


二、实战:写一个 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_mscontext.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() 返回 ToolResultget_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 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

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

相关推荐
论文复现现场1 小时前
RTX 4090 24GB 能跑 Qwen3.8-27B 吗?单卡显存计算与云端部署指南
人工智能·python·云计算·llama·gpu算力
七牛云行业应用1 小时前
Harness Engineering 是什么:从“写提示词“到“设计 Agent 边界“的工程方法论
人工智能·agent·ai编程
科技拓维者1 小时前
短视频配音音效去哪里找比较好?短视频创作者音效指南
人工智能·音视频
cjy0001111 小时前
2026AI 产品如何从一次性 Demo 变成可重复使用的业务工具?
前端·人工智能·fde
大模型码小白1 小时前
AI大模型接入SDK:人工智能核心概念与发展史
大数据·运维·人工智能·安全·prompt
杀生丸学AI1 小时前
【动态重建】Flow4DGS-SLAM:基于光流引导的4DGS-SLAM算法
人工智能·三维重建·扩散模型·4dgs·动态重建
IT_陈寒1 小时前
React的状态更新坑得我差点加班到天亮
前端·人工智能·后端
腾视科技-AI1 小时前
腾视科技TS-NV-P200车载系列AI边缘算力盒子:引领车路协同新时代,赋能多元场景应用
人工智能·科技·ai·ai算力·ai边缘算力盒子·腾视科技·ai算力盒
147API1 小时前
知识更新频繁时,为什么先做RAG再决定是否蒸馏
人工智能