工具系統 (tool system) ToolRegistry
職責
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)、內建工具共用一張表。
關鍵檔案
discover_builtin_tools:67-86— AST 掃描tools/*.py找出註冊了工具的模組:
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。
_is_registry_register_call / _module_registers_tools:30-43— 判斷某模組是否呼叫registry.registerclass ToolEntry:87-153— 工具元資料。__slots__讓 80+ 個 entry 佔用最小化:
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_task 的 max_concurrent_children)合併到 schema,避免模型看到錯誤限制。
class ToolRegistry:217-365— 註冊表本體def register:365-436— 註冊一個工具,核心是覆蓋與衝突判定:
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。
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_alias 讓 tools="mcp-foo" 這種動態 toolset 在設定裡寫得更短。
register_plugin_override_policy:316-365— 外掛工具的命名空間准入策略registry 單例:765—registry = ToolRegistry()(模組級單例)tool_error / tool_result:784-810— 工具回傳值標準封裝tools/ 目錄— 約 80 個工具檔案(terminal/browser/web/file/mcp/skills/delegate…)
資料流
- 啟動期
init_agent:276觸發 toolsets 設定。 discover_builtin_tools:67遍歷tools/目錄,用 AST 判斷每個模組是否呼叫registry.register(tools/registry.py:30)。- 命中的模組被匯入,其模組級
registry.register(...)呼叫執行,把ToolEntry寫入registry 單例:765。 - 主迴圈每輪把啟用的 toolsets 對應的
ToolEntry.schema收集進發給 provider 的tools欄位。 - provider 回傳 tool_call → 主迴圈用
get_entry(name)(tools/registry.py:274)查到 handler 執行 → 結果用tool_result:798封裝回填。
邊界與失敗
- 工具名衝突:同名內建工具拒絕註冊;外掛覆蓋內建需
override=True+ operatorallow_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時可能有執行緒在讀。_lock是RLock,mutate 序列化,reader 走 snapshot。_generation讓外層快取命中檢測。 - 模組匯入失敗:tool 檔案 import error 不拖死註冊表,只 warning 跳過,但該 tool 主迴圈呼叫不到——查 agent.log "Could not import tool module"。
小結
工具系統的關鍵在「匯入即註冊」——靠 AST 掃描避免手動維護清單,新增工具只需往 tools/ 丟一個呼叫 register 的檔案。registry 單例是全域唯一真相,主迴圈只讀它。和技能的區別:工具是死的函式,技能是可被學習迴圈改寫的流程。