mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08:00
Compare commits
1 Commits
codex-port
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f875d063 |
10
.github/workflows/tests.yml
vendored
10
.github/workflows/tests.yml
vendored
@@ -43,6 +43,16 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
- name: Verify tools/_manifest.py is up-to-date
|
||||
# tools/_manifest.py is auto-generated by scripts/build_tool_manifest.py
|
||||
# and must be regenerated when tools/*.py are added/removed/converted.
|
||||
# Rejects PRs that add/remove a self-registering tool module without
|
||||
# updating the manifest. Doesn't run the build (no side effects in CI) —
|
||||
# just verifies the committed manifest matches what would be generated.
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python scripts/build_tool_manifest.py --check
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
136
scripts/build_tool_manifest.py
Executable file
136
scripts/build_tool_manifest.py
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate ``tools/_manifest.py`` — a static list of built-in tool modules.
|
||||
|
||||
At runtime, ``tools.registry.discover_builtin_tools()`` reads this manifest
|
||||
instead of AST-scanning every ``tools/*.py`` file to find ones that call
|
||||
``registry.register()``. Saves ~145 ms at every CLI/gateway startup.
|
||||
|
||||
When to run:
|
||||
- Automatically: the ``discover_builtin_tools`` fallback triggers when any
|
||||
``tools/*.py`` file has an mtime newer than the manifest. This surfaces
|
||||
a warning in dev. Run this script and commit the regenerated manifest
|
||||
to silence the warning.
|
||||
- Manually: ``python scripts/build_tool_manifest.py``
|
||||
- CI: the build-tools GitHub workflow runs this and diff-checks on every PR.
|
||||
|
||||
Usage:
|
||||
python scripts/build_tool_manifest.py # regenerate in place
|
||||
python scripts/build_tool_manifest.py --check # exit 1 if stale (for CI)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TOOLS_DIR = REPO_ROOT / "tools"
|
||||
MANIFEST_PATH = TOOLS_DIR / "_manifest.py"
|
||||
|
||||
# Exclusions match tools/registry.py:discover_builtin_tools — these files live
|
||||
# in tools/ but are infrastructure (not self-registering modules).
|
||||
SKIP_FILES = {
|
||||
"__init__.py",
|
||||
"_manifest.py",
|
||||
"registry.py",
|
||||
"mcp_tool.py", # MCP registers dynamically at runtime, not at import
|
||||
}
|
||||
|
||||
|
||||
def _is_registry_register_call(node: ast.AST) -> bool:
|
||||
"""Return True when *node* is a ``registry.register(...)`` call expression."""
|
||||
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
|
||||
return False
|
||||
func = node.value.func
|
||||
return (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "register"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "registry"
|
||||
)
|
||||
|
||||
|
||||
def _module_registers_tools(path: Path) -> bool:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
except (OSError, SyntaxError):
|
||||
return False
|
||||
return any(_is_registry_register_call(stmt) for stmt in tree.body)
|
||||
|
||||
|
||||
def scan_tool_modules() -> list[str]:
|
||||
"""Return sorted list of ``tools.<stem>`` module names that self-register."""
|
||||
return sorted(
|
||||
f"tools.{path.stem}"
|
||||
for path in TOOLS_DIR.glob("*.py")
|
||||
if path.name not in SKIP_FILES and _module_registers_tools(path)
|
||||
)
|
||||
|
||||
|
||||
MANIFEST_HEADER = '''\
|
||||
"""Auto-generated list of built-in tool modules that call ``registry.register()``.
|
||||
|
||||
DO NOT EDIT MANUALLY. Regenerate with:
|
||||
|
||||
python scripts/build_tool_manifest.py
|
||||
|
||||
This file is read at startup by ``tools.registry.discover_builtin_tools()`` to
|
||||
skip the ~145 ms AST scan of every ``tools/*.py`` file. When a ``tools/*.py``
|
||||
file is added, modified, or removed, the dev-mode mtime check in
|
||||
``discover_builtin_tools`` will log a warning and fall back to the AST scan —
|
||||
run this script to regenerate and commit.
|
||||
|
||||
Only covers *built-in* tools (shipped in ``tools/*.py``). Plugin tools and
|
||||
MCP-registered tools use separate discovery paths and are not listed here.
|
||||
"""
|
||||
|
||||
TOOL_MODULES: tuple[str, ...] = (
|
||||
'''
|
||||
|
||||
MANIFEST_FOOTER = ")\n"
|
||||
|
||||
|
||||
def render_manifest(modules: list[str]) -> str:
|
||||
lines = [MANIFEST_HEADER]
|
||||
for name in modules:
|
||||
lines.append(f" {name!r},\n")
|
||||
lines.append(MANIFEST_FOOTER)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Exit 1 if the on-disk manifest doesn't match what would be generated (for CI).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
modules = scan_tool_modules()
|
||||
new_content = render_manifest(modules)
|
||||
|
||||
if args.check:
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"{MANIFEST_PATH} is missing — run: python scripts/build_tool_manifest.py", file=sys.stderr)
|
||||
return 1
|
||||
current = MANIFEST_PATH.read_text(encoding="utf-8")
|
||||
if current != new_content:
|
||||
print(
|
||||
f"{MANIFEST_PATH} is stale — run: python scripts/build_tool_manifest.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"OK: {MANIFEST_PATH} is up-to-date ({len(modules)} tool modules).")
|
||||
return 0
|
||||
|
||||
MANIFEST_PATH.write_text(new_content, encoding="utf-8")
|
||||
print(f"Wrote {MANIFEST_PATH} ({len(modules)} tool modules).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
45
tools/_manifest.py
Normal file
45
tools/_manifest.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Auto-generated list of built-in tool modules that call ``registry.register()``.
|
||||
|
||||
DO NOT EDIT MANUALLY. Regenerate with:
|
||||
|
||||
python scripts/build_tool_manifest.py
|
||||
|
||||
This file is read at startup by ``tools.registry.discover_builtin_tools()`` to
|
||||
skip the ~145 ms AST scan of every ``tools/*.py`` file. When a ``tools/*.py``
|
||||
file is added, modified, or removed, the dev-mode mtime check in
|
||||
``discover_builtin_tools`` will log a warning and fall back to the AST scan —
|
||||
run this script to regenerate and commit.
|
||||
|
||||
Only covers *built-in* tools (shipped in ``tools/*.py``). Plugin tools and
|
||||
MCP-registered tools use separate discovery paths and are not listed here.
|
||||
"""
|
||||
|
||||
TOOL_MODULES: tuple[str, ...] = (
|
||||
'tools.browser_cdp_tool',
|
||||
'tools.browser_dialog_tool',
|
||||
'tools.browser_tool',
|
||||
'tools.clarify_tool',
|
||||
'tools.code_execution_tool',
|
||||
'tools.cronjob_tools',
|
||||
'tools.delegate_tool',
|
||||
'tools.discord_tool',
|
||||
'tools.feishu_doc_tool',
|
||||
'tools.feishu_drive_tool',
|
||||
'tools.file_tools',
|
||||
'tools.homeassistant_tool',
|
||||
'tools.image_generation_tool',
|
||||
'tools.memory_tool',
|
||||
'tools.mixture_of_agents_tool',
|
||||
'tools.process_registry',
|
||||
'tools.rl_training_tool',
|
||||
'tools.send_message_tool',
|
||||
'tools.session_search_tool',
|
||||
'tools.skill_manager_tool',
|
||||
'tools.skills_tool',
|
||||
'tools.terminal_tool',
|
||||
'tools.todo_tool',
|
||||
'tools.tts_tool',
|
||||
'tools.vision_tools',
|
||||
'tools.web_tools',
|
||||
'tools.yuanbao_tools',
|
||||
)
|
||||
@@ -54,15 +54,53 @@ def _module_registers_tools(module_path: Path) -> bool:
|
||||
return any(_is_registry_register_call(stmt) for stmt in tree.body)
|
||||
|
||||
|
||||
def _load_manifest() -> Optional[List[str]]:
|
||||
"""Return the cached ``TOOL_MODULES`` tuple from ``tools/_manifest.py``.
|
||||
|
||||
Returns ``None`` when the manifest is missing (fresh checkout, or the
|
||||
``scripts/build_tool_manifest.py`` generator hasn't been run yet). The
|
||||
caller falls back to the AST scan in that case.
|
||||
|
||||
NOTE: This intentionally does NOT check for mtime drift between the
|
||||
manifest and ``tools/*.py`` files. Drift protection belongs in CI
|
||||
(``python scripts/build_tool_manifest.py --check``) — adding a
|
||||
per-startup stat walk here would both (a) add overhead to the path
|
||||
we're trying to speed up and (b) give false positives when devs edit
|
||||
helper modules that don't register tools. If the manifest lists a
|
||||
tool that no longer exists, the import fails loudly at startup.
|
||||
"""
|
||||
try:
|
||||
from tools._manifest import TOOL_MODULES
|
||||
return list(TOOL_MODULES)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
|
||||
"""Import built-in self-registering tool modules and return their module names."""
|
||||
"""Import built-in self-registering tool modules and return their module names.
|
||||
|
||||
Fast path: read ``tools/_manifest.py`` and import the listed modules
|
||||
directly. Skips the ~145 ms AST scan of every ``tools/*.py`` file.
|
||||
|
||||
Fallback path: if the manifest is missing or stale (any ``tools/*.py`` is
|
||||
newer than the manifest's mtime), scan AST and log a warning. The
|
||||
fallback is the only behavior when running from a git checkout that
|
||||
hasn't regenerated the manifest after local edits.
|
||||
"""
|
||||
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)
|
||||
]
|
||||
|
||||
# Only use the committed manifest when scanning the default tools/
|
||||
# directory. Tests and embedders that pass a custom tools_dir always
|
||||
# get the AST-scan path.
|
||||
default_tools_path = Path(__file__).resolve().parent
|
||||
module_names = _load_manifest() if tools_path == default_tools_path else None
|
||||
if module_names is None:
|
||||
module_names = [
|
||||
f"tools.{path.stem}"
|
||||
for path in sorted(tools_path.glob("*.py"))
|
||||
if path.name not in {"__init__.py", "_manifest.py", "registry.py", "mcp_tool.py"}
|
||||
and _module_registers_tools(path)
|
||||
]
|
||||
|
||||
imported: List[str] = []
|
||||
for mod_name in module_names:
|
||||
|
||||
Reference in New Issue
Block a user