Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,40 @@ jobs:
run: |
uv sync --dev

- name: Build and test binary executable
- name: Build binary executable
run: |
./build.sh --install-pyinstaller

- name: Test binary startup without API key (should fail gracefully)
run: |
# Test that binary fails gracefully without API key
if ./dist/openhands-cli 2>&1 | grep -q "No API key found"; then
echo "✅ Binary correctly reports missing API key"
exit 0
else
echo "❌ Binary did not report missing API key correctly"
exit 1
fi
timeout-minutes: 1

- name: Test binary startup with dummy API key (should not crash)
env:
LITELLM_API_KEY: dummy-ci-key
LITELLM_MODEL: dummy-ci-model
run: |
./build.sh --install-pyinstaller
# Test that binary doesn't crash with missing prompt files
timeout 10s ./dist/openhands-cli 2>&1 | tee output.log || true

# Check for specific error patterns that indicate problems
if grep -q "system_prompt.j2 not found" output.log; then
echo "❌ Binary has missing prompt file error"
cat output.log
exit 1
elif grep -q "Error setting up agent" output.log && grep -q "system_prompt.j2" output.log; then
echo "❌ Binary has prompt file related errors"
cat output.log
exit 1
else
echo "✅ Binary starts without critical errors"
exit 0
fi
71 changes: 57 additions & 14 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,28 +112,71 @@ def test_executable() -> bool:
if os.name != "nt":
os.chmod(exe_path, 0o755)

# Run the executable with a timeout
# Test 1: Basic startup test - should fail gracefully without API key
print(" Testing basic startup (should fail gracefully without API key)...")
result = subprocess.run(
[str(exe_path)], capture_output=True, text=True, timeout=30
[str(exe_path)],
capture_output=True,
text=True,
timeout=10,
input="\n", # Send newline to exit quickly
env={
**os.environ,
"LITELLM_API_KEY": "",
"OPENAI_API_KEY": "",
}, # Clear API keys
)

if result.returncode == 0:
print("✅ Executable test passed!")
print("Output preview:")
print(
result.stdout[:500] + "..."
if len(result.stdout) > 500
else result.stdout
)
return True
# Should return exit code 1 (no API key) but not crash
if result.returncode == 1:
print(" ✅ Executable handles missing API key correctly (exit code 1)")
if (
"No API key found" in result.stderr
or "No API key found" in result.stdout
):
print(" ✅ Proper error message displayed")
else:
print(" ⚠️ Expected API key error message not found")
print(" STDOUT:", result.stdout[:200])
print(" STDERR:", result.stderr[:200])
elif result.returncode == 0:
print(" ⚠️ Executable returned 0 but should fail without API key")
return False
else:
print(f"❌ Executable test failed with return code {result.returncode}")
print("STDERR:", result.stderr)
print(f" ❌ Unexpected return code {result.returncode}")
print(" STDOUT:", result.stdout[:500])
print(" STDERR:", result.stderr[:500])
return False

# Test 2: Check that it doesn't crash with missing prompt files
print(" Testing with dummy API key (should not crash on startup)...")
result = subprocess.run(
[str(exe_path)],
capture_output=True,
text=True,
timeout=10,
input="\n", # Send newline to exit quickly
env={
**os.environ,
"LITELLM_API_KEY": "dummy-test-key",
"LITELLM_MODEL": "dummy-model",
},
)

# Should not crash with missing prompt file error
if "system_prompt.j2 not found" in result.stderr:
print(" ❌ Executable still has missing prompt file error!")
print(" STDERR:", result.stderr)
return False
else:
print(" ✅ No missing prompt file errors detected")

print("✅ Executable test passed!")
return True

except subprocess.TimeoutExpired:
print(
"⚠️ Executable test timed out (this might be normal for interactive CLIs)"
" ⚠️ Executable test timed out (this might be normal for interactive CLIs)"
)
return True
except Exception as e:
Expand Down
4 changes: 2 additions & 2 deletions openhands-cli.spec
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ a = Analysis(
*collect_data_files('tiktoken_ext'),
*collect_data_files('litellm'),
# Include Jinja prompt templates required by the agent SDK
*collect_data_files('openhands.core.agent.codeact_agent', includes=['prompts/*.j2']),
*collect_data_files('openhands.sdk.agent.agent', includes=['prompts/*.j2']),
],
hiddenimports=[
# Explicitly include modules that might not be detected automatically
'openhands_cli.tui',
'openhands_cli.pt_style',
*collect_submodules('prompt_toolkit'),
# Include OpenHands SDK submodules explicitly to avoid resolution issues
*collect_submodules('openhands.core'),
*collect_submodules('openhands.sdk'),
*collect_submodules('openhands.tools'),

*collect_submodules('tiktoken'),
Expand Down
83 changes: 61 additions & 22 deletions openhands_cli/agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,20 @@
from openhands_cli.tui import CommandCompleter, display_banner, display_help

try:
from openhands.core.agent.codeact_agent import CodeActAgent
from openhands.core.config import LLMConfig
from openhands.core.conversation import Conversation
from openhands.core.event import EventType
from openhands.core.llm import LLM, Message, TextContent
from openhands.core.tool import Tool
from openhands.tools.execute_bash import BashExecutor, execute_bash_tool
from openhands.tools.str_replace_editor import (
from openhands.sdk import (
LLM,
Agent,
Conversation,
EventType,
LLMConfig,
Message,
TextContent,
Tool,
)
from openhands.tools import (
BashExecutor,
FileEditorExecutor,
execute_bash_tool,
str_replace_editor_tool,
)
except ImportError as e:
Expand All @@ -44,8 +49,23 @@
logger = logging.getLogger(__name__)


def setup_agent() -> tuple[LLM | None, CodeActAgent | None, Conversation | None]:
"""Setup the agent with environment variables."""
class AgentSetupError(Exception):
"""Exception raised when agent setup fails."""

def __init__(self, message: str, exit_code: int = 1):
super().__init__(message)
self.exit_code = exit_code


def setup_agent() -> tuple[LLM, Agent, Conversation]:
"""Setup the agent with environment variables.

Returns:
tuple: (llm, agent, conversation)

Raises:
AgentSetupError: If agent setup fails
"""
try:
# Get API configuration from environment
api_key = os.getenv("LITELLM_API_KEY") or os.getenv("OPENAI_API_KEY")
Expand All @@ -58,7 +78,10 @@ def setup_agent() -> tuple[LLM | None, CodeActAgent | None, Conversation | None]
"<red>Error: No API key found. Please set LITELLM_API_KEY or OPENAI_API_KEY environment variable.</red>"
)
)
return None, None, None
raise AgentSetupError(
"No API key found. Please set LITELLM_API_KEY or OPENAI_API_KEY environment variable.",
1,
)

# Configure LLM
llm_config = LLMConfig(
Expand All @@ -81,7 +104,7 @@ def setup_agent() -> tuple[LLM | None, CodeActAgent | None, Conversation | None]
]

# Create agent
agent = CodeActAgent(llm=llm, tools=tools)
agent = Agent(llm=llm, tools=tools)

# Setup conversation with callback
def conversation_callback(event: EventType) -> None:
Expand All @@ -94,10 +117,13 @@ def conversation_callback(event: EventType) -> None:
)
return llm, agent, conversation

except AgentSetupError:
# Re-raise AgentSetupError as-is
raise
except Exception as e:
print_formatted_text(HTML(f"<red>Error setting up agent: {str(e)}</red>"))
traceback.print_exc()
return None, None, None
raise AgentSetupError(f"Error setting up agent: {str(e)}", 2) from e


def display_welcome(session_id: str = "chat") -> None:
Expand All @@ -113,12 +139,17 @@ def display_welcome(session_id: str = "chat") -> None:
print()


def run_agent_chat() -> None:
"""Run the agent chat session using the agent SDK."""
def run_agent_chat() -> int:
"""Run the agent chat session using the agent SDK.

Returns:
int: Exit code (0 for success, non-zero for error)
"""
# Setup agent
llm, agent, conversation = setup_agent()
if not agent or not conversation:
return
try:
llm, agent, conversation = setup_agent()
except AgentSetupError as e:
return e.exit_code

# Generate session ID
import uuid
Expand Down Expand Up @@ -198,17 +229,25 @@ def run_agent_chat() -> None:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
break

return 0


def main() -> int:
"""Main entry point for agent chat.

def main() -> None:
"""Main entry point for agent chat."""
Returns:
int: Exit code (0 for success, non-zero for error)
"""
try:
run_agent_chat()
return run_agent_chat()
except KeyboardInterrupt:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
return 0
except Exception as e:
print_formatted_text(HTML(f"<red>Unexpected error: {str(e)}</red>"))
logger.error(f"Main error: {e}")
return 3


if __name__ == "__main__":
main()
sys.exit(main())
3 changes: 1 addition & 2 deletions openhands_cli/simple_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ def main() -> int:
# Start agent chat directly by default
from openhands_cli.agent_chat import main as run_agent_chat

run_agent_chat()
return 0
return run_agent_chat()

except ImportError as e:
print_formatted_text(
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"openhands-sdk @ git+https://github.com/All-Hands-AI/agent-sdk.git@288e440b344c67c66e2093215521a1394b1509b6#subdirectory=openhands/core",
"openhands-tools @ git+https://github.com/All-Hands-AI/agent-sdk.git@288e440b344c67c66e2093215521a1394b1509b6#subdirectory=openhands/tools",
"openhands-sdk @ git+https://github.com/All-Hands-AI/agent-sdk.git@ab8980714dd397f26fe811227afbc533c59fae70#subdirectory=openhands/sdk",
"openhands-tools @ git+https://github.com/All-Hands-AI/agent-sdk.git@ab8980714dd397f26fe811227afbc533c59fae70#subdirectory=openhands/tools",
"prompt-toolkit>=3",
]

Expand All @@ -37,6 +37,7 @@ scripts.openhands-cli = "openhands_cli.simple_main:main"
dev = [
"pre-commit>=4.3",
"pyinstaller>=6.15",
"pytest>=8.4.1",
]

[tool.poetry]
Expand All @@ -51,8 +52,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 = "288e440b344c67c66e2093215521a1394b1509b6", subdirectory = "openhands/core" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "288e440b344c67c66e2093215521a1394b1509b6", subdirectory = "openhands/tools" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "ab8980714dd397f26fe811227afbc533c59fae70", subdirectory = "openhands/sdk" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "ab8980714dd397f26fe811227afbc533c59fae70", subdirectory = "openhands/tools" }

[tool.poetry.group.dev.dependencies]
pytest = "^7.0.0"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_main_starts_agent_chat_directly(
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() starts agent chat directly without menu."""
mock_run_agent_chat.return_value = None
mock_run_agent_chat.return_value = 0

result = simple_main.main()

Expand Down
Loading