Compare commits

..

1 Commits

Author SHA1 Message Date
enyst
ba881eddd7 Rebase: V1 CLI add --task/--file and direct initial message injection
Re-applied CLI-only changes on top of latest main.
- Use args.task/args.file (no getattr)
- Direct initial_user_message param (no env var)
- Precedence: file > task; fallback behavior preserved
- Focused tests under openhands-cli/tests/

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-14 03:57:26 +00:00
68 changed files with 756 additions and 1355 deletions

View File

@@ -1,7 +1,7 @@
<a name="readme-top"></a>
<div align="center">
<img src="https://raw.githubusercontent.com/All-Hands-AI/docs/main/openhands/static/img/logo.png" alt="Logo" width="200">
<img src="./docs/static/img/logo.png" alt="Logo" width="200">
<h1 align="center">OpenHands: Code Less, Make More</h1>
</div>
@@ -38,12 +38,6 @@ call APIs, and yes—even copy code snippets from StackOverflow.
Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for OpenHands Cloud](https://app.all-hands.dev) to get started.
> [!IMPORTANT]
> **Upcoming change**: We are renaming our GitHub Org from `All-Hands-AI` to `OpenHands` on October 20th, 2025.
> Check the [tracking issue](https://github.com/All-Hands-AI/OpenHands/issues/11376) for more information.
> [!IMPORTANT]
> Using OpenHands for work? We'd love to chat! Fill out
> [this short form](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)

12
enterprise/poetry.lock generated
View File

@@ -5536,8 +5536,8 @@ websockets = ">=12"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "08cf609a996523c0199c61c768d74417b7e96109"
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
subdirectory = "openhands/agent_server"
[[package]]
@@ -5582,8 +5582,8 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "08cf609a996523c0199c61c768d74417b7e96109", subdirectory = "openhands/agent_server"}
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "08cf609a996523c0199c61c768d74417b7e96109", subdirectory = "openhands/sdk"}
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc", subdirectory = "openhands/agent_server"}
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc", subdirectory = "openhands/sdk"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5662,8 +5662,8 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "08cf609a996523c0199c61c768d74417b7e96109"
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
subdirectory = "openhands/sdk"
[[package]]

View File

@@ -784,7 +784,6 @@ class SaasNestedConversationManager(ConversationManager):
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
env_vars['ENABLE_V1'] = '0'
# We need this for LLM traces tracking to identify the source of the LLM calls
env_vars['WEB_HOST'] = WEB_HOST

View File

@@ -195,11 +195,14 @@ def update_active_working_seconds(
file_store: The FileStore instance for accessing conversation data
"""
try:
# Get all events for the conversation
events = list(event_store.get_events())
# Track agent state changes and calculate running time
running_start_time = None
total_running_seconds = 0.0
for event in event_store.search_events():
for event in events:
if isinstance(event, AgentStateChangedObservation) and event.timestamp:
event_timestamp = datetime.fromisoformat(event.timestamp).timestamp()

View File

@@ -80,7 +80,7 @@ class TestUpdateActiveWorkingSeconds:
events.append(event6)
# Configure the mock event store to return our test events
mock_event_store.search_events.return_value = events
mock_event_store.get_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -133,7 +133,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2]
mock_event_store.search_events.return_value = events
mock_event_store.get_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -178,7 +178,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2, event3]
# No final state change - agent still running
mock_event_store.search_events.return_value = events
mock_event_store.get_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -221,7 +221,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2, event3]
mock_event_store.search_events.return_value = events
mock_event_store.get_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -267,7 +267,7 @@ class TestUpdateActiveWorkingSeconds:
events = [event1, event2, event3, event4]
mock_event_store.search_events.return_value = events
mock_event_store.get_events.return_value = events
# Call the function under test with mocked session_maker
with patch(
@@ -297,7 +297,7 @@ class TestUpdateActiveWorkingSeconds:
user_id = 'test_user_error'
# Configure the mock to raise an exception
mock_event_store.search_events.side_effect = Exception('Test error')
mock_event_store.get_events.side_effect = Exception('Test error')
# Call the function under test
update_active_working_seconds(
@@ -376,7 +376,7 @@ class TestUpdateActiveWorkingSeconds:
event10.timestamp = '1970-01-01T00:00:37.000000'
events.append(event10)
mock_event_store.search_events.return_value = events
mock_event_store.get_events.return_value = events
# Call the function under test with mocked session_maker
with patch(

View File

@@ -307,7 +307,7 @@ class TheoremqaTask(Task):
# Converting the string answer to a number/list/bool/option
try:
prediction = ast.literal_eval(prediction)
prediction = eval(prediction)
except Exception:
LOGGER.warning(
f'[TASK] Failed to convert the answer: {prediction}\n{traceback.format_exc()}'

View File

@@ -111,10 +111,15 @@ for run_idx in $(seq 1 $N_RUNS); do
echo "### Evaluating on $OUTPUT_FILE ... ###"
OUTPUT_CONFIG_FILE="${OUTPUT_FILE%.jsonl}_config.json"
export EVAL_SKIP_BUILD_ERRORS=true
pip install multi-swe-bench --quiet --disable-pip-version-check > /dev/null 2>&1
COMMAND="poetry run python ./evaluation/benchmarks/multi_swe_bench/scripts/eval/update_multi_swe_bench_config.py --input $OUTPUT_FILE --output $OUTPUT_CONFIG_FILE --dataset $EVAL_DATASET;
poetry run python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
echo "Running command: $COMMAND"
# Run the command
eval $COMMAND

View File

@@ -24,8 +24,8 @@ from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
OpenHandsConfig,
get_evaluation_parser,
get_llm_config_arg,
parse_arguments,
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
@@ -166,8 +166,7 @@ def load_integration_tests() -> pd.DataFrame:
if __name__ == '__main__':
parser = get_evaluation_parser()
args, _ = parser.parse_known_args()
args = parse_arguments()
integration_tests = load_integration_tests()
llm_config = None

View File

@@ -24,5 +24,4 @@ test("mapProvider", () => {
expect(mapProvider("replicate")).toBe("Replicate");
expect(mapProvider("voyage")).toBe("Voyage AI");
expect(mapProvider("openrouter")).toBe("OpenRouter");
expect(mapProvider("clarifai")).toBe("Clarifai");
});

View File

@@ -2,7 +2,7 @@ import React from "react";
import { useLocation } from "react-router";
import { useGitUser } from "#/hooks/query/use-git-user";
import { UserActions } from "./user-actions";
import { OpenHandsLogoButton } from "#/components/shared/buttons/openhands-logo-button";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { NewProjectButton } from "#/components/shared/buttons/new-project-button";
import { ConversationPanelButton } from "#/components/shared/buttons/conversation-panel-button";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
@@ -74,7 +74,7 @@ export function Sidebar() {
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
<div className="flex flex-row md:flex-col items-center gap-[26px]">
<div className="flex items-center justify-center">
<OpenHandsLogoButton />
<AllHandsLogoButton />
</div>
<div>
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />

View File

@@ -3,13 +3,13 @@ import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
export function OpenHandsLogoButton() {
export function AllHandsLogoButton() {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.BRANDING$OPENHANDS)}
ariaLabel={t(I18nKey.BRANDING$OPENHANDS_LOGO)}
tooltip={t(I18nKey.BRANDING$ALL_HANDS_AI)}
ariaLabel={t(I18nKey.BRANDING$ALL_HANDS_LOGO)}
navLinkTo="/"
>
<AllHandsLogo width={46} height={30} />

View File

@@ -3,7 +3,6 @@ import { useConfig } from "./query/use-config";
import { useGitUser } from "./query/use-git-user";
import { getLoginMethod, LoginMethod } from "#/utils/local-storage";
import reoService, { ReoIdentity } from "#/utils/reo";
import { isProductionDomain } from "#/utils/utils";
/**
* Maps login method to Reo identity type
@@ -93,14 +92,10 @@ export const useReoTracking = () => {
const { data: user } = useGitUser();
const [hasIdentified, setHasIdentified] = React.useState(false);
// Initialize Reo.dev when in SaaS mode and on the correct domain
// Initialize Reo.dev when in SaaS mode
React.useEffect(() => {
const initReo = async () => {
if (
config?.APP_MODE === "saas" &&
isProductionDomain() &&
!reoService.isInitialized()
) {
if (config?.APP_MODE === "saas" && !reoService.isInitialized()) {
await reoService.init();
}
};
@@ -108,11 +103,10 @@ export const useReoTracking = () => {
initReo();
}, [config?.APP_MODE]);
// Identify user when user data is available and we're in SaaS mode on correct domain
// Identify user when user data is available and we're in SaaS mode
React.useEffect(() => {
if (
config?.APP_MODE !== "saas" ||
!isProductionDomain() ||
!user ||
hasIdentified ||
!reoService.isInitialized()

View File

@@ -168,8 +168,8 @@ export enum I18nKey {
GITHUB$CODE_NOT_IN_GITHUB = "GITHUB$CODE_NOT_IN_GITHUB",
GITHUB$START_FROM_SCRATCH = "GITHUB$START_FROM_SCRATCH",
AVATAR$ALT_TEXT = "AVATAR$ALT_TEXT",
BRANDING$OPENHANDS = "BRANDING$OPENHANDS",
BRANDING$OPENHANDS_LOGO = "BRANDING$OPENHANDS_LOGO",
BRANDING$ALL_HANDS_AI = "BRANDING$ALL_HANDS_AI",
BRANDING$ALL_HANDS_LOGO = "BRANDING$ALL_HANDS_LOGO",
ERROR$GENERIC = "ERROR$GENERIC",
GITHUB$AUTH_SCOPE = "GITHUB$AUTH_SCOPE",
FILE_SERVICE$INVALID_FILE_PATH = "FILE_SERVICE$INVALID_FILE_PATH",

View File

@@ -2687,37 +2687,37 @@
"tr": "Kullanıcı avatarı",
"uk": "аватар користувача"
},
"BRANDING$OPENHANDS": {
"en": "OpenHands",
"ja": "OpenHands",
"zh-CN": "OpenHands",
"zh-TW": "OpenHands",
"ko-KR": "OpenHands",
"de": "OpenHands",
"no": "OpenHands",
"it": "OpenHands",
"pt": "OpenHands",
"es": "OpenHands",
"ar": "OpenHands",
"fr": "OpenHands",
"tr": "OpenHands",
"uk": "OpenHands"
"BRANDING$ALL_HANDS_AI": {
"en": "All Hands AI",
"ja": "All Hands AI",
"zh-CN": "All Hands AI",
"zh-TW": "All Hands AI",
"ko-KR": "All Hands AI",
"de": "All Hands AI",
"no": "All Hands AI",
"it": "All Hands AI",
"pt": "All Hands AI",
"es": "All Hands AI",
"ar": "All Hands AI",
"fr": "All Hands AI",
"tr": "All Hands AI",
"uk": "All Hands AI"
},
"BRANDING$OPENHANDS_LOGO": {
"en": "OpenHands Logo",
"ja": "OpenHandsロゴ",
"zh-CN": "OpenHands标志",
"zh-TW": "OpenHands標誌",
"ko-KR": "OpenHands 로고",
"de": "OpenHands Logo",
"no": "OpenHands Logo",
"it": "Logo OpenHands",
"pt": "Logo OpenHands",
"es": "Logo de OpenHands",
"ar": "شعار OpenHands",
"fr": "Logo OpenHands",
"tr": "OpenHands Logosu",
"uk": "OpenHands лого"
"BRANDING$ALL_HANDS_LOGO": {
"en": "All Hands Logo",
"ja": "All Handsロゴ",
"zh-CN": "All Hands标志",
"zh-TW": "All Hands標誌",
"ko-KR": "All Hands 로고",
"de": "All Hands Logo",
"no": "All Hands Logo",
"it": "Logo All Hands",
"pt": "Logo All Hands",
"es": "Logo de All Hands",
"ar": "شعار All Hands",
"fr": "Logo All Hands",
"tr": "All Hands Logosu",
"uk": "All Hands лого"
},
"ERROR$GENERIC": {
"en": "An error occurred",

View File

@@ -25,7 +25,6 @@ export const MAP_PROVIDER = {
openrouter: "OpenRouter",
openhands: "OpenHands",
lemonade: "Lemonade",
clarifai: "Clarifai",
};
export const mapProvider = (provider: string) =>

View File

@@ -5,7 +5,6 @@ import { SuggestedTaskGroup } from "#/utils/types";
import { ConversationStatus } from "#/types/conversation-status";
import { GitRepository } from "#/types/git";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { PRODUCT_URL } from "#/utils/constants";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -50,13 +49,6 @@ export const isMobileDevice = (): boolean =>
"ontouchstart" in window ||
navigator.maxTouchPoints > 0;
/**
* Checks if the current domain is the production domain
* @returns True if the current domain matches the production URL
*/
export const isProductionDomain = (): boolean =>
window.location.origin === PRODUCT_URL.PRODUCTION;
interface EventActionHistory {
args?: {
LLM_API_KEY?: string;

View File

@@ -5,7 +5,6 @@ export const VERIFIED_PROVIDERS = [
"openai",
"mistral",
"lemonade",
"clarifai",
];
export const VERIFIED_MODELS = [
"o3-mini-2025-01-31",

View File

@@ -27,7 +27,6 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a pull request, send the user a short message with a link to the pull request.
* Do NOT mark a pull request as ready to review unless the user explicitly says so
* IMPORTANT: When making commits, NEVER use the `--no-verify` flag. Pre-commit hooks (if configured in `.openhands/pre-commit.sh`) must be executed to ensure code quality and enforce project standards.
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch

View File

@@ -28,7 +28,6 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a pull request, send the user a short message with a link to the pull request.
* Do NOT mark a pull request as ready to review unless the user explicitly says so
* IMPORTANT: When making commits, NEVER use the `--no-verify` flag. Pre-commit hooks (if configured in `.openhands/pre-commit.sh`) must be executed to ensure code quality and enforce project standards.
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch

View File

@@ -27,7 +27,6 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a merge request, send the user a short message with a link to the merge request.
* IMPORTANT: When making commits, NEVER use the `--no-verify` flag. Pre-commit hooks (if configured in `.openhands/pre-commit.sh`) must be executed to ensure code quality and enforce project standards.
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch

View File

@@ -164,7 +164,7 @@ def test_executable() -> bool:
)
# --- Wait for welcome ---
deadline = boot_start + 60
deadline = boot_start + 30
saw_welcome = False
captured = []

View File

@@ -0,0 +1,110 @@
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec file for OpenHands CLI.
This spec file configures PyInstaller to create a standalone executable
for the OpenHands CLI application.
"""
from pathlib import Path
import os
import sys
from PyInstaller.utils.hooks import (
collect_submodules,
collect_data_files,
copy_metadata
)
# Get the project root directory (current working directory when running PyInstaller)
project_root = Path.cwd()
a = Analysis(
['openhands_cli/simple_main.py'],
pathex=[str(project_root)],
binaries=[],
datas=[
# Include any data files that might be needed
# Add more data files here if needed in the future
*collect_data_files('tiktoken'),
*collect_data_files('tiktoken_ext'),
*collect_data_files('litellm'),
*collect_data_files('fastmcp'),
*collect_data_files('mcp'),
# Include Jinja prompt templates required by the agent SDK
*collect_data_files('openhands.sdk.agent', includes=['prompts/*.j2']),
# Include package metadata for importlib.metadata
*copy_metadata('fastmcp'),
],
hiddenimports=[
# Explicitly include modules that might not be detected automatically
*collect_submodules('openhands_cli'),
*collect_submodules('prompt_toolkit'),
# 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'),
*collect_submodules('fastmcp'),
# Include mcp but exclude CLI parts that require typer
'mcp.types',
'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',
'matplotlib',
'numpy',
'scipy',
'pandas',
'IPython',
'jupyter',
'notebook',
# Exclude mcp CLI parts that cause issues
'mcp.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
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='openhands-cli',
debug=False,
bootloader_ignore_signals=False,
strip=True, # Strip debug symbols to reduce size
upx=True, # Use UPX compression if available
upx_exclude=[],
runtime_tmpdir=None,
console=True, # CLI application needs console
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None, # Add icon path here if you have one
)

View File

@@ -1,8 +1,3 @@
"""OpenHands package."""
"""OpenHands CLI package."""
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("openhands")
except PackageNotFoundError:
__version__ = "0.0.0"
__version__ = '0.1.0'

View File

@@ -54,8 +54,7 @@ def _print_exit_hint(conversation_id: str) -> None:
)
def run_cli_entry(resume_conversation_id: str | None = None) -> None:
def run_cli_entry(resume_conversation_id: str | None = None, initial_user_message: str | None = None) -> None:
"""Run the agent chat session using the agent SDK.
@@ -82,6 +81,12 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
runner = ConversationRunner(conversation)
session = get_session_prompter()
# If an initial message is provided, send it once before entering the prompt loop
if initial_user_message:
runner.process_message(
Message(role='user', content=[TextContent(text=initial_user_message)])
)
# Main chat loop
while True:
try:

View File

@@ -19,6 +19,8 @@ Use 'serve' subcommand to launch the GUI server instead.
Examples:
openhands # Start CLI mode
openhands --resume conversation-id # Resume a conversation in CLI mode
openhands --task "Fix the bug" # Start CLI mode with an initial task message
openhands --file path/to/file.py # Start CLI mode with file content as initial context
openhands serve # Launch GUI server
openhands serve --gpu # Launch GUI server with GPU support
"""
@@ -28,8 +30,21 @@ Examples:
parser.add_argument(
'--resume',
type=str,
default=None,
help='Conversation ID to resume'
)
parser.add_argument(
'--task',
type=str,
default=None,
help='Initial user task/message to send when the session starts'
)
parser.add_argument(
'--file',
type=str,
default=None,
help='Path to a file whose contents will be sent as the initial user message (takes precedence over --task)'
)
# Only serve as subcommand
subparsers = parser.add_subparsers(

View File

@@ -113,12 +113,21 @@ def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
pull_cmd = ['docker', 'pull', runtime_image]
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
try:
subprocess.run(pull_cmd, check=True)
subprocess.run(
pull_cmd,
check=True,
timeout=300, # 5 minutes timeout
)
except subprocess.CalledProcessError:
print_formatted_text(
HTML('<ansired>❌ Failed to pull runtime image.</ansired>')
)
sys.exit(1)
except subprocess.TimeoutExpired:
print_formatted_text(
HTML('<ansired>❌ Timeout while pulling runtime image.</ansired>')
)
sys.exit(1)
print_formatted_text('')
print_formatted_text(

View File

@@ -20,6 +20,20 @@ from prompt_toolkit.formatted_text import HTML
from openhands_cli.argparsers.main_parser import create_main_parser
def _build_initial_task_from_args(args) -> str | None:
# Precedence: --file content if provided and readable; else --task; else None
if args.file:
try:
with open(args.file, 'r', encoding='utf-8') as f:
return f.read()
except Exception:
# Fall back to --task if file unreadable
if args.task:
return args.task
return None
return args.task
def main() -> None:
"""Main entry point for the OpenHands CLI.
@@ -41,8 +55,9 @@ def main() -> None:
# Import agent_chat only when needed
from openhands_cli.agent_chat import run_cli_entry
initial_task = _build_initial_task_from_args(args)
# Start agent chat
run_cli_entry(resume_conversation_id=args.resume)
run_cli_entry(resume_conversation_id=args.resume, initial_user_message=initial_task)
except KeyboardInterrupt:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
except EOFError:

View File

@@ -57,6 +57,8 @@ def display_banner(conversation_id: str, resume: bool = False) -> None:
style=DEFAULT_STYLE,
)
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
print_formatted_text('')
if not resume:
print_formatted_text(

View File

@@ -1,4 +1,3 @@
import html
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk.security.confirmation_policy import (
@@ -38,7 +37,7 @@ def ask_user_confirmation(
or '[unknown action]'
)
print_formatted_text(
HTML(f'<grey> {i}. {tool_name}: {html.escape(action_content)}...</grey>')
HTML(f'<grey> {i}. {tool_name}: {action_content}...</grey>')
)
question = 'Choose an option:'

View File

@@ -123,15 +123,9 @@ def prompt_api_key(
validator = NonEmptyValueValidator()
question = helper_text + step_counter.next_step(question)
user_input = cli_text_input(
return cli_text_input(
question, escapable=escapable, validator=validator, is_password=True
)
# If user pressed ENTER with existing key (empty input), return the existing key
if existing_api_key and not user_input.strip():
return existing_api_key.get_secret_value()
return user_input
# Advanced settings functions

View File

@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
[project]
name = "openhands"
version = "1.0.1"
version = "1.0.0"
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
readme = "README.md"
license = { text = "MIT" }
@@ -15,16 +15,15 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
# Using Git URLs for dependencies so installs from PyPI pull from GitHub
# TODO: pin package versions once agent-sdk has published PyPI packages
dependencies = [
"openhands-sdk @ git+https://github.com/All-Hands-AI/agent-sdk.git@50b094a92817e448ec4352d2950df4f19edd5a9f#subdirectory=openhands/sdk",
"openhands-tools @ git+https://github.com/All-Hands-AI/agent-sdk.git@50b094a92817e448ec4352d2950df4f19edd5a9f#subdirectory=openhands/tools",
"openhands-sdk",
"openhands-tools",
"prompt-toolkit>=3",
"typer>=0.17.4",
]
scripts = { openhands = "openhands_cli.simple_main:main" }
# Dev-only tools with uv groups: `uv sync --group dev`
scripts.openhands = "openhands_cli.simple_main:main"
[dependency-groups]
# Hatchling wheel target: include the package directory
@@ -43,9 +42,6 @@ dev = [
"ruff>=0.11.8",
]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = [ "openhands_cli" ]
@@ -100,5 +96,8 @@ 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 = "50b094a92817e448ec4352d2950df4f19edd5a9f" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "50b094a92817e448ec4352d2950df4f19edd5a9f" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "189979a5013751aa86852ab41afe9a79555e62ac" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "189979a5013751aa86852ab41afe9a79555e62ac" }
[tool.uv.extra-build-dependencies]
openhands-tools = [ "openhands.tools" ]

5
openhands-cli/pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
addopts = -p no:warnings
# Keep test discovery local to this package to avoid importing the root repo package `openhands`
# which conflicts with the agent-sdk's `openhands.sdk`.
testpaths = tests

View File

@@ -1,56 +0,0 @@
"""Test for API key preservation bug when updating settings."""
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from openhands_cli.user_actions.settings_action import prompt_api_key
from openhands_cli.tui.utils import StepCounter
def test_api_key_preservation_when_user_presses_enter():
"""Test that API key is preserved when user presses ENTER to keep current key.
This test replicates the bug where API keys disappear when updating settings.
When a user presses ENTER to keep the current API key, the function should
return the existing API key, not an empty string.
"""
step_counter = StepCounter(1)
existing_api_key = SecretStr("sk-existing-key-123")
# Mock cli_text_input to return empty string (simulating user pressing ENTER)
with patch('openhands_cli.user_actions.settings_action.cli_text_input', return_value=''):
result = prompt_api_key(
step_counter=step_counter,
provider='openai',
existing_api_key=existing_api_key,
escapable=True
)
# The bug: result is empty string instead of the existing key
# This test will fail initially, demonstrating the bug
assert result == existing_api_key.get_secret_value(), (
f"Expected existing API key '{existing_api_key.get_secret_value()}' "
f"but got '{result}'. API key should be preserved when user presses ENTER."
)
def test_api_key_update_when_user_enters_new_key():
"""Test that API key is updated when user enters a new key."""
step_counter = StepCounter(1)
existing_api_key = SecretStr("sk-existing-key-123")
new_api_key = "sk-new-key-456"
# Mock cli_text_input to return new API key
with patch('openhands_cli.user_actions.settings_action.cli_text_input', return_value=new_api_key):
result = prompt_api_key(
step_counter=step_counter,
provider='openai',
existing_api_key=existing_api_key,
escapable=True
)
# Should return the new API key
assert result == new_api_key

View File

@@ -0,0 +1,140 @@
"""Tests for initial_user_message behavior in agent_chat.run_cli_entry."""
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
import openhands_cli.agent_chat as agent_chat
class _DummyConversation:
def __init__(self, conv_id: str = "conv-123") -> None:
self.id = conv_id
# Minimal state placeholder if inspected in code paths we don't exercise
self.state = SimpleNamespace(confirmation_mode=False)
self.agent = SimpleNamespace()
class _DummySession:
def __init__(self, raise_on_prompt: BaseException) -> None:
self._ex = raise_on_prompt
def prompt(self, *_args, **_kwargs):
raise self._ex
class TestAgentChatInitialMessage:
@patch("openhands_cli.agent_chat.ConversationRunner")
@patch("openhands_cli.agent_chat.print_formatted_text")
@patch("openhands_cli.agent_chat.UserConfirmation", SimpleNamespace(ACCEPT="ACCEPT"))
@patch("openhands_cli.agent_chat.exit_session_confirmation")
@patch("openhands_cli.agent_chat._print_exit_hint")
@patch("openhands_cli.agent_chat.start_fresh_conversation")
@patch("openhands_cli.agent_chat.get_session_prompter")
@patch("openhands_cli.agent_chat.display_welcome")
@patch("openhands_cli.agent_chat.SettingsScreen")
def test_sends_initial_user_message_once(
self,
_mock_settings_screen: MagicMock,
mock_display_welcome: MagicMock,
mock_get_session: MagicMock,
mock_start_fresh: MagicMock,
_mock_exit_hint: MagicMock,
mock_exit_confirm: MagicMock,
_mock_print: MagicMock,
mock_runner_cls: MagicMock,
) -> None:
"""When initial_user_message is provided, it is sent once via runner.process_message."""
# Arrange
mock_start_fresh.return_value = _DummyConversation()
mock_exit_confirm.return_value = "ACCEPT"
mock_get_session.return_value = _DummySession(KeyboardInterrupt())
runner_instance = MagicMock()
mock_runner_cls.return_value = runner_instance
initial_text = "please do X"
# Act
agent_chat.run_cli_entry(resume_conversation_id=None, initial_user_message=initial_text)
# Assert
mock_display_welcome.assert_called_once_with("conv-123", False)
assert runner_instance.process_message.call_count == 1
# Inspect the Message argument
sent_msg = runner_instance.process_message.call_args.args[0]
assert sent_msg.role == "user"
contents = sent_msg.content
assert isinstance(contents, list) and len(contents) == 1
assert contents[0].text == initial_text
@patch("openhands_cli.agent_chat.ConversationRunner")
@patch("openhands_cli.agent_chat.print_formatted_text")
@patch("openhands_cli.agent_chat.UserConfirmation", SimpleNamespace(ACCEPT="ACCEPT"))
@patch("openhands_cli.agent_chat.exit_session_confirmation")
@patch("openhands_cli.agent_chat._print_exit_hint")
@patch("openhands_cli.agent_chat.start_fresh_conversation")
@patch("openhands_cli.agent_chat.get_session_prompter")
@patch("openhands_cli.agent_chat.display_welcome")
@patch("openhands_cli.agent_chat.SettingsScreen")
def test_no_initial_message_does_not_send(
self,
_mock_settings_screen: MagicMock,
mock_display_welcome: MagicMock,
mock_get_session: MagicMock,
mock_start_fresh: MagicMock,
_mock_exit_hint: MagicMock,
mock_exit_confirm: MagicMock,
_mock_print: MagicMock,
mock_runner_cls: MagicMock,
) -> None:
"""When no initial_user_message is provided, runner.process_message is not called before prompt."""
# Arrange
mock_start_fresh.return_value = _DummyConversation()
mock_exit_confirm.return_value = "ACCEPT"
mock_get_session.return_value = _DummySession(KeyboardInterrupt())
runner_instance = MagicMock()
mock_runner_cls.return_value = runner_instance
# Act
agent_chat.run_cli_entry(resume_conversation_id=None, initial_user_message=None)
# Assert
mock_display_welcome.assert_called_once_with("conv-123", False)
runner_instance.process_message.assert_not_called()
@patch("openhands_cli.agent_chat.ConversationRunner")
@patch("openhands_cli.agent_chat.print_formatted_text")
@patch("openhands_cli.agent_chat.UserConfirmation", SimpleNamespace(ACCEPT="ACCEPT"))
@patch("openhands_cli.agent_chat.exit_session_confirmation")
@patch("openhands_cli.agent_chat._print_exit_hint")
@patch("openhands_cli.agent_chat.start_fresh_conversation")
@patch("openhands_cli.agent_chat.get_session_prompter")
@patch("openhands_cli.agent_chat.display_welcome")
@patch("openhands_cli.agent_chat.SettingsScreen")
def test_resume_flag_propagates_to_setup_and_welcome(
self,
_mock_settings_screen: MagicMock,
mock_display_welcome: MagicMock,
mock_get_session: MagicMock,
mock_start_fresh: MagicMock,
_mock_exit_hint: MagicMock,
mock_exit_confirm: MagicMock,
_mock_print: MagicMock,
mock_runner_cls: MagicMock,
) -> None:
"""Resume ID is passed to start_fresh_conversation and reflected in display_welcome."""
# Arrange
mock_start_fresh.return_value = _DummyConversation("abc-001")
mock_exit_confirm.return_value = "ACCEPT"
mock_get_session.return_value = _DummySession(KeyboardInterrupt())
mock_runner_cls.return_value = MagicMock()
# Act
agent_chat.run_cli_entry(resume_conversation_id="abc-001", initial_user_message=None)
# Assert
mock_start_fresh.assert_called_once_with("abc-001")
mock_display_welcome.assert_called_once_with("abc-001", True)

View File

@@ -111,6 +111,8 @@ class TestLaunchGuiServer:
[
# Docker pull failure
(subprocess.CalledProcessError(1, 'docker pull'), None, 1, False, False),
# Docker pull timeout
(subprocess.TimeoutExpired('docker pull', 300), None, 1, False, False),
# Docker run failure
(MagicMock(returncode=0), subprocess.CalledProcessError(1, 'docker run'), 1, False, False),
# KeyboardInterrupt during run

View File

@@ -25,8 +25,10 @@ class TestMainEntryPoint:
# Should complete without raising an exception (graceful exit)
simple_main.main()
# Should call run_cli_entry with no resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
# Should call run_cli_entry with no resume conversation ID and no initial message
mock_run_agent_chat.assert_called_once_with(
resume_conversation_id=None, initial_user_message=None
)
@patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
@@ -88,17 +90,41 @@ class TestMainEntryPoint:
# Should call run_cli_entry with the provided resume conversation ID
mock_run_agent_chat.assert_called_once_with(
resume_conversation_id='test-conversation-id'
resume_conversation_id='test-conversation-id', initial_user_message=None
)
@pytest.mark.parametrize(
"argv,expected_kwargs",
[
(['openhands', '--file', __file__], {"resume_conversation_id": None, "initial_user_message": open(__file__, 'r', encoding='utf-8').read()}),
],
)
@pytest.mark.filterwarnings('ignore:.*')
def test_main_cli_calls_run_cli_entry_file(monkeypatch, argv, expected_kwargs):
monkeypatch.setattr(sys, "argv", argv, raising=False)
called = {}
fake_agent_chat = SimpleNamespace(
run_cli_entry=lambda **kw: called.setdefault("kwargs", kw)
)
monkeypatch.setitem(sys.modules, "openhands_cli.agent_chat", fake_agent_chat)
main()
# Compare only presence of keys since file content can be large
assert set(called["kwargs"].keys()) == set(expected_kwargs.keys())
assert called["kwargs"]["resume_conversation_id"] is None
assert isinstance(called["kwargs"]["initial_user_message"], str) and len(called["kwargs"]["initial_user_message"]) > 0
@pytest.mark.parametrize(
"argv,expected_kwargs",
[
(['openhands'], {"resume_conversation_id": None}),
(['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id'}),
(['openhands'], {"resume_conversation_id": None, "initial_user_message": None}),
(['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id', "initial_user_message": None}),
(['openhands', '--task', 'do x'], {"resume_conversation_id": None, "initial_user_message": 'do x'}),
(['openhands', '--file', 'nonexistent.txt'], {"resume_conversation_id": None, "initial_user_message": None}),
],
)
def test_main_cli_calls_run_cli_entry(monkeypatch, argv, expected_kwargs):

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Core Settings Logic tests
"""
from typing import Any
from unittest.mock import MagicMock
import pytest
from prompt_toolkit.completion import FuzzyWordCompleter
from prompt_toolkit.validation import ValidationError
from pydantic import SecretStr
from openhands_cli.user_actions.settings_action import (
NonEmptyValueValidator,
SettingsType,
choose_llm_model,
choose_llm_provider,
prompt_api_key,
settings_type_confirmation,
)
# -------------------------------
# Settings type selection
# -------------------------------
def test_settings_type_selection(mock_cli_interactions: Any) -> None:
mocks = mock_cli_interactions
# Basic
mocks.cli_confirm.return_value = 0
assert settings_type_confirmation() == SettingsType.BASIC
# Cancel/Go back
mocks.cli_confirm.return_value = 2
with pytest.raises(KeyboardInterrupt):
settings_type_confirmation()
# -------------------------------
# Provider selection flows
# -------------------------------
def test_provider_selection_with_predefined_options(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
# first option among display_options is index 0
mocks.cli_confirm.return_value = 0
step_counter = StepCounter(1)
result = choose_llm_provider(step_counter)
assert result == 'openai'
def test_provider_selection_with_custom_input(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
# Due to overlapping provider keys between VERIFIED and UNVERIFIED in fixture,
# display_options contains 4 providers (with duplicates) + alternate at index 4
mocks.cli_confirm.return_value = 4
mocks.cli_text_input.return_value = "my-provider"
step_counter = StepCounter(1)
result = choose_llm_provider(step_counter)
assert result == "my-provider"
# Verify fuzzy completer passed
_, kwargs = mocks.cli_text_input.call_args
assert isinstance(kwargs["completer"], FuzzyWordCompleter)
# -------------------------------
# Model selection flows
# -------------------------------
def test_model_selection_flows(mock_verified_models: Any, mock_cli_interactions: Any) -> None:
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
# Direct pick from predefined list
mocks.cli_confirm.return_value = 0
step_counter = StepCounter(1)
result = choose_llm_model(step_counter, "openai")
assert result in ["gpt-4o"]
# Choose custom model via input
mocks.cli_confirm.return_value = 4 # for provider with >=4 models this would be alt; in our data openai has 3 -> alt index is 3
mocks.cli_text_input.return_value = "custom-model"
# Adjust to actual alt index produced by code (len(models[:4]) yields 3 + 1 alt -> index 3)
mocks.cli_confirm.return_value = 3
step_counter2 = StepCounter(1)
result2 = choose_llm_model(step_counter2, "openai")
assert result2 == "custom-model"
# -------------------------------
# API key validation and prompting
# -------------------------------
def test_api_key_validation_and_prompting(mock_cli_interactions: Any) -> None:
# Validator standalone
validator = NonEmptyValueValidator()
doc = MagicMock(); doc.text = "sk-abc"
validator.validate(doc)
doc_empty = MagicMock(); doc_empty.text = ""
with pytest.raises(ValidationError):
validator.validate(doc_empty)
# Prompting for new key enforces validator
from openhands_cli.tui.utils import StepCounter
mocks = mock_cli_interactions
mocks.cli_text_input.return_value = "sk-new"
step_counter = StepCounter(1)
new_key = prompt_api_key(step_counter, 'provider')
assert new_key == "sk-new"
assert mocks.cli_text_input.call_args[1]["validator"] is not None
# Prompting with existing key shows mask and no validator
mocks.cli_text_input.reset_mock()
mocks.cli_text_input.return_value = "sk-updated"
existing = SecretStr("sk-existing-123")
step_counter2 = StepCounter(1)
updated = prompt_api_key(step_counter2, 'provider', existing)
assert updated == "sk-updated"
assert mocks.cli_text_input.call_args[1]["validator"] is None
assert "sk-***" in mocks.cli_text_input.call_args[0][0]

View File

@@ -0,0 +1,132 @@
import json
from unittest.mock import MagicMock, patch
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from pathlib import Path
from openhands.sdk import LLM, Conversation, LocalFileStore
from openhands.tools.preset.default import get_default_agent
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.user_actions.settings_action import SettingsType
from pydantic import SecretStr
import pytest
def read_json(path: Path) -> dict:
with open(path, "r") as f:
return json.load(f)
def make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-xyz"):
llm = LLM(model=model, api_key=SecretStr(api_key), service_id="test-service")
# Conversation(agent) signature may vary across versions; adapt if needed:
from openhands.sdk.agent import Agent
agent = Agent(llm=llm, tools=[])
conv = Conversation(agent)
return SettingsScreen(conversation=conv)
def seed_file(path: Path, model: str = "openai/gpt-4o-mini", api_key: str = "sk-old"):
store = AgentStore()
store.file_store = LocalFileStore(root=str(path))
agent = get_default_agent(
llm=LLM(model=model, api_key=SecretStr(api_key), service_id="test-service")
)
store.save(agent)
def test_llm_settings_save_and_load(tmp_path: Path):
"""Test that the settings screen can save basic LLM settings."""
screen = SettingsScreen(conversation=None)
# Mock the spec store to verify settings are saved
with patch.object(screen.agent_store, 'save') as mock_save:
screen._save_llm_settings(
model="openai/gpt-4o-mini",
api_key="sk-test-123"
)
# Verify that save was called
mock_save.assert_called_once()
# Get the agent spec that was saved
saved_spec = mock_save.call_args[0][0]
assert saved_spec.llm.model == "openai/gpt-4o-mini"
assert saved_spec.llm.api_key.get_secret_value() == "sk-test-123"
def test_first_time_setup_workflow(tmp_path: Path):
"""Test that the basic settings workflow completes without errors."""
screen = SettingsScreen()
with (
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="openai"),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="gpt-4o-mini"),
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-first"),
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
):
# The workflow should complete without errors
screen.configure_settings()
# Since the current implementation doesn't save to file, we just verify the workflow completed
assert True # If we get here, the workflow completed successfully
def test_update_existing_settings_workflow(tmp_path: Path):
"""Test that the settings update workflow completes without errors."""
settings_path = tmp_path / "agent_settings.json"
seed_file(settings_path, model="openai/gpt-4o-mini", api_key="sk-old")
screen = make_screen_with_conversation(model="openai/gpt-4o-mini", api_key="sk-old")
with (
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", return_value=SettingsType.BASIC),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", return_value="anthropic"),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", return_value="claude-3-5-sonnet"),
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", return_value="sk-updated"),
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", return_value=True),
):
# The workflow should complete without errors
screen.configure_settings()
# Since the current implementation doesn't save to file, we just verify the workflow completed
assert True # If we get here, the workflow completed successfully
@pytest.mark.parametrize(
"step_to_cancel",
["type", "provider", "model", "apikey", "save"],
)
def test_workflow_cancellation_at_each_step(tmp_path: Path, step_to_cancel: str):
screen = make_screen_with_conversation()
# Base happy-path patches
patches = {
"settings_type_confirmation": MagicMock(return_value=SettingsType.BASIC),
"choose_llm_provider": MagicMock(return_value="openai"),
"choose_llm_model": MagicMock(return_value="gpt-4o-mini"),
"prompt_api_key": MagicMock(return_value="sk-new"),
"save_settings_confirmation": MagicMock(return_value=True),
}
# Turn one step into a cancel
if step_to_cancel == "type":
patches["settings_type_confirmation"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "provider":
patches["choose_llm_provider"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "model":
patches["choose_llm_model"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "apikey":
patches["prompt_api_key"].side_effect = KeyboardInterrupt()
elif step_to_cancel == "save":
patches["save_settings_confirmation"].side_effect = KeyboardInterrupt()
with (
patch("openhands_cli.tui.settings.settings_screen.settings_type_confirmation", patches["settings_type_confirmation"]),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_provider", patches["choose_llm_provider"]),
patch("openhands_cli.tui.settings.settings_screen.choose_llm_model", patches["choose_llm_model"]),
patch("openhands_cli.tui.settings.settings_screen.prompt_api_key", patches["prompt_api_key"]),
patch("openhands_cli.tui.settings.settings_screen.save_settings_confirmation", patches["save_settings_confirmation"]),
patch.object(screen.agent_store, 'save') as mock_save,
):
screen.configure_settings()
# No settings should be saved on cancel
mock_save.assert_not_called()

53
openhands-cli/uv.lock generated
View File

@@ -660,32 +660,18 @@ wheels = [
[[package]]
name = "fastuuid"
version = "0.13.5"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/80/3c16a1edad2e6cd82fbd15ac998cc1b881f478bf1f80ca717d941c441874/fastuuid-0.13.5.tar.gz", hash = "sha256:d4976821ab424d41542e1ea39bc828a9d454c3f8a04067c06fca123c5b95a1a1", size = 18255, upload-time = "2025-09-26T09:05:38.281Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/17/13146a1e916bd2971d0a58db5e0a4ad23efdd49f78f33ac871c161f8007b/fastuuid-0.12.0.tar.gz", hash = "sha256:d0bd4e5b35aad2826403f4411937c89e7c88857b1513fe10f696544c03e9bd8e", size = 19180, upload-time = "2025-01-27T18:04:14.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/36/434f137c5970cac19e57834e1f7680e85301619d49891618c00666700c61/fastuuid-0.13.5-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:35fe8045e866bc6846f8de6fa05acb1de0c32478048484a995e96d31e21dff2a", size = 494638, upload-time = "2025-09-26T09:14:58.695Z" },
{ url = "https://files.pythonhosted.org/packages/ca/3c/083de2ac007b2b305523b9c006dba5051e5afd87a626ef1a39f76e2c6b82/fastuuid-0.13.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:02a460333f52d731a006d18a52ef6fcb2d295a1f5b1a5938d30744191b2f77b7", size = 253138, upload-time = "2025-09-26T09:13:33.283Z" },
{ url = "https://files.pythonhosted.org/packages/73/5e/630cffa1c8775db526e39e9e4c5c7db0c27be0786bb21ba82c912ae19f63/fastuuid-0.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:74b0e4f8c307b9f477a5d7284db4431ce53a3c1e3f4173db7a97db18564a6202", size = 244521, upload-time = "2025-09-26T09:14:40.682Z" },
{ url = "https://files.pythonhosted.org/packages/4d/51/55d78705f4fbdadf88fb40f382f508d6c7a4941ceddd7825fafebb4cc778/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6955a99ef455c2986f3851f4e0ccc35dec56ac1a7720f2b92e88a75d6684512e", size = 271557, upload-time = "2025-09-26T09:15:09.75Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f10c77b826738c1a27dcdaa92ea4dc1ec9d869748a99e1fde54f1379553d4854", size = 272334, upload-time = "2025-09-26T09:07:48.865Z" },
{ url = "https://files.pythonhosted.org/packages/0c/06/4c8207894eeb30414999e5c3f66ac039bc4003437eb4060d8a1bceb4cc6f/fastuuid-0.13.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb25dccbeb249d16d5e664f65f17ebec05136821d5ef462c4110e3f76b86fb86", size = 290594, upload-time = "2025-09-26T09:12:54.124Z" },
{ url = "https://files.pythonhosted.org/packages/50/69/96d221931a31d77a47cc2487bdfacfb3091edfc2e7a04b1795df1aec05df/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a5becc646a3eeafb76ce0a6783ba190cd182e3790a8b2c78ca9db2b5e87af952", size = 452835, upload-time = "2025-09-26T09:14:00.994Z" },
{ url = "https://files.pythonhosted.org/packages/25/ef/bf045f0a47dcec96247497ef3f7a31d86ebc074330e2dccc34b8dbc0468a/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:69b34363752d06e9bb0dbdf02ae391ec56ac948c6f2eb00be90dad68e80774b9", size = 468225, upload-time = "2025-09-26T09:13:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/30/46/4817ab5a3778927155a4bde92540d4c4fa996161ec8b8e080c8928b0984e/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57d0768afcad0eab8770c9b8cf904716bd3c547e8b9a4e755ee8a673b060a3a3", size = 444907, upload-time = "2025-09-26T09:14:30.163Z" },
{ url = "https://files.pythonhosted.org/packages/80/27/ab284117ce4dc9b356a7196bdbf220510285f201d27f1f078592cdc8187b/fastuuid-0.13.5-cp312-cp312-win32.whl", hash = "sha256:8ac6c6f5129d52eaa6ef9ea4b6e2f7c69468a053f3ab8e439661186b9c06bb85", size = 145415, upload-time = "2025-09-26T09:08:59.494Z" },
{ url = "https://files.pythonhosted.org/packages/f4/0c/f970a4222773b248931819f8940800b760283216ca3dda173ed027e94bdd/fastuuid-0.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:ad630e97715beefef07ec37c9c162336e500400774e2c1cbe1a0df6f80d15b9a", size = 150840, upload-time = "2025-09-26T09:13:46.115Z" },
{ url = "https://files.pythonhosted.org/packages/4f/62/74fc53f6e04a4dc5b36c34e4e679f85a4c14eec800dcdb0f2c14b5442217/fastuuid-0.13.5-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ea17dfd35e0e91920a35d91e65e5f9c9d1985db55ac4ff2f1667a0f61189cefa", size = 494678, upload-time = "2025-09-26T09:14:30.908Z" },
{ url = "https://files.pythonhosted.org/packages/09/ba/f28b9b7045738a8bfccfb9cd6aff4b91fce2669e6b383a48b0694ee9b3ff/fastuuid-0.13.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:be6ad91e5fefbcc2a4b478858a2715e386d405834ea3ae337c3b6b95cc0e47d6", size = 253162, upload-time = "2025-09-26T09:13:35.879Z" },
{ url = "https://files.pythonhosted.org/packages/b1/18/13fac89cb4c9f0cd7e81a9154a77ecebcc95d2b03477aa91d4d50f7227ee/fastuuid-0.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ea6df13a306aab3e0439d58c312ff1e6f4f07f09f667579679239b4a6121f64a", size = 244546, upload-time = "2025-09-26T09:14:58.13Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/9691167804d59411cc4269841df949f6dd5e76452ab10dcfcd1dbe04c5bc/fastuuid-0.13.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2354c1996d3cf12dc2ba3752e2c4d6edc46e1a38c63893146777b1939f3062d4", size = 271528, upload-time = "2025-09-26T09:14:48.996Z" },
{ url = "https://files.pythonhosted.org/packages/a9/b5/7a75a03d1c7aa0b6d573032fcca39391f0aef7f2caabeeb45a672bc0bd3c/fastuuid-0.13.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6cf9b7469fc26d1f9b1c43ac4b192e219e85b88fdf81d71aa755a6c08c8a817", size = 272292, upload-time = "2025-09-26T09:14:42.82Z" },
{ url = "https://files.pythonhosted.org/packages/c0/db/fa0f16cbf76e6880599533af4ef01bb586949c5320612e9d884eff13e603/fastuuid-0.13.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92ba539170097b9047551375f1ca09d8d2b4aefcc79eeae3e1c43fe49b42072e", size = 290466, upload-time = "2025-09-26T09:08:33.161Z" },
{ url = "https://files.pythonhosted.org/packages/1e/02/6b8c45bfbc8500994dd94edba7f59555f9683c4d8c9a164ae1d25d03c7c7/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:dbb81d05617bc2970765c1ad82db7e8716f6a2b7a361a14b83de5b9240ade448", size = 452838, upload-time = "2025-09-26T09:13:44.747Z" },
{ url = "https://files.pythonhosted.org/packages/27/12/85d95a84f265b888e8eb9f9e2b5aaf331e8be60c0a7060146364b3544b6a/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:d973bd6bf9d754d3cca874714ac0a6b22a47f239fb3d3c8687569db05aac3471", size = 468149, upload-time = "2025-09-26T09:13:18.712Z" },
{ url = "https://files.pythonhosted.org/packages/ad/da/dd9a137e9ea707e883c92470113a432233482ec9ad3e9b99c4defc4904e6/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e725ceef79486423f05ee657634d4b4c1ca5fb2c8a94e0708f5d6356a83f2a83", size = 444933, upload-time = "2025-09-26T09:14:09.494Z" },
{ url = "https://files.pythonhosted.org/packages/12/f4/ab363d7f4ac3989691e2dc5ae2d8391cfb0b4169e52ef7fa0ac363e936f0/fastuuid-0.13.5-cp313-cp313-win32.whl", hash = "sha256:a1c430a332ead0b2674f1ef71b17f43b8139ec5a4201182766a21f131a31e021", size = 145462, upload-time = "2025-09-26T09:14:15.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8a/52eb77d9c294a54caa0d2d8cc9f906207aa6d916a22de963687ab6db8b86/fastuuid-0.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:241fdd362fd96e6b337db62a65dd7cb3dfac20adf854573247a47510e192db6f", size = 150923, upload-time = "2025-09-26T09:13:03.923Z" },
{ url = "https://files.pythonhosted.org/packages/f6/28/442e79d6219b90208cb243ac01db05d89cc4fdf8ecd563fb89476baf7122/fastuuid-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:328694a573fe9dce556b0b70c9d03776786801e028d82f0b6d9db1cb0521b4d1", size = 247372, upload-time = "2025-01-27T18:03:40.967Z" },
{ url = "https://files.pythonhosted.org/packages/40/eb/e0fd56890970ca7a9ec0d116844580988b692b1a749ac38e0c39e1dbdf23/fastuuid-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02acaea2c955bb2035a7d8e7b3fba8bd623b03746ae278e5fa932ef54c702f9f", size = 258200, upload-time = "2025-01-27T18:04:12.138Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/4b30e376e65597a51a3dc929461a0dec77c8aec5d41d930f482b8f43e781/fastuuid-0.12.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ed9f449cba8cf16cced252521aee06e633d50ec48c807683f21cc1d89e193eb0", size = 278446, upload-time = "2025-01-27T18:04:15.877Z" },
{ url = "https://files.pythonhosted.org/packages/fe/96/cc5975fd23d2197b3e29f650a7a9beddce8993eaf934fa4ac595b77bb71f/fastuuid-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:0df2ea4c9db96fd8f4fa38d0e88e309b3e56f8fd03675a2f6958a5b082a0c1e4", size = 157185, upload-time = "2025-01-27T18:06:19.21Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e8/d2bb4f19e5ee15f6f8e3192a54a897678314151aa17d0fb766d2c2cbc03d/fastuuid-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7fe2407316a04ee8f06d3dbc7eae396d0a86591d92bafe2ca32fce23b1145786", size = 247512, upload-time = "2025-01-27T18:04:08.115Z" },
{ url = "https://files.pythonhosted.org/packages/bc/53/25e811d92fd60f5c65e098c3b68bd8f1a35e4abb6b77a153025115b680de/fastuuid-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b31dd488d0778c36f8279b306dc92a42f16904cba54acca71e107d65b60b0c", size = 258257, upload-time = "2025-01-27T18:03:56.408Z" },
{ url = "https://files.pythonhosted.org/packages/10/23/73618e7793ea0b619caae2accd9e93e60da38dd78dd425002d319152ef2f/fastuuid-0.12.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b19361ee649365eefc717ec08005972d3d1eb9ee39908022d98e3bfa9da59e37", size = 278559, upload-time = "2025-01-27T18:03:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/e4/41/6317ecfc4757d5f2a604e5d3993f353ba7aee85fa75ad8b86fce6fc2fa40/fastuuid-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8fc66b11423e6f3e1937385f655bedd67aebe56a3dcec0cb835351cfe7d358c9", size = 157276, upload-time = "2025-01-27T18:06:39.245Z" },
]
[[package]]
@@ -1280,8 +1266,8 @@ wheels = [
[[package]]
name = "litellm"
version = "1.77.7"
source = { git = "https://github.com/BerriAI/litellm.git?rev=v1.77.7.dev9#763d2f8ccdd8412dbe6d4ac0e136d9ac34dcd4c0" }
version = "1.76.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "click" },
@@ -1296,6 +1282,10 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/a3/f7c00c660972eed1ba5ed53771ac9b4235e7fb1dc410e91d35aff2778ae7/litellm-1.76.2.tar.gz", hash = "sha256:fc7af111fa0f06943d8dbebed73f88000f9902f0d0ee0882c57d0bd5c1a37ecb", size = 10189238, upload-time = "2025-09-04T00:25:09.472Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/f4/980cc81c21424026dcb48a541654fd6f4286891825a3d0dd51f02b65cbc3/litellm-1.76.2-py3-none-any.whl", hash = "sha256:a9a2ef64a598b5b4ae245f1de6afc400856477cd6f708ff633d95e2275605a45", size = 8973847, upload-time = "2025-09-04T00:25:05.353Z" },
]
[[package]]
name = "macholib"
@@ -1625,7 +1615,7 @@ wheels = [
[[package]]
name = "openhands"
version = "1.0.1"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "openhands-sdk" },
@@ -1652,8 +1642,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openhands-sdk", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=50b094a92817e448ec4352d2950df4f19edd5a9f" },
{ name = "openhands-tools", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=50b094a92817e448ec4352d2950df4f19edd5a9f" },
{ name = "openhands-sdk", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=189979a5013751aa86852ab41afe9a79555e62ac" },
{ name = "openhands-tools", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=189979a5013751aa86852ab41afe9a79555e62ac" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "typer", specifier = ">=0.17.4" },
]
@@ -1677,10 +1667,9 @@ dev = [
[[package]]
name = "openhands-sdk"
version = "1.0.0"
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=50b094a92817e448ec4352d2950df4f19edd5a9f#50b094a92817e448ec4352d2950df4f19edd5a9f" }
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=189979a5013751aa86852ab41afe9a79555e62ac#189979a5013751aa86852ab41afe9a79555e62ac" }
dependencies = [
{ name = "fastmcp" },
{ name = "httpx" },
{ name = "litellm" },
{ name = "pydantic" },
{ name = "python-frontmatter" },
@@ -1692,7 +1681,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=50b094a92817e448ec4352d2950df4f19edd5a9f#50b094a92817e448ec4352d2950df4f19edd5a9f" }
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=189979a5013751aa86852ab41afe9a79555e62ac#189979a5013751aa86852ab41afe9a79555e62ac" }
dependencies = [
{ name = "bashlex" },
{ name = "binaryornot" },

View File

@@ -3,9 +3,9 @@
At the user's request, repository {{ repository_info.repo_name }} has been cloned to {{ repository_info.repo_directory }} in the current working directory.
{% if repository_info.branch_name %}The repository has been checked out to branch "{{ repository_info.branch_name }}".
IMPORTANT: You should work within the current branch "{{ repository_info.branch_name }}" unless:
IMPORTANT: You should work within the current branch "{{ repository_info.branch_name }}" unless
1. the user explicitly instructs otherwise
2. the current branch is "main", "master", or another default branch where direct pushes may be unsafe
2. if the current branch is "main", "master", or another default branch where direct pushes may be unsafe
{% endif %}
</REPOSITORY_INFO>
{% endif %}
@@ -35,9 +35,9 @@ For example, if you are using vite.config.js, you should set server.host and ser
{% endif %}
{% if runtime_info.custom_secrets_descriptions %}
<CUSTOM_SECRETS>
You have access to the following environment variables
You are have access to the following environment variables
{% for secret_name, secret_description in runtime_info.custom_secrets_descriptions.items() %}
* **${{ secret_name }}**: {{ secret_description }}
* $**{{ secret_name }}**: {{ secret_description }}
{% endfor %}
</CUSTOM_SECRETS>
{% endif %}

View File

@@ -35,7 +35,6 @@ Your primary role is to assist users by executing commands, modifying code, and
* If there are existing git user credentials already configured, use them and add Co-authored-by: openhands <openhands@all-hands.dev> to any commits messages you make. if a git config doesn't exist use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* NEVER use the `--no-verify` flag when making commits. Pre-commit hooks must be allowed to run to enforce code quality standards and project-specific checks configured in `.openhands/pre-commit.sh`.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
* If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
</VERSION_CONTROL>

View File

@@ -130,12 +130,6 @@ def config_from_env() -> AppServerConfig:
from openhands.app_server.sandbox.docker_sandbox_spec_service import (
DockerSandboxSpecServiceInjector,
)
from openhands.app_server.sandbox.process_sandbox_service import (
ProcessSandboxServiceInjector,
)
from openhands.app_server.sandbox.process_sandbox_spec_service import (
ProcessSandboxSpecServiceInjector,
)
from openhands.app_server.sandbox.remote_sandbox_service import (
RemoteSandboxServiceInjector,
)
@@ -161,16 +155,12 @@ def config_from_env() -> AppServerConfig:
api_key=os.environ['SANDBOX_API_KEY'],
api_url=os.environ['SANDBOX_REMOTE_RUNTIME_API_URL'],
)
elif os.getenv('RUNTIME') in ('local', 'process'):
config.sandbox = ProcessSandboxServiceInjector()
else:
config.sandbox = DockerSandboxServiceInjector()
if config.sandbox_spec is None:
if os.getenv('RUNTIME') == 'remote':
config.sandbox_spec = RemoteSandboxSpecServiceInjector()
elif os.getenv('RUNTIME') in ('local', 'process'):
config.sandbox_spec = ProcessSandboxSpecServiceInjector()
else:
config.sandbox_spec = DockerSandboxSpecServiceInjector()

View File

@@ -1,438 +0,0 @@
"""Process-based sandbox service implementation.
This service creates sandboxes by spawning separate agent server processes,
each running within a dedicated directory.
"""
import asyncio
import logging
import os
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from typing import AsyncGenerator
import base62
import httpx
import psutil
from fastapi import Request
from pydantic import BaseModel, ConfigDict, Field
from openhands.agent_server.utils import utc_now
from openhands.app_server.errors import SandboxError
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
ExposedUrl,
SandboxInfo,
SandboxPage,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_service import (
SandboxService,
SandboxServiceInjector,
)
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.services.injector import InjectorState
_logger = logging.getLogger(__name__)
class ProcessInfo(BaseModel):
"""Information about a running process."""
pid: int
port: int
user_id: str | None
working_dir: str
session_api_key: str
created_at: datetime
sandbox_spec_id: str
model_config = ConfigDict(frozen=True)
# Global store
_processes: dict[str, ProcessInfo] = {}
@dataclass
class ProcessSandboxService(SandboxService):
"""Sandbox service that spawns separate agent server processes.
Each sandbox is implemented as a separate Python process running the
action execution server, with each process:
- Operating in a dedicated directory
- Listening on a unique port
- Having its own session API key
"""
user_id: str | None
sandbox_spec_service: SandboxSpecService
base_working_dir: str
base_port: int
python_executable: str
agent_server_module: str
health_check_path: str
httpx_client: httpx.AsyncClient
def __post_init__(self):
"""Initialize the service after dataclass creation."""
# Ensure base working directory exists
os.makedirs(self.base_working_dir, exist_ok=True)
def _find_unused_port(self) -> int:
"""Find an unused port starting from base_port."""
port = self.base_port
while port < self.base_port + 10000: # Try up to 10000 ports
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', port))
return port
except OSError:
port += 1
raise SandboxError('No available ports found')
def _create_sandbox_directory(self, sandbox_id: str) -> str:
"""Create a dedicated directory for the sandbox."""
sandbox_dir = os.path.join(self.base_working_dir, sandbox_id)
os.makedirs(sandbox_dir, exist_ok=True)
return sandbox_dir
async def _start_agent_process(
self,
sandbox_id: str,
port: int,
working_dir: str,
session_api_key: str,
sandbox_spec: SandboxSpecInfo,
) -> subprocess.Popen:
"""Start the agent server process."""
# Prepare environment variables
env = os.environ.copy()
env.update(sandbox_spec.initial_env)
env['SESSION_API_KEY'] = session_api_key
# Prepare command arguments
cmd = [
self.python_executable,
'-m',
self.agent_server_module,
'--port',
str(port),
]
_logger.info(
f'Starting agent process for sandbox {sandbox_id}: {" ".join(cmd)}'
)
try:
# Start the process
process = subprocess.Popen(
cmd,
env=env,
cwd=working_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait a moment for the process to start
await asyncio.sleep(1)
# Check if process is still running
if process.poll() is not None:
stdout, stderr = process.communicate()
raise SandboxError(f'Agent process failed to start: {stderr.decode()}')
return process
except Exception as e:
raise SandboxError(f'Failed to start agent process: {e}')
async def _wait_for_server_ready(self, port: int, timeout: int = 30) -> bool:
"""Wait for the agent server to be ready."""
start_time = time.time()
while time.time() - start_time < timeout:
try:
response = await self.httpx_client.get(
f'http://localhost:{port}/alive', timeout=5.0
)
if response.status_code == 200:
data = response.json()
if data.get('status') == 'ok':
return True
except Exception:
pass
await asyncio.sleep(1)
return False
def _get_process_status(self, process_info: ProcessInfo) -> SandboxStatus:
"""Get the status of a process."""
try:
process = psutil.Process(process_info.pid)
if process.is_running():
status = process.status()
if status == psutil.STATUS_RUNNING:
return SandboxStatus.RUNNING
elif status == psutil.STATUS_STOPPED:
return SandboxStatus.PAUSED
else:
return SandboxStatus.STARTING
else:
return SandboxStatus.MISSING
except (psutil.NoSuchProcess, psutil.AccessDenied):
return SandboxStatus.MISSING
async def _process_to_sandbox_info(
self, sandbox_id: str, process_info: ProcessInfo
) -> SandboxInfo:
"""Convert process info to sandbox info."""
status = self._get_process_status(process_info)
exposed_urls = None
session_api_key = None
if status == SandboxStatus.RUNNING:
# Check if server is actually responding
try:
response = await self.httpx_client.get(
f'http://localhost:{process_info.port}{self.health_check_path}',
timeout=5.0,
)
if response.status_code == 200:
exposed_urls = [
ExposedUrl(
name=AGENT_SERVER,
url=f'http://localhost:{process_info.port}',
),
]
session_api_key = process_info.session_api_key
else:
status = SandboxStatus.ERROR
except Exception:
status = SandboxStatus.ERROR
return SandboxInfo(
id=sandbox_id,
created_by_user_id=process_info.user_id,
sandbox_spec_id=process_info.sandbox_spec_id,
status=status,
session_api_key=session_api_key,
exposed_urls=exposed_urls,
created_at=process_info.created_at,
)
async def search_sandboxes(
self,
page_id: str | None = None,
limit: int = 100,
) -> SandboxPage:
"""Search for sandboxes."""
# Get all process infos
all_processes = list(_processes.items())
# Sort by creation time (newest first)
all_processes.sort(key=lambda x: x[1].created_at, reverse=True)
# Apply pagination
start_idx = 0
if page_id:
try:
start_idx = int(page_id)
except ValueError:
start_idx = 0
end_idx = start_idx + limit
paginated_processes = all_processes[start_idx:end_idx]
# Convert to sandbox infos
items = []
for sandbox_id, process_info in paginated_processes:
sandbox_info = await self._process_to_sandbox_info(sandbox_id, process_info)
items.append(sandbox_info)
# Determine next page ID
next_page_id = None
if end_idx < len(all_processes):
next_page_id = str(end_idx)
return SandboxPage(items=items, next_page_id=next_page_id)
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox."""
process_info = _processes.get(sandbox_id)
if process_info is None:
return None
return await self._process_to_sandbox_info(sandbox_id, process_info)
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Get sandbox spec
if sandbox_spec_id is None:
sandbox_spec = await self.sandbox_spec_service.get_default_sandbox_spec()
else:
sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec(
sandbox_spec_id
)
if sandbox_spec_maybe is None:
raise ValueError('Sandbox Spec not found')
sandbox_spec = sandbox_spec_maybe
# Generate unique sandbox ID and session API key
sandbox_id = base62.encodebytes(os.urandom(16))
session_api_key = base62.encodebytes(os.urandom(32))
# Find available port
port = self._find_unused_port()
# Create sandbox directory
working_dir = self._create_sandbox_directory(sandbox_id)
# Start the agent process
process = await self._start_agent_process(
sandbox_id=sandbox_id,
port=port,
working_dir=working_dir,
session_api_key=session_api_key,
sandbox_spec=sandbox_spec,
)
# Store process info
process_info = ProcessInfo(
pid=process.pid,
port=port,
user_id=self.user_id,
working_dir=working_dir,
session_api_key=session_api_key,
created_at=utc_now(),
sandbox_spec_id=sandbox_spec.id,
)
_processes[sandbox_id] = process_info
# Wait for server to be ready
if not await self._wait_for_server_ready(port):
# Clean up if server didn't start properly
await self.delete_sandbox(sandbox_id)
raise SandboxError('Agent Server Failed to start properly')
return await self._process_to_sandbox_info(sandbox_id, process_info)
async def resume_sandbox(self, sandbox_id: str) -> bool:
"""Resume a paused sandbox."""
process_info = _processes.get(sandbox_id)
if process_info is None:
return False
try:
process = psutil.Process(process_info.pid)
if process.status() == psutil.STATUS_STOPPED:
process.resume()
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
return False
async def pause_sandbox(self, sandbox_id: str) -> bool:
"""Pause a running sandbox."""
process_info = _processes.get(sandbox_id)
if process_info is None:
return False
try:
process = psutil.Process(process_info.pid)
if process.is_running():
process.suspend()
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
return False
async def delete_sandbox(self, sandbox_id: str) -> bool:
"""Delete a sandbox."""
process_info = _processes.get(sandbox_id)
if process_info is None:
return False
try:
# Terminate the process
process = psutil.Process(process_info.pid)
if process.is_running():
# Try graceful termination first
process.terminate()
try:
process.wait(timeout=10)
except psutil.TimeoutExpired:
# Force kill if graceful termination fails
process.kill()
process.wait(timeout=5)
# Clean up the working directory
import shutil
if os.path.exists(process_info.working_dir):
shutil.rmtree(process_info.working_dir, ignore_errors=True)
# Remove from our tracking
del _processes[sandbox_id]
return True
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as e:
_logger.warning(f'Error deleting sandbox {sandbox_id}: {e}')
# Still remove from tracking even if cleanup failed
if sandbox_id in _processes:
del _processes[sandbox_id]
return True
class ProcessSandboxServiceInjector(SandboxServiceInjector):
"""Dependency injector for process sandbox services."""
base_working_dir: str = Field(
default='/tmp/openhands-sandboxes',
description='Base directory for sandbox working directories',
)
base_port: int = Field(
default=8000, description='Base port number for agent servers'
)
python_executable: str = Field(
default=sys.executable,
description='Python executable to use for agent processes',
)
agent_server_module: str = Field(
default='openhands.agent_server',
description='Python module for the agent server',
)
health_check_path: str = Field(
default='/alive', description='Health check endpoint path'
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SandboxService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_httpx_client,
get_sandbox_spec_service,
get_user_context,
)
async with (
get_httpx_client(state, request) as httpx_client,
get_sandbox_spec_service(state, request) as sandbox_spec_service,
get_user_context(state, request) as user_context,
):
user_id = await user_context.get_user_id()
yield ProcessSandboxService(
user_id=user_id,
sandbox_spec_service=sandbox_spec_service,
base_working_dir=self.base_working_dir,
base_port=self.base_port,
python_executable=self.python_executable,
agent_server_module=self.agent_server_module,
health_check_path=self.health_check_path,
httpx_client=httpx_client,
)

View File

@@ -1,43 +0,0 @@
from typing import AsyncGenerator
from fastapi import Request
from pydantic import Field
from openhands.app_server.sandbox.preset_sandbox_spec_service import (
PresetSandboxSpecService,
)
from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
AGENT_SERVER_VERSION,
SandboxSpecService,
SandboxSpecServiceInjector,
)
from openhands.app_server.services.injector import InjectorState
def get_default_sandbox_specs():
return [
SandboxSpecInfo(
id=AGENT_SERVER_VERSION,
command=['python', '-m', 'openhands.agent_server'],
initial_env={
# VSCode disabled for now
'OH_ENABLE_VS_CODE': '0',
},
working_dir='',
)
]
class ProcessSandboxSpecServiceInjector(SandboxSpecServiceInjector):
specs: list[SandboxSpecInfo] = Field(
default_factory=get_default_sandbox_specs,
description='Preset list of sandbox specs',
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SandboxSpecService, None]:
yield PresetSandboxSpecService(specs=self.specs)

View File

@@ -58,7 +58,7 @@ async def start_sandbox(
return info
@router.post('/{sandbox_id}/pause', responses={404: {'description': 'Item not found'}})
@router.post('/{id}/pause', responses={404: {'description': 'Item not found'}})
async def pause_sandbox(
sandbox_id: str,
sandbox_service: SandboxService = sandbox_service_dependency,
@@ -69,7 +69,7 @@ async def pause_sandbox(
return Success()
@router.post('/{sandbox_id}/resume', responses={404: {'description': 'Item not found'}})
@router.post('/{id}/resume', responses={404: {'description': 'Item not found'}})
async def resume_sandbox(
sandbox_id: str,
sandbox_service: SandboxService = sandbox_service_dependency,

View File

@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_VERSION = '08cf609a996523c0199c61c768d74417b7e96109'
AGENT_SERVER_VERSION = 'f8ca02c4a3b847bfc50b3c5e579ce126c511fefc'
class SandboxSpecService(ABC):

View File

@@ -55,7 +55,7 @@ class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
if self.user is None:
self.user = os.getenv('DB_USER', 'postgres')
if self.password is None:
self.password = SecretStr(os.getenv('DB_PASS', 'postgres').strip())
self.password = SecretStr(os.getenv('DB_PASS', 'postgres'))
if self.gcp_db_instance is None:
self.gcp_db_instance = os.getenv('GCP_DB_INSTANCE')
if self.gcp_project is None:

View File

@@ -903,7 +903,7 @@ class AgentController:
'contextwindowexceedederror' in error_str
or 'prompt is too long' in error_str
or 'input length and `max_tokens` exceed context limit' in error_str
or 'please reduce the length of' in error_str
or 'please reduce the length of either one' in error_str
or 'the request exceeds the available context size' in error_str
or 'context length exceeded' in error_str
# For OpenRouter context window errors

View File

@@ -89,8 +89,8 @@ class OpenHandsConfig(BaseModel):
)
# Deprecated parameters - will be removed in a future version
workspace_mount_path: str | None = Field(default=None)
workspace_mount_rewrite: str | None = Field(default=None)
workspace_mount_path: str | None = Field(default=None, deprecated=True)
workspace_mount_rewrite: str | None = Field(default=None, deprecated=True)
# End of deprecated parameters
cache_dir: str = Field(default='/tmp/cache')
@@ -112,10 +112,6 @@ class OpenHandsConfig(BaseModel):
max_concurrent_conversations: int = Field(
default=3
) # Maximum number of concurrent agent loops allowed per user
client_wait_timeout: int = Field(
default=30,
description='Timeout in seconds for waiting for websocket client connection during initialization',
)
mcp_host: str = Field(default=f'localhost:{os.getenv("port", 3000)}')
mcp: MCPConfig = Field(default_factory=MCPConfig)
kubernetes: KubernetesConfig = Field(default_factory=KubernetesConfig)

View File

@@ -376,6 +376,11 @@ def get_or_create_jwt_secret(file_store: FileStore) -> str:
def finalize_config(cfg: OpenHandsConfig) -> None:
"""More tweaks to the config after it's been loaded."""
# Handle the sandbox.volumes parameter
if cfg.workspace_base is not None or cfg.workspace_mount_path is not None:
logger.openhands_logger.warning(
'DEPRECATED: The WORKSPACE_BASE and WORKSPACE_MOUNT_PATH environment variables are deprecated. '
"Please use SANDBOX_VOLUMES instead, e.g. 'SANDBOX_VOLUMES=/my/host/dir:/workspace:rw'"
)
if cfg.sandbox.volumes is not None:
# Split by commas to handle multiple mounts
mounts = cfg.sandbox.volumes.split(',')

View File

@@ -13,13 +13,7 @@ class BitBucketReposMixin(BitBucketMixinBase):
"""
async def search_repositories(
self,
query: str,
per_page: int,
sort: str,
order: str,
public: bool,
app_mode: AppMode,
self, query: str, per_page: int, sort: str, order: str, public: bool
) -> list[Repository]:
"""Search for repositories."""
repositories = []

View File

@@ -161,29 +161,6 @@ class GitHubReposMixin(GitHubMixinBase):
logger.warning(f'Failed to get user organizations: {e}')
return []
async def get_organizations_from_installations(self) -> list[str]:
"""Get list of organization logins from GitHub App installations.
This method provides a more reliable way to get organizations that the
GitHub App has access to, regardless of user membership context.
"""
try:
# Get installations with account details
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._make_request(url)
installations = response.get('installations', [])
orgs = []
for installation in installations:
account = installation.get('account', {})
if account.get('type') == 'Organization':
orgs.append(account.get('login'))
return orgs
except Exception as e:
logger.warning(f'Failed to get organizations from installations: {e}')
return []
def _fuzzy_match_org_name(self, query: str, org_name: str) -> bool:
"""Check if query fuzzy matches organization name."""
query_lower = query.lower().replace('-', '').replace('_', '').replace(' ', '')
@@ -204,13 +181,7 @@ class GitHubReposMixin(GitHubMixinBase):
return False
async def search_repositories(
self,
query: str,
per_page: int,
sort: str,
order: str,
public: bool,
app_mode: AppMode,
self, query: str, per_page: int, sort: str, order: str, public: bool
) -> list[Repository]:
url = f'{self.BASE_URL}/search/repositories'
params = {
@@ -235,12 +206,9 @@ class GitHubReposMixin(GitHubMixinBase):
query_with_user = f'org:{org} in:name {repo_query}'
params['q'] = query_with_user
elif not public:
# Expand search scope to include user's repositories and organizations the app has access to
# Expand search scope to include user's repositories and organizations they're a member of
user = await self.get_user()
if app_mode == AppMode.SAAS:
user_orgs = await self.get_organizations_from_installations()
else:
user_orgs = await self.get_user_organizations()
user_orgs = await self.get_user_organizations()
# Search in user repos and org repos separately
all_repos = []

View File

@@ -75,7 +75,6 @@ class GitLabReposMixin(GitLabMixinBase):
sort: str = 'updated',
order: str = 'desc',
public: bool = False,
app_mode: AppMode = AppMode.OSS,
) -> list[Repository]:
if public:
# When public=True, query is a GitLab URL that we need to parse

View File

@@ -294,13 +294,12 @@ class ProviderHandler:
per_page: int,
sort: str,
order: str,
app_mode: AppMode,
) -> list[Repository]:
if selected_provider:
service = self.get_service(selected_provider)
public = self._is_repository_url(query, selected_provider)
user_repos = await service.search_repositories(
query, per_page, sort, order, public, app_mode
query, per_page, sort, order, public
)
return self._deduplicate_repositories(user_repos)
@@ -310,7 +309,7 @@ class ProviderHandler:
service = self.get_service(provider)
public = self._is_repository_url(query, provider)
service_repos = await service.search_repositories(
query, per_page, sort, order, public, app_mode
query, per_page, sort, order, public
)
all_repos.extend(service_repos)
except Exception as e:

View File

@@ -458,13 +458,7 @@ class GitService(Protocol):
...
async def search_repositories(
self,
query: str,
per_page: int,
sort: str,
order: str,
public: bool,
app_mode: AppMode,
self, query: str, per_page: int, sort: str, order: str, public: bool
) -> list[Repository]:
"""Search for public repositories"""
...

View File

@@ -90,7 +90,6 @@ app.include_router(settings_router)
app.include_router(secrets_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
if server_config.enable_v1:
app.include_router(v1_router.router)
app.include_router(v1_router.router)
app.include_router(trajectory_router)
add_health_endpoints(app)

View File

@@ -30,7 +30,6 @@ class ServerConfig(ServerConfigInterface):
user_auth_class: str = (
'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
)
enable_v1: bool = os.getenv('ENABLE_V1') != '0'
def verify_config(self):
if self.config_cls:

View File

@@ -148,7 +148,7 @@ async def search_repositories(
)
try:
repos: list[Repository] = await client.search_repositories(
selected_provider, query, per_page, sort, order, server_config.app_mode
selected_provider, query, per_page, sort, order
)
return repos

View File

@@ -390,15 +390,9 @@ class WebSession:
_waiting_times = 1
if self.sio:
# Get timeout from configuration, default to 30 seconds
client_wait_timeout = self.config.client_wait_timeout
self.logger.debug(
f'Using client wait timeout: {client_wait_timeout}s for session {self.sid}'
)
# Wait once during initialization to avoid event push failures during websocket connection intervals
while self._wait_websocket_initial_complete and (
time.time() - _start_time < client_wait_timeout
time.time() - _start_time < 2
):
if bool(
self.sio.manager.rooms.get('/', {}).get(
@@ -406,18 +400,12 @@ class WebSession:
)
):
break
# Progressive backoff: start with 0.1s, increase to 1s after 10 attempts
sleep_duration = 0.1 if _waiting_times <= 10 else 1.0
# Log every 2 seconds to reduce spam
if _waiting_times % (20 if sleep_duration == 0.1 else 2) == 0:
self.logger.debug(
f'There is no listening client in the current room,'
f' waiting for the {_waiting_times}th attempt (timeout: {client_wait_timeout}s): {self.sid}'
)
self.logger.warning(
f'There is no listening client in the current room,'
f' waiting for the {_waiting_times}th attempt: {self.sid}'
)
_waiting_times += 1
await asyncio.sleep(sleep_duration)
await asyncio.sleep(0.1)
self._wait_websocket_initial_complete = False
await self.sio.emit('oh_event', data, to=ROOM_KEY.format(sid=self.sid))

View File

@@ -10,6 +10,7 @@ from pydantic import (
field_validator,
model_validator,
)
from pydantic.json import pydantic_encoder
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
@@ -71,7 +72,7 @@ class Settings(BaseModel):
if context and context.get('expose_secrets', False):
return secret_value
return str(api_key)
return pydantic_encoder(api_key)
@model_validator(mode='before')
@classmethod

View File

@@ -71,23 +71,4 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
]
model_list = openhands_models + model_list
# Add Clarifai provider models (via OpenAI-compatible endpoint)
clarifai_models = [
# clarifai featured models
'clarifai/openai.chat-completion.gpt-oss-120b',
'clarifai/openai.chat-completion.gpt-oss-20b',
'clarifai/openai.chat-completion.gpt-5',
'clarifai/openai.chat-completion.gpt-5-mini',
'clarifai/qwen.qwen3.qwen3-next-80B-A3B-Thinking',
'clarifai/qwen.qwenLM.Qwen3-30B-A3B-Instruct-2507',
'clarifai/qwen.qwenLM.Qwen3-30B-A3B-Thinking-2507',
'clarifai/qwen.qwenLM.Qwen3-14B',
'clarifai/qwen.qwenCoder.Qwen3-Coder-30B-A3B-Instruct',
'clarifai/deepseek-ai.deepseek-chat.DeepSeek-R1-0528-Qwen3-8B',
'clarifai/deepseek-ai.deepseek-chat.DeepSeek-V3_1',
'clarifai/zai.completion.GLM_4_5',
'clarifai/moonshotai.kimi.Kimi-K2-Instruct',
]
model_list = clarifai_models + model_list
return list(sorted(set(model_list)))

53
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -1425,7 +1425,7 @@ version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation", "runtime", "test"]
groups = ["main", "runtime", "test"]
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
@@ -1930,7 +1930,7 @@ version = "45.0.3"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main", "evaluation"]
groups = ["main"]
files = [
{file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"},
@@ -2212,7 +2212,7 @@ version = "1.2.18"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
groups = ["main", "evaluation"]
groups = ["main"]
files = [
{file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"},
{file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"},
@@ -5500,11 +5500,8 @@ files = [
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
@@ -6017,28 +6014,6 @@ files = [
{file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"},
]
[[package]]
name = "multi-swe-bench"
version = "0.1.2"
description = "Multi-SWE-bench: A Multilingual Benchmark for Issue Resolving"
optional = false
python-versions = ">=3.10"
groups = ["evaluation"]
files = [
{file = "multi_swe_bench-0.1.2-py3-none-any.whl", hash = "sha256:6e6cab26c026a3038109bdda7ea4366333cd210a0785bb138044f8917842e1d0"},
{file = "multi_swe_bench-0.1.2.tar.gz", hash = "sha256:ff78cce060a9483e90d571872eaf8625447be3054f4ddf8fae0ec9ea9b9f056a"},
]
[package.dependencies]
dataclasses_json = "*"
docker = "*"
gitpython = "*"
PyGithub = "*"
pyyaml = "*"
toml = "*"
tqdm = "*"
unidiff = "*"
[[package]]
name = "multidict"
version = "6.4.4"
@@ -7069,8 +7044,8 @@ websockets = ">=12"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "08cf609a996523c0199c61c768d74417b7e96109"
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
subdirectory = "openhands/agent_server"
[[package]]
@@ -7098,8 +7073,8 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "08cf609a996523c0199c61c768d74417b7e96109"
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
subdirectory = "openhands/sdk"
[[package]]
@@ -8109,7 +8084,7 @@ version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation", "runtime", "test"]
groups = ["main", "runtime", "test"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
@@ -8343,7 +8318,7 @@ version = "2.6.1"
description = "Use the full Github API v3"
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["main"]
files = [
{file = "PyGithub-2.6.1-py3-none-any.whl", hash = "sha256:6f2fa6d076ccae475f9fc392cc6cdbd54db985d4f69b8833a28397de75ed6ca3"},
{file = "pygithub-2.6.1.tar.gz", hash = "sha256:b5c035392991cca63959e9453286b41b54d83bf2de2daa7d7ff7e4312cebf3bf"},
@@ -8378,7 +8353,7 @@ version = "2.10.1"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
groups = ["main"]
files = [
{file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
{file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
@@ -8410,7 +8385,7 @@ version = "1.5.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
optional = false
python-versions = ">=3.6"
groups = ["main", "evaluation"]
groups = ["main"]
files = [
{file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"},
@@ -11913,7 +11888,7 @@ version = "1.17.2"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.8"
groups = ["main", "evaluation"]
groups = ["main"]
files = [
{file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"},
{file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"},
@@ -12608,4 +12583,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "6973cf1c9ccb0d6926006697ae4863bcd0ab740a88f09fc17c205e016782cf77"
content-hash = "90ae740f15865e77791e259038940ba45652f2639159cad26f2b3292948b32e8"

View File

@@ -113,10 +113,10 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-agent-server = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/agent_server", rev = "08cf609a996523c0199c61c768d74417b7e96109" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "08cf609a996523c0199c61c768d74417b7e96109" }
openhands-agent-server = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/agent_server", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc" }
# This refuses to install
# openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "08cf609a996523c0199c61c768d74417b7e96109" }
# openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc" }
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
@@ -186,7 +186,6 @@ pyarrow = "21.0.0"
datasets = "*"
joblib = "*"
swebench = { git = "https://github.com/ryanhoangt/SWE-bench.git", rev = "fix-modal-patch-eval" }
multi-swe-bench = "0.1.2"
[tool.poetry.scripts]
openhands = "openhands.cli.entry:main"

View File

@@ -1,343 +0,0 @@
"""Tests for ProcessSandboxService."""
import os
import tempfile
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import psutil
import pytest
from openhands.app_server.sandbox.process_sandbox_service import (
ProcessInfo,
ProcessSandboxService,
ProcessSandboxServiceInjector,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
class MockSandboxSpec:
"""Mock sandbox specification."""
def __init__(self):
self.id = 'test-spec'
self.initial_env = {'TEST_VAR': 'test_value'}
self.plugins = []
class MockSandboxSpecService:
"""Mock sandbox spec service."""
async def get_default_sandbox_spec(self):
return MockSandboxSpec()
async def get_sandbox_spec(self, spec_id: str):
if spec_id == 'test-spec':
return MockSandboxSpec()
return None
@pytest.fixture
def mock_httpx_client():
"""Mock httpx client."""
client = AsyncMock(spec=httpx.AsyncClient)
return client
@pytest.fixture
def temp_dir():
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
@pytest.fixture
def process_sandbox_service(mock_httpx_client, temp_dir):
"""Create a ProcessSandboxService instance for testing."""
return ProcessSandboxService(
user_id='test-user-id',
sandbox_spec_service=MockSandboxSpecService(),
base_working_dir=temp_dir,
base_port=9000,
python_executable='python',
agent_server_module='openhands.agent_server',
health_check_path='/alive',
httpx_client=mock_httpx_client,
)
class TestProcessSandboxService:
"""Test cases for ProcessSandboxService."""
def test_find_unused_port(self, process_sandbox_service):
"""Test finding an unused port."""
port = process_sandbox_service._find_unused_port()
assert port >= process_sandbox_service.base_port
assert port < process_sandbox_service.base_port + 10000
@patch('os.makedirs')
def test_create_sandbox_directory(self, mock_makedirs, process_sandbox_service):
"""Test creating a sandbox directory."""
sandbox_dir = process_sandbox_service._create_sandbox_directory('test-id')
expected_dir = os.path.join(process_sandbox_service.base_working_dir, 'test-id')
assert sandbox_dir == expected_dir
mock_makedirs.assert_called_once_with(expected_dir, exist_ok=True)
@pytest.mark.asyncio
async def test_wait_for_server_ready_success(self, process_sandbox_service):
"""Test waiting for server to be ready - success case."""
# Mock successful response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'ok'}
process_sandbox_service.httpx_client.get.return_value = mock_response
result = await process_sandbox_service._wait_for_server_ready(9000, timeout=1)
assert result is True
@pytest.mark.asyncio
async def test_wait_for_server_ready_timeout(self, process_sandbox_service):
"""Test waiting for server to be ready - timeout case."""
# Mock failed response
process_sandbox_service.httpx_client.get.side_effect = Exception(
'Connection failed'
)
result = await process_sandbox_service._wait_for_server_ready(9000, timeout=1)
assert result is False
@patch('psutil.Process')
def test_get_process_status_running(
self, mock_process_class, process_sandbox_service
):
"""Test getting process status for running process."""
mock_process = MagicMock()
mock_process.is_running.return_value = True
mock_process.status.return_value = psutil.STATUS_RUNNING
mock_process_class.return_value = mock_process
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
status = process_sandbox_service._get_process_status(process_info)
assert status == SandboxStatus.RUNNING
@patch('psutil.Process')
def test_get_process_status_missing(
self, mock_process_class, process_sandbox_service
):
"""Test getting process status for missing process."""
import psutil
mock_process_class.side_effect = psutil.NoSuchProcess(1234)
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
status = process_sandbox_service._get_process_status(process_info)
assert status == SandboxStatus.MISSING
@pytest.mark.asyncio
async def test_search_sandboxes_empty(self, process_sandbox_service):
"""Test searching sandboxes when none exist."""
result = await process_sandbox_service.search_sandboxes()
assert len(result.items) == 0
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_get_sandbox_not_found(self, process_sandbox_service):
"""Test getting a sandbox that doesn't exist."""
result = await process_sandbox_service.get_sandbox('nonexistent')
assert result is None
@pytest.mark.asyncio
async def test_resume_sandbox_not_found(self, process_sandbox_service):
"""Test resuming a sandbox that doesn't exist."""
result = await process_sandbox_service.resume_sandbox('nonexistent')
assert result is False
@pytest.mark.asyncio
async def test_pause_sandbox_not_found(self, process_sandbox_service):
"""Test pausing a sandbox that doesn't exist."""
result = await process_sandbox_service.pause_sandbox('nonexistent')
assert result is False
@pytest.mark.asyncio
async def test_delete_sandbox_not_found(self, process_sandbox_service):
"""Test deleting a sandbox that doesn't exist."""
result = await process_sandbox_service.delete_sandbox('nonexistent')
assert result is False
@patch('psutil.Process')
def test_get_process_status_paused(
self, mock_process_class, process_sandbox_service
):
"""Test getting process status for paused process."""
mock_process = MagicMock()
mock_process.is_running.return_value = True
mock_process.status.return_value = psutil.STATUS_STOPPED
mock_process_class.return_value = mock_process
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
status = process_sandbox_service._get_process_status(process_info)
assert status == SandboxStatus.PAUSED
@patch('psutil.Process')
def test_get_process_status_starting(
self, mock_process_class, process_sandbox_service
):
"""Test getting process status for starting process."""
mock_process = MagicMock()
mock_process.is_running.return_value = True
mock_process.status.return_value = psutil.STATUS_SLEEPING
mock_process_class.return_value = mock_process
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
status = process_sandbox_service._get_process_status(process_info)
assert status == SandboxStatus.STARTING
@patch('psutil.Process')
def test_get_process_status_access_denied(
self, mock_process_class, process_sandbox_service
):
"""Test getting process status when access is denied."""
mock_process_class.side_effect = psutil.AccessDenied(1234)
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
status = process_sandbox_service._get_process_status(process_info)
assert status == SandboxStatus.MISSING
@pytest.mark.asyncio
async def test_process_to_sandbox_info_error_status(self, process_sandbox_service):
"""Test converting process info to sandbox info when server is not responding."""
# Mock a process that's running but server is not responding
with patch.object(
process_sandbox_service,
'_get_process_status',
return_value=SandboxStatus.RUNNING,
):
# Mock httpx client to return error response
mock_response = MagicMock()
mock_response.status_code = 500
process_sandbox_service.httpx_client.get.return_value = mock_response
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
sandbox_info = await process_sandbox_service._process_to_sandbox_info(
'test-sandbox', process_info
)
assert sandbox_info.status == SandboxStatus.ERROR
assert sandbox_info.session_api_key is None
assert sandbox_info.exposed_urls is None
@pytest.mark.asyncio
async def test_process_to_sandbox_info_exception(self, process_sandbox_service):
"""Test converting process info to sandbox info when httpx raises exception."""
# Mock a process that's running but httpx raises exception
with patch.object(
process_sandbox_service,
'_get_process_status',
return_value=SandboxStatus.RUNNING,
):
# Mock httpx client to raise exception
process_sandbox_service.httpx_client.get.side_effect = Exception(
'Connection failed'
)
process_info = ProcessInfo(
pid=1234,
port=9000,
user_id='test-user-id',
working_dir='/tmp/test',
session_api_key='test-key',
created_at=datetime.now(),
sandbox_spec_id='test-spec',
)
sandbox_info = await process_sandbox_service._process_to_sandbox_info(
'test-sandbox', process_info
)
assert sandbox_info.status == SandboxStatus.ERROR
assert sandbox_info.session_api_key is None
assert sandbox_info.exposed_urls is None
class TestProcessSandboxServiceInjector:
"""Test cases for ProcessSandboxServiceInjector."""
def test_default_values(self):
"""Test default configuration values."""
injector = ProcessSandboxServiceInjector()
assert injector.base_working_dir == '/tmp/openhands-sandboxes'
assert injector.base_port == 8000
assert injector.health_check_path == '/alive'
assert injector.agent_server_module == 'openhands.agent_server'
def test_custom_values(self):
"""Test custom configuration values."""
injector = ProcessSandboxServiceInjector(
base_working_dir='/custom/path',
base_port=9000,
health_check_path='/health',
agent_server_module='custom.agent.module',
)
assert injector.base_working_dir == '/custom/path'
assert injector.base_port == 9000
assert injector.health_check_path == '/health'
assert injector.agent_server_module == 'custom.agent.module'

View File

@@ -8,7 +8,6 @@ from pydantic import SecretStr
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
from openhands.integrations.service_types import OwnerType, Repository
from openhands.integrations.service_types import ProviderType as ServiceProviderType
from openhands.server.types import AppMode
@pytest.fixture
@@ -38,12 +37,7 @@ async def test_search_repositories_url_parsing_standard_url(bitbucket_service):
) as mock_get_repo:
url = 'https://bitbucket.org/workspace/repo'
repositories = await bitbucket_service.search_repositories(
query=url,
per_page=10,
sort='updated',
order='desc',
public=True,
app_mode=AppMode.OSS,
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Verify the correct workspace/repo combination was extracted and passed
@@ -76,12 +70,7 @@ async def test_search_repositories_url_parsing_with_extra_path_segments(
# Test complex URL with query params, fragments, and extra paths
url = 'https://bitbucket.org/my-workspace/my-repo/src/feature-branch/src/main.py?at=feature-branch&fileviewer=file-view-default#lines-25'
repositories = await bitbucket_service.search_repositories(
query=url,
per_page=10,
sort='updated',
order='desc',
public=True,
app_mode=AppMode.OSS,
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Verify the correct workspace/repo combination was extracted from complex URL
@@ -98,12 +87,7 @@ async def test_search_repositories_url_parsing_invalid_url(bitbucket_service):
) as mock_get_repo:
url = 'not-a-valid-url'
repositories = await bitbucket_service.search_repositories(
query=url,
per_page=10,
sort='updated',
order='desc',
public=True,
app_mode=AppMode.OSS,
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Should return empty list for invalid URL and not call API
@@ -121,12 +105,7 @@ async def test_search_repositories_url_parsing_insufficient_path_segments(
) as mock_get_repo:
url = 'https://bitbucket.org/workspace'
repositories = await bitbucket_service.search_repositories(
query=url,
per_page=10,
sort='updated',
order='desc',
public=True,
app_mode=AppMode.OSS,
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Should return empty list for insufficient path segments and not call API

View File

@@ -277,7 +277,7 @@ async def test_github_search_repositories_with_organizations():
patch.object(service, 'get_user', return_value=mock_user),
patch.object(
service,
'get_organizations_from_installations',
'get_user_organizations',
return_value=['All-Hands-AI', 'example-org'],
),
patch.object(
@@ -285,12 +285,7 @@ async def test_github_search_repositories_with_organizations():
) as mock_request,
):
repositories = await service.search_repositories(
query='openhands',
per_page=10,
sort='stars',
order='desc',
public=False,
app_mode=AppMode.SAAS,
query='openhands', per_page=10, sort='stars', order='desc', public=False
)
# Verify that separate requests were made for user and each organization

View File

@@ -485,7 +485,7 @@ def test_custom_secrets_descriptions_serialization(prompt_dir):
# Verify that the workspace context includes the custom_secrets_descriptions
assert '<CUSTOM_SECRETS>' in workspace_context
for secret_name, secret_description in custom_secrets.items():
assert f'**${secret_name}**' in workspace_context
assert f'$**{secret_name}**' in workspace_context
assert secret_description in workspace_context
assert '<CONVERSATION_INSTRUCTIONS>' in workspace_context

View File

@@ -1,122 +0,0 @@
"""Test that agent instructions properly address pre-commit hooks.
This test ensures that the agent's instructions (microagents and system prompts)
explicitly mention NOT using --no-verify flag to ensure pre-commit hooks are executed.
"""
from pathlib import Path
import pytest
class TestPreCommitInstructions:
"""Test that agent instructions properly guide the agent to respect pre-commit hooks."""
@pytest.fixture
def microagents_dir(self) -> Path:
"""Get the microagents directory."""
return Path(__file__).parent.parent.parent / 'microagents'
@pytest.fixture
def system_prompt_file(self) -> Path:
"""Get the system prompt file for CodeActAgent."""
return (
Path(__file__).parent.parent.parent
/ 'openhands'
/ 'agenthub'
/ 'codeact_agent'
/ 'prompts'
/ 'system_prompt.j2'
)
def test_github_microagent_mentions_no_verify(self, microagents_dir: Path):
"""Test that github.md microagent mentions not using --no-verify."""
github_md = microagents_dir / 'github.md'
assert github_md.exists(), 'github.md microagent file should exist'
content = github_md.read_text()
# Check that it mentions not using --no-verify
assert '--no-verify' in content.lower(), (
'github.md should mention --no-verify flag'
)
assert 'never' in content.lower() and '--no-verify' in content.lower(), (
'github.md should instruct to NEVER use --no-verify'
)
assert 'pre-commit' in content.lower(), (
'github.md should mention pre-commit hooks'
)
def test_gitlab_microagent_mentions_no_verify(self, microagents_dir: Path):
"""Test that gitlab.md microagent mentions not using --no-verify."""
gitlab_md = microagents_dir / 'gitlab.md'
assert gitlab_md.exists(), 'gitlab.md microagent file should exist'
content = gitlab_md.read_text()
# Check that it mentions not using --no-verify
assert '--no-verify' in content.lower(), (
'gitlab.md should mention --no-verify flag'
)
assert 'never' in content.lower() and '--no-verify' in content.lower(), (
'gitlab.md should instruct to NEVER use --no-verify'
)
assert 'pre-commit' in content.lower(), (
'gitlab.md should mention pre-commit hooks'
)
def test_bitbucket_microagent_mentions_no_verify(self, microagents_dir: Path):
"""Test that bitbucket.md microagent mentions not using --no-verify."""
bitbucket_md = microagents_dir / 'bitbucket.md'
assert bitbucket_md.exists(), 'bitbucket.md microagent file should exist'
content = bitbucket_md.read_text()
# Check that it mentions not using --no-verify
assert '--no-verify' in content.lower(), (
'bitbucket.md should mention --no-verify flag'
)
assert 'never' in content.lower() and '--no-verify' in content.lower(), (
'bitbucket.md should instruct to NEVER use --no-verify'
)
assert 'pre-commit' in content.lower(), (
'bitbucket.md should mention pre-commit hooks'
)
def test_system_prompt_mentions_no_verify(self, system_prompt_file: Path):
"""Test that system_prompt.j2 mentions not using --no-verify."""
assert system_prompt_file.exists(), (
'system_prompt.j2 file should exist for CodeActAgent'
)
content = system_prompt_file.read_text()
# Check that it mentions not using --no-verify in VERSION_CONTROL section
assert '--no-verify' in content.lower(), (
'system_prompt.j2 should mention --no-verify flag'
)
assert 'never' in content.lower() and '--no-verify' in content.lower(), (
'system_prompt.j2 should instruct to NEVER use --no-verify'
)
assert 'pre-commit' in content.lower(), (
'system_prompt.j2 should mention pre-commit hooks'
)
def test_git_microagents_show_correct_commit_examples(self, microagents_dir: Path):
"""Test that git-related microagents show commit examples without --no-verify."""
for microagent_file in ['github.md', 'gitlab.md', 'bitbucket.md']:
file_path = microagents_dir / microagent_file
content = file_path.read_text()
# Find all code examples with git commit
lines = content.split('\n')
in_code_block = False
for line in lines:
if line.strip().startswith('```'):
in_code_block = not in_code_block
elif in_code_block and 'git commit' in line:
# Ensure the example doesn't use --no-verify
assert '--no-verify' not in line.lower(), (
f'{microagent_file} should not show examples using '
f'--no-verify flag: {line}'
)