Système d'outils ToolRegistry
Responsabilité
Un Tool est une fonction atomique que l'agent peut appeler (terminal, navigateur, fichier, MCP, appel de compétence…). À l'import du module, ToolRegistry découvre automatiquement, via un scan AST, les outils (tools) déclarés dans tools/*.py et les enregistre ; il expose une table de correspondance name → ToolEntry que la boucle principale utilise pour distribuer les appels d'outils. Les outils sont statiques — par opposition aux compétences qui sont « évolutives ».
Mot de conception
Pourquoi un registre (registry) plutôt que des branches codées en dur ? hermes-agent a environ 80 fichiers d'outils ; si chaque nouvel outil devait être enregistré dans une chaîne if name == "xxx", ça pourrirait vite. Le scan AST fait que « déposer dans tools/ un fichier qui appelle registry.register » est l'action d'intégration. registry est un singleton global ; les outils dynamiques MCP, les plugins (plugins) et les outils intégrés partagent une seule table.
Fichiers clés
discover_builtin_tools:67-86— scan AST detools/*.pypour trouver les modules qui enregistrent des outils :
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 importedOn saute __init__.py / registry.py / mcp_tool.py ; les autres .py sont importés dès que l'AST confirme qu'ils ont appelé registry.register, et l'import lui-même déclenche le register(...) au niveau module. L'échec d'import d'un module ne produit qu'un warning.
_is_registry_register_call / _module_registers_tools:30-43— décide si un module appelleregistry.registerclass ToolEntry:87-153— métadonnées d'un outil.__slots__minimise l'empreinte des 80+ entrées :
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 est un callable zéro-arg, invoqué à chaque get_definitions(), pour fusionner dans le schema les champs dépendant de la config runtime (par ex. max_concurrent_children de delegate_task) — afin que le modèle ne voie pas des limites erronées.
class ToolRegistry:217-365— le registre lui-mêmedef register:365-436— enregistre un outil, le cœur étant la détection de conflit et de remplacement :
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 refresh écrase MCP
elif override:
# plugin opt-in explicite pour écraser un builtin, nécessite operator allow_tool_override
...
else:
logger.error("Tool registration REJECTED: '%s' ...", name, toolset, existing.toolset)
return
self._tools[name] = ToolEntry(...)
self._generation += 1Le remplacement d'un outil homonyme est refusé par défaut. MCP contre MCP est autorisé (scénario de refresh notifications/tools/list_changed) ; un plugin qui écrase un builtin nécessite override=True + operator allow_tool_override: true, sinon PermissionError. L'incrément de _generation permet au cache externe de détecter un miss.
get_entry / register_toolset_alias:274-290— requête :
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)La boucle principale, après avoir reçu tool_call.name, fait un seul get_entry pour récupérer le handler. register_toolset_alias permet d'écrire plus court, dans la config, les toolsets dynamiques comme tools="mcp-foo".
register_plugin_override_policy:316-365— stratégie d'admission des espaces de noms pour les outils issus de pluginssingleton registry:765—registry = ToolRegistry()(singleton au niveau module)tool_error / tool_result:784-810— enveloppe standard des valeurs de retour d'outilrépertoire tools/— environ 80 fichiers d'outils (terminal/browser/web/file/mcp/skills/delegate…)
Flux de données
- Au démarrage,
init_agent:276déclenche la configuration des toolsets. discover_builtin_tools:67parcourt le répertoiretools/et utilise un scan AST pour déterminer si chaque module appelleregistry.register; les modules retenus sont importés, et leurs appelsregistry.register(...)au niveau module s'exécutent et écrivent unToolEntrydanssingleton registry:765.- À chaque tour, la boucle principale collecte les
ToolEntry.schemades toolsets activés dans le champtoolsenvoyé au provider. - Le provider renvoie un tool_call → la boucle principale utilise
get_entry(name)(tools/registry.py:274) pour trouver le handler, l'exécute → le résultat est enveloppé viatool_result:798et réinjecté.
Limites et échecs
- Conflit de noms d'outils : un outil intégré homonyme refuse l'enregistrement ; un plugin qui écrase un builtin nécessite
override=True+ operatorallow_tool_override: true, sinonPermissionError. Exception : MCP contre MCP (préfixemcp-), où le nuke-and-repave du server au refresh est légitime. - Bruit
check_fn: les sondes Docker / playwright peuvent retourner False ponctuellement._check_fn_cacheda un TTL 30 s + fenêtre de grâce 60 s : dans la fenêtre de grâce, même si la nouvelle sonde échoue, on renvoie le dernier True connu — pour éviter qu'un sous-agentdelegate_taskse plaigne soudain « Tool read_file does not exist ». - Concurrence MCP : quand le server MCP pousse
tools/list_changed, des threads peuvent être en train de lire._lockest unRLock, les mutations sont sérialisées, les lecteurs passent par un snapshot._generationpermet au cache externe de détecter un miss. - Échec d'import de module : un fichier d'outil en erreur d'import ne fait pas tomber le registre, seul un warning est émis et l'outil est sauté — mais la boucle principale ne pourra pas l'appeler ; chercher « Could not import tool module » dans agent.log.
Résumé
La subtilité du système d'outils tient à « importer c'est s'enregistrer » — grâce au scan AST, on évite de maintenir une liste à la main ; ajouter un outil consiste simplement à déposer dans tools/ un fichier qui appelle register. Le singleton registry est la seule source de vérité globale, et la boucle principale se contente de le lire. Différence avec les compétences : les outils sont des fonctions figées, les compétences sont des flux que la boucle d'apprentissage peut réécrire.