Skip to content

工具系統 (tool system) ToolRegistry

源码版本v2026.7.20

職責

Tool 是 agent 能呼叫的原子函式(終端、瀏覽器、檔案、MCP、技能呼叫……)。ToolRegistry 在模組匯入時透過 AST 掃描自動發現 tools/*.py 裡的工具並註冊,提供一個 name → ToolEntry 的查詢表給主迴圈做工具分發 (tool dispatch)。工具是靜態的——與技能的「可演化」相對。

設計動機

為什麼用註冊表 (registry) 而非硬編碼分支?hermes-agent 有約 80 個工具檔案,新加工具如果要去 if name == "xxx" 鏈結裡登記,很快就會腐爛。AST 掃描讓「往 tools/ 丟一個呼叫 registry.register 的檔案」就是接入動作。registry 是全域單例,MCP 動態工具、外掛 (plugin)、內建工具共用一張表。

關鍵檔案

python
def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
    tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent
    module_names = [
        f"tools.{path.stem}"
        for path in sorted(tools_path.glob("*.py"))
        if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"}
        and _module_registers_tools(path)
    ]
    imported: List[str] = []
    for mod_name in module_names:
        try:
            importlib.import_module(mod_name)
            imported.append(mod_name)
        except Exception as e:
            logger.warning("Could not import tool module %s: %s", mod_name, e)
    return imported

跳過 __init__.py / registry.py / mcp_tool.py,其餘 .py 只要 AST 判定呼叫過 registry.register 就匯入,匯入動作本身觸發模組級 register(...)。單模組匯入失敗只 warning。

python
class ToolEntry:
    __slots__ = (
        "name", "toolset", "schema", "handler", "check_fn",
        "requires_env", "is_async", "description", "emoji",
        "max_result_size_chars", "dynamic_schema_overrides",
    )

dynamic_schema_overrides 是零參 callable,每次 get_definitions() 時呼叫,把依賴執行時配置的欄位(比如 delegate_taskmax_concurrent_children)合併到 schema,避免模型看到錯誤限制。

python
def register(self, name, toolset, schema, handler, ..., override: bool = False):
    with self._lock:
        existing = self._tools.get(name)
        if existing and existing.toolset != toolset:
            both_mcp = (existing.toolset.startswith("mcp-")
                        and toolset.startswith("mcp-"))
            if both_mcp:
                ...  # MCP 刷新覆蓋 MCP
            elif override:
                # 外掛顯式 opt-in 覆蓋內建,需要 operator allow_tool_override
                ...
            else:
                logger.error("Tool registration REJECTED: '%s' ...", name, toolset, existing.toolset)
                return
        self._tools[name] = ToolEntry(...)
        self._generation += 1

同名工具預設拒絕覆蓋。MCP 對 MCP 允許(notifications/tools/list_changed 刷新場景),外掛覆蓋內建需要 override=True + operator allow_tool_override: true,否則 PermissionError_generation 自增讓外層快取能 memoize。

python
def get_entry(self, name: str) -> Optional[ToolEntry]:
    """Return a registered tool entry by name, or None."""
    with self._lock:
        return self._tools.get(name)

主迴圈拿到 tool_call.name 後只查一次 get_entry 就拿到 handler。register_toolset_aliastools="mcp-foo" 這種動態 toolset 在設定裡寫得更短。

資料流

  1. 啟動期 init_agent:276 觸發 toolsets 設定。
  2. discover_builtin_tools:67 遍歷 tools/ 目錄,用 AST 判斷每個模組是否呼叫 registry.register(tools/registry.py:30)。
  3. 命中的模組被匯入,其模組級 registry.register(...) 呼叫執行,把 ToolEntry 寫入 registry 單例:765
  4. 主迴圈每輪把啟用的 toolsets 對應的 ToolEntry.schema 收集進發給 provider 的 tools 欄位。
  5. provider 回傳 tool_call → 主迴圈用 get_entry(name)(tools/registry.py:274)查到 handler 執行 → 結果用 tool_result:798 封裝回填。

邊界與失敗

  • 工具名衝突:同名內建工具拒絕註冊;外掛覆蓋內建需 override=True + operator allow_tool_override: true,否則 PermissionError。MCP 對 MCP(mcp- 前綴)例外,server 刷新 nuke-and-repave 合法。
  • check_fn 抖動:Docker / playwright 探針可能瞬時 False。_check_fn_cached 有 30s TTL + 60s grace window:grace 視窗內即使新探針失敗也回傳上次成功的 True,避免 delegate_task 子 agent 突然報「Tool read_file does not exist」。
  • MCP 並發:MCP server 推 tools/list_changed 時可能有執行緒在讀。_lockRLock,mutate 序列化,reader 走 snapshot。_generation 讓外層快取命中檢測。
  • 模組匯入失敗:tool 檔案 import error 不拖死註冊表,只 warning 跳過,但該 tool 主迴圈呼叫不到——查 agent.log "Could not import tool module"。

小結

工具系統的關鍵在「匯入即註冊」——靠 AST 掃描避免手動維護清單,新增工具只需往 tools/ 丟一個呼叫 register 的檔案。registry 單例是全域唯一真相,主迴圈只讀它。和技能的區別:工具是死的函式,技能是可被學習迴圈改寫的流程。

非官方社群學習站,內容以 MIT 授權的 NousResearch/hermes-agent 原始碼為依據。