mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
openhands/
...
v1-cli-add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba881eddd7 |
@@ -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
12
enterprise/poetry.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,7 +25,6 @@ export const MAP_PROVIDER = {
|
||||
openrouter: "OpenRouter",
|
||||
openhands: "OpenHands",
|
||||
lemonade: "Lemonade",
|
||||
clarifai: "Clarifai",
|
||||
};
|
||||
|
||||
export const mapProvider = (provider: string) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,6 @@ export const VERIFIED_PROVIDERS = [
|
||||
"openai",
|
||||
"mistral",
|
||||
"lemonade",
|
||||
"clarifai",
|
||||
];
|
||||
export const VERIFIED_MODELS = [
|
||||
"o3-mini-2025-01-31",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_executable() -> bool:
|
||||
)
|
||||
|
||||
# --- Wait for welcome ---
|
||||
deadline = boot_start + 60
|
||||
deadline = boot_start + 30
|
||||
saw_welcome = False
|
||||
captured = []
|
||||
|
||||
|
||||
110
openhands-cli/openhands-cli.spec
Normal file
110
openhands-cli/openhands-cli.spec
Normal 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
|
||||
)
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
5
openhands-cli/pytest.ini
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
140
openhands-cli/tests/test_agent_chat_initial_message.py
Normal file
140
openhands-cli/tests/test_agent_chat_initial_message.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
126
openhands-cli/tests/test_settings_input.py
Normal file
126
openhands-cli/tests/test_settings_input.py
Normal 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]
|
||||
132
openhands-cli/tests/test_settings_workflow.py
Normal file
132
openhands-cli/tests/test_settings_workflow.py
Normal 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
53
openhands-cli/uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
53
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
)
|
||||
Reference in New Issue
Block a user