新增 Tool

对应源码

扩展环节源码
Tool 基类契约nanobot/agent/tools/base.py::Tool
发现、scope 和 entry pointnanobot/agent/tools/loader.py::ToolLoader.discover、load
注册与 schemananobot/agent/tools/registry.py::ToolRegistry
配置和上下文接入nanobot/config/schema.py、nanobot/agent/tools/context.py
Runner 执行边界nanobot/agent/runner.py::_run_tool、_execute_tools
示例和测试example/nanobot/learning-CN/examples/text-stats-tool-plugin/、tests/agent/test_tool_loader_entrypoints.py、tests/tools/

学习目标

读完后,你应当能够:

  • 实现一个最小 Tool 契约;
  • 解释内置扫描与外部 entry point 两种发现方式;
  • 决定 Tool 的 scope、配置和并发属性;
  • 为参数校验、副作用和安全边界设计测试。

前置知识

1. 最小 Tool 契约

nanobot/agent/tools/base.py::Tool 要求:

  • name
  • description
  • parameters
  • async execute(**kwargs)

一个只做纯文本统计的最小形态:

from typing import Any
 
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
 
 
@tool_parameters(
    tool_parameters_schema(
        required=["text"],
        text=StringSchema("Text whose words should be counted"),
    )
)
class WordCountTool(Tool):
    @property
    def name(self) -> str:
        return "word_count"
 
    @property
    def description(self) -> str:
        return "Count whitespace-separated words in text."
 
    @property
    def read_only(self) -> bool:
        return True
 
    async def execute(self, text: str, **_: Any) -> str:
        return str(len(text.split()))

这是说明契约的示例,不要求现在写入 nanobot 源码。

2. 为什么 description 也是运行逻辑

Provider 看到的是 Tool.to_schema() 生成的 function schema。模型主要依据:

  • name 判断大类;
  • description 判断何时使用;
  • parameter description 决定每个字段的语义。

模糊的 schema 会让模型传入错误参数,即使 Python 实现完全正确。描述应说明用途和边界,不要把内部实现细节堆给模型。

3. 参数转换与校验顺序

ToolRegistry.execute() 会:

  1. 按名称找 Tool;
  2. 将可安全转换的字符串转成 integer/number/boolean;
  3. 调用 validate_params();
  4. 校验失败时返回明确错误;
  5. 校验成功才调用 execute()。

Tool 的 JSON Schema validator 支持常见类型、required、enum、min/max、字符串长度、数组和嵌套对象。业务约束仍可覆盖 validate_params()。

Schema 校验不等于业务授权

"path" 是字符串,只能证明类型正确;是否允许访问该路径仍要调用 workspace policy。

4. 内置 Tool 如何被发现

ToolLoader.discover() 用 pkgutil.iter_modules() 扫描 nanobot.agent.tools:

  • 跳过 base、schema、registry、loader、mcp 等基础模块;
  • 导入其他公开模块;
  • 查找具体、非抽象、非私有的 Tool 子类;
  • 跳过 _plugin_discoverable=False;
  • 按类名排序。

因此,内置 Tool 通常只需放入一个可扫描模块,不需要在 AgentLoop 手工 import/register。需要 AgentLoop 本身引用的特殊 Tool 可以像 MyTool 一样关闭自动发现并手工注册,但这不是普通扩展的首选。

5. 外部 Tool plugin

不想改 nanobot 源码时,可在独立 Python 包中暴露 entry point:

[project.entry-points."nanobot.tools"]
word-count = "my_nanobot_tools:WordCountTool"

ToolLoader._discover_plugins() 会加载 nanobot.tools 组中的 class。

名称冲突规则:

  • 内置 Tool 先加载;
  • plugin 若与内置 Tool 的 tool.name 冲突,会被跳过;
  • 其他碰撞会记录警告,后注册项可能覆盖已有项。

不要依赖覆盖行为实现“猴子补丁”;应选择唯一、稳定的 Tool name。

6. ToolContext 与生命周期

Tool 可通过 classmethod 接入运行时:

@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
    ...
 
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
    ...

ToolContext 可提供:

  • tools config;
  • workspace;
  • MessageBus;
  • subagent manager;
  • Cron service;
  • file state store;
  • Provider snapshot loader;
  • image generation provider config;
  • timezone;
  • RuntimeEventBus 等扩展上下文。

只读取实际需要的字段。一个纯函数 Tool 不必依赖整个 runtime。

7. scope

_scopes 决定 Tool 在哪些 registry 中出现:

  • core
  • subagent
  • memory

基类默认只有 {"core"}。例如,允许 main Agent 使用的工具不一定适合 Dream/Memory runner 或 subagent。

把 Tool 加入 subagent 前,检查:

  • 是否会修改 main Session;
  • 是否需要主 MessageBus;
  • 是否依赖只有 main loop 才有的状态;
  • workspace 权限是否仍正确。

对应实现与测试:

  • nanobot/agent/tools/loader.py::ToolLoader.load
  • tests/agent/test_tool_loader_scopes.py

8. 配置接入

内置 Tool 常用:

  • config_key
  • config_cls()
  • enabled(ctx)
  • create(ctx)

例如 ExecTool 用 config_key="exec",配置类型在 shell.py,ToolsConfig 通过延迟导入解析类型。

不要假设 entry point 自动完成所有配置接入

ToolLoader 负责发现和实例化,不会替你设计 WebUI 表单、配置迁移或文档。外部 plugin 若需要自定义配置,应实际验证 ToolsConfig 的 extra 字段形态,并在 enabled/create 中显式解析;内置 Tool 则还要同步 nanobot/config/schema.py 中的 ToolsConfig。

9. 并发属性

基类定义:

  • read_only=False
  • exclusive=False
  • concurrency_safe = read_only and not exclusive

只有当并发执行不会竞争共享状态时,才设置 read_only=True 或覆盖并发属性。只读网络请求也可能受共享 rate limit、非线程安全 client 或全局 cursor 影响。

10. 安全与结果契约

新 Tool 至少考虑:

  • 路径:复用 resolve_workspace_path();
  • URL:复用 validate_url_target();
  • secret:不把 key 写进 schema、结果或日志;
  • timeout:外部 I/O 必须有界;
  • cancellation:释放文件、client、process;
  • output:限制体积,避免一次结果塞满上下文;
  • error:给模型可行动的错误,但不要提示绕过策略;
  • side effect:description 明确行为,必要时要求显式确认。

11. 最小测试集

  1. schema 正确;
  2. 合法参数返回预期;
  3. 缺失、错误类型和边界值;
  4. enabled/create 读取真实 config 对象;
  5. ToolLoader 能发现且 scope 正确;
  6. 文件/网络安全边界;
  7. 取消、超时和资源清理;
  8. 结果过大或外部错误。

测试参考:

  • tests/tools/test_tool_loader.py
  • tests/agent/test_tool_loader_entrypoints.py
  • tests/agent/test_tool_loader_scopes.py
  • tests/tools/test_tool_registry.py
  • 与同类 Tool 对应的 tests/tools/test_*.py

12. 最小修改范围

内置 Tool

  • nanobot/agent/tools/<name>.py
  • nanobot/config/schema.py(仅当需要一等配置)
  • tests/tools/test_<name>.py
  • 用户配置文档(仅当存在用户可配置项)

外部 plugin

  • 独立 package;
  • entry point;
  • plugin 自己的 tests/readme;
  • 不改 AgentLoop。

自检

  1. async def 是否意味着 Tool 可以并发?
  2. 为什么参数 schema 不能代替路径授权?
  3. 内置 Tool 与 entry-point Tool 的注册路径有何不同?
  4. 一个 Skill 已能完成的流程,为什么不应立即新增 Tool?

下一篇:新增 Skill