mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
perf(termux): speed up non-tui cli startup
This commit is contained in:
parent
5aa4727f34
commit
6dbbf20ff4
2 changed files with 383 additions and 56 deletions
|
|
@ -275,6 +275,91 @@ def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _read_git_revision_fingerprint(repo_root: Path) -> str | None:
|
||||
"""Return a cheap checkout fingerprint without spawning git."""
|
||||
git_dir = repo_root / ".git"
|
||||
try:
|
||||
if git_dir.is_file():
|
||||
for line in git_dir.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
if key.strip() == "gitdir" and value.strip():
|
||||
git_dir = (repo_root / value.strip()).resolve()
|
||||
break
|
||||
head_file = git_dir / "HEAD"
|
||||
head = head_file.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if head.startswith("ref:"):
|
||||
ref = head.split(":", 1)[1].strip()
|
||||
ref_file = git_dir / ref
|
||||
if ref_file.exists():
|
||||
return f"git:{ref}:{ref_file.read_text(encoding='utf-8', errors='replace').strip()}"
|
||||
return f"git:HEAD:{head}"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _termux_bundled_skills_fingerprint() -> str:
|
||||
"""Cheap invalidation key for Termux bundled-skill startup sync."""
|
||||
git_fp = _read_git_revision_fingerprint(PROJECT_ROOT)
|
||||
if git_fp:
|
||||
return git_fp
|
||||
skills_dir = PROJECT_ROOT / "skills"
|
||||
try:
|
||||
stat = skills_dir.stat()
|
||||
return f"skills:{__version__}:{__release_date__}:{stat.st_mtime_ns}:{stat.st_size}"
|
||||
except OSError:
|
||||
return f"skills:{__version__}:{__release_date__}:missing"
|
||||
|
||||
|
||||
def _termux_bundled_skills_stamp_path() -> Path:
|
||||
return get_hermes_home() / "skills" / ".termux_bundled_sync_stamp"
|
||||
|
||||
|
||||
def _termux_bundled_skills_sync_needed() -> bool:
|
||||
if not _is_termux_startup_environment():
|
||||
return True
|
||||
if os.environ.get("HERMES_TERMUX_FORCE_SKILLS_SYNC") == "1":
|
||||
return True
|
||||
try:
|
||||
stamp = _termux_bundled_skills_stamp_path()
|
||||
return stamp.read_text(encoding="utf-8").strip() != _termux_bundled_skills_fingerprint()
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def _mark_termux_bundled_skills_synced() -> None:
|
||||
if not _is_termux_startup_environment():
|
||||
return
|
||||
try:
|
||||
stamp = _termux_bundled_skills_stamp_path()
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.write_text(_termux_bundled_skills_fingerprint() + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _sync_bundled_skills_for_startup() -> bool:
|
||||
"""Sync bundled skills, but skip unchanged Termux checkouts cheaply.
|
||||
|
||||
Hashing every bundled skill is safe but expensive on older Android
|
||||
storage. The git/ref stamp keeps post-update correctness: a changed
|
||||
checkout revision forces one real sync, then later starts skip it.
|
||||
"""
|
||||
if _is_termux_startup_environment() and not _termux_bundled_skills_sync_needed():
|
||||
return False
|
||||
|
||||
from tools.skills_sync import sync_skills
|
||||
|
||||
sync_skills(quiet=True)
|
||||
_mark_termux_bundled_skills_synced()
|
||||
return True
|
||||
|
||||
|
||||
def _termux_should_prefetch_update_check() -> bool:
|
||||
if not _is_termux_startup_environment():
|
||||
return True
|
||||
return os.environ.get("HERMES_TERMUX_PREFETCH_UPDATES") == "1"
|
||||
|
||||
|
||||
def _relative_time(ts) -> str:
|
||||
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
|
||||
if not ts:
|
||||
|
|
@ -1523,19 +1608,20 @@ def cmd_chat(args):
|
|||
print("You can run 'hermes setup' at any time to configure.")
|
||||
sys.exit(1)
|
||||
|
||||
# Start update check in background (runs while other init happens)
|
||||
try:
|
||||
from hermes_cli.banner import prefetch_update_check
|
||||
# Start update check in background (runs while other init happens).
|
||||
# On Termux this imports rich/prompt_toolkit in the foreground and then
|
||||
# competes for CPU on single-core devices, so keep it opt-in there.
|
||||
if _termux_should_prefetch_update_check():
|
||||
try:
|
||||
from hermes_cli.banner import prefetch_update_check
|
||||
|
||||
prefetch_update_check()
|
||||
except Exception:
|
||||
pass
|
||||
prefetch_update_check()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sync bundled skills on every CLI launch (fast -- skips unchanged skills)
|
||||
try:
|
||||
from tools.skills_sync import sync_skills
|
||||
|
||||
sync_skills(quiet=True)
|
||||
_sync_bundled_skills_for_startup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -5971,8 +6057,7 @@ def cmd_import(args):
|
|||
run_import(args)
|
||||
|
||||
|
||||
def cmd_version(args):
|
||||
"""Show version."""
|
||||
def _print_version_info(*, check_updates: bool = True) -> None:
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
print(f"Project: {PROJECT_ROOT}")
|
||||
|
||||
|
|
@ -5992,6 +6077,9 @@ def cmd_version(args):
|
|||
except ImportError:
|
||||
print("OpenAI SDK: Not installed")
|
||||
|
||||
if not check_updates:
|
||||
return
|
||||
|
||||
# Show update status (synchronous — acceptable since user asked for version info)
|
||||
try:
|
||||
from hermes_cli.banner import check_for_updates
|
||||
|
|
@ -6010,6 +6098,11 @@ def cmd_version(args):
|
|||
pass
|
||||
|
||||
|
||||
def cmd_version(args):
|
||||
"""Show version."""
|
||||
_print_version_info(check_updates=True)
|
||||
|
||||
|
||||
def cmd_uninstall(args):
|
||||
"""Uninstall Hermes Agent."""
|
||||
_require_tty("uninstall")
|
||||
|
|
@ -10515,6 +10608,137 @@ def _plugin_cli_discovery_needed() -> bool:
|
|||
return True
|
||||
|
||||
|
||||
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
|
||||
_AGENT_SUBCOMMANDS = {
|
||||
"cron": ("cron_command", {"run", "tick"}),
|
||||
"gateway": ("gateway_command", {"run"}),
|
||||
"mcp": ("mcp_action", {"serve"}),
|
||||
}
|
||||
|
||||
|
||||
def _prepare_agent_startup(args) -> None:
|
||||
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
if not (
|
||||
args.command in _AGENT_COMMANDS
|
||||
or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
|
||||
):
|
||||
return
|
||||
|
||||
_accept_hooks = bool(getattr(args, "accept_hooks", False))
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"plugin discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# MCP tool discovery — no event loop running in CLI/TUI startup,
|
||||
# so inline is safe. Moved here from model_tools.py module scope
|
||||
# to avoid freezing the gateway's event loop on its first message
|
||||
# via the same lazy import path (#16856).
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
|
||||
register_from_config(load_config(), accept_hooks=_accept_hooks)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"shell-hook registration failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _set_chat_arg_defaults(args) -> None:
|
||||
for attr, default in [
|
||||
("query", None),
|
||||
("model", None),
|
||||
("provider", None),
|
||||
("toolsets", None),
|
||||
("verbose", False),
|
||||
("resume", None),
|
||||
("continue_last", None),
|
||||
("worktree", False),
|
||||
]:
|
||||
if not hasattr(args, attr):
|
||||
setattr(args, attr, default)
|
||||
|
||||
|
||||
def _is_termux_fast_version_argv(argv: list[str]) -> bool:
|
||||
return argv in (["--version"], ["-V"], ["version"])
|
||||
|
||||
|
||||
def _try_termux_fast_cli_launch() -> bool:
|
||||
"""Run obvious Termux non-TUI chat/oneshot/version paths on a light parser."""
|
||||
if not _is_termux_startup_environment():
|
||||
return False
|
||||
if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
|
||||
return False
|
||||
|
||||
argv = sys.argv[1:]
|
||||
if "-h" in argv or "--help" in argv:
|
||||
return False
|
||||
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
|
||||
return False
|
||||
|
||||
if _is_termux_fast_version_argv(argv):
|
||||
_print_version_info(check_updates=False)
|
||||
return True
|
||||
|
||||
first = _first_positional_argv()
|
||||
has_oneshot = any(
|
||||
arg == "-z" or arg == "--oneshot" or arg.startswith("--oneshot=")
|
||||
for arg in argv
|
||||
)
|
||||
if not has_oneshot and first not in {None, "chat"}:
|
||||
return False
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
args = parser.parse_args(_coalesce_session_name_args(argv))
|
||||
|
||||
if getattr(args, "version", False):
|
||||
_print_version_info(check_updates=False)
|
||||
return True
|
||||
|
||||
if getattr(args, "oneshot", None):
|
||||
_prepare_agent_startup(args)
|
||||
from hermes_cli.oneshot import run_oneshot
|
||||
|
||||
sys.exit(
|
||||
run_oneshot(
|
||||
args.oneshot,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
)
|
||||
)
|
||||
|
||||
if (args.resume or args.continue_last) and args.command is None:
|
||||
args.command = "chat"
|
||||
|
||||
if args.command in {None, "chat"}:
|
||||
_set_chat_arg_defaults(args)
|
||||
_prepare_agent_startup(args)
|
||||
cmd_chat(args)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _try_termux_fast_tui_launch() -> bool:
|
||||
"""Launch obvious Termux TUI invocations before building every subparser.
|
||||
|
||||
|
|
@ -10575,6 +10799,8 @@ def main():
|
|||
|
||||
if _try_termux_fast_tui_launch():
|
||||
return
|
||||
if _try_termux_fast_cli_launch():
|
||||
return
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
|
|
@ -13373,51 +13599,7 @@ Examples:
|
|||
# so introspection/management commands (hermes hooks list, cron
|
||||
# list, gateway status, mcp add, ...) don't pay discovery cost or
|
||||
# trigger consent prompts for hooks the user is still inspecting.
|
||||
# Groups with mixed admin/CRUD vs. agent-running entries narrow via
|
||||
# the nested subcommand (dest varies by parser).
|
||||
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
|
||||
_AGENT_SUBCOMMANDS = {
|
||||
"cron": ("cron_command", {"run", "tick"}),
|
||||
"gateway": ("gateway_command", {"run"}),
|
||||
"mcp": ("mcp_action", {"serve"}),
|
||||
}
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
if args.command in _AGENT_COMMANDS or (
|
||||
_sub_attr and getattr(args, _sub_attr, None) in _sub_set
|
||||
):
|
||||
_accept_hooks = bool(getattr(args, "accept_hooks", False))
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"plugin discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# MCP tool discovery — no event loop running in CLI/TUI startup,
|
||||
# so inline is safe. Moved here from model_tools.py module scope
|
||||
# to avoid freezing the gateway's event loop on its first message
|
||||
# via the same lazy import path (#16856).
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
|
||||
register_from_config(load_config(), accept_hooks=_accept_hooks)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"shell-hook registration failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
_prepare_agent_startup(args)
|
||||
|
||||
# Handle top-level --oneshot / -z: single-shot mode, stdout = final
|
||||
# response only, nothing else. Bypasses cli.py entirely.
|
||||
|
|
|
|||
|
|
@ -283,6 +283,151 @@ def test_fast_tui_launch_is_termux_only(monkeypatch, main_mod):
|
|||
assert main_mod._try_termux_fast_tui_launch() is False
|
||||
|
||||
|
||||
def test_termux_fast_cli_launch_chat_uses_light_parser(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
prepared = []
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["hermes", "chat", "-q", "hello", "--toolsets", "web,terminal"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"cmd_chat",
|
||||
lambda args: captured.update(
|
||||
{"query": args.query, "toolsets": args.toolsets, "command": args.command}
|
||||
),
|
||||
)
|
||||
|
||||
assert main_mod._try_termux_fast_cli_launch() is True
|
||||
assert prepared == ["chat"]
|
||||
assert captured == {
|
||||
"query": "hello",
|
||||
"toolsets": "web,terminal",
|
||||
"command": "chat",
|
||||
}
|
||||
|
||||
|
||||
def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
prepared = []
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "-z", "hello", "--model", "gpt-test", "--provider", "openai"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.oneshot",
|
||||
types.SimpleNamespace(
|
||||
run_oneshot=lambda prompt, **kwargs: captured.update(
|
||||
{"prompt": prompt, **kwargs}
|
||||
)
|
||||
or 17
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod._try_termux_fast_cli_launch()
|
||||
|
||||
assert exc.value.code == 17
|
||||
assert prepared == [None]
|
||||
assert captured == {
|
||||
"prompt": "hello",
|
||||
"model": "gpt-test",
|
||||
"provider": "openai",
|
||||
"toolsets": None,
|
||||
}
|
||||
|
||||
|
||||
def test_termux_fast_cli_launch_version_skips_update_check(monkeypatch, main_mod):
|
||||
captured = []
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "version"])
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_print_version_info", lambda *, check_updates: captured.append(check_updates)
|
||||
)
|
||||
|
||||
assert main_mod._try_termux_fast_cli_launch() is True
|
||||
assert captured == [False]
|
||||
|
||||
|
||||
def test_termux_fast_cli_launch_skips_help(monkeypatch, main_mod):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "chat", "--help"])
|
||||
|
||||
assert main_mod._try_termux_fast_cli_launch() is False
|
||||
|
||||
|
||||
def test_termux_fast_cli_launch_can_be_disabled(monkeypatch, main_mod):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setenv("HERMES_TERMUX_DISABLE_FAST_CLI", "1")
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "version"])
|
||||
|
||||
assert main_mod._try_termux_fast_cli_launch() is False
|
||||
|
||||
|
||||
def test_termux_bundled_skills_stamp_controls_sync(monkeypatch, tmp_path, main_mod):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
|
||||
|
||||
assert main_mod._termux_bundled_skills_sync_needed() is True
|
||||
main_mod._mark_termux_bundled_skills_synced()
|
||||
assert main_mod._termux_bundled_skills_sync_needed() is False
|
||||
|
||||
monkeypatch.setenv("HERMES_TERMUX_FORCE_SKILLS_SYNC", "1")
|
||||
assert main_mod._termux_bundled_skills_sync_needed() is True
|
||||
|
||||
|
||||
def test_termux_skips_bundled_skill_sync_when_stamp_fresh(monkeypatch, tmp_path, main_mod):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
|
||||
main_mod._mark_termux_bundled_skills_synced()
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"tools.skills_sync",
|
||||
types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)),
|
||||
)
|
||||
|
||||
assert main_mod._sync_bundled_skills_for_startup() is False
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_termux_forced_bundled_skill_sync_runs(monkeypatch, tmp_path, main_mod):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setenv("HERMES_TERMUX_FORCE_SKILLS_SYNC", "1")
|
||||
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"tools.skills_sync",
|
||||
types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)),
|
||||
)
|
||||
|
||||
assert main_mod._sync_bundled_skills_for_startup() is True
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue