CLI(V1): binary speedup (#11006)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra 2025-09-18 10:19:07 -07:00 committed by GitHub
parent 1231b78aea
commit 6dbbf76231
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 128 additions and 34 deletions

View File

@ -83,7 +83,6 @@ def check_pyinstaller() -> bool:
print(' uv add --dev pyinstaller')
return False
def build_executable(
spec_file: str = 'openhands-cli.spec',
clean: bool = True,
@ -274,7 +273,7 @@ def main() -> int:
# Build the executable
if not args.no_build and not build_executable(
args.spec, clean=not args.no_clean, install_pyinstaller=args.install_pyinstaller
args.spec, clean=not args.no_clean
):
return 1
@ -290,5 +289,7 @@ def main() -> int:
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,63 @@
import atexit, os, sys, time
from collections import defaultdict
ENABLE = os.getenv("IMPORT_PROFILING", "0") not in ("", "0", "false", "False")
OUT = "dist/import_profiler.csv"
THRESHOLD_MS = float(os.getenv("IMPORT_PROFILING_THRESHOLD_MS", "0"))
if ENABLE:
timings = defaultdict(float) # module -> total seconds (first load only)
counts = defaultdict(int) # module -> number of first-loads (should be 1)
max_dur = defaultdict(float) # module -> max single load seconds
try:
import importlib._bootstrap as _bootstrap # type: ignore[attr-defined]
except Exception:
_bootstrap = None
start_time = time.perf_counter()
if _bootstrap is not None:
_orig_find_and_load = _bootstrap._find_and_load
def _timed_find_and_load(name, import_):
preloaded = name in sys.modules # cache hit?
t0 = time.perf_counter()
try:
return _orig_find_and_load(name, import_)
finally:
if not preloaded:
dt = time.perf_counter() - t0
timings[name] += dt
counts[name] += 1
if dt > max_dur[name]:
max_dur[name] = dt
_bootstrap._find_and_load = _timed_find_and_load
@atexit.register
def _dump_import_profile():
def ms(s): return f"{s*1000:.3f}"
items = [
(name, counts[name], timings[name], max_dur[name])
for name in timings
if timings[name]*1000 >= THRESHOLD_MS
]
items.sort(key=lambda x: x[2], reverse=True)
try:
with open(OUT, "w", encoding="utf-8") as f:
f.write("module,count,total_ms,max_ms\n")
for name, cnt, tot_s, max_s in items:
f.write(f"{name},{cnt},{ms(tot_s)},{ms(max_s)}\n")
# brief summary
if items:
w = max(len(n) for n, *_ in items[:25])
sys.stderr.write("\n=== Import Time Profile (first-load only) ===\n")
sys.stderr.write(f"{'module'.ljust(w)} count total_ms max_ms\n")
for name, cnt, tot_s, max_s in items[:25]:
sys.stderr.write(
f"{name.ljust(w)} {str(cnt).rjust(5)} {ms(tot_s).rjust(8)} {ms(max_s).rjust(7)}\n"
)
sys.stderr.write(f"\nImport profile written to: {OUT}\n")
except Exception as e:
sys.stderr.write(f"[import-profiler] failed to write profile: {e}\n")

View File

@ -44,7 +44,6 @@ a = Analysis(
# Include OpenHands SDK submodules explicitly to avoid resolution issues
*collect_submodules('openhands.sdk'),
*collect_submodules('openhands.tools'),
*collect_submodules('tiktoken'),
*collect_submodules('tiktoken_ext'),
*collect_submodules('litellm'),
@ -54,10 +53,14 @@ a = Analysis(
'mcp.client',
'mcp.server',
'mcp.shared',
'openhands.tools.execute_bash',
'openhands.tools.str_replace_editor',
'openhands.tools.task_tracker',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
# runtime_hooks=[str(project_root / "hooks" / "rthook_profile_imports.py")],
excludes=[
# Exclude unnecessary modules to reduce binary size
'tkinter',
@ -70,7 +73,13 @@ a = Analysis(
'notebook',
# Exclude mcp CLI parts that cause issues
'mcp.cli',
'mcp.cli.cli',
'prompt_toolkit.contrib.ssh',
'fastmcp.cli',
'boto3',
'botocore',
'posthog',
'browser-use',
'openhands.tools.browser_use'
],
noarchive=False,
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar

View File

@ -4,10 +4,9 @@ Simple main entry point for OpenHands CLI.
This is a simplified version that demonstrates the TUI functionality.
"""
import traceback
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.agent_chat import run_cli_entry
def main() -> None:
@ -19,8 +18,6 @@ def main() -> None:
"""
try:
# Start agent chat directly by default
from openhands_cli.agent_chat import run_cli_entry
run_cli_entry()
except ImportError as e:
@ -37,6 +34,7 @@ def main() -> None:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
except Exception as e:
print_formatted_text(HTML(f"<red>Error starting agent chat: {e}</red>"))
import traceback
traceback.print_exc()
raise

View File

@ -17,8 +17,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"openhands-sdk @ git+https://github.com/All-Hands-AI/agent-sdk.git@f8e800a93a3726555a30d1c42a692f4c556187c5#subdirectory=openhands/sdk",
"openhands-tools @ git+https://github.com/All-Hands-AI/agent-sdk.git@f8e800a93a3726555a30d1c42a692f4c556187c5#subdirectory=openhands/tools",
"openhands-sdk @ git+https://github.com/All-Hands-AI/agent-sdk.git@0e62eeeba193905478187e2f3117846d6cc97718#subdirectory=openhands/sdk",
"openhands-tools @ git+https://github.com/All-Hands-AI/agent-sdk.git@0e62eeeba193905478187e2f3117846d6cc97718#subdirectory=openhands/tools",
"prompt-toolkit>=3",
"typer>=0.17.4",
]
@ -53,8 +53,8 @@ packages = [ { include = "openhands_cli" } ]
[tool.poetry.dependencies]
python = "^3.12"
prompt-toolkit = "^3.0.0"
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "f8e800a93a3726555a30d1c42a692f4c556187c5", subdirectory = "openhands/sdk" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "f8e800a93a3726555a30d1c42a692f4c556187c5", subdirectory = "openhands/tools" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "0e62eeeba193905478187e2f3117846d6cc97718", subdirectory = "openhands/sdk" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "0e62eeeba193905478187e2f3117846d6cc97718", subdirectory = "openhands/tools" }
[tool.poetry.group.dev.dependencies]
pytest = "^7.0.0"
@ -115,4 +115,4 @@ disallow_untyped_defs = true
ignore_missing_imports = true
[tool.uv.sources]
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "f8e800a93a3726555a30d1c42a692f4c556187c5" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "0e62eeeba193905478187e2f3117846d6cc97718" }

View File

@ -10,56 +10,79 @@ from openhands_cli import simple_main
class TestMainEntryPoint:
"""Test the main entry point behavior."""
@patch('openhands_cli.agent_chat.run_cli_entry')
@patch('openhands_cli.agent_chat.setup_agent')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.PromptSession')
def test_main_starts_agent_chat_directly(
self, mock_run_agent_chat: MagicMock
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_agent: MagicMock
) -> None:
"""Test that main() starts agent chat directly without menu."""
mock_run_agent_chat.return_value = None
"""Test that main() starts agent chat directly when setup succeeds."""
# Mock setup_agent to return a valid conversation
mock_conversation = MagicMock()
mock_setup_agent.return_value = mock_conversation
# Mock prompt session to raise KeyboardInterrupt to exit the loop
mock_prompt_session.return_value.prompt.side_effect = KeyboardInterrupt()
# Should complete without raising an exception
# Should complete without raising an exception (graceful exit)
simple_main.main()
# Should call run_agent_chat directly
mock_run_agent_chat.assert_called_once()
# Should call setup_agent
mock_setup_agent.assert_called_once()
@patch('openhands_cli.agent_chat.run_cli_entry')
@patch('openhands_cli.simple_main.run_cli_entry')
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles ImportError gracefully."""
mock_run_agent_chat.side_effect = ImportError('Missing dependency')
# Should raise ImportError (no longer using sys.exit)
# Should raise ImportError (re-raised after handling)
with pytest.raises(ImportError) as exc_info:
simple_main.main()
assert str(exc_info.value) == 'Missing dependency'
@patch('openhands_cli.agent_chat.run_cli_entry')
@patch('openhands_cli.agent_chat.setup_agent')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.PromptSession')
def test_main_handles_keyboard_interrupt(
self, mock_run_agent_chat: MagicMock
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_agent: MagicMock
) -> None:
"""Test that main() handles KeyboardInterrupt gracefully."""
mock_run_agent_chat.side_effect = KeyboardInterrupt()
# Mock setup_agent to return a valid conversation
mock_conversation = MagicMock()
mock_setup_agent.return_value = mock_conversation
# Mock prompt session to raise KeyboardInterrupt
mock_prompt_session.return_value.prompt.side_effect = KeyboardInterrupt()
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.agent_chat.run_cli_entry')
def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None:
@patch('openhands_cli.agent_chat.setup_agent')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.PromptSession')
def test_main_handles_eof_error(
self, mock_prompt_session: MagicMock, mock_runner: MagicMock, mock_setup_agent: MagicMock
) -> None:
"""Test that main() handles EOFError gracefully."""
mock_run_agent_chat.side_effect = EOFError()
# Mock setup_agent to return a valid conversation
mock_conversation = MagicMock()
mock_setup_agent.return_value = mock_conversation
# Mock prompt session to raise EOFError
mock_prompt_session.return_value.prompt.side_effect = EOFError()
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.agent_chat.run_cli_entry')
@patch('openhands_cli.simple_main.run_cli_entry')
def test_main_handles_general_exception(
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() handles general exceptions."""
mock_run_agent_chat.side_effect = Exception('Unexpected error')
# Should raise Exception (no longer using sys.exit)
# Should raise Exception (re-raised after handling)
with pytest.raises(Exception) as exc_info:
simple_main.main()

8
openhands-cli/uv.lock generated
View File

@ -1488,8 +1488,8 @@ requires-dist = [
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=6" },
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1" },
{ name = "openhands-sdk", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=f8e800a93a3726555a30d1c42a692f4c556187c5" },
{ name = "openhands-tools", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=f8e800a93a3726555a30d1c42a692f4c556187c5" },
{ name = "openhands-sdk", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=0e62eeeba193905478187e2f3117846d6cc97718" },
{ name = "openhands-tools", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=0e62eeeba193905478187e2f3117846d6cc97718" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
@ -1508,7 +1508,7 @@ dev = [
[[package]]
name = "openhands-sdk"
version = "1.0.0"
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=f8e800a93a3726555a30d1c42a692f4c556187c5#f8e800a93a3726555a30d1c42a692f4c556187c5" }
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=0e62eeeba193905478187e2f3117846d6cc97718#0e62eeeba193905478187e2f3117846d6cc97718" }
dependencies = [
{ name = "fastmcp" },
{ name = "litellm" },
@ -1521,7 +1521,7 @@ dependencies = [
[[package]]
name = "openhands-tools"
version = "1.0.0"
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=f8e800a93a3726555a30d1c42a692f4c556187c5#f8e800a93a3726555a30d1c42a692f4c556187c5" }
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=0e62eeeba193905478187e2f3117846d6cc97718#0e62eeeba193905478187e2f3117846d6cc97718" }
dependencies = [
{ name = "bashlex" },
{ name = "binaryornot" },