ツールの実行基盤
目次
Hermes のツールは自分自身を登録する関数で、ツールセットにまとめられ、中央の登録簿と振り分けの仕組みを通して実行されます。
主なファイル:
tools/registry.pymodel_tools.pytoolsets.pytools/terminal_tool.pytools/environments/*
ツールの登録のしかた
各ツールのモジュールは、読み込まれた時点で registry.register(...) を呼びます。
ツールのモジュールを読み込んで見つけ出し、モデルに渡すスキーマの一覧を組み立てるのは model_tools.py の役目です。
registry.register() の動き
tools/ にあるツールのファイルは、モジュールの最上位で registry.register() を呼んで自分を宣言します。関数の書き方は次のとおりです。
registry.register(
name="terminal", # Unique tool name (used in API schemas)
toolset="terminal", # Toolset this tool belongs to
schema={...}, # Model-facing schema (description, parameters)
handler=handle_terminal, # The function that executes when the tool is called
check_fn=check_terminal, # Optional: returns True/False for availability
requires_env=["SOME_VAR"], # Optional: env vars needed (for UI display)
is_async=False, # Whether the handler is an async coroutine
description="Run commands", # Optional ToolEntry registry metadata
emoji="💻", # Emoji for spinner/progress display
)呼び出すたびに ToolEntry が作られ、シングルトンの ToolRegistry._tools 辞書にツール名をキーとして入ります。別のツールセットにある既存のツールを覆い隠すような登録は拒否されます(エラーログが出ます)。ただし呼び出し側が override=True を渡した場合は別で、さらにプラグインが組み込みツールを上書きするときは、config.yaml に plugins.entries.<plugin_id>.allow_tool_override: true という運用者の明示的な許可も必要です。
モデルに対する説明として正となるのは schema["description"] です。引数の description= が埋めるのは ToolEntry.description のほうで、get_definitions() は entry.schema から OpenAI 形式の関数定義を組み立て、description を持たないスキーマに entry.description を写すことはしません。したがって description= を書いただけではモデルへの説明にはならず、両者が食い違えばモデルが見るのはスキーマ側の値です。登録簿の利用者があえて別のメタデータを必要とする場合を除き、説明はスキーマに一度だけ書くのが安全です。
探索: discover_builtin_tools()
model_tools.py が読み込まれると、tools/registry.py の discover_builtin_tools() が呼ばれます。この関数は tools/*.py のファイルを AST 解析で走査し、最上位に registry.register() の呼び出しがあるモジュールを見つけて読み込みます。
# tools/registry.py (simplified)
def discover_builtin_tools(tools_dir=None):
tools_path = Path(tools_dir) if tools_dir else Path(__file__).parent
for path in sorted(tools_path.glob("*.py")):
if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}:
continue
if _module_registers_tools(path): # AST check for top-level registry.register()
importlib.import_module(f"tools.{path.stem}")この自動探索のおかげで、新しいツールのファイルは何もしなくても拾われます。手で一覧を保守する必要はありません。AST の判定は最上位の registry.register() だけを対象にする(関数の中の呼び出しは見ない)ので、tools/ にある補助モジュールが読み込まれることはありません。
読み込みのたびに、そのモジュールの registry.register() が実行されます。任意扱いのツールで起きたエラー(画像生成に必要な fal_client が無い場合など)は捕まえてログに残すだけで、ほかのツールの読み込みは止まりません。
中心となるツールの探索が終わると、MCP のツールとプラグインのツールも探されます。
- MCP のツール —
tools.mcp_tool_discovery.discover_mcp_tools()(窓口のtools.mcp_toolから再公開)が MCP サーバーの設定を読み、外部サーバーのツールを登録します。 - プラグインのツール —
hermes_cli.plugins.discover_plugins()が利用者・プロジェクト・pip のプラグインを読み込み、その中にツールを登録するものがあれば加わります。
ツールが使えるかの判定(check_fn)
各ツールは任意で check_fn を持てます。使えるときに True、そうでなければ False を返す呼び出し可能なものです。よくある判定は次のとおりです。
- API キーがあるか — ウェブ検索なら
lambda: bool(os.environ.get("SERP_API_KEY"))など - サービスが動いているか — Honcho のサーバーが設定されているかを確かめる、など
- 実行ファイルが入っているか — ブラウザ操作のツール向けに
playwrightがあるかを確かめる、など
registry.get_definitions() がモデルに渡すスキーマの一覧を組み立てるとき、各ツールの check_fn() を実行します。
# Simplified from registry.py
if entry.check_fn:
try:
available = bool(entry.check_fn())
except Exception:
available = False # Exceptions = unavailable
if not available:
continue # Skip this tool entirely押さえておきたい動きは次のとおりです。
- 判定の結果は呼び出しごとにキャッシュされます。複数のツールが同じ
check_fnを共有していれば、実行は 1 回だけです。 check_fn()の中で例外が出た場合は「使えない」とみなします(安全側に倒します)。is_toolset_available()メソッドは、そのツールセットのcheck_fnが通るかを確かめます。画面表示とツールセットの解決に使われます。
ツールセットの解決
ツールセットは名前の付いたツールの束です。Hermes は次を通してこれを解決します。
- 有効・無効のツールセットの明示的な一覧
- プラットフォームごとの既定の組み合わせ(
hermes-cli、hermes-telegramなど) - 動的な MCP のツールセット
hermes-acpのような、用途を絞って選び抜かれた束
get_tool_definitions() による絞り込み
入口となるのは model_tools.get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode) です。
enabled_toolsetsが渡された場合 — そのツールセットのツールだけが対象になります。各ツールセット名はresolve_toolset()で解決され、複合のツールセットは個々のツール名に展開されます。
disabled_toolsetsが渡された場合 — すべてのツールセットから始めて、無効にしたものを差し引きます。
- どちらも無い場合 — 知られているツールセットをすべて含めます。
- 登録簿での絞り込み — 解決したツール名の集合が
registry.get_definitions()に渡され、そこでcheck_fnによる絞り込みが行われ、OpenAI 形式のスキーマが返ります。
- スキーマの動的な書き換え — 絞り込みのあと、
execute_codeとbrowser_navigateのスキーマが調整され、実際に絞り込みを通過したツールだけを指すようになります(使えないツールをモデルが思い込みで呼ぶのを防ぎます)。
昔のツールセット名
_tools が末尾に付く古いツールセット名(web_tools、terminal_tools など)は、後方互換のために _LEGACY_TOOLSET_MAP を通して今のツール名へ対応づけられます。
振り分け
実行時、ツールは中央の登録簿を通して振り分けられます。ただし、メモリ・todo・セッション検索の処理のように、エージェントの階層で扱うツールは例外です。
振り分けの流れ: モデルの tool_call からハンドラの実行まで
モデルが tool_call を返したとき、流れは次のようになります。
Model response with tool_call
↓
agent loop (`agent/conversation_loop.py`, via `run_agent.py`'s `AIAgent` facade)
↓
model_tools.handle_function_call(name, args, task_id, user_task)
↓
[Agent-loop tools?] → handled directly by agent loop (todo, memory, session_search, delegate_task)
↓
[Plugin pre-hook] → invoke_hook("pre_tool_call", ...)
↓
registry.dispatch(name, args, **kwargs)
↓
Look up ToolEntry by name
↓
[Async handler?] → bridge via _run_async()
[Sync handler?] → call directly
↓
Return result string (or JSON error)
↓
[Plugin post-hook] → invoke_hook("post_tool_call", ...)エラーの包み方
ツールの実行は、二段構えでエラー処理に包まれています。
registry.dispatch()— ハンドラから出たあらゆる例外を捕まえ、{"error": "Tool execution failed: ExceptionType: message"}を JSON として返します。
handle_function_call()— 振り分け全体をもう一段の try/except で包み、{"error": "Error executing tool_name: message"}を返します。
これにより、モデルが受け取るのは常に整った JSON の文字列になり、処理されないままの例外が渡ることはありません。
エージェントループが扱うツール
次の 4 つは、エージェントの階層の状態(TodoStore、MemoryStore など)を必要とするため、登録簿での振り分けより前に横取りされます。
todo_list— 計画とタスクの管理memory— 残しておくメモリへの書き込みsession_search— セッションをまたいだ想起delegate_task— 副エージェントのセッションを起こす
これらのツールのスキーマも登録簿には登録されていますが(get_tool_definitions のため)、万一そのまま振り分けに届いた場合、ハンドラはエラーの雛形を返します。
非同期のつなぎ
ツールのハンドラが非同期の場合、_run_async() が同期の振り分け経路へつなぎます。
- CLI の経路(動いているループが無い) — 常設のイベントループを使い、キャッシュした非同期クライアントを生かしておきます
- ゲートウェイの経路(ループが動いている) — 使い捨てのスレッドを立てて
asyncio.run()を実行します - ワーカースレッド(ツールの並列実行) — スレッドごとの常設ループをスレッドローカルに持たせます
DANGEROUS_PATTERNS による承認の流れ
ターミナルのツールには、tools/approval.py で定義された危険なコマンドの承認の仕組みが組み込まれています。
- パターンの定義 —
DANGEROUS_PATTERNSは(regex, description)の組の一覧で、壊す方向の操作を網羅します。
- 再帰的な削除(
rm -rf) - ファイルシステムの初期化(
mkfs、dd) - SQL の破壊的な操作(
DROP TABLE、WHEREの無いDELETE FROM) - システム設定の上書き(
> /etc/) - サービスの操作(
systemctl stop) - 遠隔からのコード実行(
curl | sh) - フォーク爆弾、プロセスの強制終了、ほか
- 検出 — ターミナルのコマンドを実行する前に、
detect_dangerous_command(command)がすべてのパターンと照合します。
- 承認の問い合わせ — 一致するものがあった場合:
- CLI のとき — 対話的に、承認する・拒否する・以後ずっと許可する、を利用者に尋ねます
- ゲートウェイのとき — 非同期の承認コールバックが、依頼をメッセージのプラットフォームへ送ります
- 賢い承認 — 任意で、補助の LLM がパターンに当たっただけの危険度の低いコマンドを自動承認できます(たとえば
rm -rf node_modules/は安全ですが「再帰的な削除」に一致します)
- セッションの状態 — 承認はセッションごとに記録されます。そのセッションで一度「再帰的な削除」を承認すれば、以後の
rm -rfでは聞き直しません。
- 恒久的な許可リスト — 「以後ずっと許可する」を選ぶと、そのパターンが
config.yamlのcommand_allowlistに書き込まれ、セッションをまたいで残ります。
ターミナルと実行環境
ターミナルの仕組みは複数の実行先に対応しています。
- local
- docker
- ssh
- singularity
- modal
- daytona
- vercel_sandbox
さらに次にも対応します。
- タスクごとの作業ディレクトリの上書き
- 背後で動くプロセスの管理
- PTY モード
- 危険なコマンドの承認コールバック
tools/process_registry_checkpoint.py は、動作中プロセスのチェックポイントと、 PID を取り違えないプロセスの引き取りを受け持ちます。終了したプロセスの出力は別扱いで、 tools/process_registry_results.py がプロファイルの logs/process-results/ の下に、 1 プロセスにつき 1 通の受領記録を、秘匿処理をかけたうえで一括で書き込みます。 共有の PID チェックポイントを書き換えて別の親の結果を上書きすることはできません。 レジストリは完了イベントを解放する前に受領記録を保存し、一度きりの待ち受けはそのイベントを待ちます。 既存のプロセス照会メソッドは、保持されたスナップショットを読むだけで、PID を引き取ったり 通知を積んだりはしません。読み出しには、そのプロセスを起こした永続セッションか、その圧縮後の 継続セッションが必要です。ハンドルを知っているだけでは、保持された結果を読む権限にはなりません。 レジストリは出力の読み取りを始める前に持ち主を控えます。CLI から起動したプロセスや通知を出さない プロセスでも同じで、読み取りスレッドには生成元のプロファイル情報が引き継がれます。 受領記録の秘匿処理は、実行中の出力を見せない設定とは無関係に必ずかかります。保持は日数と件数で 上限が決まります。
並行実行
ツールの呼び出しは、ツールの組み合わせや対話の要件に応じて、順番に実行されることも同時に実行されることもあります。